Compare commits

..

141 Commits

Author SHA1 Message Date
ElementRobot d4414341d6 v26.0.0-rc.1 2023-06-01 16:54:25 +01:00
ElementRobot f173014fc0 Prepare changelog for v26.0.0-rc.1 2023-06-01 16:54:22 +01:00
ElementRobot 81cb44db7f Merge branch 'develop' into staging
# Conflicts:
#	spec/unit/room.spec.ts
#	src/models/event-timeline.ts
#	src/models/thread.ts
2023-06-01 16:49:33 +01:00
Michael Telatynski 71f9b25db7 Ensure we do not add relations to the wrong timeline (#3427)
* Do not assume that a relation lives in main timeline if we do not know its parent

* For pagination, partition relations with unknown parents into a separate bucket

And only add them to relation map, no timelines

* Make addLiveEvents async and have it fetch parent events of unknown relations to not insert into the wrong timeline

* Fix tests not awaiting addLIveEvents

* Fix handling of thread roots in eventShouldLiveIn

* Fix types

* Fix tests

* Fix import

* Stash thread ID of relations in unsigned to be stashed in sync accumulator

* Persist after processing

* Revert "Persist after processing"

This reverts commit 05ed6409b35f5e9bea3b699d0abcaac3d02588c5.

* Update unsigned field name to match MSC4023

* Persist after processing to store thread id in unsigned sync accumulator

* Add test

* Fix replayEvents getting doubled up due to Thread::addEvents being called in createThread and separately

* Fix test

* Switch to using UnstableValue

* Add comment

* Iterate
2023-06-01 15:29:05 +00:00
Enrico Schwendig 9c5c7ddb17 Add remote user and call id in webrtc call stats (#3413)
* Refactor names in webrtc stats

* Refactor summary stats reporter to gatherer

* Add call and opponent member id to call stats reports

* Update opponent member when we know them

* Add missing return type

* remove async in test

* mark new stats property as optional to avoid braking changes
2023-06-01 15:07:44 +00:00
sigmaSd feb424b0a9 mention deno support in the README (#3417)
* mention deno support in the README

It seems to work fine, and I have a demo bot with it https://github.com/sigmaSd/deno-matrix-bot

* Update README.md
2023-06-01 07:52:55 +00:00
Travis Ralston 648a1a09b1 Mark room version 10 as safe (#3425)
As should have been done a year ago.
2023-05-31 14:15:49 +00:00
Michael Telatynski 56c5375bbd Remove spec non-compliant extended glob format (#3423)
* Remove spec non-compliant extended glob format

* Simplify

* Remove tests for non spec compliant behaviour

* Remove stale rules
2023-05-31 09:05:55 +00:00
Andy Balaam b29e1e9d21 Adjust fetchEditsWhereNeeded to use a clearer filter and async function (#3411)
* Make a clear and explicit filter on which events are considered for fetchEventsWhereNeeded

* Convert the logic in fetchEventsWhereNeeded to an async function
2023-05-26 08:29:35 +00:00
Enrico Schwendig 7ade461a4c Refactor naming of webrtc stats reporter (#3404)
* Refactor names in webrtc stats

* Refactor summary stats reporter to gatherer

* Add test for signal change

* rename test
2023-05-26 07:56:49 +00:00
Andy Balaam b5414ea914 Fix bug where original event was inserted into timeline instead of the edit event (#3398)
* Fix an existing test for editing messages in threads

While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.

* Move editing test into thread.spec.ts

* Isolate Thread global modification in beforeAll()

* Delete unneeded setUnsigned call

* Use standard message-creation methods

* Rename event variables

* Rename sender->user

* Remove unneeded variables

* Extract distractions into functions

* Fetch edits for thread messages

Modifies fetchEditsWhereNeeded to allow edits of threaded messages. The
code before prevented any relations from fetching edits, but of course
events in threads are relations.

We definitely want thread messages to get their edits fetched, and I
assume this is working in the real code, probably because the event
being looked at is some kind of eventmapped thing that doesn't have
proper relations visible on it.

In tests, if we don't make this change, we can't see edits getting
fetched.

* Add a test for fetching edits on demand in a thread

This test demonstrates the current behaviour, which contains a bug - we
don't actually add the right event to the timeline.

* Fix bug where original event was inserted into timeline instead of the edit event
2023-05-25 15:48:48 +00:00
Andy Balaam 711bf4710d Test inserting edit events into the thread timeline "on demand" (#3410)
* Fix an existing test for editing messages in threads

While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.

* Move editing test into thread.spec.ts

* Isolate Thread global modification in beforeAll()

* Delete unneeded setUnsigned call

* Use standard message-creation methods

* Rename event variables

* Rename sender->user

* Remove unneeded variables

* Extract distractions into functions

* Fetch edits for thread messages

Modifies fetchEditsWhereNeeded to allow edits of threaded messages. The
code before prevented any relations from fetching edits, but of course
events in threads are relations.

We definitely want thread messages to get their edits fetched, and I
assume this is working in the real code, probably because the event
being looked at is some kind of eventmapped thing that doesn't have
proper relations visible on it.

In tests, if we don't make this change, we can't see edits getting
fetched.

* Add a test for fetching edits on demand in a thread

This test demonstrates the current behaviour, which contains a bug - we
don't actually add the right event to the timeline.
2023-05-25 16:32:42 +01:00
Andy Balaam 48b60bb885 Simplify thread-editing test (#3409)
* Fix an existing test for editing messages in threads

While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.

* Move editing test into thread.spec.ts

* Isolate Thread global modification in beforeAll()

* Delete unneeded setUnsigned call

* Use standard message-creation methods

* Rename event variables

* Rename sender->user

* Remove unneeded variables

* Extract distractions into functions
2023-05-25 14:03:58 +00:00
Andy Balaam 7ed14dc749 Only add a local receipt if it's after an existing receipt (#3399)
* Add a test for creating local echo receipts in threads

* Only add local receipt if it's after existing receipt

* Refactor local receipt tests to be shorter

* Tests for local receipts where we DO have recursive relations support
2023-05-25 13:59:14 +00:00
Andy Balaam 26736b6bb1 Move editing test into thread.spec.ts (#3408)
* Fix an existing test for editing messages in threads

While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.

* Move editing test into thread.spec.ts
2023-05-25 13:20:59 +00:00
Andy Balaam 056aae823d Fix an existing test for editing messages in threads (#3407)
While attempting to test a new change, I discovered that the test
"should allow edits to be added to thread timeline" did not actually
fail if its assertions failed. Further, those assertions were incorrect.

So this change fixes the test to create the thread, wait for it to be
initialised, and then add events to it. This simplifies the flow and
ensures the test fails if something unexpected happens.
2023-05-25 12:38:53 +00:00
Andy Balaam d1bfdca0c9 Isolate changes to Thread global into a single describe block (#3390) 2023-05-25 11:45:47 +00:00
Andy Balaam c0577c29c4 Minor improvements to the code inserting events into timelines (#3389)
* Remove unneeded pling

* Comment clarifying why we bail out in insertEventIntoTimeline
2023-05-25 11:44:56 +00:00
Richard van der Hoff a2d1dee0a1 Minor fixes to CryptoApi doc comments (#3406)
Followups to #3287 and #3360: minor clarifications to the doc-comments
2023-05-24 16:02:40 +00:00
Richard van der Hoff d7bcdff29b Fix re-exports for SAS.ts (#3405)
These three are only types, not objects we can export.

Fixes warnings in EW (and probably some build failures for someone somewhere):

```
2023-05-24 11:27:28.294 [element-js] WARNING in ../matrix-js-sdk/src/crypto/verification/SAS.ts 31:0-123
2023-05-24 11:27:28.294 [element-js] "export 'EmojiMapping' was not found in '../../crypto-api/verification'
2023-05-24 11:27:28.294 [element-js]
2023-05-24 11:27:28.294 [element-js] WARNING in ../matrix-js-sdk/src/crypto/verification/SAS.ts 31:0-123
2023-05-24 11:27:28.294 [element-js] "export 'GeneratedSas' (reexported as 'IGeneratedSas') was not found in '../../crypto-api/verification'
2023-05-24 11:27:28.294 [element-js]
2023-05-24 11:27:28.294 [element-js] WARNING in ../matrix-js-sdk/src/crypto/verification/SAS.ts 31:0-123
2023-05-24 11:27:28.294 [element-js] "export 'ShowSasCallbacks' (reexported as 'ISasEvent') was not found in '../../crypto-api/verification'
```
2023-05-24 10:54:10 +00:00
Eric Eastwood ed71cdeccd Add more context for why threaded vs unthreaded read receipts (#3378)
* Add more context for why threaded vs unthreaded read receipts

See https://github.com/matrix-org/matrix-js-sdk/pull/3339#discussion_r1195364120

* Language updates from Andy

See https://github.com/matrix-org/matrix-js-sdk/pull/3378#discussion_r1197622113

* Fix lints
2023-05-24 10:48:22 +00:00
Michael Telatynski 729f924de1 Prioritise entirely supported flows for UIA (#3402)
* Prioritise entirely supported flows for UIA

* Add tests

* Fix types

* Apply suggestions from code review

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

* Update src/interactive-auth.ts

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-05-24 10:33:48 +00:00
Andy Balaam 4732098731 Test for inserting events into the timeline in timestamp order (#3391)
* Move mkReaction into test-utils so it can be used by other code

* Basic test for inserting messages into the thread timeline
2023-05-24 10:29:57 +00:00
ElementRobot a7d503a8ac [Backport staging] Fix mark as unread button (#3401)
Co-authored-by: Michael Weimann <michaelw@matrix.org>
2023-05-24 09:02:18 +01:00
Michael Weimann 063d69eff1 Fix mark as unread button (#3393)
* Fix mark as unread button

* Revert to prefer the last event from the main timeline

* Refactor room test

* Fix type

* Improve docs

* Insert events to the end of the timeline

* Improve test doc
2023-05-23 20:34:18 +00:00
Richard van der Hoff 614f446361 Combine QrCodeEvent, SasEvent and VerificationEvent (#3386)
* Move IReciprocateQr to `crypto-api` and rename

* Move ISasEvent to `crypto-api`, and rename

... and add some comments

* Combine QrCodeEvent, SasEvent and VerificationEvent together

... as a precursor to extracting a single `Verifier` interface for `SAS` and `ReciprocateQRCode`.

`enum`s are slightly magical things that have both a type and a value, so we
have to re-export their backwards-compatibility fudges twice.

* Update src/crypto/verification/Base.ts
2023-05-22 11:02:10 +00:00
Michael Telatynski b62fac9dad Update client.ts 2023-05-19 16:39:02 +01:00
ElementRobot ff07cb642f v25.2.0-rc.5 2023-05-19 16:35:34 +01:00
ElementRobot fba3cf2756 Prepare changelog for v25.2.0-rc.5 2023-05-19 16:35:32 +01:00
Michael Telatynski 5973c66726 Make sonar happier about our code & tests (#3388) 2023-05-19 16:33:19 +01:00
ElementRobot b22fc1f9d9 [Backport staging] Attempt a potential workaround for stuck notifs (#3387)
Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
2023-05-19 15:57:55 +01:00
Richard van der Hoff 3f48a954d8 Move crypto classes into a separate namespace (#3385)
* Move crypto classes into a separate namespace

* Add in re-exports for backwards compatibility

* Update src/matrix.ts
2023-05-19 12:43:16 +00:00
Michael Telatynski b5d544df68 Update sonarqube.yml (#3376) 2023-05-19 12:16:29 +00:00
Andy Balaam cd26ba67d4 Attempt a potential workaround for stuck notifs (#3384)
* Attempt a potential workaround for stuck notifs

* Remove TODOs

* Fix backwards logic about server support for MSC3981 in fetchEditsWhereNeeded

* Check for lack of MSC3981 server support before calling insertEventIntoTimeline

* If no parent event is found, insert purely based on timestamp

* Mark temporary methods as internal
2023-05-19 12:34:26 +01:00
Michael Telatynski 488390365a Add typedoc-plugin-coverage (#3379)
* Add typedoc-plugin-coverage

* Specify coverageLabel
2023-05-19 10:35:19 +00:00
Richard van der Hoff b7b1129478 Fix bug with pendingEventOrdering: "chronological" (#3382)
If we don't do this, then we end up trying to match the MAC on our own
`m.key.verification.mac`, which of course fails.
2023-05-18 13:47:24 +00:00
Richard van der Hoff 0fa9528a3f Comments for typed-event-emitter classes (#3380)
* Comments for typed-event-emitter classes

* Export `TypedEventEmitter`

... to stop tsdoc complaining.
2023-05-18 12:22:51 +00:00
Enrico Schwendig a7b1dcaf95 Filter all summary stats which collect the first time webrtc metrics. (#3377)
* remove comment

* Filter all summery stats which collect the first time webrtc stats

* Fix lint issues

* Fix lint second issues
2023-05-17 15:40:33 +00:00
Richard van der Hoff bb5bccbf78 Element-R: initial implementation of bootstrapCrossSigning (#3368)
* Working `bootstrapCrossSigning` for rust

* Remove unused `oldBackendOnly`

* update tests

* another test
2023-05-16 20:30:32 +00:00
renovate[bot] ece3ccb958 Lock file maintenance (#3375)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 16:31:36 +00:00
renovate[bot] a769cf88f7 Update dependency eslint-plugin-unicorn to v47 (#3373)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 15:23:34 +00:00
renovate[bot] 8b0f1a0c59 Update dependency @types/node to v18.16.9 (#3370)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-05-16 15:19:43 +00:00
renovate[bot] 978e748246 Update dependency eslint-plugin-jsdoc to v44 (#3372)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 15:15:21 +00:00
renovate[bot] 32a5cc4728 Update matrix-org/netlify-pr-preview action to v2 (#3374)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 15:01:13 +00:00
renovate[bot] d580f56c0b Update all non-major dependencies (#3371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 14:51:21 +00:00
Michael Telatynski 789458e0d4 Add methods to terminate idb worker (#3362) 2023-05-16 16:05:55 +01:00
renovate[bot] 8d14d45272 Update typescript-eslint monorepo to v5.59.6 (#3334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-16 14:37:05 +00:00
ElementRobot 2ec1fa6605 v25.2.0-rc.4 2023-05-16 14:22:04 +01:00
ElementRobot f15d682938 Prepare changelog for v25.2.0-rc.4 2023-05-16 14:22:02 +01:00
ElementRobot 21a10a2d14 v25.2.0-rc.3 2023-05-16 14:17:04 +01:00
ElementRobot fc02e550bd Prepare changelog for v25.2.0-rc.3 2023-05-16 14:17:02 +01:00
Michael Telatynski 78637a0689 Fix docs deployment 2023-05-16 14:15:27 +01:00
ElementRobot 4ca882fcd4 v25.2.0-rc.2 2023-05-16 13:58:35 +01:00
ElementRobot 13ee0eb7f5 Prepare changelog for v25.2.0-rc.2 2023-05-16 13:58:33 +01:00
ElementRobot cb018dfc80 Fix tsconfig-build.json 2023-05-16 13:58:05 +01:00
ElementRobot 7574dacdb3 v25.2.0-rc.1 2023-05-16 13:37:19 +01:00
ElementRobot 0c417b7c32 Prepare changelog for v25.2.0-rc.1 2023-05-16 13:37:17 +01:00
ElementRobot daf845d7bd Fix changelog_head.py script to be Python 3 compatible 2023-05-16 13:36:40 +01:00
Michael Telatynski 52792ec89b Fix CI failure on develop due to force merged PR and prettier failure (#3369) 2023-05-16 11:52:58 +00:00
Matthew Hodgson 6dc4a62e8c Merge pull request #3367 from matrix-org/matthew/fix-accumulated-sync-summaries
fix accumulated sync summaries - andy review
2023-05-16 12:34:54 +01:00
Matthew Hodgson 1cd8ea61ea merge 2023-05-16 12:08:02 +01:00
Matthew Hodgson 0c5eb277e4 incorporate andy review 2023-05-16 12:05:56 +01:00
Matthew Hodgson d459a91af3 correctly accumulate sync summaries. (#3366)
if a sync summary for (say) invited_member_count goes from 1 to 0, it should be
accumluated as 0, rather than 1.

Should fix https://github.com/vector-im/element-web/issues/23345
2023-05-16 10:41:35 +00:00
Matthew Hodgson 18722d0031 correctly accumulate sync summaries.
if a sync summary for (say) invited_member_count goes from 1 to 0, it should be
accumluated as 0, rather than 1.

Should fix https://github.com/vector-im/element-web/issues/23345
2023-05-16 11:34:06 +01:00
RiotRobot 5119934268 Merge branch 'master' into develop 2023-05-16 09:12:28 +01:00
RiotRobot 077da23d08 v25.1.1 2023-05-16 09:10:34 +01:00
RiotRobot 083b4cb17e Prepare changelog for v25.1.1 2023-05-16 09:10:31 +01:00
Richard van der Hoff 4316009401 Element-R: support for SigningKeysUploadRequest (#3365)
* OutgoingRequestProcessor: support for SigningKeysUploadRequest

* Tests

* Bump matrix-org/matrix-sdk-crypto-js

... to pick up bug fixes for outgoing requests
2023-05-15 19:14:05 +00:00
Richard van der Hoff 72f3c360b6 Add CryptoApi.getCrossSigningKeyId (#3360) 2023-05-15 18:46:33 +01:00
Enrico Schwendig fcbc195fbe Check permission on mute mic, only if no audio track exists. (#3359)
* check permission only if no audio track

* fix linter issues

* add missing tests for perfect negotiation pattern

* add null case in unit tests for audio muting

* fix issue with type MediaStream

* force right type of mock methode

* format code
2023-05-15 14:38:43 +00:00
Richard van der Hoff af38021d28 Deprecate device methods in MatrixClient (#3357) 2023-05-15 10:37:35 +00:00
Michael Telatynski 5e8cb9fa18 Fix typedoc release documentation deployment (#3358)
* Prune typedoc docs before generating new ones

* Only maintain 10 major versions

* Switch to deploy mechanism which doesn't mangle symlinks

* Convert absolute symlinks to relative
2023-05-15 09:14:35 +00:00
Michael Telatynski 6ef9f6c55e Enable better tree shaking (#3356) 2023-05-15 08:10:44 +00:00
Timo e6a3b0ebc0 Total summary count (#3351)
* add audio concealment to stats report

* audio concealment to summary

* make ts linter happy

* format and rename

* fix and add tests

* make it prettier!

* we can make it even prettier ?!

* review

* fix tests

* pretty

* one empty line to ...

* remove ratio in audio concealment (ratio is now done in the summary)

* remove comment

* fix test

* add peer connections to summary report

* tests
2023-05-14 16:58:38 +00:00
Robin aaae55736f Keep measuring a call feed's volume after a stream replacement (#3361) 2023-05-13 17:54:29 +00:00
Timo 9e586ab634 Audio concealment (#3349)
* add audio concealment to stats report

* audio concealment to summary

* make ts linter happy

* format and rename

* fix and add tests

* make it prettier!

* we can make it even prettier ?!

* review

* fix tests

* pretty

* one empty line to ...

* remove ratio in audio concealment (ratio is now done in the summary)

* remove comment

* fix test
2023-05-12 16:24:27 +00:00
Richard van der Hoff 7ff44d4a50 Integration test for bootstrapCrossSigning (#3355)
* Stub implementation of bootstrapCrossSigning

* Integration test for `bootstrapCrossSigning`
2023-05-12 16:19:18 +00:00
Richard van der Hoff 63abd00ca7 Element-R: Stub out isCrossSigningReady and isSecretStorageReady (#3354)
* Stub implementation of `isCrossSigningReady`

* Stub implementation of `isSecretStorageReady`

* add tests to meet quality gate

* factor out common

* Remove accidentally-added file
2023-05-12 12:21:52 +00:00
Richard van der Hoff 40f2579158 Pass SecretStorage into RustCrypto (#3353)
* Pass SecretStorage into RustCrypto

* Update src/rust-crypto/rust-crypto.ts
2023-05-12 09:38:33 +00:00
Richard van der Hoff ceb2a57feb Rename and move crypto.IBootstrapCrossSigningOpts (#3352)
* Define `UIAuthCallback` type and use in `IBootstrapCrossSigningOpts`

* Move `IBootstrapCrossSigningOpts` to `crypto-api` and rename

* Replace uses of `IBootstrapCrossSigningOpts`

... with `BootstrapCrossSigningOpts`

* Update src/crypto-api.ts
2023-05-11 18:41:58 +00:00
Enrico Schwendig 90e8336797 Do an ICE Restart if WebRTC become disconnected (#3341)
* Do an ice restart if ICE disconnected

  - Waite two seconds after disconnected
  - Remove check for finish ICE gathering and try to add each local candidate. Avoid race in multible ICE gathering

* Add tests for failed iceConnectionState

* suppress type check in unit test

* fix pr issues
2023-05-11 14:00:26 +00:00
Andy Balaam 73ca9c9ed2 Revert "Update JS-DevTools/npm-publish action to v2 (#3336)" (#3350)
Attempting to fix failure to publish to NPM due to an "invalid token"
error https://github.com/matrix-org/matrix-js-sdk/actions/runs/4926808655/jobs/8819822367 .

This reverts commit ca9263fa64.
2023-05-10 10:32:22 +00:00
RiotRobot cc065c2772 Resetting package fields for development 2023-05-09 15:14:29 +01:00
RiotRobot 028be0fee2 Merge branch 'master' into develop 2023-05-09 15:14:24 +01:00
RiotRobot d4500da59a v25.1.0 2023-05-09 15:12:52 +01:00
RiotRobot b6aef6772e Prepare changelog for v25.1.0 2023-05-09 15:12:49 +01:00
Michael Telatynski 49696cecbd Use WeakMap in ReEmitter to help enable garbage collection (#3344) 2023-05-09 13:24:34 +00:00
renovate[bot] 83cb52c89c Lock file maintenance (#3312)
* Lock file maintenance

* Fix types

* Iterate

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-05-09 13:11:30 +00:00
renovate[bot] e82bae2c4d Update dependency @types/node to v18.16.6 (#3347)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-09 10:52:45 +00:00
renovate[bot] ee2b0204aa Update dependency @types/node to v18.16.4 (#3335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-09 04:58:36 +00:00
renovate[bot] 1ec7670f6a Update babel monorepo (#3332)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-08 02:47:30 +00:00
Michael Telatynski 8ab2e10471 Update types to match spec (#3330) 2023-05-05 08:13:07 +00:00
renovate[bot] 8ff8685ae5 Update dependency rimraf to v5 (#3298)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-04 21:43:38 +00:00
Florian Duros f3772cdf82 Avoid upload a new fallback key at every /sync (#3338) 2023-05-03 13:10:02 +00:00
Andy Balaam ee2f1cdfd4 Accumulate receipts for the main thread and unthreaded separately. (#3339)
Fixes matrix-org/element-web#24629
2023-05-02 15:57:53 +00:00
Florian Duros 0de73a0b3e Update @matrix-org/matrix-sdk-crypto-js to ^0.1.0-alpha.8 (#3337) 2023-05-02 15:53:36 +00:00
renovate[bot] be742e811c Update dependency @types/jest to v29.5.1 (#3333)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-02 13:58:33 +00:00
renovate[bot] ca9263fa64 Update JS-DevTools/npm-publish action to v2 (#3336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-02 13:56:42 +00:00
renovate[bot] 9f619be08d Update peter-evans/create-pull-request digest to 284f54f (#3331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-05-02 13:40:31 +00:00
RiotRobot 7792254c12 v25.1.0-rc.1 2023-05-02 11:31:10 +01:00
RiotRobot fff41f1f27 Prepare changelog for v25.1.0-rc.1 2023-05-02 11:31:04 +01:00
renovate[bot] 3e0f9f582e Update all non-major dependencies (#3329)
* Update all non-major dependencies

* Create typedoc.json

* Update typedoc-plugin-missing-exports

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-04-28 08:41:28 +00:00
Michael Telatynski 47ba8cfa24 Correct interactive-auth types (#3328) 2023-04-28 07:52:47 +00:00
renovate[bot] 54bb4c8011 Update all non-major dependencies (#3295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-27 23:55:40 +00:00
Kerry 71e763263e add client method to remove pusher (#3324)
* add client method to remove pusher

* remove unused type
2023-04-27 21:49:35 +00:00
Andy Balaam 2ebcda2a55 Tests for the ReceiptAccumulator (#3326) 2023-04-27 13:06:17 +00:00
Janne Mareike Koschinski e10db6f7e9 Implement MSC 3981 (#3248)
* Implement MSC 3891

* Add necessary mocks to tests

* Only set recurse parameter if supported

* fix: address review comments

* task: unify unstable prefix code between client and tests

* Add test for relations recursion

* Make prettier happier :)

* Revert "task: unify unstable prefix code between client and tests"

This reverts commit f7401e05

* Fix broken tests
2023-04-27 13:01:48 +00:00
Michael Weimann 1e041a2957 Add Room.getLastLiveEvent and Room.getLastThread (#3321)
* Add room.getLastLiveEvent and remove room.lastThread

* Deprecate Room.lastThread

* Add comments about timestamps

* Improve lastThread prop doc

* Simplify test structure
2023-04-27 11:44:27 +00:00
Florian Duros 1631d6f3c0 Fix racing between one-time-keys processing and sync (#3327) 2023-04-27 10:21:01 +00:00
Andy Balaam 3d86821258 Move receipt-specific logic into ReceiptAccumulator (#3320)
* Extract receipt accumulation logic into ReceiptAccumulator

* Rename readReceipts to unthreadedReadReceipts

* Move AccumulatedReceipt into receipt-accumulator

* Move the logic for consuming events into ReceiptAccumulator
2023-04-27 07:25:16 +00:00
Enrico Schwendig 261bc81554 Make webrtc stats collection optional (#3307)
* stats: disable stats collection if interval zero

* stats: add groupcall property for stats interval

* stats: disable collecting webrtc stats by default

* add setup methode for group call stats

* suppress lint errors in test
2023-04-27 06:56:58 +00:00
Michael Telatynski 8f701f43fb Use safeSet in recursivelyAssign (#3323) 2023-04-27 06:13:38 +00:00
Richard van der Hoff 4bdb9111dd Fix incorrect uses of IAuthData (#3322)
`IAuthData` is the response, not the request
2023-04-26 18:15:58 +00:00
Andy Balaam 93e2135223 Factor out ReceiptAccumulator (#3319)
* Performance tests for receipt accumulation

* Split ReceiptAccumulator into its own module
2023-04-26 15:07:28 +00:00
Enrico Schwendig 56cb05aac0 Adjust negotiation process for pending answer process (#3314)
* add debug statements

* adjust negotiation process

* switch tp simpler proof setLocalDescription()

* fix second race in answer pending state and renegotiation trigger

* revert simpler proof setLocalDescription because of pre SDP munging. I will refactor this in an extra PR
* add state of answer pending process on the second received answer methode as well. Now in any case of receiving answer we take care of this state.

* Clean up pending state in error case
2023-04-26 15:04:50 +00:00
renovate[bot] 38d5b202c9 Update peter-evans/create-pull-request action to v5 (#3300)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-26 12:47:31 +00:00
David Baker cfffa9c518 Fix lack of media when a user reconnects (#3318)
* Fix lack of media when a user reconnects

This fixes broken media when someone reconnects to the call after
a forced disconnect (when their old call gets replaced immediately
by a new call). We listen for changes in the call feeds and the tearing
down of the feeds for the old call caused us to remove the feed for
the new call.

Also adds the call to the calls map before it'as initialised, such that
it's the active call for the user/device when the feedsChanged event arrives,
otherwise we'll ignore the event.

* Fix tests
2023-04-26 10:00:08 +00:00
Florian Duros 73dbd709d8 Element-R: Stub findVerificationRequestDMInProgress and getStoredCrossSigningForUser (#3315)
* Add `findVerificationRequestDMInProgress` into `CryptoBackend` and stub it `rust-crypto`

* Add `getStoredCrossSigningForUser` into `CryptoBackend` and stub it `rust-crypto`
2023-04-25 17:52:20 +01:00
Michael Telatynski eef67e2c03 Deprecate MatrixClient::resolveRoomAlias (#3316) 2023-04-25 15:39:01 +00:00
RiotRobot e3498f0668 Resetting package fields for development 2023-04-25 09:44:25 +01:00
RiotRobot f04c147faa Merge branch 'master' into develop 2023-04-25 09:44:20 +01:00
Richard van der Hoff c2afc357b9 Add some comments to OlmDecryption.decryptEvent (#3308) 2023-04-24 11:25:07 +00:00
Richard van der Hoff 1d3f67f2ce DeviceVerificationStatus: add new signedByOwner property (#3311) 2023-04-24 10:41:36 +00:00
Michael Telatynski 514e4f07f1 Update sonarcloud.yml 2023-04-24 08:38:14 +01:00
Florian Duros fbb1c4b2bd Element-R: wire up device lists (#3272)
* Add `getUserDeviceInfo` to `CryptoBackend` and old crypto impl

* Add `getUserDeviceInfo` WIP impl to `rust-crypto`

* Add tests for `downloadUncached`

* WIP test

* Fix typo and use `downloadDeviceToJsDevice`

* Add `getUserDeviceInfo` to `client.ts`

* Use new `Device` class instead of `IDevice`

* Add tests for `device-convertor`

* Add method description for `isInRustUserIds` in `rust-crypto.ts`

* Misc

* Fix typo

* Fix `rustDeviceToJsDevice`

* Fix comments and new one

* Review of `device.ts`

* Remove `getUserDeviceInfo` from `client.ts`

* Review of `getUserDeviceInfo` in `rust-crypto.ts`

* Fix typo in `index.ts`

* Review `device-converter.ts`

* Add documentation to `getUserDeviceInfo` in `crypto-api.ts`

* Last changes in comments
2023-04-21 14:03:02 +00:00
Florian Duros 63dea599b1 Update @matrix-org/matrix-sdk-crypto-js to 0.1.0-alpha.7 (#3309) 2023-04-20 16:37:09 +00:00
Janne Mareike Koschinski 743ba5f050 Increase log level for scheduler (#3152) 2023-04-20 12:31:58 +00:00
Michael Telatynski cb180b4195 Improve types (#3305) 2023-04-20 08:02:38 +00:00
Richard van der Hoff c805b7e29d DeviceVerificationStatus: take a param object (#3303) 2023-04-19 14:38:23 +01:00
Michael Weimann 8f6814450b Add proper deprecation doc to room fields (#3291) 2023-04-19 08:08:37 +00:00
Michael Telatynski 37e8391cde Node 20 support (#3302) 2023-04-19 09:11:32 +01:00
Enrico Schwendig 90234402a7 stats: calculate received media and ignore not added tracks (#3301)
* stats: calculate received media by ignore not added tracks

* stats: fix lint issue

---------

Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2023-04-19 06:50:27 +00:00
renovate[bot] 8ecf603d73 Update dependency eslint-plugin-jsdoc to v41 (#3297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Kerry <kerrya@element.io>
2023-04-19 03:11:41 +00:00
renovate[bot] af243581ff Update typescript-eslint monorepo to v5.58.0 (#3296)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-18 22:10:19 +00:00
renovate[bot] 01afed9ff9 Update dawidd6/action-download-artifact digest to 246dbf4 (#3294)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-18 22:10:19 +00:00
Michael Telatynski 2687bb37fb Update tests.yml 2023-04-18 17:51:06 +01:00
Michael Telatynski 65b1a10803 Fix TimelineWindow getEvents exploding if no neigbouring timeline (#3285) 2023-04-18 12:45:58 +00:00
Michael Telatynski 9d230ef0d6 Use typedoc-plugin-versions (#3289)
* Install typedoc-plugin-versions

* Iterate

* Remove typedoc.json

* Simplify

* Simplify

* gnu vs bsd

* Iterate

* Iterate
2023-04-18 12:39:17 +00:00
Richard van der Hoff a03438f2af New CryptoApi.getDeviceVerificationStatus api (#3287)
* Element-R: implement `{get,set}TrustCrossSignedDevices`

A precursor to https://github.com/vector-im/element-web/issues/25092

* Pull out new `DeviceVerificationStatus`

Define a new base class to replace `DeviceTrustLevel`. The intention is to have
a cleaner interface which is easier to expose from the new crypto impl

* Define, and implement, a new `CryptoApi.getDeviceVerificationStatus`

This is similar to `checkDeviceTrust`, which we're deprecating, but:
 * is `async`, meaning we can implement it in Rust
 * Returns a `DeviceVerificationStatus` instead of a `DeviceTrustLevel`
 * Returns `null` rather than "not verified" if the device is unknown

* add some tests

* Export DeviceVerificationStatus as a proper class

... so that we can instantiate it in tests
2023-04-18 10:52:13 +00:00
Enrico Schwendig 8c30a3b0df Add max jitter and max packet loss (#3293)
* stats: add max jitter and max packet loss

* stats: add test for max jitter and packet loss

* stats: add build summery report tests

* stats: switch to packetsLost instead of packetsTotal
2023-04-18 10:28:59 +00:00
Richard van der Hoff c61d53eed0 Element-R: implement {get,set}TrustCrossSignedDevices (#3281)
A precursor to https://github.com/vector-im/element-web/issues/25092
2023-04-18 10:28:47 +00:00
132 changed files with 6957 additions and 2031 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ jobs:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
with:
workflow: static_analysis.yml
run_id: ${{ github.event.workflow_run.id }}
@@ -22,7 +22,7 @@ jobs:
path: docs
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v1
uses: matrix-org/netlify-pr-preview@v2
with:
path: docs
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
+20 -27
View File
@@ -11,6 +11,12 @@ jobs:
- name: 🧮 Checkout code
uses: actions/checkout@v3
- name: 🧮 Checkout gh-pages
uses: actions/checkout@v3
with:
ref: gh-pages
path: _docs
- name: 🔧 Yarn cache
uses: actions/setup-node@v3
with:
@@ -19,38 +25,25 @@ jobs:
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
- name: 📖 Generate JSDoc
run: "yarn gendoc"
- name: 📋 Copy to temp
- name: 🔨 Install symlinks
run: |
cp -a "./_docs" "$RUNNER_TEMP/"
sudo apt-get update
sudo apt-get install -y symlinks
- name: 🧮 Checkout gh-pages
uses: actions/checkout@v3
with:
ref: gh-pages
- name: 🔪 Prepare
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
- name: 📖 Generate docs
run: |
VERSION="${GITHUB_REF_NAME#v}"
[ ! -e "$VERSION" ] || rm -r $VERSION
cp -r $RUNNER_TEMP/_docs/ $VERSION
# Add the new directory to the index if it isn't there already
if ! grep -q ">Version $VERSION</a>" index.html; then
perl -i -pe 'BEGIN {$rel=shift} $_ =~ /^<\/ul>/ && print
"<li><a href=\"${rel}/index.html\">Version ${rel}</a></li>\n"' "$VERSION" index.html
fi
yarn tpv purge --yes --out _docs --stale --major 10
yarn gendoc
symlinks -rc _docs
- name: 🚀 Deploy
uses: peaceiris/actions-gh-pages@373f7f263a76c20808c831209c920827a82a2847 # v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
keep_files: true
publish_dir: .
run: |
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
git add . --all
git commit -m "Update docs"
git push
working-directory: _docs
npm:
name: Publish
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v2.3
uses: matrix-org/sonarcloud-workflow-action@v2.5
# 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:
+3 -3
View File
@@ -11,7 +11,7 @@ jobs:
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
prepare:
name: Prepare
if: github.event.workflow_run.event != 'merge_group'
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
runs-on: ubuntu-latest
outputs:
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
@@ -20,7 +20,7 @@ jobs:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@5e780fc7bbd0cac69fc73271ed86edf5dcb72d67 # v2
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
with:
workflow: tests.yaml
run_id: ${{ github.event.workflow_run.id }}
@@ -36,7 +36,7 @@ jobs:
sonarqube:
name: 🩻 SonarQube
if: github.event.workflow_run.event != 'merge_group'
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
needs: prepare
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
+7
View File
@@ -67,6 +67,13 @@ jobs:
- name: Generate Docs
run: "yarn run gendoc --treatWarningsAsErrors"
# Upload artifact duplicates symlink contents so we do this to save 75% space
- name: Flatten symlink and write _redirects
run: |
find _docs -mindepth 1 -maxdepth 1 ! -type f ! -name stable -printf '/%f/* /stable/:splat\n' > _docs/_redirects
find _docs -mindepth 1 -maxdepth 1 -type l -delete
find _docs -mindepth 1 -maxdepth 1 -type d -execdir mv {} stable \; -quit
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4
uses: peter-evans/create-pull-request@284f54f989303d2699d373481a0cfa13ad5a6666 # v5
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/upgrade-deps
+53
View File
@@ -1,3 +1,56 @@
Changes in [26.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v26.0.0-rc.1) (2023-06-01)
============================================================================================================
## 🚨 BREAKING CHANGES
* Ensure we do not add relations to the wrong timeline ([\#3427](https://github.com/matrix-org/matrix-js-sdk/pull/3427)). Fixes vector-im/element-web#25450 and vector-im/element-web#25494.
* Deprecate `QrCodeEvent`, `SasEvent` and `VerificationEvent` ([\#3386](https://github.com/matrix-org/matrix-js-sdk/pull/3386)).
## 🦖 Deprecations
* Move crypto classes into a separate namespace ([\#3385](https://github.com/matrix-org/matrix-js-sdk/pull/3385)).
## ✨ Features
* Mention deno support in the README ([\#3417](https://github.com/matrix-org/matrix-js-sdk/pull/3417)). Contributed by @sigmaSd.
* Mark room version 10 as safe ([\#3425](https://github.com/matrix-org/matrix-js-sdk/pull/3425)).
* Prioritise entirely supported flows for UIA ([\#3402](https://github.com/matrix-org/matrix-js-sdk/pull/3402)).
* Add methods to terminate idb worker ([\#3362](https://github.com/matrix-org/matrix-js-sdk/pull/3362)).
* Total summary count ([\#3351](https://github.com/matrix-org/matrix-js-sdk/pull/3351)). Contributed by @toger5.
* Audio concealment ([\#3349](https://github.com/matrix-org/matrix-js-sdk/pull/3349)). Contributed by @toger5.
## 🐛 Bug Fixes
* Correctly accumulate sync summaries. ([\#3366](https://github.com/matrix-org/matrix-js-sdk/pull/3366)). Fixes vector-im/element-web#23345.
* Keep measuring a call feed's volume after a stream replacement ([\#3361](https://github.com/matrix-org/matrix-js-sdk/pull/3361)). Fixes vector-im/element-call#1051.
* Element-R: Avoid uploading a new fallback key at every `/sync` ([\#3338](https://github.com/matrix-org/matrix-js-sdk/pull/3338)). Fixes vector-im/element-web#25215.
* Accumulate receipts for the main thread and unthreaded separately ([\#3339](https://github.com/matrix-org/matrix-js-sdk/pull/3339)). Fixes vector-im/element-web#24629.
* Remove spec non-compliant extended glob format ([\#3423](https://github.com/matrix-org/matrix-js-sdk/pull/3423)). Fixes vector-im/element-web#25474.
* Fix bug where original event was inserted into timeline instead of the edit event ([\#3398](https://github.com/matrix-org/matrix-js-sdk/pull/3398)). Contributed by @andybalaam.
* Only add a local receipt if it's after an existing receipt ([\#3399](https://github.com/matrix-org/matrix-js-sdk/pull/3399)). Contributed by @andybalaam.
* Attempt a potential workaround for stuck notifs ([\#3384](https://github.com/matrix-org/matrix-js-sdk/pull/3384)). Fixes vector-im/element-web#25406. Contributed by @andybalaam.
* Fix verification bug with `pendingEventOrdering: "chronological"` ([\#3382](https://github.com/matrix-org/matrix-js-sdk/pull/3382)).
Changes in [25.1.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.1) (2023-05-16)
==================================================================================================
## 🐛 Bug Fixes
* Rebuild to fix packaging glitch in 25.1.0. Fixes #3363
Changes in [25.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.1.0) (2023-05-09)
==================================================================================================
## 🦖 Deprecations
* Deprecate MatrixClient::resolveRoomAlias ([\#3316](https://github.com/matrix-org/matrix-js-sdk/pull/3316)).
## ✨ Features
* add client method to remove pusher ([\#3324](https://github.com/matrix-org/matrix-js-sdk/pull/3324)). Contributed by @kerryarchibald.
* Implement MSC 3981 ([\#3248](https://github.com/matrix-org/matrix-js-sdk/pull/3248)). Fixes vector-im/element-web#25021. Contributed by @justjanne.
* Added `Room.getLastLiveEvent` and `Room.getLastThread`. Deprecated `Room.lastThread` in favour of `Room.getLastThread`. ([\#3321](https://github.com/matrix-org/matrix-js-sdk/pull/3321)).
* Element-R: wire up device lists ([\#3272](https://github.com/matrix-org/matrix-js-sdk/pull/3272)). Contributed by @florianduros.
* Node 20 support ([\#3302](https://github.com/matrix-org/matrix-js-sdk/pull/3302)).
## 🐛 Bug Fixes
* Fix racing between one-time-keys processing and sync ([\#3327](https://github.com/matrix-org/matrix-js-sdk/pull/3327)). Fixes vector-im/element-web#25214. Contributed by @florianduros.
* Fix lack of media when a user reconnects ([\#3318](https://github.com/matrix-org/matrix-js-sdk/pull/3318)).
* Fix TimelineWindow getEvents exploding if no neigbouring timeline ([\#3285](https://github.com/matrix-org/matrix-js-sdk/pull/3285)). Fixes vector-im/element-web#25104.
Changes in [25.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v25.0.0) (2023-04-25)
==================================================================================================
+2
View File
@@ -55,6 +55,8 @@ client.publicRooms(function (err, data) {
See below for how to include libolm to enable end-to-end-encryption. Please check
[the Node.js terminal app](examples/node) for a more complex example.
You can also use the sdk with [Deno](https://deno.land/) (`import npm:matrix-js-sdk`) but its not officialy supported.
To start the client:
```javascript
+12 -9
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "25.0.0",
"version": "26.0.0-rc.1",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
@@ -55,7 +55,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.6",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.9",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
@@ -101,16 +101,16 @@
"debug": "^4.3.4",
"docdash": "^2.0.0",
"domexception": "^4.0.0",
"eslint": "8.37.0",
"eslint": "8.40.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^27.1.6",
"eslint-plugin-jsdoc": "^40.0.0",
"eslint-plugin-jsdoc": "^44.0.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^46.0.0",
"eslint-plugin-unicorn": "^47.0.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"fetch-mock-jest": "^1.5.1",
@@ -119,14 +119,17 @@
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.7",
"rimraf": "^4.0.0",
"prettier": "2.8.8",
"rimraf": "^5.0.0",
"terser": "^5.5.1",
"ts-node": "^10.9.1",
"tsify": "^5.0.2",
"typedoc": "^0.23.20",
"typedoc": "^0.24.0",
"typedoc-plugin-coverage": "^2.1.0",
"typedoc-plugin-mdn-links": "^3.0.3",
"typedoc-plugin-missing-exports": "^1.0.0",
"typedoc-plugin-missing-exports": "^2.0.0",
"typedoc-plugin-versions": "^0.2.3",
"typedoc-plugin-versions-cli": "^0.1.12",
"typescript": "^5.0.0"
},
"@casualbot/jest-sonar-reporter": {
+1 -1
View File
@@ -15,4 +15,4 @@ for line in sys.stdin:
break
found_first_header = True
elif not re.match(r"^=+$", line) and len(line) > 0:
print line
print(line)
+117
View File
@@ -0,0 +1,117 @@
/*
Copyright 2023 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 fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { CRYPTO_BACKENDS, InitCrypto } from "../test-utils/test-utils";
import { createClient, MatrixClient, UIAuthCallback } from "../../src";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
const TEST_USER_ID = "@alice:localhost";
const TEST_DEVICE_ID = "xzcvb";
/**
* Integration tests for cross-signing functionality.
*
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
* to provide the most effective integration tests possible.
*/
describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: string, initCrypto: InitCrypto) => {
let aliceClient: MatrixClient;
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
fetchMock.config.warnOnFallback = false;
const homeserverUrl = "https://alice-server.com";
aliceClient = createClient({
baseUrl: homeserverUrl,
userId: TEST_USER_ID,
accessToken: "akjgkrgjs",
deviceId: TEST_DEVICE_ID,
});
await initCrypto(aliceClient);
});
afterEach(async () => {
await aliceClient.stopClient();
fetchMock.mockReset();
});
describe("bootstrapCrossSigning (before initialsync completes)", () => {
it("publishes keys if none were yet published", async () => {
// have account_data requests return an empty object
fetchMock.get("express:/_matrix/client/r0/user/:userId/account_data/:type", {});
// we expect a request to upload signatures for our device ...
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
// ... and one to upload the cross-signing keys (with UIA)
fetchMock.post(
// legacy crypto uses /unstable/; /v3/ is correct
{
url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"),
name: "upload-keys",
},
{},
);
// provide a UIA callback, so that the cross-signing keys are uploaded
const authDict = { type: "test" };
const uiaCallback: UIAuthCallback<void> = async (makeRequest) => {
await makeRequest(authDict);
};
// now bootstrap cross signing, and check it resolves successfully
await aliceClient.bootstrapCrossSigning({
authUploadDeviceSigningKeys: uiaCallback,
});
// check the cross-signing keys upload
expect(fetchMock.called("upload-keys")).toBeTruthy();
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
const keysBody = JSON.parse(keysOpts!.body as string);
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
// there should be a key of each type
// master key is signed by the device
expect(keysBody).toHaveProperty(`master_key.signatures.[${TEST_USER_ID}].[ed25519:${TEST_DEVICE_ID}]`);
const masterKeyId = Object.keys(keysBody.master_key.keys)[0];
// ssk and usk are signed by the master key
expect(keysBody).toHaveProperty(`self_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
expect(keysBody).toHaveProperty(`user_signing_key.signatures.[${TEST_USER_ID}].[${masterKeyId}]`);
const sskId = Object.keys(keysBody.self_signing_key.keys)[0];
// check the publish call
expect(fetchMock.called("upload-sigs")).toBeTruthy();
const [, sigsOpts] = fetchMock.lastCall("upload-sigs")!;
const body = JSON.parse(sigsOpts!.body as string);
// there should be a signature for our device, by our self-signing key.
expect(body).toHaveProperty(
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[${sskId}]`,
);
});
});
});
+177 -1
View File
@@ -48,6 +48,8 @@ import { DeviceInfo } from "../../src/crypto/deviceinfo";
import { E2EKeyReceiver, IE2EKeyReceiver } from "../test-utils/E2EKeyReceiver";
import { ISyncResponder, SyncResponder } from "../test-utils/SyncResponder";
import { escapeRegExp } from "../../src/utils";
import { downloadDeviceToJsDevice } from "../../src/rust-crypto/device-converter";
import { flushPromises } from "../test-utils/flushPromises";
const ROOM_ID = "!room:id";
@@ -1109,7 +1111,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
} catch (e) {
expect((e as any).name).toEqual("UnknownDeviceError");
expect([...(e as any).devices.keys()]).toEqual([aliceClient.getUserId()!]);
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID"));
expect((e as any).devices.get(aliceClient.getUserId()!).has("DEVICE_ID")).toBeTruthy();
}
// mark the device as known, and resend.
@@ -1997,4 +1999,178 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(res.fallbackKeysCount).toBeGreaterThan(0);
});
});
describe("getUserDeviceInfo", () => {
afterEach(() => {
jest.useRealTimers();
});
// From https://spec.matrix.org/v1.6/client-server-api/#post_matrixclientv3keysquery
// Using extracted response from matrix.org, it needs to have real keys etc to pass old crypto verification
const queryResponseBody = {
device_keys: {
"@testing_florian1:matrix.org": {
EBMMPAFOPU: {
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
device_id: "EBMMPAFOPU",
keys: {
"curve25519:EBMMPAFOPU": "HyhQD4mXwNViqns0noABW9NxHbCAOkriQ4QKGGndk3w",
"ed25519:EBMMPAFOPU": "xSQaxrFOTXH+7Zjo+iwb445hlNPFjnx1O3KaV3Am55k",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:EBMMPAFOPU":
"XFJVq9HmO5lfJN7l6muaUt887aUHg0/poR3p9XHGXBrLUqzfG7Qllq7jjtUjtcTc5CMD7/mpsXfuC2eV+X1uAw",
},
},
user_id: "@testing_florian1:matrix.org",
unsigned: {
device_display_name: "display name",
},
},
},
},
failures: {},
master_keys: {
"@testing_florian1:matrix.org": {
user_id: "@testing_florian1:matrix.org",
usage: ["master"],
keys: {
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
"O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:UKAQMJSJZC":
"q4GuzzuhZfTpwrlqnJ9+AEUtEfEQ0um1PO3puwp/+vidzFicw0xEPjedpJoASYQIJ8XJAAWX8Q235EKeCzEXCA",
},
},
},
},
self_signing_keys: {
"@testing_florian1:matrix.org": {
user_id: "@testing_florian1:matrix.org",
usage: ["self_signing"],
keys: {
"ed25519:YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo":
"YYWIHBCuKGEy9CXiVrfBVR0N1I60JtiJTNCWjiLAFzo",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
"yckmxgQ3JA5bb205/RunJipnpZ37ycGNf4OFzDwAad++chd71aGHqAMQ1f6D2GVfl8XdHmiRaohZf4mGnDL0AA",
},
},
},
},
user_signing_keys: {
"@testing_florian1:matrix.org": {
user_id: "@testing_florian1:matrix.org",
usage: ["user_signing"],
keys: {
"ed25519:Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs":
"Maa77okgZxnABGqaiChEUnV4rVsAI61WXWeL5TSEUhs",
},
signatures: {
"@testing_florian1:matrix.org": {
"ed25519:O5s5RoLaz93Bjf/pg55oJeCVeYYoruQhqEd0Mda6lq0":
"WxNNXb13yCrBwXUQzdDWDvWSQ/qWCfwpvssOudlAgbtMzRESMbCTDkeA8sS1awaAtUmu7FrPtDb5LYfK/EE2CQ",
},
},
},
},
};
function awaitKeyQueryRequest(): Promise<Record<string, []>> {
return new Promise((resolve) => {
const listener = (url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
// Resolve with request payload
resolve(content.device_keys);
// Return response of `/keys/query`
return queryResponseBody;
};
for (const path of ["/_matrix/client/r0/keys/query", "/_matrix/client/v3/keys/query"]) {
fetchMock.post(new URL(path, aliceClient.getHomeserverUrl()).toString(), listener);
}
});
}
it("Download uncached keys for known user", async () => {
const queryPromise = awaitKeyQueryRequest();
const user = "@testing_florian1:matrix.org";
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
// Wait for `/keys/query` to be called
const deviceKeysPayload = await queryPromise;
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
expect(devicesInfo.get(user)?.size).toBe(1);
// Convert the expected device to IDevice and check
expect(devicesInfo.get(user)?.get("EBMMPAFOPU")).toStrictEqual(
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]?.EBMMPAFOPU),
);
});
it("Download uncached keys for unknown user", async () => {
const queryPromise = awaitKeyQueryRequest();
const user = "@bob:xyz";
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user], true);
// Wait for `/keys/query` to be called
const deviceKeysPayload = await queryPromise;
expect(deviceKeysPayload).toStrictEqual({ [user]: [] });
// The old crypto has an empty map for `@bob:xyz`
// The new crypto does not have the `@bob:xyz` entry in `devicesInfo`
expect(devicesInfo.get(user)?.size).toBeFalsy();
});
it("Get devices from tacked users", async () => {
jest.useFakeTimers();
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const queryPromise = awaitKeyQueryRequest();
const user = "@testing_florian1:matrix.org";
// `user` will be added to the room
syncResponder.sendOrQueueSyncResponse(getSyncResponse([user, "@bob:xyz"]));
// Advance local date to 2 minutes
// The old crypto only runs the upload every 60 seconds
jest.setSystemTime(Date.now() + 2 * 60 * 1000);
await syncPromise(aliceClient);
// Old crypto: for alice: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
jest.runAllTimers();
// Old crypto: for alice: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
await flushPromises();
// Wait for alice to query `user` keys
await queryPromise;
// Old crypto: for `user`: run over the `sleep(5)` in `doQueuedQueries` of `DeviceList`
jest.runAllTimers();
// Old crypto: for `user`: run the `processQueryResponseForUser` in `doQueuedQueries` of `DeviceList`
// It will add `@testing_florian1:matrix.org` devices to the DeviceList
await flushPromises();
const devicesInfo = await aliceClient.getCrypto()!.getUserDeviceInfo([user]);
// We should only have the `user` in it
expect(devicesInfo.size).toBe(1);
// We are expecting only the EBMMPAFOPU device
expect(devicesInfo.get(user)!.size).toBe(1);
expect(devicesInfo.get(user)!.get("EBMMPAFOPU")).toEqual(
downloadDeviceToJsDevice(queryResponseBody.device_keys[user]["EBMMPAFOPU"]),
);
});
});
});
+121 -9
View File
@@ -21,11 +21,13 @@ import {
EventStatus,
EventTimeline,
EventTimelineSet,
EventType,
Filter,
IEvent,
MatrixClient,
MatrixEvent,
PendingEventOrdering,
RelationType,
Room,
} from "../../src/matrix";
import { logger } from "../../src/logger";
@@ -33,6 +35,7 @@ import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/util
import { TestClient } from "../TestClient";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
import { emitPromise } from "../test-utils/test-utils";
import { Feature, ServerSupport } from "../../src/feature";
const userId = "@alice:localhost";
const userName = "Alice";
@@ -1139,7 +1142,7 @@ describe("MatrixClient event timelines", function () {
const prom = emitPromise(room, ThreadEvent.Update);
// Assume we're seeing the reply while loading backlog
room.addLiveEvents([THREAD_REPLY2]);
await room.addLiveEvents([THREAD_REPLY2]);
httpBackend
.when(
"GET",
@@ -1153,7 +1156,118 @@ describe("MatrixClient event timelines", function () {
});
await flushHttp(prom);
// but while loading the metadata, a new reply has arrived
room.addLiveEvents([THREAD_REPLY3]);
await room.addLiveEvents([THREAD_REPLY3]);
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
// then the events should still be all in the right order
expect(thread.events.map((it) => it.getId())).toEqual([
THREAD_ROOT.event_id,
THREAD_REPLY.event_id,
THREAD_REPLY2.getId(),
THREAD_REPLY3.getId(),
]);
});
it("should ensure thread events don't get reordered with recursive relations", async () => {
// Test data for a second reply to the first thread
const THREAD_REPLY2 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 2",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY2.localTimestamp += 1000;
const THREAD_ROOT_REACTION = utils.mkEvent({
event: true,
type: EventType.Reaction,
user: userId,
room: roomId,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: THREAD_ROOT.event_id!,
key: Math.random().toString(),
},
},
});
THREAD_ROOT_REACTION.localTimestamp += 2000;
// Test data for a second reply to the first thread
const THREAD_REPLY3 = utils.mkEvent({
room: roomId,
user: userId,
type: "m.room.message",
content: {
"body": "thread reply 3",
"msgtype": "m.text",
"m.relates_to": {
// We can't use the const here because we change server support mode for test
rel_type: "io.element.thread",
event_id: THREAD_ROOT.event_id,
},
},
event: true,
});
THREAD_REPLY3.localTimestamp += 3000;
// Test data for the first thread, with the second reply
const THREAD_ROOT_UPDATED = {
...THREAD_ROOT,
unsigned: {
...THREAD_ROOT.unsigned,
"m.relations": {
...THREAD_ROOT.unsigned!["m.relations"],
"io.element.thread": {
...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"],
count: 3,
latest_event: THREAD_REPLY3.event,
},
},
},
};
// @ts-ignore
client.clientOpts.threadSupport = true;
client.canSupport.set(Feature.RelationsRecursion, ServerSupport.Stable);
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
const prom = emitPromise(room, ThreadEvent.Update);
// Assume we're seeing the reply while loading backlog
await room.addLiveEvents([THREAD_REPLY2]);
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({
dir: Direction.Backward,
limit: 3,
recurse: true,
}),
)
.respond(200, {
chunk: [THREAD_REPLY3.event, THREAD_ROOT_REACTION, THREAD_REPLY2.event, THREAD_REPLY],
});
await flushHttp(prom);
// but while loading the metadata, a new reply has arrived
await room.addLiveEvents([THREAD_REPLY3]);
const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!;
// then the events should still be all in the right order
expect(thread.events.map((it) => it.getId())).toEqual([
@@ -1458,7 +1572,7 @@ describe("MatrixClient event timelines", function () {
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD_ROOT_UPDATED);
respondToEvent(THREAD2_ROOT);
room.addLiveEvents([THREAD_REPLY2]);
await room.addLiveEvents([THREAD_REPLY2]);
await httpBackend.flushAllExpected();
await prom;
expect(thread.length).toBe(2);
@@ -1823,11 +1937,6 @@ describe("MatrixClient event timelines", function () {
.respond(200, function () {
return THREAD_ROOT;
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function () {
return THREAD_ROOT;
});
httpBackend
.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function () {
@@ -1847,7 +1956,10 @@ describe("MatrixClient event timelines", function () {
encodeURIComponent(THREAD_ROOT.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({ dir: Direction.Backward, from: "start_token" }),
buildRelationPaginationQuery({
dir: Direction.Backward,
from: "start_token",
}),
)
.respond(200, function () {
return {
+38
View File
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import HttpBackend from "matrix-mock-request";
import { Mocked } from "jest-mock";
import * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client";
@@ -24,6 +25,7 @@ import { THREAD_RELATION_TYPE } from "../../src/models/thread";
import { IFilterDefinition } from "../../src/filter";
import { ISearchResults } from "../../src/@types/search";
import { IStore } from "../../src/store";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
describe("MatrixClient", function () {
const userId = "@alice:localhost";
@@ -1412,6 +1414,42 @@ describe("MatrixClient", function () {
await client!.uploadKeys();
});
});
describe("getCryptoTrustCrossSignedDevices", () => {
it("should throw if e2e is disabled", () => {
expect(() => client!.getCryptoTrustCrossSignedDevices()).toThrow("End-to-end encryption disabled");
});
it("should proxy to the crypto backend", async () => {
const mockBackend = {
getTrustCrossSignedDevices: jest.fn().mockReturnValue(true),
} as unknown as Mocked<CryptoBackend>;
client!["cryptoBackend"] = mockBackend;
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(true);
mockBackend.getTrustCrossSignedDevices.mockReturnValue(false);
expect(client!.getCryptoTrustCrossSignedDevices()).toBe(false);
});
});
describe("setCryptoTrustCrossSignedDevices", () => {
it("should throw if e2e is disabled", () => {
expect(() => client!.setCryptoTrustCrossSignedDevices(false)).toThrow("End-to-end encryption disabled");
});
it("should proxy to the crypto backend", async () => {
const mockBackend = {
setTrustCrossSignedDevices: jest.fn(),
} as unknown as Mocked<CryptoBackend>;
client!["cryptoBackend"] = mockBackend;
client!.setCryptoTrustCrossSignedDevices(true);
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(true);
client!.setCryptoTrustCrossSignedDevices(false);
expect(mockBackend.setTrustCrossSignedDevices).toHaveBeenLastCalledWith(false);
});
});
});
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
+124
View File
@@ -36,11 +36,15 @@ import {
NotificationCountType,
IEphemeral,
Room,
IndexedDBStore,
RelationType,
} from "../../src";
import { ReceiptType } from "../../src/@types/read_receipts";
import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync";
import * as utils from "../test-utils/test-utils";
import { TestClient } from "../TestClient";
import { emitPromise, mkEvent, mkMessage } from "../test-utils/test-utils";
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
describe("MatrixClient syncing", () => {
const selfUserId = "@alice:localhost";
@@ -1867,4 +1871,124 @@ describe("MatrixClient syncing (IndexedDB version)", () => {
idbClient.stopClient();
idbHttpBackend.stop();
});
it("should query server for which thread a 2nd order relation belongs to and stash in sync accumulator", async () => {
const roomId = "!room:example.org";
async function startClient(client: MatrixClient): Promise<void> {
await Promise.all([
idbClient.startClient({
// Without this all events just go into the main timeline
threadSupport: true,
}),
idbHttpBackend.flushAllExpected(),
emitPromise(idbClient, ClientEvent.Room),
]);
}
function assertEventsExpected(client: MatrixClient): void {
const room = client.getRoom(roomId);
const mainTimelineEvents = room!.getLiveTimeline().getEvents();
expect(mainTimelineEvents).toHaveLength(1);
expect(mainTimelineEvents[0].getContent().body).toEqual("Test");
const thread = room!.getThread("$someThreadId")!;
expect(thread.replayEvents).toHaveLength(1);
expect(thread.replayEvents![0].getRelation()!.key).toEqual("🪿");
}
let idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
store: new IndexedDBStore({
indexedDB: global.indexedDB,
dbName: "test",
}),
});
let idbHttpBackend = idbTestClient.httpBackend;
let idbClient = idbTestClient.client;
await idbClient.store.startup();
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
const syncRoomSection = {
join: {
[roomId]: {
timeline: {
prev_batch: "foo",
events: [
mkMessage({
room: roomId,
user: selfUserId,
msg: "Test",
}),
mkEvent({
room: roomId,
user: selfUserId,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: "$someUnknownEvent",
key: "🪿",
},
},
type: "m.reaction",
}),
],
},
},
},
};
idbHttpBackend.when("GET", "/sync").respond(200, {
...syncData,
rooms: syncRoomSection,
});
idbHttpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/event/%24someUnknownEvent`).respond(
200,
mkEvent({
room: roomId,
user: selfUserId,
content: {
"body": "Thread response",
"m.relates_to": {
rel_type: THREAD_RELATION_TYPE.name,
event_id: "$someThreadId",
},
},
type: "m.room.message",
}),
);
await startClient(idbClient);
assertEventsExpected(idbClient);
idbHttpBackend.verifyNoOutstandingExpectation();
// Force sync accumulator to persist, reset client, assert it doesn't re-fetch event on next start-up
await idbClient.store.save(true);
await idbClient.stopClient();
await idbClient.store.destroy();
await idbHttpBackend.stop();
idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, {
store: new IndexedDBStore({
indexedDB: global.indexedDB,
dbName: "test",
}),
});
idbHttpBackend = idbTestClient.httpBackend;
idbClient = idbTestClient.client;
await idbClient.store.startup();
idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] });
idbHttpBackend.when("GET", "/pushrules/").respond(200, {});
idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
idbHttpBackend.when("GET", "/sync").respond(200, syncData);
await startClient(idbClient);
assertEventsExpected(idbClient);
idbHttpBackend.verifyNoOutstandingExpectation();
await idbClient.stopClient();
await idbHttpBackend.stop();
});
});
@@ -89,7 +89,7 @@ describe("MatrixClient syncing", () => {
const thread = mkThread({ room, client: client!, authorId: selfUserId, participantUserIds: [selfUserId] });
const threadReply = thread.events.at(-1)!;
room.addLiveEvents([thread.rootEvent]);
await room.addLiveEvents([thread.rootEvent]);
// Initialize read receipt datastructure before testing the reaction
room.addReceiptToStructure(thread.rootEvent.getId()!, ReceiptType.Read, selfUserId, { ts: 1 }, false);
+70 -82
View File
@@ -42,6 +42,7 @@ import { SyncApiOptions, SyncState } from "../../src/sync";
import { IStoredClientOpts } from "../../src/client";
import { logger } from "../../src/logger";
import { emitPromise } from "../test-utils/test-utils";
import { defer } from "../../src/utils";
describe("SlidingSyncSdk", () => {
let client: MatrixClient | undefined;
@@ -301,67 +302,57 @@ describe("SlidingSyncSdk", () => {
},
};
it("can be created with required_state and timeline", () => {
it("can be created with required_state and timeline", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomA);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.name).toEqual(data[roomA].name);
expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.name).toEqual(data[roomA].name);
expect(gotRoom!.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline);
});
it("can be created with timeline only", () => {
it("can be created with timeline only", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomB);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.name).toEqual(data[roomB].name);
expect(gotRoom.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.name).toEqual(data[roomB].name);
expect(gotRoom!.getMyMembership()).toEqual("join");
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline);
});
it("can be created with a highlight_count", () => {
it("can be created with a highlight_count", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomC);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
expect(gotRoom).toBeTruthy();
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(
data[roomC].highlight_count,
);
});
it("can be created with a notification_count", () => {
it("can be created with a notification_count", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomD);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
expect(gotRoom).toBeTruthy();
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(
data[roomD].notification_count,
);
});
it("can be created with an invited/joined_count", () => {
it("can be created with an invited/joined_count", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomG);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
expect(gotRoom.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.getInvitedMemberCount()).toEqual(data[roomG].invited_count);
expect(gotRoom!.getJoinedMemberCount()).toEqual(data[roomG].joined_count);
});
it("can be created with live events", () => {
let seenLiveEvent = false;
it("can be created with live events", async () => {
const seenLiveEventDeferred = defer<boolean>();
const listener = (
ev: MatrixEvent,
room?: Room,
@@ -371,43 +362,37 @@ describe("SlidingSyncSdk", () => {
) => {
if (timelineData?.liveEvent) {
assertTimelineEvents([ev], data[roomH].timeline.slice(-1));
seenLiveEvent = true;
seenLiveEventDeferred.resolve(true);
}
};
client!.on(RoomEvent.Timeline, listener);
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomH, data[roomH]);
await emitPromise(client!, ClientEvent.Room);
client!.off(RoomEvent.Timeline, listener);
const gotRoom = client!.getRoom(roomH);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.name).toEqual(data[roomH].name);
expect(gotRoom.getMyMembership()).toEqual("join");
expect(gotRoom).toBeTruthy();
expect(gotRoom!.name).toEqual(data[roomH].name);
expect(gotRoom!.getMyMembership()).toEqual("join");
// check the entire timeline is correct
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), data[roomH].timeline);
expect(seenLiveEvent).toBe(true);
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), data[roomH].timeline);
await expect(seenLiveEventDeferred.promise).resolves.toBeTruthy();
});
it("can be created with invite_state", () => {
it("can be created with invite_state", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomE);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.getMyMembership()).toEqual("invite");
expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.getMyMembership()).toEqual("invite");
expect(gotRoom!.currentState.getJoinRule()).toEqual(JoinRule.Invite);
});
it("uses the 'name' field to caluclate the room name", () => {
it("uses the 'name' field to caluclate the room name", async () => {
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]);
await emitPromise(client!, ClientEvent.Room);
const gotRoom = client!.getRoom(roomF);
expect(gotRoom).toBeDefined();
if (gotRoom == null) {
return;
}
expect(gotRoom.name).toEqual(data[roomF].name);
expect(gotRoom).toBeTruthy();
expect(gotRoom!.name).toEqual(data[roomF].name);
});
describe("updating", () => {
@@ -419,33 +404,33 @@ describe("SlidingSyncSdk", () => {
name: data[roomA].name,
});
const gotRoom = client!.getRoom(roomA);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
const newTimeline = data[roomA].timeline;
newTimeline.push(newEvent);
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline);
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-3), newTimeline);
});
it("can update with a new required_state event", async () => {
let gotRoom = client!.getRoom(roomB);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Invite); // default
mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, {
required_state: [mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, "")],
timeline: [],
name: data[roomB].name,
});
gotRoom = client!.getRoom(roomB);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted);
expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Restricted);
});
it("can update with a new highlight_count", async () => {
@@ -456,11 +441,11 @@ describe("SlidingSyncSdk", () => {
highlight_count: 1,
});
const gotRoom = client!.getRoom(roomC);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1);
});
it("can update with a new notification_count", async () => {
@@ -471,11 +456,11 @@ describe("SlidingSyncSdk", () => {
notification_count: 1,
});
const gotRoom = client!.getRoom(roomD);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1);
});
it("can update with a new joined_count", () => {
@@ -486,11 +471,11 @@ describe("SlidingSyncSdk", () => {
joined_count: 1,
});
const gotRoom = client!.getRoom(roomG);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
expect(gotRoom.getJoinedMemberCount()).toEqual(1);
expect(gotRoom!.getJoinedMemberCount()).toEqual(1);
});
// Regression test for a bug which caused the timeline entries to be out-of-order
@@ -512,7 +497,7 @@ describe("SlidingSyncSdk", () => {
initial: true, // e.g requested via room subscription
});
const gotRoom = client!.getRoom(roomA);
expect(gotRoom).toBeDefined();
expect(gotRoom).toBeTruthy();
if (gotRoom == null) {
return;
}
@@ -530,7 +515,7 @@ describe("SlidingSyncSdk", () => {
);
// we expect the timeline now to be oldTimeline (so the old events are in fact old)
assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), oldTimeline);
assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), oldTimeline);
});
});
});
@@ -626,9 +611,9 @@ describe("SlidingSyncSdk", () => {
await httpBackend!.flush("/profile", 1, 1000);
await emitPromise(client!, RoomMemberEvent.Name);
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
const inviteeMember = room.getMember(invitee)!;
expect(inviteeMember).toBeDefined();
expect(inviteeMember).toBeTruthy();
expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url);
expect(inviteeMember.name).toEqual(inviteeProfile.displayname);
});
@@ -723,7 +708,7 @@ describe("SlidingSyncSdk", () => {
],
});
globalData = client!.getAccountData(globalType)!;
expect(globalData).toBeDefined();
expect(globalData).toBeTruthy();
expect(globalData.getContent()).toEqual(globalContent);
});
@@ -744,6 +729,7 @@ describe("SlidingSyncSdk", () => {
foo: "bar",
};
const roomType = "test";
await emitPromise(client!, ClientEvent.Room);
ext.onResponse({
rooms: {
[roomId]: [
@@ -755,9 +741,9 @@ describe("SlidingSyncSdk", () => {
},
});
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
const event = room.getAccountData(roomType)!;
expect(event).toBeDefined();
expect(event).toBeTruthy();
expect(event.getContent()).toEqual(roomContent);
});
@@ -943,8 +929,9 @@ describe("SlidingSyncSdk", () => {
],
initial: true,
});
await emitPromise(client!, ClientEvent.Room);
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
expect(room.getMember(selfUserId)?.typing).toEqual(false);
ext.onResponse({
rooms: {
@@ -984,7 +971,7 @@ describe("SlidingSyncSdk", () => {
initial: true,
});
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
expect(room.getMember(selfUserId)?.typing).toEqual(false);
ext.onResponse({
rooms: {
@@ -1077,12 +1064,13 @@ describe("SlidingSyncSdk", () => {
],
initial: true,
});
await emitPromise(client!, ClientEvent.Room);
const room = client!.getRoom(roomId)!;
expect(room).toBeDefined();
expect(room).toBeTruthy();
expect(room.getReadReceiptForUserId(alice, true)).toBeNull();
ext.onResponse(generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567));
const receipt = room.getReadReceiptForUserId(alice);
expect(receipt).toBeDefined();
expect(receipt).toBeTruthy();
expect(receipt?.eventId).toEqual(lastEvent.event_id);
expect(receipt?.data.ts).toEqual(1234567);
expect(receipt?.data.thread_id).toBeFalsy();
+1 -1
View File
@@ -1698,7 +1698,7 @@ describe("SlidingSync", () => {
});
function timeout(delayMs: number, reason: string): { promise: Promise<never>; cancel: () => void } {
let timeoutId: NodeJS.Timeout;
let timeoutId: ReturnType<typeof setTimeout>;
return {
promise: new Promise((resolve, reject) => {
timeoutId = setTimeout(() => {
+1 -1
View File
@@ -23,5 +23,5 @@ try {
global.Olm = require("@matrix-org/olm");
logger.log("loaded libolm");
} catch (e) {
logger.warn("unable to run crypto tests: libolm not available");
logger.warn("unable to run crypto tests: libolm not available", e);
}
+72 -1
View File
@@ -6,7 +6,7 @@ import "../olm-loader";
import { logger } from "../../src/logger";
import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src";
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType, RelationType } from "../../src";
import { SyncState } from "../../src/sync";
import { eventMapperFor } from "../../src/event-mapper";
@@ -258,6 +258,9 @@ export interface IMessageOpts {
* @param opts.user - The user ID for the event.
* @param opts.msg - Optional. The content.body for the event.
* @param opts.event - True to make a MatrixEvent.
* @param opts.relatesTo - An IEventRelation relating this to another event.
* @param opts.ts - The timestamp of the event.
* @param opts.event - True to make a MatrixEvent.
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
* @returns The event
*/
@@ -297,6 +300,7 @@ interface IReplyMessageOpts extends IMessageOpts {
* @param opts.room - The room ID for the event.
* @param opts.user - The user ID for the event.
* @param opts.msg - Optional. The content.body for the event.
* @param opts.ts - The timestamp of the event.
* @param opts.replyToMessage - The replied message
* @param opts.event - True to make a MatrixEvent.
* @param client - If passed along with opts.event=true will be used to set up re-emitters.
@@ -330,6 +334,73 @@ export function mkReplyMessage(
return mkEvent(eventOpts, client);
}
/**
* Create a reaction event.
*
* @param target - the event we are reacting to.
* @param client - the MatrixClient
* @param userId - the userId of the sender
* @param roomId - the id of the room we are in
* @param ts - The timestamp of the event.
* @returns The event
*/
export function mkReaction(
target: MatrixEvent,
client: MatrixClient,
userId: string,
roomId: string,
ts?: number,
): MatrixEvent {
return mkEvent(
{
event: true,
type: EventType.Reaction,
user: userId,
room: roomId,
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: target.getId()!,
key: Math.random().toString(),
},
},
ts,
},
client,
);
}
export function mkEdit(
target: MatrixEvent,
client: MatrixClient,
userId: string,
roomId: string,
msg?: string,
ts?: number,
) {
msg = msg ?? `Edit of ${target.getId()}`;
return mkEvent(
{
event: true,
type: EventType.RoomMessage,
user: userId,
room: roomId,
content: {
"body": `* ${msg}`,
"m.new_content": {
body: msg,
},
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: target.getId()!,
},
},
ts,
},
client,
);
}
/**
* A mock implementation of webstorage
*/
+21 -1
View File
@@ -115,6 +115,26 @@ type MakeThreadProps = {
ts?: number;
};
type MakeThreadResult = {
/**
* Thread model
*/
thread: Thread;
/**
* Thread root event
*/
rootEvent: MatrixEvent;
/**
* Events added to the thread
*/
events: MatrixEvent[];
};
/**
* Starts a new thread in a room by creating a message as thread root.
* Also creates a Thread model and adds it to the room.
* Does not insert the messages into a timeline.
*/
export const mkThread = ({
room,
client,
@@ -122,7 +142,7 @@ export const mkThread = ({
participantUserIds,
length = 2,
ts = 1,
}: MakeThreadProps): { thread: Thread; rootEvent: MatrixEvent; events: MatrixEvent[] } => {
}: MakeThreadProps): MakeThreadResult => {
const { rootEvent, events } = makeThreadEvents({
roomId: room.roomId,
authorId,
+2
View File
@@ -239,6 +239,8 @@ export class MockRTCPeerConnection {
public triggerIncomingDataChannel(): void {
this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent);
}
public restartIce(): void {}
}
export class MockRTCRtpSender {
+5 -30
View File
@@ -381,12 +381,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
@@ -617,12 +612,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await secondAliceClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(secondAliceClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
@@ -725,12 +715,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
@@ -805,12 +790,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
@@ -897,12 +877,7 @@ describe("Crypto", function () {
event.senderCurve25519Key = null;
// @ts-ignore private properties
event.claimedEd25519Key = null;
try {
await bobClient.crypto!.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy();
}),
);
+3 -2
View File
@@ -24,10 +24,11 @@ import * as olmlib from "../../../src/crypto/olmlib";
import { MatrixError } from "../../../src/http-api";
import { logger } from "../../../src/logger";
import { ICrossSigningKey, ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
import { CryptoEvent, IBootstrapCrossSigningOpts } from "../../../src/crypto";
import { CryptoEvent } from "../../../src/crypto";
import { IDevice } from "../../../src/crypto/deviceinfo";
import { TestClient } from "../../TestClient";
import { resetCrossSigningKeys } from "./crypto-utils";
import { BootstrapCrossSigningOpts } from "../../../src/crypto-api";
const PUSH_RULES_RESPONSE: Response = {
method: "GET",
@@ -146,7 +147,7 @@ describe("Cross Signing", function () {
alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({} as T);
const authUploadDeviceSigningKeys: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
await func({});
};
+58
View File
@@ -0,0 +1,58 @@
/*
Copyright 2023 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 { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { DeviceVerification } from "../../../src";
import { deviceInfoToDevice } from "../../../src/crypto/device-converter";
describe("device-converter", () => {
const userId = "@alice:example.com";
const deviceId = "xcvf";
// All parameters for DeviceInfo initialization
const keys = {
[`ed25519:${deviceId}`]: "key1",
[`curve25519:${deviceId}`]: "key2",
};
const algorithms = ["algo1", "algo2"];
const verified = DeviceVerification.Verified;
const signatures = { [userId]: { [deviceId]: "sign1" } };
const displayName = "display name";
const unsigned = {
device_display_name: displayName,
};
describe("deviceInfoToDevice", () => {
it("should convert a DeviceInfo to a Device", () => {
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified, signatures, unsigned }, deviceId);
const device = deviceInfoToDevice(deviceInfo, userId);
expect(device.deviceId).toBe(deviceId);
expect(device.userId).toBe(userId);
expect(device.verified).toBe(verified);
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
expect(device.displayName).toBe(displayName);
});
it("should add empty signatures", () => {
const deviceInfo = DeviceInfo.fromStorage({ keys, algorithms, verified }, deviceId);
const device = deviceInfoToDevice(deviceInfo, userId);
expect(device.signatures.size).toBe(0);
});
});
});
+3 -14
View File
@@ -148,22 +148,14 @@ describe("Secrets", function () {
it("should throw if given a key that doesn't exist", async function () {
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
try {
await alice.storeSecret("foo", "bar", ["this secret does not exist"]);
// should be able to use expect(...).toThrow() but mocha still fails
// the test even when it throws for reasons I have no inclination to debug
expect(true).toBeFalsy();
} catch (e) {}
await expect(alice.storeSecret("foo", "bar", ["this secret does not exist"])).rejects.toBeTruthy();
alice.stopClient();
});
it("should refuse to encrypt with zero keys", async function () {
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
try {
await alice.storeSecret("foo", "bar", []);
expect(true).toBeFalsy();
} catch (e) {}
await expect(alice.storeSecret("foo", "bar", [])).rejects.toBeTruthy();
alice.stopClient();
});
@@ -214,10 +206,7 @@ describe("Secrets", function () {
it("should refuse to encrypt if no keys given and no default key", async function () {
const alice = await makeTestClient({ userId: "@alice:example.com", deviceId: "Osborne2" });
try {
await alice.storeSecret("foo", "bar");
expect(true).toBeFalsy();
} catch (e) {}
await expect(alice.storeSecret("foo", "bar")).rejects.toBeTruthy();
alice.stopClient();
});
+21 -4
View File
@@ -144,7 +144,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(aliceSasEvent.sas);
e.confirm();
aliceSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
aliceSasEvent.mismatch();
}
@@ -169,7 +169,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(bobSasEvent.sas);
e.confirm();
bobSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
bobSasEvent.mismatch();
}
@@ -374,6 +374,12 @@ describe("SAS verification", function () {
expect(bobDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(bobDeviceTrust.isCrossSigningVerified()).toBeFalsy();
const bobDeviceVerificationStatus = (await alice.client
.getCrypto()!
.getDeviceVerificationStatus("@bob:example.com", "Dynabook"))!;
expect(bobDeviceVerificationStatus.localVerified).toBe(true);
expect(bobDeviceVerificationStatus.crossSigningVerified).toBe(false);
const aliceTrust = bob.client.checkUserTrust("@alice:example.com");
expect(aliceTrust.isCrossSigningVerified()).toBeTruthy();
expect(aliceTrust.isTofu()).toBeTruthy();
@@ -381,6 +387,17 @@ describe("SAS verification", function () {
const aliceDeviceTrust = bob.client.checkDeviceTrust("@alice:example.com", "Osborne2");
expect(aliceDeviceTrust.isLocallyVerified()).toBeTruthy();
expect(aliceDeviceTrust.isCrossSigningVerified()).toBeFalsy();
const aliceDeviceVerificationStatus = (await bob.client
.getCrypto()!
.getDeviceVerificationStatus("@alice:example.com", "Osborne2"))!;
expect(aliceDeviceVerificationStatus.localVerified).toBe(true);
expect(aliceDeviceVerificationStatus.crossSigningVerified).toBe(false);
const unknownDeviceVerificationStatus = await bob.client
.getCrypto()!
.getDeviceVerificationStatus("@alice:example.com", "xyz");
expect(unknownDeviceVerificationStatus).toBe(null);
});
});
@@ -502,7 +519,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(aliceSasEvent.sas);
e.confirm();
aliceSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
aliceSasEvent.mismatch();
}
@@ -526,7 +543,7 @@ describe("SAS verification", function () {
expect(e.sas).toEqual(bobSasEvent.sas);
e.confirm();
bobSasEvent.confirm();
} catch (error) {
} catch {
e.mismatch();
bobSasEvent.mismatch();
}
+2 -86
View File
@@ -24,13 +24,10 @@ import {
MatrixClient,
MatrixEvent,
MatrixEventEvent,
RelationType,
Room,
RoomEvent,
} from "../../src";
import { FeatureSupport, Thread } from "../../src/models/thread";
import { Thread } from "../../src/models/thread";
import { ReEmitter } from "../../src/ReEmitter";
import { eventMapperFor } from "../../src/event-mapper";
describe("EventTimelineSet", () => {
const roomId = "!foo:bar";
@@ -82,6 +79,7 @@ describe("EventTimelineSet", () => {
beforeEach(() => {
client = utils.mock(MatrixClient, "MatrixClient");
client.reEmitter = utils.mock(ReEmitter, "ReEmitter");
client.canSupport = new Map();
room = new Room(roomId, client, userA);
eventTimelineSet = new EventTimelineSet(room);
eventTimeline = new EventTimeline(eventTimelineSet);
@@ -205,88 +203,6 @@ describe("EventTimelineSet", () => {
expect(liveTimeline.getEvents().length).toStrictEqual(0);
});
it("should allow edits to be added to thread timeline", async () => {
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
Thread.hasServerSideSupport = FeatureSupport.Stable;
const sender = "@alice:matrix.org";
const root = utils.mkEvent({
event: true,
content: {
body: "Thread root",
},
type: EventType.RoomMessage,
sender,
});
room.addLiveEvents([root]);
const threadReply = utils.mkEvent({
event: true,
content: {
"body": "Thread reply",
"m.relates_to": {
event_id: root.getId()!,
rel_type: RelationType.Thread,
},
},
type: EventType.RoomMessage,
sender,
});
root.setUnsigned({
"m.relations": {
[RelationType.Thread]: {
count: 1,
latest_event: {
content: threadReply.getContent(),
origin_server_ts: 5,
room_id: room.roomId,
sender,
type: EventType.RoomMessage,
event_id: threadReply.getId()!,
user_id: sender,
age: 1,
},
current_user_participated: true,
},
},
});
const editToThreadReply = utils.mkEvent({
event: true,
content: {
"body": " * edit",
"m.new_content": {
"body": "edit",
"msgtype": "m.text",
"org.matrix.msc1767.text": "edit",
},
"m.relates_to": {
event_id: threadReply.getId()!,
rel_type: RelationType.Replace,
},
},
type: EventType.RoomMessage,
sender,
});
jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => {
thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true });
return true;
});
jest.spyOn(client, "relations").mockResolvedValue({
events: [],
});
const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false);
thread.once(RoomEvent.TimelineReset, () => {
const lastEvent = thread.timeline.at(-1)!;
expect(lastEvent.getContent().body).toBe(" * edit");
});
});
describe("non-room timeline", () => {
it("Adds event to timeline", () => {
const nonRoomEventTimelineSet = new EventTimelineSet(
+43
View File
@@ -517,4 +517,47 @@ describe("InteractiveAuth", () => {
expect(ia.getEmailSid()).toEqual(sid);
});
});
it("should prioritise shorter flows", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest: doRequest,
stateUpdated: stateUpdated,
requestEmailToken: jest.fn(),
authData: {
session: "sessionId",
flows: [{ stages: [AuthType.Recaptcha, AuthType.Password] }, { stages: [AuthType.Password] }],
params: {},
},
});
// @ts-ignore
ia.chooseStage();
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
});
it("should prioritise flows with entirely supported stages", async () => {
const doRequest = jest.fn();
const stateUpdated = jest.fn();
const ia = new InteractiveAuth({
matrixClient: getFakeClient(),
doRequest: doRequest,
stateUpdated: stateUpdated,
requestEmailToken: jest.fn(),
authData: {
session: "sessionId",
flows: [{ stages: ["com.devture.shared_secret_auth"] }, { stages: [AuthType.Password] }],
params: {},
},
supportedStages: [AuthType.Password],
});
// @ts-ignore
ia.chooseStage();
expect(ia.getChosenFlow()?.stages).toEqual([AuthType.Password]);
});
});
+94
View File
@@ -70,6 +70,7 @@ import { SyncState } from "../../src/sync";
import * as featureUtils from "../../src/feature";
import { StubStore } from "../../src/store/stub";
import { SecretStorageKeyDescriptionAesV1, ServerSideSecretStorageImpl } from "../../src/secret-storage";
import { CryptoBackend } from "../../src/common-crypto/CryptoBackend";
jest.useFakeTimers();
@@ -2750,6 +2751,60 @@ describe("MatrixClient", function () {
});
});
// these wrappers are deprecated, but we need coverage of them to pass the quality gate
describe("Crypto wrappers", () => {
describe("exception if no crypto", () => {
it("isCrossSigningReady", () => {
expect(() => client.isCrossSigningReady()).toThrow("End-to-end encryption disabled");
});
it("bootstrapCrossSigning", () => {
expect(() => client.bootstrapCrossSigning({})).toThrow("End-to-end encryption disabled");
});
it("isSecretStorageReady", () => {
expect(() => client.isSecretStorageReady()).toThrow("End-to-end encryption disabled");
});
});
describe("defer to crypto backend", () => {
let mockCryptoBackend: Mocked<CryptoBackend>;
beforeEach(() => {
mockCryptoBackend = {
isCrossSigningReady: jest.fn(),
bootstrapCrossSigning: jest.fn(),
isSecretStorageReady: jest.fn(),
stop: jest.fn().mockResolvedValue(undefined),
} as unknown as Mocked<CryptoBackend>;
client["cryptoBackend"] = mockCryptoBackend;
});
it("isCrossSigningReady", async () => {
const testResult = "test";
mockCryptoBackend.isCrossSigningReady.mockResolvedValue(testResult as unknown as boolean);
expect(await client.isCrossSigningReady()).toBe(testResult);
expect(mockCryptoBackend.isCrossSigningReady).toHaveBeenCalledTimes(1);
});
it("bootstrapCrossSigning", async () => {
const testOpts = {};
mockCryptoBackend.bootstrapCrossSigning.mockResolvedValue(undefined);
await client.bootstrapCrossSigning(testOpts);
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledTimes(1);
expect(mockCryptoBackend.bootstrapCrossSigning).toHaveBeenCalledWith(testOpts);
});
it("isSecretStorageReady", async () => {
client["cryptoBackend"] = mockCryptoBackend;
const testResult = "test";
mockCryptoBackend.isSecretStorageReady.mockResolvedValue(testResult as unknown as boolean);
expect(await client.isSecretStorageReady()).toBe(testResult);
expect(mockCryptoBackend.isSecretStorageReady).toHaveBeenCalledTimes(1);
});
});
});
describe("paginateEventTimeline()", () => {
describe("notifications timeline", () => {
const unsafeNotification = {
@@ -2909,4 +2964,43 @@ describe("MatrixClient", function () {
});
});
});
describe("pushers", () => {
const pusher = {
app_id: "test",
app_display_name: "Test App",
data: {},
device_display_name: "test device",
kind: "http",
lang: "en-NZ",
pushkey: "1234",
};
beforeEach(() => {
makeClient();
const response: HttpLookup = {
method: Method.Post,
path: "/pushers/set",
data: {},
};
httpLookups = [response];
jest.spyOn(client.http, "authedRequest").mockClear();
});
it("should make correct request to set pusher", async () => {
const result = await client.setPusher(pusher);
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, pusher);
expect(result).toEqual({});
});
it("should make correct request to remove pusher", async () => {
const result = await client.removePusher(pusher.pushkey, pusher.app_id);
expect(client.http.authedRequest).toHaveBeenCalledWith(Method.Post, "/pushers/set", undefined, {
pushkey: pusher.pushkey,
app_id: pusher.app_id,
kind: null,
});
expect(result).toEqual({});
});
});
});
+305 -5
View File
@@ -14,17 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
import { Room } from "../../../src/models/room";
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
import { mkThread } from "../../test-utils/thread";
import { Room, RoomEvent } from "../../../src/models/room";
import { Thread, THREAD_RELATION_TYPE, ThreadEvent, FeatureSupport } from "../../../src/models/thread";
import { makeThreadEvent, mkThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
import { emitPromise, mkMessage, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, MatrixEvent } from "../../../src";
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
import { ReceiptType } from "../../../src/@types/read_receipts";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../test-utils/client";
import { ReEmitter } from "../../../src/ReEmitter";
import { Feature, ServerSupport } from "../../../src/feature";
import { eventMapperFor } from "../../../src/event-mapper";
describe("Thread", () => {
describe("constructor", () => {
@@ -424,4 +427,301 @@ describe("Thread", () => {
expect(mock).toHaveBeenCalledWith("b1", "f1");
});
});
describe("insertEventIntoTimeline", () => {
it("Inserts a reaction in timestamp order", () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
const client = createClientWithEventMapper();
const userId = "user1";
const room = new Room("room1", client, userId);
// Given a thread with a root plus 5 messages
const { thread, events } = mkThread({
room,
client,
authorId: userId,
participantUserIds: ["@bob:hs", "@chia:hs", "@dv:hs"],
length: 6,
ts: 100, // Events will be at ts 100, 101, 102, 103, 104 and 105
});
// When we insert a reaction to the second thread message
const replyEvent = mkReaction(events[2], client, userId, room.roomId, 104);
thread.insertEventIntoTimeline(replyEvent);
// Then the reaction is inserted based on its timestamp
expect(thread.events.map((ev) => ev.getId())).toEqual([
events[0].getId(),
events[1].getId(),
events[2].getId(),
events[3].getId(),
events[4].getId(),
replyEvent.getId(),
events[5].getId(),
]);
});
describe("Without relations recursion support", () => {
it("Creates a local echo receipt for new events", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client without relations recursion support
const client = createClientWithEventMapper();
// And a thread with an added event (with later timestamp)
const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
// Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt).toBeTruthy();
expect(receipt?.eventId).toEqual(message.getId());
expect(receipt?.data.ts).toEqual(100);
expect(receipt?.data.thread_id).toEqual(thread.id);
// (And the receipt was synthetic)
expect(thread.getReadReceiptForUserId(userId, true)).toBeNull();
});
it("Doesn't create a local echo receipt for events before an existing receipt", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client without relations recursion support
const client = createClientWithEventMapper();
// And a thread with an added event with a lower timestamp than its other events
const userId = "user1";
const { thread } = await createThreadAndEvent(client, 200, 100, userId);
// Then no receipt was added to the thread (the receipt is still
// for the thread root). This happens because since we have no
// recursive relations support, we know that sometimes events
// appear out of order, so we have to check their timestamps as
// a guess of the correct order.
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
});
});
describe("With relations recursion support", () => {
it("Creates a local echo receipt for new events", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client WITH relations recursion support
const client = createClientWithEventMapper(
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
);
// And a thread with an added event (with later timestamp)
const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
// Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message.getId());
});
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
// Assumption: no server side support because if we have it, events
// can only be added to the timeline after the thread has been
// initialised, and we are not properly initialising it here.
expect(Thread.hasServerSideSupport).toBe(FeatureSupport.None);
// Given a client WITH relations recursion support
const client = createClientWithEventMapper(
new Map([[Feature.RelationsRecursion, ServerSupport.Stable]]),
);
// And a thread with an added event with a lower timestamp than its other events
const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 200, 100, userId);
// Then a receipt was added to the thread, because relations
// recursion is available, so we trust the server to have
// provided us with events in the right order.
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message.getId());
});
});
async function createThreadAndEvent(
client: MatrixClient,
rootTs: number,
eventTs: number,
userId: string,
): Promise<{ thread: Thread; message: MatrixEvent }> {
const room = new Room("room1", client, userId);
// Given a thread
const { thread } = mkThread({
room,
client,
authorId: userId,
participantUserIds: [],
ts: rootTs,
});
// Sanity: the current receipt is for the thread root
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res()));
// When we add a message that is before the latest receipt
const message = makeThreadEvent({
event: true,
rootEventId: thread.id,
replyToEventId: thread.id,
user: userId,
room: room.roomId,
ts: eventTs,
});
await thread.addEvent(message, false, true);
await awaitTimelineEvent;
return { thread, message };
}
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {
const client = mock(MatrixClient, "MatrixClient");
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = canSupport;
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
mocked(client.supportsThreads).mockReturnValue(true);
return client;
}
});
describe("Editing events", () => {
describe("Given server support for threads", () => {
let previousThreadHasServerSideSupport: FeatureSupport;
beforeAll(() => {
previousThreadHasServerSideSupport = Thread.hasServerSideSupport;
Thread.hasServerSideSupport = FeatureSupport.Stable;
});
afterAll(() => {
Thread.hasServerSideSupport = previousThreadHasServerSideSupport;
});
it("Adds edits from sync to the thread timeline and applies them", async () => {
// Given a thread
const client = createClient();
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
// When a message and an edit are added to the thread
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
await thread.addEvent(messageToEdit, false);
await thread.addEvent(editEvent, false);
// Then both events end up in the timeline
const lastEvent = thread.timeline.at(-1)!;
const secondLastEvent = thread.timeline.at(-2)!;
expect(lastEvent).toBe(editEvent);
expect(secondLastEvent).toBe(messageToEdit);
// And the first message has been edited
expect(secondLastEvent.getContent().body).toEqual("edit");
});
it("Adds edits fetched on demand to the thread timeline and applies them", async () => {
// Given we don't support recursive relations
const client = createClient(new Map([[Feature.RelationsRecursion, ServerSupport.Unsupported]]));
// And we have a thread
const user = "@alice:matrix.org";
const room = "!room:z";
const thread = await createThread(client, user, room);
// When a message is added to the thread, and an edit to it is provided on demand
const messageToEdit = createThreadMessage(thread.id, user, room, "Thread reply");
// (fetchEditsWhereNeeded only applies to encrypted messages for some reason)
messageToEdit.event.type = EventType.RoomMessageEncrypted;
const editEvent = mkEdit(messageToEdit, client, user, room, "edit");
mocked(client.relations).mockImplementation(async (_roomId, eventId) => {
if (eventId === messageToEdit.getId()) {
return { events: [editEvent] };
} else {
return { events: [] };
}
});
await thread.addEvent(messageToEdit, false);
// Then both events end up in the timeline
const lastEvent = thread.timeline.at(-1)!;
const secondLastEvent = thread.timeline.at(-2)!;
expect(lastEvent).toBe(editEvent);
expect(secondLastEvent).toBe(messageToEdit);
// And the first message has been edited
expect(secondLastEvent.getContent().body).toEqual("edit");
});
});
});
});
/**
* Create a message event that lives in a thread
*/
function createThreadMessage(threadId: string, user: string, room: string, msg: string): MatrixEvent {
return makeThreadEvent({
event: true,
user,
room,
msg,
rootEventId: threadId,
replyToEventId: threadId,
});
}
/**
* Create a thread and wait for it to be properly initialised (so you can safely
* add events to it and expect them to appear in the timeline.
*/
async function createThread(client: MatrixClient, user: string, roomId: string): Promise<Thread> {
const root = mkMessage({ event: true, user, room: roomId, msg: "Thread root" });
const room = new Room(roomId, client, "@roomcreator:x");
// Ensure the root is in the room timeline
root.setThreadId(root.getId());
await room.addLiveEvents([root]);
// Create the thread and wait for it to be initialised
const thread = room.createThread(root.getId()!, root, [], false);
await new Promise<void>((res) => thread.once(RoomEvent.TimelineReset, () => res()));
return thread;
}
/**
* Create a MatrixClient that supports threads and has all the methods used when
* creating a thread that call out to HTTP endpoints mocked out.
*/
function createClient(canSupport = new Map()): MatrixClient {
const client = mock(MatrixClient, "MatrixClient");
client.reEmitter = mock(ReEmitter, "ReEmitter");
client.canSupport = canSupport;
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getEventMapper").mockReturnValue(eventMapperFor(client, {}));
// Mock methods that call out to HTTP endpoints
jest.spyOn(client, "paginateEventTimeline").mockResolvedValue(true);
jest.spyOn(client, "relations").mockResolvedValue({ events: [] });
jest.spyOn(client, "fetchRoomEvent").mockResolvedValue({});
return client;
}
-78
View File
@@ -97,51 +97,6 @@ describe("NotificationService", function () {
pattern: "foo*bar",
rule_id: "foobar",
},
{
actions: [
"notify",
{
set_tweak: "sound",
value: "default",
},
{
set_tweak: "highlight",
},
],
enabled: true,
pattern: "p[io]ng",
rule_id: "pingpong",
},
{
actions: [
"notify",
{
set_tweak: "sound",
value: "default",
},
{
set_tweak: "highlight",
},
],
enabled: true,
pattern: "I ate [0-9] pies",
rule_id: "pies",
},
{
actions: [
"notify",
{
set_tweak: "sound",
value: "default",
},
{
set_tweak: "highlight",
},
],
enabled: true,
pattern: "b[!ai]ke",
rule_id: "bakebike",
},
],
override: [
{
@@ -289,39 +244,6 @@ describe("NotificationService", function () {
expect(actions.tweaks.highlight).toEqual(true);
});
// TODO: This is not spec compliant behaviour.
//
// See https://spec.matrix.org/v1.5/client-server-api/#conditions-1 which
// describes pattern should glob:
//
// 1. * matches 0 or more characters;
// 2. ? matches exactly one character
it("should bing on character group ([abc]) bing words.", function () {
testEvent.event.content!.body = "Ping!";
let actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(true);
testEvent.event.content!.body = "Pong!";
actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(true);
});
// TODO: This is not spec compliant behaviour. (See above.)
it("should bing on character range ([a-z]) bing words.", function () {
testEvent.event.content!.body = "I ate 6 pies";
const actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(true);
});
// TODO: This is not spec compliant behaviour. (See above.)
it("should bing on character negation ([!a]) bing words.", function () {
testEvent.event.content!.body = "boke";
let actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(true);
testEvent.event.content!.body = "bake";
actions = pushProcessor.actionsForEvent(testEvent);
expect(actions.tweaks.highlight).toEqual(false);
});
it("should not bing on room server ACL changes", function () {
testEvent = utils.mkEvent({
type: EventType.RoomServerAcl,
+215
View File
@@ -0,0 +1,215 @@
/*
Copyright 2023 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 { ReceiptType } from "../../src/@types/read_receipts";
import { ReceiptAccumulator } from "../../src/receipt-accumulator";
const roomId = "!foo:bar";
describe("ReceiptAccumulator", function () {
/*
* Note: at the time of writing, the ReceiptAccumulator uses the order of
* events from sync as the determinant of which one is most recent. This
* is correct, but inconsistent with other areas of the code, since we
* don't persist this order, so in other places we are forced to use the
* timestamp of events and hope that this matches up.
*
* The tests in this file provide ts values that are consistent with the
* sync order, so they should still pass if we change to using ts to
* determine order.
*
* Ideally, we would keep track of sync order so we can use it everywhere.
* This would mean we are consistent with how the homeserver sees receipts
* and notifications.
*/
it("Discards previous unthreaded receipts for the same user", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2));
});
it("Discards previous threaded receipts for the same user in the same thread", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1");
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread1"));
});
it("Collects multiple receipts for the same user if they are in different threads", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2");
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
["$event2", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
]),
);
});
it("Collects multiple receipts for different users", () => {
const acc = new ReceiptAccumulator();
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1");
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, "thread1"],
["$event2", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
]),
);
});
it("Collects last receipt for various users and threads", () => {
const acc = new ReceiptAccumulator();
// Below, if ts=1, this receipt is going to be superceded by another
// with ts=2
const receipts = [
newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1),
newReceipt("$event2", ReceiptType.Read, "@bobby:localhost", 1, "thread1"),
newReceipt("$event3", ReceiptType.Read, "@alice:localhost", 1, "thread1"),
newReceipt("$event4", ReceiptType.Read, "@bobby:localhost", 1),
newReceipt("$event5", ReceiptType.Read, "@bobby:localhost", 2),
newReceipt("$event6", ReceiptType.Read, "@alice:localhost", 2),
newReceipt("$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"),
newReceipt("$event8", ReceiptType.Read, "@bobby:localhost", 1, "thread2"),
newReceipt("$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"),
newReceipt("$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"),
newReceipt("$eventB", ReceiptType.Read, "@alice:localhost", 1, "thread2"),
newReceipt("$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"),
];
acc.consumeEphemeralEvents(receipts);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
// Only the ts=2 receipts make it through
expect(newEvent).toEqual(
newMultiReceipt([
["$event5", ReceiptType.Read, "@bobby:localhost", 2, undefined],
["$event6", ReceiptType.Read, "@alice:localhost", 2, undefined],
["$event7", ReceiptType.Read, "@bobby:localhost", 2, "thread1"],
["$event9", ReceiptType.Read, "@bobby:localhost", 2, "thread2"],
["$eventA", ReceiptType.Read, "@alice:localhost", 2, "thread1"],
["$eventC", ReceiptType.Read, "@alice:localhost", 2, "thread2"],
]),
);
});
it("Keeps main thread receipts even when an unthreaded receipt came later", () => {
const acc = new ReceiptAccumulator();
// Given receipts for the special thread "main" and also unthreaded
// receipts (which have no thread id).
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1, "main");
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2);
// When we collect them
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
// We preserve both: thread:main and unthreaded receipts are different
// things, with different meanings.
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, "main"],
["$event2", ReceiptType.Read, "@alice:localhost", 2, undefined],
]),
);
});
it("Keeps unthreaded receipts even when a main thread receipt came later", () => {
const acc = new ReceiptAccumulator();
// Given receipts for the special thread "main" and also unthreaded
// receipts (which have no thread id).
const receipt1 = newReceipt("$event1", ReceiptType.Read, "@alice:localhost", 1);
const receipt2 = newReceipt("$event2", ReceiptType.Read, "@alice:localhost", 2, "main");
// When we collect them
acc.consumeEphemeralEvents([receipt1, receipt2]);
const newEvent = acc.buildAccumulatedReceiptEvent(roomId);
// We preserve both: thread:main and unthreaded receipts are different
// things, with different meanings.
expect(newEvent).toEqual(
newMultiReceipt([
["$event1", ReceiptType.Read, "@alice:localhost", 1, undefined],
["$event2", ReceiptType.Read, "@alice:localhost", 2, "main"],
]),
);
});
});
const newReceipt = (
eventId: string,
receiptType: ReceiptType,
userId: string,
ts: number,
threadId: string | undefined = undefined,
) => {
return {
type: "m.receipt",
room_id: roomId,
content: {
[eventId]: {
[receiptType]: {
[userId]: { ts, thread_id: threadId },
},
},
},
};
};
const newMultiReceipt = (infoArray: Array<[string, ReceiptType, string, number, string | undefined]>) => {
return {
type: "m.receipt",
room_id: roomId,
content: Object.fromEntries(
infoArray.map(([eventId, receiptType, userId, ts, threadId]) => {
return [
eventId,
{
[receiptType]: {
[userId]: { ts, thread_id: threadId },
},
},
];
}),
),
};
};
+352 -224
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,79 @@
/*
Copyright 2023 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 { Mocked } from "jest-mock";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
describe("CrossSigningIdentity", () => {
describe("bootstrapCrossSigning", () => {
/** the CrossSigningIdentity implementation under test */
let crossSigning: CrossSigningIdentity;
/** a mocked-up OlmMachine which crossSigning is connected to */
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
/** A mock OutgoingRequestProcessor which crossSigning is connected to */
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
beforeEach(async () => {
await RustSdkCryptoJs.initAsync();
olmMachine = {
crossSigningStatus: jest.fn(),
bootstrapCrossSigning: jest.fn(),
close: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
outgoingRequestProcessor = {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor);
});
it("should do nothing if keys are present on-device and in secret storage", async () => {
olmMachine.crossSigningStatus.mockResolvedValue({
hasMaster: true,
hasSelfSigning: true,
hasUserSigning: true,
});
// TODO: secret storage
await crossSigning.bootstrapCrossSigning({});
expect(olmMachine.bootstrapCrossSigning).not.toHaveBeenCalled();
expect(outgoingRequestProcessor.makeOutgoingRequest).not.toHaveBeenCalled();
});
it("should call bootstrapCrossSigning if a reset is forced", async () => {
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
await crossSigning.bootstrapCrossSigning({ setupNewCrossSigning: true });
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
});
it("should call bootstrapCrossSigning if we need new keys", async () => {
olmMachine.crossSigningStatus.mockResolvedValue({
hasMaster: false,
hasSelfSigning: false,
hasUserSigning: false,
});
olmMachine.bootstrapCrossSigning.mockResolvedValue([]);
await crossSigning.bootstrapCrossSigning({});
expect(olmMachine.bootstrapCrossSigning).toHaveBeenCalledWith(true);
});
});
});
@@ -24,11 +24,12 @@ import {
KeysUploadRequest,
RoomMessageRequest,
SignatureUploadRequest,
SigningKeysUploadRequest,
ToDeviceRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, UIAuthCallback } from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
describe("OutgoingRequestProcessor", () => {
@@ -80,6 +81,12 @@ describe("OutgoingRequestProcessor", () => {
"https://example.com/_matrix/client/v3/keys/signatures/upload",
],
["KeysBackupRequest", KeysBackupRequest, "PUT", "https://example.com/_matrix/client/v3/room_keys/keys"],
[
"SigningKeysUploadRequest",
SigningKeysUploadRequest,
"POST",
"https://example.com/_matrix/client/v3/keys/device_signing/upload",
],
];
test.each(tests)(`should handle %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
@@ -171,6 +178,40 @@ describe("OutgoingRequestProcessor", () => {
httpBackend.verifyNoOutstandingRequests();
});
it("should handle SigningKeysUploadRequests with UIA", async () => {
// first, mock up a request as we might expect to receive it from the Rust layer ...
const testReq = { foo: "bar" };
const outgoingRequest = new SigningKeysUploadRequest("1234", JSON.stringify(testReq));
// also create a UIA callback
const authCallback: UIAuthCallback<Object> = async (makeRequest) => {
return await makeRequest({ type: "test" });
};
// ... then poke the request into the OutgoingRequestProcessor under test
const reqProm = processor.makeOutgoingRequest(outgoingRequest, authCallback);
// Now: check that it makes a matching HTTP request ...
const testResponse = '{"result":1}';
httpBackend
.when("POST", "/_matrix")
.check((req) => {
expect(req.path).toEqual("https://example.com/_matrix/client/v3/keys/device_signing/upload");
expect(JSON.parse(req.rawData)).toEqual({ foo: "bar", auth: { type: "test" } });
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
// ... and that it calls OlmMachine.markAsSent.
const markSentCallPromise = awaitCallToMarkAsSent();
await httpBackend.flushAllExpected();
await Promise.all([reqProm, markSentCallPromise]);
expect(olmMachine.markRequestAsSent).toHaveBeenCalledWith("1234", outgoingRequest.type, testResponse);
httpBackend.verifyNoOutstandingRequests();
});
it("does not explode with unknown requests", async () => {
const outgoingRequest = { id: "5678", type: 987 };
const markSentCallPromise = awaitCallToMarkAsSent();
@@ -0,0 +1,68 @@
/*
Copyright 2023 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 { DeviceKeys, DeviceVerification } from "../../../src";
import { downloadDeviceToJsDevice } from "../../../src/rust-crypto/device-converter";
describe("device-converter", () => {
const userId = "@alice:example.com";
const deviceId = "xcvf";
// All parameters for QueryDevice initialization
const keys = {
[`ed25519:${deviceId}`]: "key1",
[`curve25519:${deviceId}`]: "key2",
};
const algorithms = ["algo1", "algo2"];
const signatures = { [userId]: { [deviceId]: "sign1" } };
const displayName = "display name";
const unsigned = {
device_display_name: displayName,
};
describe("downloadDeviceToJsDevice", () => {
it("should convert a QueryDevice to a Device", () => {
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
keys,
algorithms,
device_id: deviceId,
user_id: userId,
signatures,
unsigned,
};
const device = downloadDeviceToJsDevice(queryDevice);
expect(device.deviceId).toBe(deviceId);
expect(device.userId).toBe(userId);
expect(device.verified).toBe(DeviceVerification.Unverified);
expect(device.getIdentityKey()).toBe(keys[`curve25519:${deviceId}`]);
expect(device.getFingerprint()).toBe(keys[`ed25519:${deviceId}`]);
expect(device.displayName).toBe(displayName);
});
it("should add empty signatures", () => {
const queryDevice: DeviceKeys[keyof DeviceKeys] = {
keys,
algorithms,
device_id: deviceId,
user_id: userId,
};
const device = downloadDeviceToJsDevice(queryDevice);
expect(device.signatures.size).toBe(0);
});
});
});
+112 -12
View File
@@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022-2023 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.
@@ -22,11 +22,12 @@ import { Mocked } from "jest-mock";
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { initRustCrypto } from "../../../src/rust-crypto";
import { IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
import { IHttpOpts, IToDeviceEvent, MatrixClient, MatrixHttpApi } from "../../../src";
import { mkEvent } from "../../test-utils/test-utils";
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../../src/@types/crypto";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -35,16 +36,15 @@ afterEach(() => {
indexedDB = new IDBFactory();
});
describe("RustCrypto", () => {
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
describe("RustCrypto", () => {
describe(".exportRoomKeys", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
rustCrypto = await makeTestRustCrypto();
});
it("should return a list", async () => {
@@ -57,8 +57,7 @@ describe("RustCrypto", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
rustCrypto = await makeTestRustCrypto();
});
it("should pass through unencrypted to-device messages", async () => {
@@ -94,6 +93,32 @@ describe("RustCrypto", () => {
});
});
it("isCrossSigningReady", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.isCrossSigningReady()).resolves.toBe(false);
});
it("getCrossSigningKeyId", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.getCrossSigningKeyId()).resolves.toBe(null);
});
it("bootstrapCrossSigning delegates to CrossSigningIdentity", async () => {
const rustCrypto = await makeTestRustCrypto();
const mockCrossSigningIdentity = {
bootstrapCrossSigning: jest.fn().mockResolvedValue(undefined),
};
// @ts-ignore private property
rustCrypto.crossSigningIdentity = mockCrossSigningIdentity;
await rustCrypto.bootstrapCrossSigning({});
expect(mockCrossSigningIdentity.bootstrapCrossSigning).toHaveBeenCalledWith({});
});
it("isSecretStorageReady", async () => {
const rustCrypto = await makeTestRustCrypto();
await expect(rustCrypto.isSecretStorageReady()).resolves.toBe(false);
});
describe("outgoing requests", () => {
/** the RustCrypto implementation under test */
let rustCrypto: RustCrypto;
@@ -141,7 +166,13 @@ describe("RustCrypto", () => {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
rustCrypto = new RustCrypto(olmMachine, {} as MatrixHttpApi<any>, TEST_USER, TEST_DEVICE_ID);
rustCrypto = new RustCrypto(
olmMachine,
{} as MatrixHttpApi<any>,
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
);
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
});
@@ -206,8 +237,7 @@ describe("RustCrypto", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
const mockHttpApi = {} as MatrixClient["http"];
rustCrypto = (await initRustCrypto(mockHttpApi, TEST_USER, TEST_DEVICE_ID)) as RustCrypto;
rustCrypto = await makeTestRustCrypto();
});
it("should handle unencrypted events", () => {
@@ -230,4 +260,74 @@ describe("RustCrypto", () => {
expect(res.encrypted).toBeTruthy();
});
});
describe("get|setTrustCrossSignedDevices", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto();
});
it("should be true by default", () => {
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
});
it("should be easily turn-off-and-on-able", () => {
rustCrypto.setTrustCrossSignedDevices(false);
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(false);
rustCrypto.setTrustCrossSignedDevices(true);
expect(rustCrypto.getTrustCrossSignedDevices()).toBe(true);
});
});
describe("getDeviceVerificationStatus", () => {
let rustCrypto: RustCrypto;
let olmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
beforeEach(() => {
olmMachine = {
getDevice: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
rustCrypto = new RustCrypto(
olmMachine,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
);
});
it("should call getDevice", async () => {
olmMachine.getDevice.mockResolvedValue({
isCrossSigningTrusted: jest.fn().mockReturnValue(false),
isLocallyTrusted: jest.fn().mockReturnValue(false),
isCrossSignedByOwner: jest.fn().mockReturnValue(false),
} as unknown as RustSdkCryptoJs.Device);
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
expect(olmMachine.getDevice.mock.calls[0][0].toString()).toEqual("@user:domain");
expect(olmMachine.getDevice.mock.calls[0][1].toString()).toEqual("device");
expect(res?.crossSigningVerified).toBe(false);
expect(res?.localVerified).toBe(false);
expect(res?.signedByOwner).toBe(false);
});
it("should return null for unknown device", async () => {
olmMachine.getDevice.mockResolvedValue(undefined);
const res = await rustCrypto.getDeviceVerificationStatus("@user:domain", "device");
expect(res).toBe(null);
});
});
});
/** build a basic RustCrypto instance for testing
*
* just provides default arguments for initRustCrypto()
*/
async function makeTestRustCrypto(
http: MatrixHttpApi<IHttpOpts & { onlyData: true }> = {} as MatrixClient["http"],
userId: string = TEST_USER,
deviceId: string = TEST_DEVICE_ID,
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
): Promise<RustCrypto> {
return await initRustCrypto(http, userId, deviceId, secretStorage);
}
+27
View File
@@ -254,4 +254,31 @@ describe("IndexedDBStore", () => {
});
await expect(store.startup()).rejects.toThrow("Test");
});
it("remote worker should terminate upon destroy call", async () => {
const terminate = jest.fn();
const worker = new (class MockWorker {
private onmessage!: (data: any) => void;
postMessage(data: any) {
this.onmessage({
data: {
command: "cmd_success",
seq: data.seq,
result: [],
},
});
}
public terminate = terminate;
})() as unknown as Worker;
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
workerFactory: () => worker,
});
await store.startup();
await expect(store.destroy()).resolves;
expect(terminate).toHaveBeenCalled();
});
});
+90
View File
@@ -356,6 +356,77 @@ describe("SyncAccumulator", function () {
});
});
it("can handle large numbers of identical receipts", () => {
const testSize = 1000; // Make this big to check performance (e.g. 10 million ~= 10s)
const newReceipt = (ts: number) => {
return {
type: "m.receipt",
room_id: "!foo:bar",
content: {
"$event1:localhost": {
[ReceiptType.Read]: {
"@alice:localhost": { ts },
},
},
},
};
};
const receipts = [];
for (let i = 0; i < testSize; i++) {
receipts.push(newReceipt(testSize - i));
}
sa.accumulate(
syncSkeleton({
ephemeral: {
events: receipts,
},
}),
);
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
expect(events.length).toEqual(1);
expect(events[0]).toEqual(newReceipt(1));
});
it("can handle large numbers of receipts for different users and events", () => {
const testSize = 100; // Make this big to check performance (e.g. 1 million ~= 10s)
const newReceipt = (ts: number) => {
return {
type: "m.receipt",
room_id: "!foo:bar",
content: {
[`$event${ts}:localhost`]: {
[ReceiptType.Read]: {
[`@alice${ts}:localhost`]: { ts },
},
},
},
};
};
const receipts = [];
for (let i = 0; i < testSize; i++) {
receipts.push(newReceipt(testSize - i));
}
sa.accumulate(
syncSkeleton({
ephemeral: {
events: receipts,
},
}),
);
const events = sa.getJSON().roomsData.join["!foo:bar"].ephemeral.events;
expect(events.length).toEqual(1);
expect(events[0]["content"]["$event1:localhost"]).toEqual({ "m.read": { "@alice1:localhost": { ts: 1 } } });
expect(Object.keys(events[0]["content"]).length).toEqual(testSize);
});
it("should accumulate threaded read receipts", () => {
const receipt1 = {
type: "m.receipt",
@@ -474,6 +545,25 @@ describe("SyncAccumulator", function () {
expect(summary["m.heroes"]).toEqual(["@bob:bar"]);
});
it("should correctly update summary properties to zero", function () {
// When we receive updates of a summary property, the last of which is 0
sa.accumulate(
createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 2,
}),
);
sa.accumulate(
createSyncResponseWithSummary({
"m.heroes": ["@alice:bar"],
"m.invited_member_count": 0,
}),
);
const summary = sa.getJSON().roomsData.join["!foo:bar"].summary;
// Then we give an answer of 0
expect(summary["m.invited_member_count"]).toEqual(0);
});
it("should return correctly adjusted age attributes", () => {
const delta = 1000;
const startingTs = 1000;
+17
View File
@@ -30,6 +30,8 @@ import {
sortEventsByLatestContentTimestamp,
safeSet,
MapWithDefault,
globToRegexp,
escapeRegExp,
} from "../../src/utils";
import { logger } from "../../src/logger";
import { mkMessage } from "../test-utils/test-utils";
@@ -725,4 +727,19 @@ describe("utils", function () {
await utils.immediate();
});
});
describe("escapeRegExp", () => {
it("should escape XYZ", () => {
expect(escapeRegExp("[FIT-Connect Zustelldienst \\(Testumgebung\\)]")).toMatchInlineSnapshot(
`"\\[FIT-Connect Zustelldienst \\\\\\(Testumgebung\\\\\\)\\]"`,
);
});
});
describe("globToRegexp", () => {
it("should not explode when given regexes as globs", () => {
const result = globToRegexp("[FIT-Connect Zustelldienst \\(Testumgebung\\)]");
expect(result).toMatchInlineSnapshot(`"\\[FIT-Connect Zustelldienst \\\\\\(Testumgebung\\\\\\)\\]"`);
});
});
});
+129 -9
View File
@@ -25,6 +25,7 @@ import {
CallType,
CallState,
CallParty,
CallDirection,
} from "../../../src/webrtc/call";
import {
MCallAnswer,
@@ -1053,14 +1054,7 @@ describe("Call", function () {
mockSendEvent.mockReset();
let caught = false;
try {
call.reject();
} catch (e) {
caught = true;
}
expect(caught).toEqual(true);
expect(() => call.reject()).toThrow();
expect(client.client.sendEvent).not.toHaveBeenCalled();
call.hangup(CallErrorCode.UserHangup, true);
@@ -1652,12 +1646,18 @@ describe("Call", function () {
beforeEach(async () => {
jest.useFakeTimers();
jest.spyOn(call, "hangup");
await fakeIncomingCall(client, call, "1");
mockPeerConn = call.peerConn as unknown as MockRTCPeerConnection;
mockPeerConn.iceConnectionState = "disconnected";
mockPeerConn.iceConnectionStateChangeListener!();
jest.spyOn(mockPeerConn, "restartIce");
});
it("should restart ICE gathering after being disconnected for 2 seconds", () => {
jest.advanceTimersByTime(3 * 1000);
expect(mockPeerConn.restartIce).toHaveBeenCalled();
});
it("should hang up after being disconnected for 30 seconds", () => {
@@ -1665,6 +1665,20 @@ describe("Call", function () {
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
});
it("should restart ICE gathering once again after ICE being failed", () => {
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(mockPeerConn.restartIce).toHaveBeenCalled();
});
it("should call hangup after ICE being failed and if there not exists a restartIce method", () => {
// @ts-ignore
mockPeerConn.restartIce = null;
mockPeerConn.iceConnectionState = "failed";
mockPeerConn.iceConnectionStateChangeListener!();
expect(call.hangup).toHaveBeenCalledWith(CallErrorCode.IceFailed, false);
});
it("should not hangup if we've managed to re-connect", () => {
mockPeerConn.iceConnectionState = "connected";
mockPeerConn.iceConnectionStateChangeListener!();
@@ -1692,4 +1706,110 @@ describe("Call", function () {
expect(onReplace).toHaveBeenCalled();
});
});
describe("should handle glare in negotiation process", () => {
beforeEach(async () => {
// cut methods not want to test
call.hangup = () => null;
call.isLocalOnHold = () => true;
// @ts-ignore
call.updateRemoteSDPStreamMetadata = jest.fn();
// @ts-ignore
call.getRidOfRTXCodecs = jest.fn();
// @ts-ignore
call.createAnswer = jest.fn().mockResolvedValue({});
// @ts-ignore
call.sendVoipEvent = jest.fn();
});
it("and reject remote offer if not polite and have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).not.toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do have pending answer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have not a local offer
// @ts-ignore
call.makingOffer = false;
// If we have a setRemoteDescription() answer operation pending, then
// we will be "stable" by the time the next setRemoteDescription() is
// executed, so we count this being readyForOffer when deciding whether to
// ignore the offer.
// @ts-ignore
call.isSettingRemoteAnswerPending = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and not reject remote offer if not polite and do not have pending local offer", async () => {
// not polite user == CallDirection.Outbound
call.direction = CallDirection.Outbound;
// have no local offer
// @ts-ignore
call.makingOffer = false;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "stable",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
it("and if polite do rollback pending local offer", async () => {
// polite user == CallDirection.Inbound
call.direction = CallDirection.Inbound;
// have already a local offer
// @ts-ignore
call.makingOffer = true;
const offerEvent = makeMockEvent("@test:foo", {
description: {
type: "offer",
sdp: DUMMY_SDP,
},
});
// @ts-ignore
call.peerConn = {
signalingState: "have-local-offer",
setRemoteDescription: jest.fn(),
};
await call.onNegotiateReceived(offerEvent);
expect(call.peerConn?.setRemoteDescription).toHaveBeenCalled();
});
});
});
+108 -7
View File
@@ -186,10 +186,7 @@ describe("Group Call", function () {
it("sets state to local call feed uninitialized when getUserMedia() fails", async () => {
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValue("Error");
try {
await groupCall.initLocalCallFeed();
} catch (e) {}
await expect(groupCall.initLocalCallFeed()).rejects.toBeTruthy();
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
});
@@ -388,6 +385,10 @@ describe("Group Call", function () {
call = new MockMatrixCall(room.roomId, groupCall.groupCallId);
await groupCall.create();
const deviceCallMap = new Map<string, MatrixCall>();
deviceCallMap.set(FAKE_DEVICE_ID_1, call.typed());
(groupCall as any).calls.set(FAKE_USER_ID_1, deviceCallMap);
});
it("ignores changes, if we can't get user id of opponent", async () => {
@@ -513,8 +514,7 @@ describe("Group Call", function () {
await groupCall.setMicrophoneMuted(false);
expect(groupCall.isMicrophoneMuted()).toEqual(false);
jest.advanceTimersByTime(groupCall.pttMaxTransmitTime + 100);
await jest.advanceTimersByTimeAsync(groupCall.pttMaxTransmitTime + 100);
expect(groupCall.isMicrophoneMuted()).toEqual(true);
});
@@ -581,7 +581,15 @@ describe("Group Call", function () {
});
mockCall.sendMetadataUpdate = jest.fn().mockReturnValue(metadataUpdatePromise);
const getUserMediaStreamFlush = Promise.resolve("stream");
// @ts-ignore
mockCall.cleint = {
getMediaHandler: {
getUserMediaStream: jest.fn().mockReturnValue(getUserMediaStreamFlush),
},
};
const mutePromise = groupCall.setMicrophoneMuted(true);
await getUserMediaStreamFlush;
// we should be muted at this point, before the metadata update has been sent
expect(groupCall.isMicrophoneMuted()).toEqual(true);
expect(mockCall.localUsermediaFeed.setAudioVideoMuted).toHaveBeenCalled();
@@ -888,14 +896,34 @@ describe("Group Call", function () {
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns false when no permission for audio stream", async () => {
it("returns false when no permission for audio stream and localCallFeed do not have an audio track", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockRejectedValueOnce(
new Error("No Permission"),
);
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns false when user media stream null", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(false);
// @ts-ignore
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream").mockResolvedValue({} as MediaStream);
expect(await groupCall.setMicrophoneMuted(false)).toBe(false);
});
it("returns true when no permission for audio stream but localCallFeed has a audio track already", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
// @ts-ignore
jest.spyOn(groupCall.localCallFeed, "hasAudioTrack", "get").mockReturnValue(true);
jest.spyOn(mockClient.getMediaHandler(), "getUserMediaStream");
expect(mockClient.getMediaHandler().getUserMediaStream).not.toHaveBeenCalled();
expect(await groupCall.setMicrophoneMuted(false)).toBe(true);
});
it("returns false when unmuting video with no video device", async () => {
const groupCall = await createAndEnterGroupCall(mockClient, room);
jest.spyOn(mockClient.getMediaHandler(), "hasVideoDevice").mockResolvedValue(false);
@@ -1625,4 +1653,77 @@ describe("Group Call", function () {
expect(room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, FAKE_USER_ID_2)).toBe(null);
});
});
describe("collection stats", () => {
let groupCall: GroupCall;
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(0);
});
afterAll(() => jest.useRealTimers());
beforeEach(async () => {
const typedMockClient = new MockCallMatrixClient(FAKE_USER_ID_1, FAKE_DEVICE_ID_1, FAKE_SESSION_ID_1);
const mockClient = typedMockClient.typed();
const room = new Room(FAKE_ROOM_ID, mockClient, FAKE_USER_ID_1);
groupCall = new GroupCall(
mockClient,
room,
GroupCallType.Video,
false,
GroupCallIntent.Prompt,
FAKE_CONF_ID,
);
});
it("should be undefined if not get stats", async () => {
// @ts-ignore
const stats = groupCall.stats;
expect(stats).toBeUndefined();
});
it("should be defined after first access", async () => {
groupCall.getGroupCallStats();
// @ts-ignore
const stats = groupCall.stats;
expect(stats).toBeDefined();
});
it("with every number should do nothing if no stats exists.", async () => {
groupCall.setGroupCallStatsInterval(0);
// @ts-ignore
let stats = groupCall.stats;
expect(stats).toBeUndefined();
groupCall.setGroupCallStatsInterval(10000);
// @ts-ignore
stats = groupCall.stats;
expect(stats).toBeUndefined();
});
it("with number should stop existing stats", async () => {
const stats = groupCall.getGroupCallStats();
// @ts-ignore
const stop = jest.spyOn(stats, "stop");
// @ts-ignore
const start = jest.spyOn(stats, "start");
groupCall.setGroupCallStatsInterval(0);
expect(stop).toHaveBeenCalled();
expect(start).not.toHaveBeenCalled();
});
it("with number should restart existing stats", async () => {
const stats = groupCall.getGroupCallStats();
// @ts-ignore
const stop = jest.spyOn(stats, "stop");
// @ts-ignore
const start = jest.spyOn(stats, "start");
groupCall.setGroupCallStatsInterval(10000);
expect(stop).toHaveBeenCalled();
expect(start).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,189 @@
/*
Copyright 2023 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 { CallStatsReportGatherer } from "../../../../src/webrtc/stats/callStatsReportGatherer";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
import { MediaSsrcHandler } from "../../../../src/webrtc/stats/media/mediaSsrcHandler";
const CALL_ID = "CALL_ID";
const USER_ID = "USER_ID";
describe("CallStatsReportGatherer", () => {
let collector: CallStatsReportGatherer;
let rtcSpy: RTCPeerConnection;
let emitter: StatsReportEmitter;
beforeEach(() => {
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
rtcSpy.addEventListener = jest.fn();
emitter = new StatsReportEmitter();
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
});
describe("on process stats", () => {
it("if active calculate stats reports", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
const report = {} as RTCStatsReport;
report.forEach = jest.fn().mockReturnValue([]);
getStats.mockResolvedValue(report);
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(actual).toEqual({
isFirstCollection: true,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
expect(collector.getActive()).toBeTruthy();
});
it("if not active do not calculate stats reports", async () => {
collector.setActive(false);
const getStats = jest.spyOn(rtcSpy, "getStats");
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).not.toHaveBeenCalled();
});
it("if get reports fails, the collector becomes inactive", async () => {
expect(collector.getActive()).toBeTruthy();
const getStats = jest.spyOn(rtcSpy, "getStats");
getStats.mockRejectedValue(new Error("unknown"));
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
it("if active and getStats returns not an RTCStatsReport inside a promise the collector fails and becomes inactive", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
// @ts-ignore
getStats.mockReturnValue({});
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(actual).toEqual({
isFirstCollection: true,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
it("if active and the collector runs not the first time the Summery Stats is marked as not fits collection", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
// @ts-ignore
collector.previousStatsReport = {} as RTCStatsReport;
const report = {} as RTCStatsReport;
report.forEach = jest.fn().mockReturnValue([]);
getStats.mockResolvedValue(report);
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(actual).toEqual({
isFirstCollection: false,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
expect(collector.getActive()).toBeTruthy();
});
});
describe("on signal state change event", () => {
let events: { [key: string]: any };
beforeEach(() => {
events = [];
// Define the addEventListener method with a Jest mock function
rtcSpy.addEventListener = jest.fn((event: any, callback: any) => {
events[event] = callback;
});
collector = new CallStatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
});
it("in case of stable, parse remote and local description", () => {
// @ts-ignore
const mediaSsrcHandler = {
parse: jest.fn(),
ssrcToMid: jest.fn(),
findMidBySsrc: jest.fn(),
getSsrcToMidMap: jest.fn(),
} as MediaSsrcHandler;
const remoteSDP = "sdp";
const localSDP = "sdp";
// @ts-ignore
rtcSpy.signalingState = "stable";
// @ts-ignore
rtcSpy.currentRemoteDescription = <RTCSessionDescription>{ sdp: remoteSDP };
// @ts-ignore
rtcSpy.currentLocalDescription = <RTCSessionDescription>{ sdp: localSDP };
// @ts-ignore
collector.trackStats.mediaSsrcHandler = mediaSsrcHandler;
events["signalingstatechange"]();
expect(mediaSsrcHandler.parse).toHaveBeenCalledWith(remoteSDP, "remote");
expect(mediaSsrcHandler.parse).toHaveBeenCalledWith(localSDP, "local");
});
});
});
@@ -16,7 +16,7 @@ limitations under the License.
import { TrackID } from "../../../../src/webrtc/stats/statsReport";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
import { StatsReportBuilder } from "../../../../src/webrtc/stats/statsReportBuilder";
import { ConnectionStatsReportBuilder } from "../../../../src/webrtc/stats/connectionStatsReportBuilder";
describe("StatsReportBuilder", () => {
const LOCAL_VIDEO_TRACK_ID = "LOCAL_VIDEO_TRACK_ID";
@@ -39,7 +39,7 @@ describe("StatsReportBuilder", () => {
describe("should build stats", () => {
it("by media track stats.", async () => {
expect(StatsReportBuilder.build(stats)).toEqual({
expect(ConnectionStatsReportBuilder.build(stats)).toEqual({
bitrate: {
audio: {
download: 4000,
@@ -91,6 +91,13 @@ describe("StatsReportBuilder", () => {
["REMOTE_AUDIO_TRACK_ID", 0.1],
["REMOTE_VIDEO_TRACK_ID", 50],
]),
audioConcealment: new Map([
["REMOTE_AUDIO_TRACK_ID", { concealedAudio: 3000, totalAudioDuration: 3000 * 20 }],
]),
totalAudioConcealment: {
concealedAudio: 3000,
totalAudioDuration: (1 / 0.05) * 3000,
},
});
});
});
@@ -104,6 +111,7 @@ describe("StatsReportBuilder", () => {
remoteAudioTrack.setLoss({ packetsTotal: 20, packetsLost: 0, isDownloadStream: true });
remoteAudioTrack.setBitrate({ download: 4000, upload: 0 });
remoteAudioTrack.setJitter(0.1);
remoteAudioTrack.setAudioConcealment(3000, 3000 * 20);
localVideoTrack.setCodec("v8");
localVideoTrack.setLoss({ packetsTotal: 30, packetsLost: 6, isDownloadStream: false });
@@ -13,7 +13,7 @@ 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 { ConnectionStatsReporter } from "../../../../src/webrtc/stats/connectionStatsReporter";
import { ConnectionStatsBuilder } from "../../../../src/webrtc/stats/connectionStatsBuilder";
describe("ConnectionStatsReporter", () => {
describe("should on bandwidth stats", () => {
@@ -22,11 +22,11 @@ describe("ConnectionStatsReporter", () => {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
});
it("build empty bandwidth report if chromium starts attributes not available", () => {
const stats = {} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
@@ -36,11 +36,11 @@ describe("ConnectionStatsReporter", () => {
availableIncomingBitrate: 1000,
availableOutgoingBitrate: 2000,
} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 1, upload: 2 });
});
it("build empty bandwidth report if chromium starts attributes not available", () => {
const stats = {} as RTCIceCandidatePairStats;
expect(ConnectionStatsReporter.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
expect(ConnectionStatsBuilder.buildBandwidthReport(stats)).toEqual({ download: 0, upload: 0 });
});
});
});
+30 -5
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { GroupCallStats } from "../../../../src/webrtc/stats/groupCallStats";
import { SummaryStats } from "../../../../src/webrtc/stats/summaryStats";
import { CallStatsReportSummary } from "../../../../src/webrtc/stats/callStatsReportSummary";
const GROUP_CALL_ID = "GROUP_ID";
const LOCAL_USER_ID = "LOCAL_USER_ID";
@@ -68,7 +68,7 @@ describe("GroupCallStats", () => {
jest.useRealTimers();
});
it("starting processing as well without stats collectors", async () => {
it("starting processing stats as well without stats collectors", async () => {
// @ts-ignore
stats.processStats = jest.fn();
stats.start();
@@ -77,17 +77,42 @@ describe("GroupCallStats", () => {
expect(stats.processStats).toHaveBeenCalled();
});
it("not starting processing stats if interval 0", async () => {
const statsDisabled = new GroupCallStats(GROUP_CALL_ID, LOCAL_USER_ID, 0);
// @ts-ignore
statsDisabled.processStats = jest.fn();
statsDisabled.start();
jest.advanceTimersByTime(TIME_INTERVAL);
// @ts-ignore
expect(statsDisabled.processStats).not.toHaveBeenCalled();
});
it("starting processing and calling the collectors", async () => {
stats.addStatsReportGatherer("CALL_ID", "USER_ID", mockRTCPeerConnection());
const collector = stats.getStatsReportGatherer("CALL_ID");
stats.reports.emitSummaryStatsReport = jest.fn();
const summaryStats = {
isFirstCollection: true,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: { count: 0, muted: 0 },
videoTrackSummary: { count: 0, muted: 0 },
} as SummaryStats;
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
} as CallStatsReportSummary;
let processStatsSpy;
if (collector) {
processStatsSpy = jest.spyOn(collector, "processStats").mockResolvedValue(summaryStats);
@@ -1,84 +0,0 @@
/*
Copyright 2023 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 { StatsReportGatherer } from "../../../../src/webrtc/stats/statsReportGatherer";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
const CALL_ID = "CALL_ID";
const USER_ID = "USER_ID";
describe("StatsReportGatherer", () => {
let collector: StatsReportGatherer;
let rtcSpy: RTCPeerConnection;
let emitter: StatsReportEmitter;
beforeEach(() => {
rtcSpy = { getStats: () => new Promise<RTCStatsReport>(() => null) } as RTCPeerConnection;
rtcSpy.addEventListener = jest.fn();
emitter = new StatsReportEmitter();
collector = new StatsReportGatherer(CALL_ID, USER_ID, rtcSpy, emitter);
});
describe("on process stats", () => {
it("if active calculate stats reports", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
const report = {} as RTCStatsReport;
report.forEach = jest.fn().mockReturnValue([]);
getStats.mockResolvedValue(report);
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(actual).toEqual({
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: { count: 0, muted: 0 },
videoTrackSummary: { count: 0, muted: 0 },
});
expect(collector.getActive()).toBeTruthy();
});
it("if not active do not calculate stats reports", async () => {
collector.setActive(false);
const getStats = jest.spyOn(rtcSpy, "getStats");
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).not.toHaveBeenCalled();
});
it("if get reports fails, the collector becomes inactive", async () => {
expect(collector.getActive()).toBeTruthy();
const getStats = jest.spyOn(rtcSpy, "getStats");
getStats.mockRejectedValue(new Error("unknown"));
await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
it("if active an RTCStatsReport not a promise the collector becomes inactive", async () => {
const getStats = jest.spyOn(rtcSpy, "getStats");
// @ts-ignore
getStats.mockReturnValue({});
const actual = await collector.processStats("GROUP_CALL_ID", "LOCAL_USER_ID");
expect(actual).toEqual({
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: { count: 0, muted: 0 },
videoTrackSummary: { count: 0, muted: 0 },
});
expect(getStats).toHaveBeenCalled();
expect(collector.getActive()).toBeFalsy();
});
});
});
@@ -0,0 +1,592 @@
/*
Copyright 2023 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 { SummaryStatsReportGatherer } from "../../../../src/webrtc/stats/summaryStatsReportGatherer";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
describe("SummaryStatsReportGatherer", () => {
let reporter: SummaryStatsReportGatherer;
let emitter: StatsReportEmitter;
beforeEach(() => {
emitter = new StatsReportEmitter();
emitter.emitSummaryStatsReport = jest.fn();
reporter = new SummaryStatsReportGatherer(emitter);
});
describe("build Summary Stats Report", () => {
it("should do nothing if summary list empty", async () => {
reporter.build([]);
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
});
it("should do nothing if a summary stats element collection the is first time", async () => {
reporter.build([
{
isFirstCollection: true,
receivedMedia: 10,
receivedAudioMedia: 4,
receivedVideoMedia: 6,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
]);
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
});
it("should trigger new summary report", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 10,
receivedAudioMedia: 4,
receivedVideoMedia: 6,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 13,
receivedAudioMedia: 0,
receivedVideoMedia: 13,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 5,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 10,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 15,
receivedAudioMedia: 6,
receivedVideoMedia: 9,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 100,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0.5,
percentageReceivedAudioMedia: 0.5,
percentageReceivedVideoMedia: 0.75,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 4,
percentageConcealedAudio: 0.0375,
});
});
it("as received video Media, although video was not received, but because video muted", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 10,
receivedAudioMedia: 10,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received no video Media, because only on video was muted", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 10,
receivedAudioMedia: 10,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 2,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 0,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received no audio Media, although audio not received and audio muted", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 100,
receivedAudioMedia: 0,
receivedVideoMedia: 100,
audioTrackSummary: {
count: 1,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 0,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("should find max jitter and max packet loss", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 20,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 40,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 20,
maxPacketLoss: 40,
peerConnections: 4,
percentageConcealedAudio: 0,
});
});
it("as received video Media, if no audio track received should count as received Media", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 10,
receivedAudioMedia: 0,
receivedVideoMedia: 10,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received audio Media, if no video track received should count as received Media", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 22,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("as received no media at all, as received Media", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 0,
maxPacketLoss: 0,
peerConnections: 1,
percentageConcealedAudio: 0,
});
});
it("should filter the first time summery stats", async () => {
const summary = [
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: true,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 20,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
},
{
isFirstCollection: false,
receivedMedia: 1,
receivedAudioMedia: 1,
receivedVideoMedia: 1,
audioTrackSummary: {
count: 1,
muted: 0,
maxJitter: 2,
maxPacketLoss: 5,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 1,
muted: 0,
maxJitter: 0,
maxPacketLoss: 40,
concealedAudio: 0,
totalAudio: 0,
},
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
maxJitter: 2,
maxPacketLoss: 40,
peerConnections: 3,
percentageConcealedAudio: 0,
});
});
});
});
@@ -1,127 +0,0 @@
/*
Copyright 2023 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 { SummaryStatsReporter } from "../../../../src/webrtc/stats/summaryStatsReporter";
import { StatsReportEmitter } from "../../../../src/webrtc/stats/statsReportEmitter";
describe("SummaryStatsReporter", () => {
let reporter: SummaryStatsReporter;
let emitter: StatsReportEmitter;
beforeEach(() => {
emitter = new StatsReportEmitter();
emitter.emitSummaryStatsReport = jest.fn();
reporter = new SummaryStatsReporter(emitter);
});
describe("build Summary Stats Report", () => {
it("should do nothing if summary list empty", async () => {
reporter.build([]);
expect(emitter.emitSummaryStatsReport).not.toHaveBeenCalled();
});
it("should trigger new summary report", async () => {
const summary = [
{
receivedMedia: 10,
receivedAudioMedia: 4,
receivedVideoMedia: 6,
audioTrackSummary: { count: 1, muted: 0 },
videoTrackSummary: { count: 1, muted: 0 },
},
{
receivedMedia: 13,
receivedAudioMedia: 0,
receivedVideoMedia: 13,
audioTrackSummary: { count: 1, muted: 0 },
videoTrackSummary: { count: 1, muted: 0 },
},
{
receivedMedia: 0,
receivedAudioMedia: 0,
receivedVideoMedia: 0,
audioTrackSummary: { count: 1, muted: 0 },
videoTrackSummary: { count: 1, muted: 0 },
},
{
receivedMedia: 15,
receivedAudioMedia: 6,
receivedVideoMedia: 9,
audioTrackSummary: { count: 1, muted: 0 },
videoTrackSummary: { count: 1, muted: 0 },
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0.5,
percentageReceivedAudioMedia: 0.5,
percentageReceivedVideoMedia: 0.75,
});
});
it("as received video Media, although video was not received, but because video muted", async () => {
const summary = [
{
receivedMedia: 10,
receivedAudioMedia: 10,
receivedVideoMedia: 0,
audioTrackSummary: { count: 1, muted: 0 },
videoTrackSummary: { count: 1, muted: 1 },
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 1,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 1,
});
});
it("as received no video Media, because only on video was muted", async () => {
const summary = [
{
receivedMedia: 10,
receivedAudioMedia: 10,
receivedVideoMedia: 0,
audioTrackSummary: { count: 1, muted: 0 },
videoTrackSummary: { count: 2, muted: 1 },
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 1,
percentageReceivedVideoMedia: 0,
});
});
it("as received no audio Media, although audio not received and audio muted", async () => {
const summary = [
{
receivedMedia: 100,
receivedAudioMedia: 0,
receivedVideoMedia: 100,
audioTrackSummary: { count: 1, muted: 1 },
videoTrackSummary: { count: 1, muted: 0 },
},
];
reporter.build(summary);
expect(emitter.emitSummaryStatsReport).toHaveBeenCalledWith({
percentageReceivedMedia: 0,
percentageReceivedAudioMedia: 0,
percentageReceivedVideoMedia: 1,
});
});
});
});
@@ -13,20 +13,20 @@ 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 { TrackStatsReporter } from "../../../../src/webrtc/stats/trackStatsReporter";
import { TrackStatsBuilder } from "../../../../src/webrtc/stats/trackStatsBuilder";
import { MediaTrackStats } from "../../../../src/webrtc/stats/media/mediaTrackStats";
describe("TrackStatsReporter", () => {
describe("TrackStatsBuilder", () => {
describe("should on frame and resolution stats", () => {
it("creating empty frame and resolution report, if no data available.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildFramerateResolution(trackStats, {});
TrackStatsBuilder.buildFramerateResolution(trackStats, {});
expect(trackStats.getFramerate()).toEqual(0);
expect(trackStats.getResolution()).toEqual({ width: -1, height: -1 });
});
it("creating empty frame and resolution report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildFramerateResolution(trackStats, {
TrackStatsBuilder.buildFramerateResolution(trackStats, {
framesPerSecond: 22.2,
frameHeight: 180,
frameWidth: 360,
@@ -39,7 +39,7 @@ describe("TrackStatsReporter", () => {
describe("should on simulcast", () => {
it("creating simulcast framerate.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.calculateSimulcastFramerate(
TrackStatsBuilder.calculateSimulcastFramerate(
trackStats,
{
framesSent: 100,
@@ -58,7 +58,7 @@ describe("TrackStatsReporter", () => {
describe("should on bytes received stats", () => {
it("creating build bitrate received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildBitrateReceived(
TrackStatsBuilder.buildBitrateReceived(
trackStats,
{
bytesReceived: 2001000,
@@ -73,7 +73,7 @@ describe("TrackStatsReporter", () => {
describe("should on bytes send stats", () => {
it("creating build bitrate send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildBitrateSend(
TrackStatsBuilder.buildBitrateSend(
trackStats,
{
bytesSent: 2001000,
@@ -90,7 +90,7 @@ describe("TrackStatsReporter", () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
const remote = {} as RTCStatsReport;
remote.get = jest.fn().mockReturnValue({ mimeType: "video/v8" });
TrackStatsReporter.buildCodec(remote, trackStats, { codecId: "codecID" });
TrackStatsBuilder.buildCodec(remote, trackStats, { codecId: "codecID" });
expect(trackStats.getCodec()).toEqual("v8");
});
});
@@ -98,7 +98,7 @@ describe("TrackStatsReporter", () => {
describe("should on package lost stats", () => {
it("creating build package lost on send report.", async () => {
const trackStats = new MediaTrackStats("1", "local", "video");
TrackStatsReporter.buildPacketsLost(
TrackStatsBuilder.buildPacketsLost(
trackStats,
{
type: "outbound-rtp",
@@ -114,7 +114,7 @@ describe("TrackStatsReporter", () => {
});
it("creating build package lost on received report.", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildPacketsLost(
TrackStatsBuilder.buildPacketsLost(
trackStats,
{
type: "inbound-rtp",
@@ -133,7 +133,7 @@ describe("TrackStatsReporter", () => {
describe("should set state of a TrackStats", () => {
it("to not alive if Transceiver undefined", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.setTrackStatsState(trackStats, undefined);
TrackStatsBuilder.setTrackStatsState(trackStats, undefined);
expect(trackStats.alive).toBeFalsy();
});
@@ -145,7 +145,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpSender,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeFalsy();
});
@@ -162,7 +162,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
});
@@ -179,7 +179,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpSender,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
});
@@ -195,7 +195,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeFalsy();
});
@@ -211,7 +211,7 @@ describe("TrackStatsReporter", () => {
} as RTCRtpReceiver,
} as RTCRtpTransceiver;
TrackStatsReporter.setTrackStatsState(trackStats, ts);
TrackStatsBuilder.setTrackStatsState(trackStats, ts);
expect(trackStats.alive).toBeTruthy();
expect(trackStats.muted).toBeTruthy();
});
@@ -219,29 +219,134 @@ describe("TrackStatsReporter", () => {
describe("should build Track Summary", () => {
it("and returns empty summary if stats list empty", async () => {
const summary = TrackStatsReporter.buildTrackSummary([]);
const summary = TrackStatsBuilder.buildTrackSummary([]);
expect(summary).toEqual({
audioTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary if stats list not empty", async () => {
const summary = TrackStatsReporter.buildTrackSummary([]);
it("and returns summary if stats list not empty and ignore local summery", async () => {
const trackStatsList = buildMockTrackStatsList();
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 0,
count: 2,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 0,
count: 3,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary and count muted if alive", async () => {
const trackStatsList = buildMockTrackStatsList();
trackStatsList[1].muted = true;
trackStatsList[5].muted = true;
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 3,
muted: 1,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary and ignore muted if not alive", async () => {
const trackStatsList = buildMockTrackStatsList();
trackStatsList[1].muted = true;
trackStatsList[1].alive = false;
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
videoTrackSummary: {
count: 3,
muted: 0,
maxJitter: 0,
maxPacketLoss: 0,
concealedAudio: 0,
totalAudio: 0,
},
});
});
it("and returns summary and build max jitter, packet loss and audio conealment", async () => {
const trackStatsList = buildMockTrackStatsList();
// video remote
trackStatsList[1].setJitter(12);
trackStatsList[4].setJitter(66);
trackStatsList[6].setJitter(1);
trackStatsList[1].setLoss({ packetsLost: 55, packetsTotal: 0, isDownloadStream: true });
trackStatsList[4].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
trackStatsList[6].setLoss({ packetsLost: 1, packetsTotal: 0, isDownloadStream: true });
// audio remote
trackStatsList[2].setJitter(1);
trackStatsList[5].setJitter(15);
trackStatsList[2].setLoss({ packetsLost: 5, packetsTotal: 0, isDownloadStream: true });
trackStatsList[5].setLoss({ packetsLost: 0, packetsTotal: 0, isDownloadStream: true });
trackStatsList[2].setAudioConcealment(220, 2000);
trackStatsList[5].setAudioConcealment(180, 2000);
const summary = TrackStatsBuilder.buildTrackSummary(trackStatsList);
expect(summary).toEqual({
audioTrackSummary: {
count: 2,
muted: 0,
maxJitter: 15,
maxPacketLoss: 5,
concealedAudio: 400,
totalAudio: 4000,
},
videoTrackSummary: {
count: 3,
muted: 0,
maxJitter: 66,
maxPacketLoss: 55,
concealedAudio: 0,
totalAudio: 0,
},
});
});
@@ -250,26 +355,51 @@ describe("TrackStatsReporter", () => {
describe("should build jitter value in Track Stats", () => {
it("and returns track stats without jitter if report not 'inbound-rtp'", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildJitter(trackStats, { jitter: 0.01 });
TrackStatsBuilder.buildJitter(trackStats, { jitter: 0.01 });
expect(trackStats.getJitter()).toEqual(0);
});
it("and returns track stats with jitter", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp", jitter: 0.01 });
expect(trackStats.getJitter()).toEqual(10);
});
it("and returns negative jitter if stats has no jitter value", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp" });
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp" });
expect(trackStats.getJitter()).toEqual(-1);
});
it("and returns jitter as number", async () => {
const trackStats = new MediaTrackStats("1", "remote", "video");
TrackStatsReporter.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
TrackStatsBuilder.buildJitter(trackStats, { type: "inbound-rtp", jitter: "0.5" });
expect(trackStats.getJitter()).toEqual(500);
});
});
});
function buildMockTrackStatsList(): MediaTrackStats[] {
const trackStats1 = new MediaTrackStats("1", "local", "video");
trackStats1.muted = false;
trackStats1.alive = true;
const trackStats2 = new MediaTrackStats("1", "remote", "video");
trackStats2.muted = false;
trackStats2.alive = true;
const trackStats3 = new MediaTrackStats("1", "remote", "audio");
trackStats3.muted = false;
trackStats3.alive = true;
const trackStats4 = new MediaTrackStats("1", "local", "audio");
trackStats4.muted = false;
trackStats4.alive = true;
const trackStats5 = new MediaTrackStats("1", "remote", "video");
trackStats5.muted = false;
trackStats5.alive = true;
const trackStats6 = new MediaTrackStats("1", "remote", "audio");
trackStats6.muted = false;
trackStats6.alive = true;
const trackStats7 = new MediaTrackStats("1", "remote", "video");
trackStats7.muted = false;
trackStats7.alive = true;
return [trackStats1, trackStats2, trackStats3, trackStats4, trackStats5, trackStats6, trackStats7];
}
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { TransportStatsReporter } from "../../../../src/webrtc/stats/transportStatsReporter";
import { TransportStatsBuilder } from "../../../../src/webrtc/stats/transportStatsBuilder";
import { TransportStats } from "../../../../src/webrtc/stats/transportStats";
describe("TransportStatsReporter", () => {
@@ -35,7 +35,7 @@ describe("TransportStatsReporter", () => {
it("build new transport stats if all properties there", () => {
const { report, stats } = mockStatsReport(isFocus, 0);
const conferenceStatsTransport: TransportStats[] = [];
const transportStats = TransportStatsReporter.buildReport(report, stats, conferenceStatsTransport, isFocus);
const transportStats = TransportStatsBuilder.buildReport(report, stats, conferenceStatsTransport, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
@@ -54,8 +54,8 @@ describe("TransportStatsReporter", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 1);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
transportStats = TransportStatsBuilder.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsBuilder.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
@@ -84,8 +84,8 @@ describe("TransportStatsReporter", () => {
const mock1 = mockStatsReport(isFocus, 0);
const mock2 = mockStatsReport(isFocus, 0);
let transportStats: TransportStats[] = [];
transportStats = TransportStatsReporter.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsReporter.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
transportStats = TransportStatsBuilder.buildReport(mock1.report, mock1.stats, transportStats, isFocus);
transportStats = TransportStatsBuilder.buildReport(mock2.report, mock2.stats, transportStats, isFocus);
expect(transportStats).toEqual([
{
ip: `${remoteIC.ip + 0}:${remoteIC.port}`,
@@ -13,16 +13,16 @@ 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 { StatsValueFormatter } from "../../../../src/webrtc/stats/statsValueFormatter";
import { ValueFormatter } from "../../../../src/webrtc/stats/valueFormatter";
describe("StatsValueFormatter", () => {
describe("ValueFormatter", () => {
describe("on get non negative values", () => {
it("formatter shod return number", async () => {
expect(StatsValueFormatter.getNonNegativeValue("2")).toEqual(2);
expect(StatsValueFormatter.getNonNegativeValue(0)).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue("-2")).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue("")).toEqual(0);
expect(StatsValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
expect(ValueFormatter.getNonNegativeValue("2")).toEqual(2);
expect(ValueFormatter.getNonNegativeValue(0)).toEqual(0);
expect(ValueFormatter.getNonNegativeValue("-2")).toEqual(0);
expect(ValueFormatter.getNonNegativeValue("")).toEqual(0);
expect(ValueFormatter.getNonNegativeValue(NaN)).toEqual(0);
});
});
});
+7
View File
@@ -235,6 +235,13 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
"org.matrix.msc3890.local_notification_settings",
);
/**
* https://github.com/matrix-org/matrix-doc/pull/4023
*
* @experimental
*/
export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
export interface IEncryptedFile {
url: string;
mimetype?: string;
+1
View File
@@ -186,6 +186,7 @@ export interface IRelationsRequestOpts {
to?: string;
limit?: number;
dir?: Direction;
recurse?: boolean; // MSC3981 Relations Recursion https://github.com/matrix-org/matrix-spec-proposals/pull/3981
}
export interface IRelationsResponse {
+2 -2
View File
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { IAuthData } from "../interactive-auth";
import { IAuthDict, IAuthData } from "../interactive-auth";
/**
* Helper type to represent HTTP request body for a UIA enabled endpoint
*/
export type UIARequest<T> = T & {
auth?: IAuthData;
auth?: IAuthDict;
};
/**
+3 -1
View File
@@ -25,7 +25,7 @@ export class ReEmitter {
public constructor(private readonly target: EventEmitter) {}
// Map from emitter to event name to re-emitter
private reEmitters = new Map<EventEmitter, Map<string, (...args: any[]) => void>>();
private reEmitters = new WeakMap<EventEmitter, Map<string, (...args: any[]) => void>>();
public reEmit(source: EventEmitter, eventNames: string[]): void {
let reEmittersByEvent = this.reEmitters.get(source);
@@ -35,6 +35,8 @@ export class ReEmitter {
}
for (const eventName of eventNames) {
if (reEmittersByEvent.has(eventName)) continue;
// We include the source as the last argument for event handlers which may need it,
// such as read receipt listeners on the client class which won't have the context
// of the room.
+3 -3
View File
@@ -142,7 +142,7 @@ export class AutoDiscovery {
},
};
if (!wellknown || !wellknown["m.homeserver"]) {
if (!wellknown?.["m.homeserver"]) {
logger.error("No m.homeserver key in config");
clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
@@ -171,7 +171,7 @@ export class AutoDiscovery {
// Step 3: Make sure the homeserver URL points to a homeserver.
const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`);
if (!hsVersions || !hsVersions.raw?.["versions"]) {
if (!hsVersions?.raw?.["versions"]) {
logger.error("Invalid /versions response");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
@@ -345,7 +345,7 @@ export class AutoDiscovery {
const response = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
if (!response) return {};
return response.raw || {};
return response.raw ?? {};
}
/**
+105 -74
View File
@@ -36,7 +36,11 @@ import { StubStore } from "./store/stub";
import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler";
import {
GroupCallEventHandler,
GroupCallEventHandlerEvent,
GroupCallEventHandlerEventHandlerMap,
} from "./webrtc/groupCallEventHandler";
import * as utils from "./utils";
import { replaceParam, QueryDict, sleep, noUnsafeEventProps, safeSet } from "./utils";
import { Direction, EventTimeline } from "./models/event-timeline";
@@ -74,7 +78,6 @@ import {
CryptoEventHandlerMap,
fixBackupKey,
ICryptoCallbacks,
IBootstrapCrossSigningOpts,
ICheckOwnCrossSigningTrustOpts,
isCryptoAvailable,
VerificationMethod,
@@ -181,7 +184,6 @@ import { IThreepid } from "./@types/threepids";
import { CryptoStore, OutgoingRoomKeyRequest } from "./crypto/store/base";
import { GroupCall, IGroupCallDataChannelOptions, GroupCallIntent, GroupCallType } from "./webrtc/groupCall";
import { MediaHandler } from "./webrtc/mediaHandler";
import { GroupCallEventHandler } from "./webrtc/groupCallEventHandler";
import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
import { TypedEventEmitter } from "./models/typed-event-emitter";
import { MAIN_ROOM_TIMELINE, ReceiptType } from "./@types/read_receipts";
@@ -205,7 +207,7 @@ import { LocalNotificationSettings } from "./@types/local_notifications";
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
import { CryptoBackend } from "./common-crypto/CryptoBackend";
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
import { CryptoApi } from "./crypto-api";
import { BootstrapCrossSigningOpts, CryptoApi } from "./crypto-api";
import { DeviceInfoMap } from "./crypto/DeviceList";
import {
AddSecretStorageKeyOpts,
@@ -635,7 +637,7 @@ interface IJoinRequestBody {
interface ITagMetadata {
[key: string]: any;
order: number;
order?: number;
}
interface IMessagesResponse {
@@ -715,7 +717,7 @@ interface IJoinedMembersResponse {
}
export interface IRegisterRequestParams {
auth?: IAuthData;
auth?: IAuthDict;
username?: string;
password?: string;
refresh_token?: boolean;
@@ -2228,7 +2230,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
// needed.
const RustCrypto = await import("./rust-crypto");
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId, this.secretStorage);
this.cryptoBackend = rustCrypto;
// attach the event listeners needed by RustCrypto
@@ -2295,6 +2297,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param forceDownload - Always download the keys even if cached.
*
* @returns A promise which resolves to a map userId-\>deviceId-\>`DeviceInfo`
*
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
*/
public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> {
if (!this.crypto) {
@@ -2309,6 +2313,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param userId - the user to list keys for.
*
* @returns list of devices
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
*/
public getStoredDevicesForUser(userId: string): DeviceInfo[] {
if (!this.crypto) {
@@ -2324,6 +2329,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param deviceId - unique identifier for the device
*
* @returns device or null
* @deprecated Prefer {@link CryptoApi.getUserDeviceInfo}
*/
public getStoredDevice(userId: string, deviceId: string): DeviceInfo | null {
if (!this.crypto) {
@@ -2434,10 +2440,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns the VerificationRequest that is in progress, if any
*/
public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.findVerificationRequestDMInProgress(roomId);
return this.cryptoBackend.findVerificationRequestDMInProgress(roomId);
}
/**
@@ -2568,14 +2574,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Get the user's cross-signing key ID.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
* Get the ID of one of the user's cross-signing keys
*
* @param type - The type of key to get the ID of. One of
* "master", "self_signing", or "user_signing". Defaults to "master".
*
* @returns the key ID
* @deprecated prefer {@link Crypto.CryptoApi#getCrossSigningKeyId}
*/
public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null {
if (!this.crypto) {
@@ -2594,10 +2599,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns the cross signing information for the user.
*/
public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.getStoredCrossSigningForUser(userId);
return this.cryptoBackend.getStoredCrossSigningForUser(userId);
}
/**
@@ -2621,12 +2626,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @param userId - The ID of the user whose devices is to be checked.
* @param deviceId - The ID of the device to check
*
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus | `CryptoApi.getDeviceVerificationStatus`}
*/
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
if (!this.cryptoBackend) {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.cryptoBackend.checkDeviceTrust(userId, deviceId);
return this.crypto.checkDeviceTrust(userId, deviceId);
}
/**
@@ -2730,12 +2737,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* bootstrapCrossSigning() completes successfully, this function should
* return true.
* @returns True if cross-signing is ready to be used on this device
* @deprecated Prefer {@link CryptoApi.isCrossSigningReady | `CryptoApi.isCrossSigningReady`}:
*/
public isCrossSigningReady(): Promise<boolean> {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.isCrossSigningReady();
return this.cryptoBackend.isCrossSigningReady();
}
/**
@@ -2745,15 +2753,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* This function:
* - creates new cross-signing keys if they are not found locally cached nor in
* secret storage (if it has been setup)
* secret storage (if it has been set up)
*
* The cross-signing API is currently UNSTABLE and may change without notice.
* @deprecated Prefer {@link CryptoApi.bootstrapCrossSigning | `CryptoApi.bootstrapCrossSigning`}.
*/
public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts): Promise<void> {
if (!this.crypto) {
public bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.bootstrapCrossSigning(opts);
return this.cryptoBackend.bootstrapCrossSigning(opts);
}
/**
@@ -2764,24 +2772,28 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Default: true
*
* @returns True if trusting cross-signed devices
*
* @deprecated Prefer {@link CryptoApi.getTrustCrossSignedDevices | `CryptoApi.getTrustCrossSignedDevices`}.
*/
public getCryptoTrustCrossSignedDevices(): boolean {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.getCryptoTrustCrossSignedDevices();
return this.cryptoBackend.getTrustCrossSignedDevices();
}
/**
* See getCryptoTrustCrossSignedDevices
*
* @param val - True to trust cross-signed devices
*
* @deprecated Prefer {@link CryptoApi.setTrustCrossSignedDevices | `CryptoApi.setTrustCrossSignedDevices`}.
*/
public setCryptoTrustCrossSignedDevices(val: boolean): void {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
this.crypto.setCryptoTrustCrossSignedDevices(val);
this.cryptoBackend.setTrustCrossSignedDevices(val);
}
/**
@@ -2838,15 +2850,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* bootstrapSecretStorage() completes successfully, this function should
* return true.
*
* The Secure Secret Storage API is currently UNSTABLE and may change without notice.
*
* @returns True if secret storage is ready to be used on this device
* @deprecated Prefer {@link CryptoApi.isSecretStorageReady | `CryptoApi.isSecretStorageReady`}:
*/
public isSecretStorageReady(): Promise<boolean> {
if (!this.crypto) {
if (!this.cryptoBackend) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.isSecretStorageReady();
return this.cryptoBackend.isSecretStorageReady();
}
/**
@@ -4079,27 +4090,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
queryString["server_name"] = opts.viaServers;
}
try {
const data: IJoinRequestBody = {};
const signedInviteObj = await signPromise;
if (signedInviteObj) {
data.third_party_signed = signedInviteObj;
}
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
const roomId = res.room_id;
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
const room = syncApi.createRoom(roomId);
if (opts.syncRoom) {
// v2 will do this for us
// return syncApi.syncRoom(room);
}
return room;
} catch (e) {
throw e; // rethrow for reject
const data: IJoinRequestBody = {};
const signedInviteObj = await signPromise;
if (signedInviteObj) {
data.third_party_signed = signedInviteObj;
}
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
const roomId = res.room_id;
const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
const syncRoom = syncApi.createRoom(roomId);
if (opts.syncRoom) {
// v2 will do this for us
// return syncApi.syncRoom(room);
}
return syncRoom;
}
/**
@@ -4179,7 +4186,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: to an empty object
* @returns Rejects: with an error response.
*/
public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata): Promise<{}> {
public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata = {}): Promise<{}> {
const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
$userId: this.credentials.userId!,
$roomId: roomId,
@@ -4997,7 +5004,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
rpEvent?: MatrixEvent,
): Promise<{}> {
const room = this.getRoom(roomId);
if (room && room.hasPendingEvent(rmEventId)) {
if (room?.hasPendingEvent(rmEventId)) {
throw new Error(`Cannot set read marker to a pending event (${rmEventId})`);
}
@@ -5050,9 +5057,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const key = ts + "_" + url;
// If there's already a request in flight (or we've handled it), return that instead.
const cachedPreview = this.urlPreviewCache[key];
if (cachedPreview) {
return cachedPreview;
if (key in this.urlPreviewCache) {
return this.urlPreviewCache[key];
}
const resp = this.http.authedRequest<IPreviewUrlResponse>(
@@ -5567,11 +5573,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
room.currentState.setUnknownStateEvents(stateEvents);
}
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
const [timelineEvents, threadedEvents, unknownRelations] =
room.partitionThreadedEvents(matrixEvents);
this.processAggregatedTimelineEvents(room, timelineEvents);
room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
this.processThreadEvents(room, threadedEvents, true);
unknownRelations.forEach((event) => room.relations.aggregateChildEvent(event));
room.oldState.paginationToken = res.end ?? null;
if (res.chunk.length === 0) {
@@ -5680,11 +5688,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
}
const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
const [timelineEvents, threadedEvents, unknownRelations] = timelineSet.room.partitionThreadedEvents(events);
timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
// The target event is not in a thread but process the contextual events, so we can show any threads around it.
this.processThreadEvents(timelineSet.room, threadedEvents, true);
this.processAggregatedTimelineEvents(timelineSet.room, timelineEvents);
unknownRelations.forEach((event) => timelineSet.relations.aggregateChildEvent(event));
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
@@ -5730,6 +5739,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return undefined;
}
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
if (Thread.hasServerSideSupport) {
if (Thread.hasServerSideFwdPaginationSupport) {
if (!timelineSet.thread) {
@@ -5742,14 +5752,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Backward, from: res.start },
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
);
const resNewer: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Forward, from: res.end },
{ dir: Direction.Forward, from: res.end, recurse: recurse || undefined },
);
const events = [
// Order events from most recent to oldest (reverse-chronological).
@@ -5797,7 +5807,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Backward, from: res.start },
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
);
const eventsNewer: IEvent[] = [];
let nextBatch: Optional<string> = res.end;
@@ -5807,7 +5817,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Forward, from: nextBatch },
{ dir: Direction.Forward, from: nextBatch, recurse: recurse || undefined },
);
nextBatch = resNewer.next_batch ?? null;
eventsNewer.push(...resNewer.chunk);
@@ -5878,12 +5888,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
);
event = res.chunk?.[0];
} else if (timelineSet.thread && Thread.hasServerSideSupport) {
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
const res = await this.fetchRelations(
timelineSet.room.roomId,
timelineSet.thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Backward, limit: 1 },
{ dir: Direction.Backward, limit: 1, recurse: recurse || undefined },
);
event = res.chunk?.[0];
} else {
@@ -6158,10 +6169,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("Unknown room " + eventTimeline.getRoomId());
}
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, {
dir,
limit: opts.limit,
from: token ?? undefined,
recurse: recurse || undefined,
})
.then(async (res) => {
const mapper = this.getEventMapper();
@@ -6220,7 +6233,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
const timelineSet = eventTimeline.getTimelineSet();
const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
const [timelineEvents, , unknownRelations] = room.partitionThreadedEvents(matrixEvents);
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
this.processAggregatedTimelineEvents(room, timelineEvents);
this.processThreadRoots(
@@ -6228,6 +6241,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
false,
);
unknownRelations.forEach((event) => room.relations.aggregateChildEvent(event));
const atEnd = res.end === undefined || res.end === res.start;
@@ -7851,7 +7865,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: On success, the token response
* or UIA auth data.
*/
public async requestLoginToken(auth?: IAuthData): Promise<UIAResponse<LoginTokenPostResponse>> {
public async requestLoginToken(auth?: IAuthDict): Promise<UIAResponse<LoginTokenPostResponse>> {
// use capabilities to determine which revision of the MSC is being used
const capabilities = await this.getCapabilities();
// use r1 endpoint if capability is exposed otherwise use old r0 endpoint
@@ -7950,6 +7964,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) {
params = replaceParam("dir", "org.matrix.msc3715.dir", params);
}
if (this.canSupport.get(Feature.RelationsRecursion) === ServerSupport.Unstable) {
params = replaceParam("recurse", "org.matrix.msc3981.recurse", params);
}
const queryString = utils.encodeParams(params);
let templatedUrl = "/rooms/$roomId/relations/$eventId";
@@ -8230,7 +8247,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers: string[] }> {
// eslint-disable-line camelcase
// TODO: deprecate this or resolveRoomAlias
const path = utils.encodeUri("/directory/room/$alias", {
$alias: alias,
});
@@ -8240,10 +8256,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* @returns Promise which resolves: Object with room_id and servers.
* @returns Rejects: with an error response.
* @deprecated use `getRoomIdForAlias` instead
*/
// eslint-disable-next-line camelcase
public resolveRoomAlias(roomAlias: string): Promise<{ room_id: string; servers: string[] }> {
// TODO: deprecate this or getRoomIdForAlias
const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias });
return this.http.request(Method.Get, path);
}
@@ -8615,6 +8631,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Post, path, undefined, pusher);
}
/**
* Removes an existing pusher
* @param pushKey - pushkey of pusher to remove
* @param appId - app_id of pusher to remove
* @returns Promise which resolves: Empty json object on success
* @returns Rejects: with an error response.
*/
public removePusher(pushKey: string, appId: string): Promise<{}> {
const path = "/pushers/set";
const body = {
pushkey: pushKey,
app_id: appId,
kind: null, // marks pusher for removal
};
return this.http.authedRequest(Method.Post, path, undefined, body);
}
/**
* Persists local notification settings
* @returns Promise which resolves: an empty object
@@ -8837,7 +8870,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Get, "/keys/changes", qps);
}
public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> {
public uploadDeviceSigningKeys(auth?: IAuthDict, keys?: CrossSigningKeys): Promise<{}> {
// API returns empty object
const data = Object.assign({}, keys);
if (auth) Object.assign(data, { auth });
@@ -8986,7 +9019,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param identityAccessToken - The `access_token` field of the Identity
* Server `/account/register` response (see {@link registerWithIdentityServer}).
*
* @returns Promise which resolves: Object, currently with no parameters.
* @returns Promise which resolves: Object, containing success boolean.
* @returns Rejects: with an error response.
* @throws Error if No identity server is set
*/
@@ -8995,8 +9028,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
clientSecret: string,
msisdnToken: string,
identityAccessToken: string,
): Promise<any> {
// TODO: Types
): Promise<{ success: boolean }> {
const params = {
sid: sid,
client_secret: clientSecret,
@@ -9027,7 +9059,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* This must be the same value submitted in the requestToken call.
* @param msisdnToken - The MSISDN token, as enetered by the user.
*
* @returns Promise which resolves: Object, currently with no parameters.
* @returns Promise which resolves: Object, containing success boolean.
* @returns Rejects: with an error response.
*/
public submitMsisdnTokenOtherUrl(
@@ -9035,8 +9067,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
sid: string,
clientSecret: string,
msisdnToken: string,
): Promise<any> {
// TODO: Types
): Promise<{ success: boolean }> {
const params = {
sid: sid,
client_secret: clientSecret,
+22 -11
View File
@@ -18,9 +18,10 @@ import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
import { MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { CryptoApi } from "../crypto-api";
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
import { IEncryptedEventInfo } from "../crypto/api";
import { IEventDecryptionResult } from "../@types/crypto";
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
/**
* Common interface for the crypto implementations
@@ -51,16 +52,6 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*/
checkUserTrust(userId: string): UserTrustLevel;
/**
* Get the verification level for a given device
*
* TODO: define this better
*
* @param userId - user to be checked
* @param deviceId - device to be checked
*/
checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
/**
* Encrypt an event according to the configuration of the room.
*
@@ -87,6 +78,26 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* @param event - event to be checked
*/
getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
/**
* Finds a DM verification request that is already in progress for the given room id
*
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
*/
findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined;
/**
* Get the cross signing information for a given user.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
*
* @param userId - the user ID to get the cross-signing info for.
*
* @returns the cross signing information for the user.
*/
getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null;
}
/** The methods which crypto implementations should expose to the Sync api */
+2 -2
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as utils from "./utils";
import { encodeParams } from "./utils";
/**
* Get the HTTP URL for an MXC URI.
@@ -74,6 +74,6 @@ export function getHttpUriForMxc(
serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
}
const urlParams = Object.keys(params).length === 0 ? "" : "?" + utils.encodeParams(params);
const urlParams = Object.keys(params).length === 0 ? "" : "?" + encodeParams(params);
return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
}
+190
View File
@@ -16,6 +16,15 @@ limitations under the License.
import type { IMegolmSessionData } from "./@types/crypto";
import { Room } from "./models/room";
import { DeviceMap } from "./models/device";
import { UIAuthCallback } from "./interactive-auth";
/** Types of cross-signing key */
export enum CrossSigningKey {
Master = "master",
SelfSigning = "self_signing",
UserSigning = "user_signing",
}
/**
* Public interface to the cryptography parts of the js-sdk
@@ -72,4 +81,185 @@ export interface CryptoApi {
* session export objects
*/
exportRoomKeys(): Promise<IMegolmSessionData[]>;
/**
* Get the device information for the given list of users.
*
* For any users whose device lists are cached (due to sharing an encrypted room with the user), the
* cached device data is returned.
*
* If there are uncached users, and the `downloadUncached` parameter is set to `true`,
* a `/keys/query` request is made to the server to retrieve these devices.
*
* @param userIds - The users to fetch.
* @param downloadUncached - If true, download the device list for users whose device list we are not
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
*
* @returns A map `{@link DeviceMap}`.
*/
getUserDeviceInfo(userIds: string[], downloadUncached?: boolean): Promise<DeviceMap>;
/**
* Set whether to trust other user's signatures of their devices.
*
* If false, devices will only be considered 'verified' if we have
* verified that device individually (effectively disabling cross-signing).
*
* `true` by default.
*
* @param val - the new value
*/
setTrustCrossSignedDevices(val: boolean): void;
/**
* Return whether we trust other user's signatures of their devices.
*
* @see {@link Crypto.CryptoApi#setTrustCrossSignedDevices}
*
* @returns `true` if we trust cross-signed devices, otherwise `false`.
*/
getTrustCrossSignedDevices(): boolean;
/**
* Get the verification status of a given device.
*
* @param userId - The ID of the user whose device is to be checked.
* @param deviceId - The ID of the device to check
*
* @returns `null` if the device is unknown, or has not published any encryption keys (implying it does not support
* encryption); otherwise the verification status of the device.
*/
getDeviceVerificationStatus(userId: string, deviceId: string): Promise<DeviceVerificationStatus | null>;
/**
* Checks whether cross signing:
* - is enabled on this account and trusted by this device
* - has private keys either cached locally or stored in secret storage
*
* If this function returns false, bootstrapCrossSigning() can be used
* to fix things such that it returns true. That is to say, after
* bootstrapCrossSigning() completes successfully, this function should
* return true.
*
* @returns True if cross-signing is ready to be used on this device
*/
isCrossSigningReady(): Promise<boolean>;
/**
* Get the ID of one of the user's cross-signing keys.
*
* @param type - The type of key to get the ID of. One of `CrossSigningKey.Master`, `CrossSigningKey.SelfSigning`,
* or `CrossSigningKey.UserSigning`. Defaults to `CrossSigningKey.Master`.
*
* @returns If cross-signing has been initialised on this device, the ID of the given key. Otherwise, null
*/
getCrossSigningKeyId(type?: CrossSigningKey): Promise<string | null>;
/**
* Bootstrap cross-signing by creating keys if needed.
*
* If everything is already set up, then no changes are made, so this is safe to run to ensure
* cross-signing is ready for use.
*
* This function:
* - creates new cross-signing keys if they are not found locally cached nor in
* secret storage (if it has been set up)
* - publishes the public keys to the server if they are not already published
* - stores the private keys in secret storage if secret storage is set up.
*
* @param opts - options object
*/
bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void>;
/**
* Checks whether secret storage:
* - is enabled on this account
* - is storing cross-signing private keys
* - is storing session backup key (if enabled)
*
* If this function returns false, bootstrapSecretStorage() can be used
* to fix things such that it returns true. That is to say, after
* bootstrapSecretStorage() completes successfully, this function should
* return true.
*
* @returns True if secret storage is ready to be used on this device
*/
isSecretStorageReady(): Promise<boolean>;
}
/**
* Options object for `CryptoApi.bootstrapCrossSigning`.
*/
export interface BootstrapCrossSigningOpts {
/** Optional. Reset the cross-signing keys even if keys already exist. */
setupNewCrossSigning?: boolean;
/**
* An application callback to collect the authentication data for uploading the keys. If not given, the keys
* will not be uploaded to the server (which seems like a bad thing?).
*/
authUploadDeviceSigningKeys?: UIAuthCallback<void>;
}
export class DeviceVerificationStatus {
/**
* True if this device has been signed by its owner (and that signature verified).
*
* This doesn't necessarily mean that we have verified the device, since we may not have verified the
* owner's cross-signing key.
*/
public readonly signedByOwner: boolean;
/**
* True if this device has been verified via cross signing.
*
* This does *not* take into account `trustCrossSignedDevices`.
*/
public readonly crossSigningVerified: boolean;
/**
* TODO: tofu magic wtf does this do?
*/
public readonly tofu: boolean;
/**
* True if the device has been marked as locally verified.
*/
public readonly localVerified: boolean;
/**
* True if the client has been configured to trust cross-signed devices via {@link CryptoApi#setTrustCrossSignedDevices}.
*/
private readonly trustCrossSignedDevices: boolean;
public constructor(
opts: Partial<DeviceVerificationStatus> & {
/**
* True if cross-signed devices should be considered verified for {@link DeviceVerificationStatus#isVerified}.
*/
trustCrossSignedDevices?: boolean;
},
) {
this.signedByOwner = opts.signedByOwner ?? false;
this.crossSigningVerified = opts.crossSigningVerified ?? false;
this.tofu = opts.tofu ?? false;
this.localVerified = opts.localVerified ?? false;
this.trustCrossSignedDevices = opts.trustCrossSignedDevices ?? false;
}
/**
* Check if we should consider this device "verified".
*
* A device is "verified" if either:
* * it has been manually marked as such via {@link MatrixClient#setDeviceVerified}.
* * it has been cross-signed with a verified signing key, **and** the client has been configured to trust
* cross-signed devices via {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
*
* @returns true if this device is verified via any means.
*/
public isVerified(): boolean {
return this.localVerified || (this.trustCrossSignedDevices && this.crossSigningVerified);
}
}
export * from "./crypto-api/verification";
+112
View File
@@ -0,0 +1,112 @@
/*
Copyright 2023 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 { MatrixEvent } from "../models/event";
/** Events emitted by `Verifier`. */
export enum VerifierEvent {
/**
* The verification has been cancelled, by us or the other side.
*
* The payload is either an {@link Error}, or an (incoming or outgoing) {@link MatrixEvent}, depending on
* unspecified reasons.
*/
Cancel = "cancel",
/**
* SAS data has been exchanged and should be displayed to the user.
*
* The payload is the {@link ShowQrCodeCallbacks} object.
*/
ShowSas = "show_sas",
/**
* QR code data should be displayed to the user.
*
* The payload is the {@link ShowQrCodeCallbacks} object.
*/
ShowReciprocateQr = "show_reciprocate_qr",
}
/** Listener type map for {@link VerifierEvent}s. */
export type VerifierEventHandlerMap = {
[VerifierEvent.Cancel]: (e: Error | MatrixEvent) => void;
[VerifierEvent.ShowSas]: (sas: ShowSasCallbacks) => void;
[VerifierEvent.ShowReciprocateQr]: (qr: ShowQrCodeCallbacks) => void;
};
/**
* Callbacks for user actions while a QR code is displayed.
*
* This is exposed as the payload of a `VerifierEvent.ShowReciprocateQr` event, or can be retrieved directly from the
* verifier as `reciprocateQREvent`.
*/
export interface ShowQrCodeCallbacks {
/** The user confirms that the verification data matches */
confirm(): void;
/** Cancel the verification flow */
cancel(): void;
}
/**
* Callbacks for user actions while a SAS is displayed.
*
* This is exposed as the payload of a `VerifierEvent.ShowSas` event, or directly from the verifier as `sasEvent`.
*/
export interface ShowSasCallbacks {
/** The generated SAS to be shown to the user */
sas: GeneratedSas;
/** Function to call if the user confirms that the SAS matches.
*
* @returns A Promise that completes once the m.key.verification.mac is queued.
*/
confirm(): Promise<void>;
/**
* Function to call if the user finds the SAS does not match.
*
* Sends an `m.key.verification.cancel` event with a `m.mismatched_sas` error code.
*/
mismatch(): void;
/** Cancel the verification flow */
cancel(): void;
}
/** A generated SAS to be shown to the user, in alternative formats */
export interface GeneratedSas {
/**
* The SAS as three numbers between 0 and 8191.
*
* Only populated if the `decimal` SAS method was negotiated.
*/
decimal?: [number, number, number];
/**
* The SAS as seven emojis.
*
* Only populated if the `emoji` SAS method was negotiated.
*/
emoji?: EmojiMapping[];
}
/**
* An emoji for the generated SAS. A tuple `[emoji, name]` where `emoji` is the emoji itself and `name` is the
* English name.
*/
export type EmojiMapping = [emoji: string, name: string];
+16 -16
View File
@@ -31,6 +31,7 @@ import { ICryptoCallbacks } from ".";
import { ISignatures } from "../@types/signed";
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
import { DeviceVerificationStatus } from "../crypto-api";
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
@@ -628,15 +629,20 @@ export class UserTrustLevel {
}
/**
* Represents the ways in which we trust a device
* Represents the ways in which we trust a device.
*
* @deprecated Use {@link DeviceVerificationStatus}.
*/
export class DeviceTrustLevel {
export class DeviceTrustLevel extends DeviceVerificationStatus {
public constructor(
public readonly crossSigningVerified: boolean,
public readonly tofu: boolean,
private readonly localVerified: boolean,
private readonly trustCrossSignedDevices: boolean,
) {}
crossSigningVerified: boolean,
tofu: boolean,
localVerified: boolean,
trustCrossSignedDevices: boolean,
signedByOwner = false,
) {
super({ crossSigningVerified, tofu, localVerified, trustCrossSignedDevices, signedByOwner });
}
public static fromUserTrustLevel(
userTrustLevel: UserTrustLevel,
@@ -648,16 +654,10 @@ export class DeviceTrustLevel {
userTrustLevel.isTofu(),
localVerified,
trustCrossSignedDevices,
true,
);
}
/**
* @returns true if this device is verified via any means
*/
public isVerified(): boolean {
return Boolean(this.isLocallyVerified() || (this.trustCrossSignedDevices && this.isCrossSigningVerified()));
}
/**
* @returns true if this device is verified via cross signing
*/
@@ -688,7 +688,7 @@ export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: O
_expectedPublicKey: string,
): Promise<Uint8Array> {
const key = await new Promise<any>((resolve) => {
return store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
store.getSecretStorePrivateKey(txn, resolve, type);
});
});
@@ -790,7 +790,7 @@ export async function requestKeysDuringVerification(
})();
// We call getCrossSigningKey() for its side-effects
return Promise.race<KeysDuringVerification | void>([
Promise.race<KeysDuringVerification | void>([
Promise.all([
crossSigning.getCrossSigningKey("master"),
crossSigning.getCrossSigningKey("self_signing"),
+1 -1
View File
@@ -116,7 +116,7 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
public async load(): Promise<void> {
await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => {
this.hasFetched = Boolean(deviceData && deviceData.devices);
this.hasFetched = Boolean(deviceData?.devices);
this.devices = deviceData ? deviceData.devices : {};
this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {};
this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {};
+3 -2
View File
@@ -19,7 +19,7 @@ import { MatrixEvent } from "../models/event";
import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import { Method, ClientPrefix } from "../http-api";
import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index";
import { Crypto, ICryptoCallbacks } from "./index";
import {
ClientEvent,
ClientEventHandlerMap,
@@ -31,9 +31,10 @@ import {
import { IKeyBackupInfo } from "./keybackup";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { AccountDataClient, SecretStorageKeyDescription } from "../secret-storage";
import { BootstrapCrossSigningOpts } from "../crypto-api";
interface ICrossSigningKeys {
authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
authUpload: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>;
}
+17 -6
View File
@@ -192,12 +192,23 @@ class OlmDecryption extends DecryptionAlgorithm {
});
}
// check that the device that encrypted the event belongs to the user
// that the event claims it's from. We need to make sure that our
// device list is up-to-date. If the device is unknown, we can only
// assume that the device logged out. Some event handlers, such as
// secret sharing, may be more strict and reject events that come from
// unknown devices.
// check that the device that encrypted the event belongs to the user that the event claims it's from.
//
// To do this, we need to make sure that our device list is up-to-date. If the device is unknown, we can only
// assume that the device logged out and accept it anyway. Some event handlers, such as secret sharing, may be
// more strict and reject events that come from unknown devices.
//
// This is a defence against the following scenario:
//
// * Alice has verified Bob and Mallory.
// * Mallory gets control of Alice's server, and sends a megolm session to Alice using her (Mallory's)
// senderkey, but claiming to be from Bob.
// * Mallory sends more events using that session, claiming to be from Bob.
// * Alice sees that the senderkey is verified (since she verified Mallory) so marks events those
// events as verified even though the sender is forged.
//
// In practice, it's not clear that the js-sdk would behave that way, so this may be only a defence in depth.
await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
if (senderKeyUser !== event.getSender() && senderKeyUser != undefined) {
+1 -6
View File
@@ -19,6 +19,7 @@ import { IKeyBackupInfo } from "./keybackup";
import type { AddSecretStorageKeyOpts } from "../secret-storage";
/* re-exports for backwards compatibility. */
export { CrossSigningKey } from "../crypto-api";
export type {
AddSecretStorageKeyOpts as IAddSecretStorageKeyOpts,
PassphraseInfo as IPassphraseInfo,
@@ -27,12 +28,6 @@ export type {
// TODO: Merge this with crypto.js once converted
export enum CrossSigningKey {
Master = "master",
SelfSigning = "self_signing",
UserSigning = "user_signing",
}
export interface IEncryptedEventInfo {
/**
* whether the event is encrypted (if not encrypted, some of the other properties may not be set)
+45
View File
@@ -0,0 +1,45 @@
/*
Copyright 2023 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 { Device } from "../models/device";
import { DeviceInfo } from "./deviceinfo";
/**
* Convert a {@link DeviceInfo} to a {@link Device}.
* @param deviceInfo - deviceInfo to convert
* @param userId - id of the user that owns the device.
*/
export function deviceInfoToDevice(deviceInfo: DeviceInfo, userId: string): Device {
const keys = new Map<string, string>(Object.entries(deviceInfo.keys));
const displayName = deviceInfo.getDisplayName() || undefined;
const signatures = new Map<string, Map<string, string>>();
if (deviceInfo.signatures) {
for (const userId in deviceInfo.signatures) {
signatures.set(userId, new Map(Object.entries(deviceInfo.signatures[userId])));
}
}
return new Device({
deviceId: deviceInfo.deviceId,
userId: userId,
keys,
algorithms: deviceInfo.algorithms,
verified: deviceInfo.verified,
signatures,
displayName,
});
}
+1 -6
View File
@@ -15,6 +15,7 @@ limitations under the License.
*/
import { ISignatures } from "../@types/signed";
import { DeviceVerification } from "../models/device";
export interface IDevice {
keys: Record<string, string>;
@@ -25,12 +26,6 @@ export interface IDevice {
signatures?: ISignatures;
}
enum DeviceVerification {
Blocked = -1,
Unverified = 0,
Verified = 1,
}
/**
* Information about a user's device
*/
+104 -26
View File
@@ -35,7 +35,13 @@ import * as algorithms from "./algorithms";
import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning";
import { EncryptionSetupBuilder } from "./EncryptionSetup";
import { SecretStorage as LegacySecretStorage } from "./SecretStorage";
import { ICreateSecretStorageOpts, IEncryptedEventInfo, IImportRoomKeysOpts, IRecoveryKey } from "./api";
import {
CrossSigningKey,
ICreateSecretStorageOpts,
IEncryptedEventInfo,
IImportRoomKeysOpts,
IRecoveryKey,
} from "./api";
import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager";
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import { VerificationBase } from "./verification/Base";
@@ -45,7 +51,7 @@ import { keyFromPassphrase } from "./key_passphrase";
import { decodeRecoveryKey, encodeRecoveryKey } from "./recoverykey";
import { VerificationRequest } from "./verification/request/VerificationRequest";
import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel";
import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel";
import { Request, ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel";
import { IllegalMethod } from "./verification/IllegalMethod";
import { KeySignatureUploadError } from "../errors";
import { calculateKeyCheck, decryptAES, encryptAES } from "./aes";
@@ -54,7 +60,7 @@ import { BackupManager } from "./backup";
import { IStore } from "../store";
import { Room, RoomEvent } from "../models/room";
import { RoomMember, RoomMemberEvent } from "../models/room-member";
import { EventStatus, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
import { EventStatus, IContent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
import { ToDeviceBatch } from "../models/ToDeviceMessage";
import {
ClientEvent,
@@ -70,7 +76,6 @@ import { ISyncStateData } from "../sync";
import { CryptoStore } from "./store/base";
import { IVerificationChannel } from "./verification/request/Channel";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { IContent } from "../models/event";
import { IDeviceLists, ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
import { ISignatures } from "../@types/signed";
import { IMessage } from "./algorithms/olm";
@@ -80,14 +85,20 @@ import { MapWithDefault, recursiveMapToObject } from "../utils";
import {
AccountDataClient,
AddSecretStorageKeyOpts,
SECRET_STORAGE_ALGORITHM_V1_AES,
SecretStorageCallbacks,
SecretStorageKeyDescription,
SecretStorageKeyObject,
SecretStorageKeyTuple,
SECRET_STORAGE_ALGORITHM_V1_AES,
SecretStorageCallbacks,
ServerSideSecretStorageImpl,
} from "../secret-storage";
import { ISecretRequest } from "./SecretSharing";
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
import { Device, DeviceMap } from "../models/device";
import { deviceInfoToDevice } from "./device-converter";
/* re-exports for backwards compatibility */
export type { BootstrapCrossSigningOpts as IBootstrapCrossSigningOpts } from "../crypto-api";
const DeviceVerification = DeviceInfo.DeviceVerification;
@@ -124,16 +135,6 @@ interface IInitOpts {
pickleKey?: string;
}
export interface IBootstrapCrossSigningOpts {
/** Optional. Reset even if keys already exist. */
setupNewCrossSigning?: boolean;
/**
* A function that makes the request requiring auth. Receives the auth data as an object.
* Can be called multiple times, first with an empty authDict, to obtain the flows.
*/
authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>;
}
export interface ICryptoCallbacks extends SecretStorageCallbacks {
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
@@ -605,18 +606,23 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @returns True if trusting cross-signed devices
*/
public getTrustCrossSignedDevices(): boolean {
return this.trustCrossSignedDevices;
}
/**
* @deprecated Use {@link Crypto.CryptoApi#getTrustCrossSignedDevices}.
*/
public getCryptoTrustCrossSignedDevices(): boolean {
return this.trustCrossSignedDevices;
}
/**
* See getCryptoTrustCrossSignedDevices
* This may be set before initCrypto() is called to ensure no races occur.
*
* @param val - True to trust cross-signed devices
*/
public setCryptoTrustCrossSignedDevices(val: boolean): void {
public setTrustCrossSignedDevices(val: boolean): void {
this.trustCrossSignedDevices = val;
for (const userId of this.deviceList.getKnownUserIds()) {
@@ -634,6 +640,13 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
}
/**
* @deprecated Use {@link Crypto.CryptoApi#setTrustCrossSignedDevices}.
*/
public setCryptoTrustCrossSignedDevices(val: boolean): void {
this.setTrustCrossSignedDevices(val);
}
/**
* Create a recovery key from a user-supplied passphrase.
*
@@ -754,7 +767,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
public async bootstrapCrossSigning({
authUploadDeviceSigningKeys,
setupNewCrossSigning,
}: IBootstrapCrossSigningOpts = {}): Promise<void> {
}: BootstrapCrossSigningOpts = {}): Promise<void> {
logger.log("Bootstrapping cross-signing");
const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
@@ -1407,6 +1420,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @returns the key ID
*/
public getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
return Promise.resolve(this.getCrossSigningId(type));
}
// old name, for backwards compatibility
public getCrossSigningId(type: string): string | null {
return this.crossSigningInfo.getId(type);
}
@@ -1440,10 +1458,22 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* Check whether a given device is trusted.
*
* @param userId - The ID of the user whose devices is to be checked.
* @param userId - The ID of the user whose device is to be checked.
* @param deviceId - The ID of the device to check
*
* @returns
*/
public async getDeviceVerificationStatus(
userId: string,
deviceId: string,
): Promise<DeviceVerificationStatus | null> {
const device = this.deviceList.getStoredDevice(userId, deviceId);
if (!device) {
return null;
}
return this.checkDeviceInfoTrust(userId, device);
}
/**
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
*/
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
const device = this.deviceList.getStoredDevice(userId, deviceId);
@@ -1456,7 +1486,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* @param userId - The ID of the user whose devices is to be checked.
* @param device - The device info object to check
*
* @returns
* @deprecated Use {@link Crypto.CryptoApi.getDeviceVerificationStatus}.
*/
public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel {
const trustedLocally = !!device?.isVerified();
@@ -1805,7 +1835,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @param value - whether to blacklist all unverified devices by default
*
* @deprecated Set {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
* @deprecated Set {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
*/
public setGlobalBlacklistUnverifiedDevices(value: boolean): void {
this.globalBlacklistUnverifiedDevices = value;
@@ -1814,7 +1844,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* @returns whether to blacklist all unverified devices by default
*
* @deprecated Reference {@link CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
* @deprecated Reference {@link Crypto.CryptoApi#globalBlacklistUnverifiedDevices | CryptoApi.globalBlacklistUnverifiedDevices} directly.
*/
public getGlobalBlacklistUnverifiedDevices(): boolean {
return this.globalBlacklistUnverifiedDevices;
@@ -2038,6 +2068,54 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
return this.deviceList.getStoredDevicesForUser(userId);
}
/**
* Get the device information for the given list of users.
*
* @param userIds - The users to fetch.
* @param downloadUncached - If true, download the device list for users whose device list we are not
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
*
* @returns A map `{@link DeviceMap}`.
*/
public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise<DeviceMap> {
const deviceMapByUserId = new Map<string, Map<string, Device>>();
// Keep the users without device to download theirs keys
const usersWithoutDeviceInfo: string[] = [];
for (const userId of userIds) {
const deviceInfos = await this.getStoredDevicesForUser(userId);
// If there are device infos for a userId, we transform it into a map
// Else, the keys will be downloaded after
if (deviceInfos) {
const deviceMap = new Map(
// Convert DeviceInfo to Device
deviceInfos.map((deviceInfo) => [deviceInfo.deviceId, deviceInfoToDevice(deviceInfo, userId)]),
);
deviceMapByUserId.set(userId, deviceMap);
} else {
usersWithoutDeviceInfo.push(userId);
}
}
// Download device info for users without device infos
if (downloadUncached && usersWithoutDeviceInfo.length > 0) {
const newDeviceInfoMap = await this.downloadKeys(usersWithoutDeviceInfo);
newDeviceInfoMap.forEach((deviceInfoMap, userId) => {
const deviceMap = new Map<string, Device>();
// Convert DeviceInfo to Device
deviceInfoMap.forEach((deviceInfo, deviceId) =>
deviceMap.set(deviceId, deviceInfoToDevice(deviceInfo, userId)),
);
// Put the new device infos into the returned map
deviceMapByUserId.set(userId, deviceMap);
});
}
return deviceMapByUserId;
}
/**
* Get the stored keys for a single device
*
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { logger, PrefixedLogger } from "../../logger";
import * as utils from "../../utils";
import { deepCompare } from "../../utils";
import {
CryptoStore,
IDeviceData,
@@ -158,7 +158,7 @@ export class Backend implements CryptoStore {
const existing = cursor.value;
if (utils.deepCompare(existing.requestBody, requestBody)) {
if (deepCompare(existing.requestBody, requestBody)) {
// got a match
callback(existing);
return;
+3 -4
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { logger } from "../../logger";
import * as utils from "../../utils";
import { safeSet, deepCompare, promiseTry } from "../../utils";
import {
CryptoStore,
IDeviceData,
@@ -33,7 +33,6 @@ import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
import { safeSet } from "../../utils";
/**
* Internal module. in-memory storage for e2e.
@@ -90,7 +89,7 @@ export class MemoryCryptoStore implements CryptoStore {
public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
const requestBody = request.requestBody;
return utils.promiseTry(() => {
return promiseTry(() => {
// first see if we already have an entry for this request.
const existing = this._getOutgoingRoomKeyRequest(requestBody);
@@ -138,7 +137,7 @@ export class MemoryCryptoStore implements CryptoStore {
// eslint-disable-next-line @typescript-eslint/naming-convention
private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null {
for (const existing of this.outgoingRoomKeyRequests) {
if (utils.deepCompare(existing.requestBody, requestBody)) {
if (deepCompare(existing.requestBody, requestBody)) {
return existing;
}
}
+14 -7
View File
@@ -28,7 +28,8 @@ import { KeysDuringVerification, requestKeysDuringVerification } from "../CrossS
import { IVerificationChannel } from "./request/Channel";
import { MatrixClient } from "../../client";
import { VerificationRequest } from "./request/VerificationRequest";
import { ListenerMap, TypedEventEmitter } from "../../models/typed-event-emitter";
import { TypedEventEmitter } from "../../models/typed-event-emitter";
import { VerifierEvent, VerifierEventHandlerMap } from "../../crypto-api/verification";
const timeoutException = new Error("Verification timed out");
@@ -40,18 +41,24 @@ export class SwitchStartEventError extends Error {
export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void;
export enum VerificationEvent {
Cancel = "cancel",
}
/** @deprecated use VerifierEvent */
export type VerificationEvent = VerifierEvent;
/** @deprecated use VerifierEvent */
export const VerificationEvent = VerifierEvent;
/** @deprecated use VerifierEventHandlerMap */
export type VerificationEventHandlerMap = {
[VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void;
};
// The type parameters of VerificationBase are no longer used, but we need some placeholders to maintain
// backwards compatibility with applications that reference the class.
export class VerificationBase<
Events extends string,
Arguments extends ListenerMap<Events | VerificationEvent>,
> extends TypedEventEmitter<Events | VerificationEvent, Arguments, VerificationEventHandlerMap> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Events extends string = VerifierEvent,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Arguments = VerifierEventHandlerMap,
> extends TypedEventEmitter<VerifierEvent, VerifierEventHandlerMap> {
private cancelled = false;
private _done = false;
private promise: Promise<void> | null = null;
+8 -15
View File
@@ -18,7 +18,7 @@ limitations under the License.
* QR code key verification.
*/
import { VerificationBase as Base, VerificationEventHandlerMap } from "./Base";
import { VerificationBase as Base } from "./Base";
import { newKeyMismatchError, newUserCancelledError } from "./Error";
import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib";
import { logger } from "../../logger";
@@ -26,25 +26,18 @@ import { VerificationRequest } from "./request/VerificationRequest";
import { MatrixClient } from "../../client";
import { IVerificationChannel } from "./request/Channel";
import { MatrixEvent } from "../../models/event";
import { ShowQrCodeCallbacks, VerifierEvent } from "../../crypto-api/verification";
export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1";
export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1";
interface IReciprocateQr {
confirm(): void;
cancel(): void;
}
/** @deprecated use VerifierEvent */
export type QrCodeEvent = VerifierEvent;
/** @deprecated use VerifierEvent */
export const QrCodeEvent = VerifierEvent;
export enum QrCodeEvent {
ShowReciprocateQr = "show_reciprocate_qr",
}
type EventHandlerMap = {
[QrCodeEvent.ShowReciprocateQr]: (qr: IReciprocateQr) => void;
} & VerificationEventHandlerMap;
export class ReciprocateQRCode extends Base<QrCodeEvent, EventHandlerMap> {
public reciprocateQREvent?: IReciprocateQr;
export class ReciprocateQRCode extends Base {
public reciprocateQREvent?: ShowQrCodeCallbacks;
public static factory(
channel: IVerificationChannel,
+17 -26
View File
@@ -21,7 +21,7 @@ limitations under the License.
import anotherjson from "another-json";
import { Utility, SAS as OlmSAS } from "@matrix-org/olm";
import { VerificationBase as Base, SwitchStartEventError, VerificationEventHandlerMap } from "./Base";
import { VerificationBase as Base, SwitchStartEventError } from "./Base";
import {
errorFactory,
newInvalidMessageError,
@@ -33,6 +33,14 @@ import { logger } from "../../logger";
import { IContent, MatrixEvent } from "../../models/event";
import { generateDecimalSas } from "./SASDecimal";
import { EventType } from "../../@types/event";
import { EmojiMapping, GeneratedSas, ShowSasCallbacks, VerifierEvent } from "../../crypto-api/verification";
// backwards-compatibility exports
export type {
ShowSasCallbacks as ISasEvent,
GeneratedSas as IGeneratedSas,
EmojiMapping,
} from "../../crypto-api/verification";
const START_TYPE = EventType.KeyVerificationStart;
@@ -44,8 +52,6 @@ const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short
const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment");
type EmojiMapping = [emoji: string, name: string];
const emojiMapping: EmojiMapping[] = [
["🐶", "dog"], // 0
["🐱", "cat"], // 1
@@ -133,20 +139,8 @@ const sasGenerators = {
emoji: generateEmojiSas,
} as const;
export interface IGeneratedSas {
decimal?: [number, number, number];
emoji?: EmojiMapping[];
}
export interface ISasEvent {
sas: IGeneratedSas;
confirm(): Promise<void>;
cancel(): void;
mismatch(): void;
}
function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas {
const sas: IGeneratedSas = {};
function generateSas(sasBytes: Uint8Array, methods: string[]): GeneratedSas {
const sas: GeneratedSas = {};
for (const method of methods) {
if (method in sasGenerators) {
// @ts-ignore - ts doesn't like us mixing types like this
@@ -220,19 +214,16 @@ function intersection<T>(anArray: T[], aSet: Set<T>): T[] {
return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : [];
}
export enum SasEvent {
ShowSas = "show_sas",
}
/** @deprecated use VerifierEvent */
export type SasEvent = VerifierEvent;
/** @deprecated use VerifierEvent */
export const SasEvent = VerifierEvent;
type EventHandlerMap = {
[SasEvent.ShowSas]: (sas: ISasEvent) => void;
} & VerificationEventHandlerMap;
export class SAS extends Base<SasEvent, EventHandlerMap> {
export class SAS extends Base {
private waitingForAccept?: boolean;
public ourSASPubKey?: string;
public theirSASPubKey?: string;
public sasEvent?: ISasEvent;
public sasEvent?: ShowSasCallbacks;
// eslint-disable-next-line @typescript-eslint/naming-convention
public static get NAME(): string {
@@ -125,7 +125,7 @@ export class InRoomChannel implements IVerificationChannel {
// part of a verification request, so be noisy when rejecting something
if (type === REQUEST_TYPE) {
if (!content || typeof content.to !== "string" || !content.to.length) {
logger.log("InRoomChannel: validateEvent: " + "no valid to " + (content && content.to));
logger.log("InRoomChannel: validateEvent: " + "no valid to " + content.to);
return false;
}
@@ -134,7 +134,7 @@ export class InRoomChannel implements IVerificationChannel {
logger.log(
"InRoomChannel: validateEvent: " +
`not directed to or sent by me: ${event.getSender()}` +
`, ${content && content.to}`,
`, ${content.to}`,
);
return false;
}
@@ -208,10 +208,17 @@ export class InRoomChannel implements IVerificationChannel {
this.requestEventId = InRoomChannel.getTransactionId(event);
}
// With pendingEventOrdering: "chronological", we will see events that have been sent but not yet reflected
// back via /sync. These are "local echoes" and are identifiable by their txnId
const isLocalEcho = !!event.getTxnId();
// Alternatively, we may see an event that we sent that is reflected back via /sync. These are "remote echoes"
// and have a transaction ID in the "unsigned" data
const isRemoteEcho = !!event.getUnsigned().transaction_id;
const isSentByUs = event.getSender() === this.client.getUserId();
return request.handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs);
return request.handleEvent(type, event, isLiveEvent, isLocalEcho || isRemoteEcho, isSentByUs);
}
/**
+1 -2
View File
@@ -25,14 +25,13 @@ import {
ISendEventFromWidgetResponseData,
} from "matrix-widget-api";
import { IEvent, IContent, EventStatus } from "./models/event";
import { MatrixEvent, IEvent, IContent, EventStatus } from "./models/event";
import { ISendEventResponse } from "./@types/requests";
import { EventType } from "./@types/event";
import { logger } from "./logger";
import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts, SendToDeviceContentMap } from "./client";
import { SyncApi, SyncState } from "./sync";
import { SlidingSyncSdk } from "./sliding-sync-sdk";
import { MatrixEvent } from "./models/event";
import { User } from "./models/user";
import { Room } from "./models/room";
import { ToDeviceBatch, ToDevicePayload } from "./models/ToDeviceMessage";
+4
View File
@@ -31,6 +31,7 @@ export enum Feature {
LoginTokenRequest = "LoginTokenRequest",
RelationBasedRedactions = "RelationBasedRedactions",
AccountDataDeletion = "AccountDataDeletion",
RelationsRecursion = "RelationsRecursion",
}
type FeatureSupportCondition = {
@@ -56,6 +57,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
[Feature.AccountDataDeletion]: {
unstablePrefixes: ["org.matrix.msc3391"],
},
[Feature.RelationsRecursion]: {
unstablePrefixes: ["org.matrix.msc3981"],
},
};
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
+3 -3
View File
@@ -18,7 +18,7 @@ limitations under the License.
* This is an internal module. See {@link MatrixHttpApi} for the public class.
*/
import * as utils from "../utils";
import { checkObjectHasKeys, encodeParams } from "../utils";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { Method } from "./method";
import { ConnectionError, MatrixError } from "./errors";
@@ -45,7 +45,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
public readonly opts: O,
) {
utils.checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
opts.onlyData = !!opts.onlyData;
opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
}
@@ -304,7 +304,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL {
const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path);
if (queryParams) {
utils.encodeParams(queryParams, url.searchParams);
encodeParams(queryParams, url.searchParams);
}
return url;
}
+13 -13
View File
@@ -17,7 +17,7 @@ limitations under the License.
import { FetchHttpApi } from "./fetch";
import { FileType, IContentUri, IHttpOpts, Upload, UploadOpts, UploadResponse } from "./interface";
import { MediaPrefix } from "./prefix";
import * as utils from "../utils";
import { defer, QueryDict, removeElement } from "../utils";
import * as callbacks from "../realtime-callbacks";
import { Method } from "./method";
import { ConnectionError } from "./errors";
@@ -58,14 +58,14 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
total: 0,
abortController,
} as Upload;
const defer = utils.defer<UploadResponse>();
const deferred = defer<UploadResponse>();
if (global.XMLHttpRequest) {
const xhr = new global.XMLHttpRequest();
const timeoutFn = function (): void {
xhr.abort();
defer.reject(new Error("Timeout"));
deferred.reject(new Error("Timeout"));
};
// set an initial timeout of 30s; we'll advance it each time we get a progress notification
@@ -84,16 +84,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
}
if (xhr.status >= 400) {
defer.reject(parseErrorResponse(xhr, xhr.responseText));
deferred.reject(parseErrorResponse(xhr, xhr.responseText));
} else {
defer.resolve(JSON.parse(xhr.responseText));
deferred.resolve(JSON.parse(xhr.responseText));
}
} catch (err) {
if ((<Error>err).name === "AbortError") {
defer.reject(err);
deferred.reject(err);
return;
}
defer.reject(new ConnectionError("request failed", <Error>err));
deferred.reject(new ConnectionError("request failed", <Error>err));
}
break;
}
@@ -131,7 +131,7 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
xhr.abort();
});
} else {
const queryParams: utils.QueryDict = {};
const queryParams: QueryDict = {};
if (includeFilename && fileName) {
queryParams.filename = fileName;
}
@@ -146,16 +146,16 @@ export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
.then((response) => {
return this.opts.onlyData ? <UploadResponse>response : response.json();
})
.then(defer.resolve, defer.reject);
.then(deferred.resolve, deferred.reject);
}
// remove the upload from the list on completion
upload.promise = defer.promise.finally(() => {
utils.removeElement(this.uploads, (elem) => elem === upload);
upload.promise = deferred.promise.finally(() => {
removeElement(this.uploads, (elem) => elem === upload);
});
abortController.signal.addEventListener("abort", () => {
utils.removeElement(this.uploads, (elem) => elem === upload);
defer.reject(new DOMException("Aborted", "AbortError"));
removeElement(this.uploads, (elem) => elem === upload);
deferred.reject(new DOMException("Aborted", "AbortError"));
});
this.uploads.push(upload);
return upload.promise;
+54 -6
View File
@@ -20,12 +20,13 @@ import { logger } from "./logger";
import { MatrixClient } from "./client";
import { defer, IDeferred } from "./utils";
import { MatrixError } from "./http-api";
import { UIAResponse } from "./@types/uia";
const EMAIL_STAGE_TYPE = "m.login.email.identity";
const MSISDN_STAGE_TYPE = "m.login.msisdn";
export interface UIAFlow {
stages: AuthType[];
stages: Array<AuthType | string>;
}
export interface IInputs {
@@ -44,7 +45,14 @@ export interface IStageStatus {
error?: string;
}
/**
* Data returned in the body of a 401 response from a UIA endpoint.
*
* @see https://spec.matrix.org/v1.6/client-server-api/#user-interactive-api-in-the-rest-api
*/
export interface IAuthData {
// XXX: many of the fields here (`type`, `available_flows`, `required_stages`, etc) look like they
// shouldn't be here. They aren't in the spec and it's unclear what they are supposed to do. Be wary of using them.
session?: string;
type?: string;
completed?: string[];
@@ -77,6 +85,11 @@ export enum AuthType {
UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token",
}
/**
* The parameters which are submitted as the `auth` dict in a UIA request
*
* @see https://spec.matrix.org/v1.6/client-server-api/#authentication-types
*/
export interface IAuthDict {
// [key: string]: any;
type?: string;
@@ -106,6 +119,16 @@ export class NoAuthFlowFoundError extends Error {
}
}
/**
* The type of an application callback to perform the user-interactive bit of UIA.
*
* It is called with a single parameter, `makeRequest`, which is a function which takes the UIA parameters and
* makes the HTTP request.
*
* The generic parameter `T` is the type of the response of the endpoint, once it is eventually successful.
*/
export type UIAuthCallback<T> = (makeRequest: (authData: IAuthDict) => Promise<UIAResponse<T>>) => Promise<T>;
interface IOpts {
/**
* A matrix client to use for the auth process
@@ -133,13 +156,21 @@ interface IOpts {
*/
emailSid?: string;
/**
* If specified, will prefer flows which entirely consist of listed stages.
* These should normally be of type AuthTypes but can be string when supporting custom auth stages.
*
* This can be used to avoid needing the fallback mechanism.
*/
supportedStages?: Array<AuthType | string>;
/**
* Called with the new auth dict to submit the request.
* Also passes a second deprecated arg which is a flag set to true if this request is a background request.
* The busyChanged callback should be used instead of the background flag.
* Should return a promise which resolves to the successful response or rejects with a MatrixError.
*/
doRequest(auth: IAuthData | null, background: boolean): Promise<IAuthData>;
doRequest(auth: IAuthDict | null, background: boolean): Promise<IAuthData>;
/**
* Called when the status of the UI auth changes,
* ie. when the state of an auth stage changes of when the auth flow moves to a new stage.
@@ -153,7 +184,7 @@ interface IOpts {
* m.login.email.identity:
* * emailSid: string, the sid of the active email auth session
*/
stateUpdated(nextStage: AuthType, status: IStageStatus): void;
stateUpdated(nextStage: AuthType | string, status: IStageStatus): void;
/**
* A function that takes the email address (string), clientSecret (string), attempt number (int) and
@@ -193,6 +224,7 @@ export class InteractiveAuth {
private readonly busyChangedCallback?: IOpts["busyChanged"];
private readonly stateUpdatedCallback: IOpts["stateUpdated"];
private readonly requestEmailTokenCallback: IOpts["requestEmailToken"];
private readonly supportedStages?: Set<string>;
private data: IAuthData;
private emailSid?: string;
@@ -220,6 +252,7 @@ export class InteractiveAuth {
if (opts.sessionId) this.data.session = opts.sessionId;
this.clientSecret = opts.clientSecret || this.matrixClient.generateClientSecret();
this.emailSid = opts.emailSid;
if (opts.supportedStages !== undefined) this.supportedStages = new Set(opts.supportedStages);
}
/**
@@ -441,7 +474,7 @@ export class InteractiveAuth {
* This can be set to true for requests that just poll to see if auth has
* been completed elsewhere.
*/
private async doRequest(auth: IAuthData | null, background = false): Promise<void> {
private async doRequest(auth: IAuthDict | null, background = false): Promise<void> {
try {
const result = await this.requestCallback(auth, background);
this.attemptAuthDeferred!.resolve(result);
@@ -548,7 +581,7 @@ export class InteractiveAuth {
* @returns login type
* @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
*/
private chooseStage(): AuthType | undefined {
private chooseStage(): AuthType | string | undefined {
if (this.chosenFlow === null) {
this.chosenFlow = this.chooseFlow();
}
@@ -558,6 +591,17 @@ export class InteractiveAuth {
return nextStage;
}
// Returns a low number for flows we consider best. Counts increase for longer flows and even more so
// for flows which contain stages not listed in `supportedStages`.
private scoreFlow(flow: UIAFlow): number {
let score = flow.stages.length;
if (this.supportedStages !== undefined) {
// Add 10 points to the score for each unsupported stage in the flow.
score += flow.stages.filter((stage) => !this.supportedStages!.has(stage)).length * 10;
}
return score;
}
/**
* Pick one of the flows from the returned list
* If a flow using all of the inputs is found, it will
@@ -580,6 +624,10 @@ export class InteractiveAuth {
const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid);
const haveMsisdn = Boolean(this.inputs.phoneCountry) && Boolean(this.inputs.phoneNumber);
// Flows are not represented in a significant order, so we can choose any we support best
// Sort flows based on how many unsupported stages they contain ascending
flows.sort((a, b) => this.scoreFlow(a) - this.scoreFlow(b));
for (const flow of flows) {
let flowHasEmail = false;
let flowHasMsisdn = false;
@@ -610,7 +658,7 @@ export class InteractiveAuth {
* @internal
* @returns login type
*/
private firstUncompletedStage(flow: UIAFlow): AuthType | undefined {
private firstUncompletedStage(flow: UIAFlow): AuthType | string | undefined {
const completed = this.data.completed || [];
return flow.stages.find((stageType) => !completed.includes(stageType));
}
+24 -1
View File
@@ -37,7 +37,9 @@ export * from "./models/event-timeline-set";
export * from "./models/poll";
export * from "./models/room-member";
export * from "./models/room-state";
export * from "./models/typed-event-emitter";
export * from "./models/user";
export * from "./models/device";
export * from "./scheduler";
export * from "./filter";
export * from "./timeline-window";
@@ -62,9 +64,30 @@ export { createNewMatrixCall } from "./webrtc/call";
export type { MatrixCall } from "./webrtc/call";
export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall";
export type { GroupCall } from "./webrtc/groupCall";
export type { CryptoApi } from "./crypto-api";
export { CryptoEvent } from "./crypto";
/**
* Types supporting cryptography.
*
* The most important is {@link Crypto.CryptoApi}, an instance of which can be retrieved via
* {@link MatrixClient.getCrypto}.
*/
export * as Crypto from "./crypto-api";
/**
* Backwards compatibility re-export
* @internal
* @deprecated use {@link Crypto.CryptoApi}
*/
export type { CryptoApi } from "./crypto-api";
/**
* Backwards compatibility re-export
* @internal
* @deprecated use {@link Crypto.DeviceVerificationStatus}
*/
export { DeviceVerificationStatus } from "./crypto-api";
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
/**
+81
View File
@@ -0,0 +1,81 @@
/*
Copyright 2023 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.
*/
/** State of the verification of the device. */
export enum DeviceVerification {
Blocked = -1,
Unverified = 0,
Verified = 1,
}
/** A map from user ID to device ID to Device */
export type DeviceMap = Map<string, Map<string, Device>>;
type DeviceParameters = Pick<Device, "deviceId" | "userId" | "algorithms" | "keys"> & Partial<Device>;
/**
* Information on a user's device, as returned by {@link Crypto.CryptoApi.getUserDeviceInfo}.
*/
export class Device {
/** id of the device */
public readonly deviceId: string;
/** id of the user that owns the device */
public readonly userId: string;
/** list of algorithms supported by this device */
public readonly algorithms: string[];
/** a map from `<key type>:<id> -> <base64-encoded key>` */
public readonly keys: Map<string, string>;
/** whether the device has been verified/blocked by the user */
public readonly verified: DeviceVerification;
/** a map `<userId, map<algorithm:device_id, signature>>` */
public readonly signatures: Map<string, Map<string, string>>;
/** display name of the device */
public readonly displayName?: string;
public constructor(opts: DeviceParameters) {
this.deviceId = opts.deviceId;
this.userId = opts.userId;
this.algorithms = opts.algorithms;
this.keys = opts.keys;
this.verified = opts.verified || DeviceVerification.Unverified;
this.signatures = opts.signatures || new Map();
this.displayName = opts.displayName;
}
/**
* Get the fingerprint for this device (ie, the Ed25519 key)
*
* @returns base64-encoded fingerprint of this device
*/
public getFingerprint(): string | undefined {
return this.keys.get(`ed25519:${this.deviceId}`);
}
/**
* Get the identity key for this device (ie, the Curve25519 key)
*
* @returns base64-encoded identity key of this device
*/
public getIdentityKey(): string | undefined {
return this.keys.get(`curve25519:${this.deviceId}`);
}
}
+88
View File
@@ -756,6 +756,94 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
this.emit(RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data);
}
/**
* Insert event to the given timeline, and emit Room.timeline. Assumes
* we have already checked we don't know about this event.
*
* TEMPORARY: until we have recursive relations, we need this function
* to exist to allow us to insert events in timeline order, which is our
* best guess for Sync Order.
* This is a copy of addEventToTimeline above, modified to insert the event
* after the event it relates to, and before any event with a later
* timestamp. This is our best guess at Sync Order.
*
* Will fire "Room.timeline" for each event added.
*
* @internal
*
* @param options - addEventToTimeline options
*
* @remarks
* Fires {@link RoomEvent.Timeline}
*/
public insertEventIntoTimeline(event: MatrixEvent, timeline: EventTimeline, roomState: RoomState): void {
if (timeline.getTimelineSet() !== this) {
throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " +
"in timelineSet(threadId=${this.thread?.id})`);
}
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
// threaded message should not be in the main timeline).
//
// We can only run this check for timelines with a `room` because `canContain`
// requires it
if (this.room && !this.canContain(event)) {
let eventDebugString = `event=${event.getId()}`;
if (event.threadRootId) {
eventDebugString += `(belongs to thread=${event.threadRootId})`;
}
logger.warn(
`EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` +
`in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`,
);
return;
}
// Find the event that this event is related to - the "parent"
const parentEventId = event.relationEventId;
if (!parentEventId) {
// Not related to anything - we just append
this.addEventToTimeline(event, timeline, {
toStartOfTimeline: false,
fromCache: false,
timelineWasEmpty: false,
roomState,
});
return;
}
const parentEvent = this.findEventById(parentEventId);
const timelineEvents = timeline.getEvents();
// Start searching from the parent event, or if it's not loaded, start
// at the beginning and insert purely using timestamp order.
const parentIndex = parentEvent !== undefined ? timelineEvents.indexOf(parentEvent) : 0;
let insertIndex = parentIndex;
for (; insertIndex < timelineEvents.length; insertIndex++) {
const nextEvent = timelineEvents[insertIndex];
if (nextEvent.getTs() > event.getTs()) {
// We found an event later than ours, so insert before that.
break;
}
}
// If we got to the end of the loop, insertIndex points at the end of
// the list.
const eventId = event.getId()!;
timeline.insertEvent(event, insertIndex, roomState);
this._eventIdToTimeline.set(eventId, timeline);
this.relations.aggregateParentEvent(event);
this.relations.aggregateChildEvent(event, this);
const data: IRoomTimelineData = {
timeline: timeline,
liveEvent: timeline == this.liveTimeline,
};
this.emit(RoomEvent.Timeline, event, this.room, false, false, data);
}
/**
* Replaces event with ID oldEventId with one with newEventId, if oldEventId is
* recognised. Otherwise, add to the live timeline. Used to handle remote echos.
+39
View File
@@ -427,6 +427,45 @@ export class EventTimeline {
}
}
/**
* Insert a new event into the timeline, and update the state.
*
* TEMPORARY: until we have recursive relations, we need this function
* to exist to allow us to insert events in timeline order, which is our
* best guess for Sync Order.
* This is a copy of addEvent above, modified to allow inserting an event at
* a specific index.
*
* @internal
*/
public insertEvent(event: MatrixEvent, insertIndex: number, roomState: RoomState): void {
const timelineSet = this.getTimelineSet();
if (timelineSet.room) {
EventTimeline.setEventMetadata(event, roomState, false);
// modify state but only on unfiltered timelineSets
if (event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
roomState.setStateEvents([event], {});
// it is possible that the act of setting the state event means we
// can set more metadata (specifically sender/target props), so try
// it again if the prop wasn't previously set. It may also mean that
// the sender/target is updated (if the event set was a room member event)
// so we want to use the *updated* member (new avatar/name) instead.
//
// However, we do NOT want to do this on member events if we're going
// back in time, else we'll set the .sender value for BEFORE the given
// member event, whereas we want to set the .sender value for the ACTUAL
// member event itself.
if (!event.sender || event.getType() === EventType.RoomMember) {
EventTimeline.setEventMetadata(event, roomState, false);
}
}
}
this.events.splice(insertIndex, 0, event); // insert element
}
/**
* Remove an event from the timeline
*
+8 -1
View File
@@ -24,7 +24,13 @@ import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk";
import type { IEventDecryptionResult } from "../@types/crypto";
import { logger } from "../logger";
import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event";
import {
EVENT_VISIBILITY_CHANGE_TYPE,
EventType,
MsgType,
RelationType,
UNSIGNED_THREAD_ID_FIELD,
} from "../@types/event";
import { Crypto } from "../crypto";
import { deepSortedObjectEntries, internaliseString } from "../utils";
import { RoomMember } from "./room-member";
@@ -63,6 +69,7 @@ export interface IUnsigned {
"transaction_id"?: string;
"invite_room_state"?: StrippedState[];
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
}
export interface IThreadBundledRelationship {
+1 -1
View File
@@ -186,7 +186,7 @@ export class IgnoredInvites {
}
let regexp: RegExp;
try {
regexp = new RegExp(globToRegexp(glob, false));
regexp = new RegExp(globToRegexp(glob));
} catch (ex) {
// Assume invalid event.
continue;
+2 -2
View File
@@ -20,7 +20,7 @@ import {
WrappedReceipt,
} from "../@types/read_receipts";
import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
import * as utils from "../utils";
import { isSupportedReceiptType } from "../utils";
import { MatrixEvent } from "./event";
import { EventType } from "../@types/event";
import { EventTimelineSet } from "./event-timeline-set";
@@ -267,7 +267,7 @@ export abstract class ReadReceipt<
public getUsersReadUpTo(event: MatrixEvent): string[] {
return this.getReceiptsForEvent(event)
.filter(function (receipt) {
return utils.isSupportedReceiptType(receipt.type);
return isSupportedReceiptType(receipt.type);
})
.map(function (receipt) {
return receipt.userId;
+7 -7
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils";
import { removeDirectionOverrideChars, removeHiddenChars } from "../utils";
import { User } from "./user";
import { MatrixEvent } from "./event";
import { RoomState } from "./room-state";
@@ -206,8 +206,8 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
// not quite raw: we strip direction override chars so it can safely be inserted into
// blocks of text without breaking the text direction
this.rawDisplayName = utils.removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? "");
if (!this.rawDisplayName || !utils.removeHiddenChars(this.rawDisplayName)) {
this.rawDisplayName = removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? "");
if (!this.rawDisplayName || !removeHiddenChars(this.rawDisplayName)) {
this.rawDisplayName = this.userId;
}
@@ -407,7 +407,7 @@ function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
if (!utils.removeHiddenChars(displayName)) return false;
if (!removeHiddenChars(displayName)) return false;
if (!roomState) return false;
@@ -432,11 +432,11 @@ function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?
function calculateDisplayName(selfUserId: string, displayName: string | undefined, disambiguate: boolean): string {
if (!displayName || displayName === selfUserId) return selfUserId;
if (disambiguate) return utils.removeDirectionOverrideChars(displayName) + " (" + selfUserId + ")";
if (disambiguate) return removeDirectionOverrideChars(displayName) + " (" + selfUserId + ")";
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
if (!utils.removeHiddenChars(displayName)) return selfUserId;
if (!removeHiddenChars(displayName)) return selfUserId;
// We always strip the direction override characters (LRO and RLO).
// These override the text direction for all subsequent characters
@@ -449,5 +449,5 @@ function calculateDisplayName(selfUserId: string, displayName: string | undefine
// names should flip into the correct direction automatically based on
// the characters, and you can still embed rtl in ltr or vice versa
// with the embed chars or marker chars.
return utils.removeDirectionOverrideChars(displayName);
return removeDirectionOverrideChars(displayName);
}
+6 -6
View File
@@ -16,7 +16,7 @@ limitations under the License.
import { RoomMember } from "./room-member";
import { logger } from "../logger";
import * as utils from "../utils";
import { isNumber, removeHiddenChars } from "../utils";
import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event";
import { IEvent, MatrixEvent, MatrixEventEvent } from "./event";
import { MatrixClient } from "../client";
@@ -759,7 +759,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
* @returns An array of user IDs or an empty array.
*/
public getUserIdsWithDisplayName(displayName: string): string[] {
return this.displayNameToUserIds.get(utils.removeHiddenChars(displayName)) ?? [];
return this.displayNameToUserIds.get(removeHiddenChars(displayName)) ?? [];
}
/**
@@ -798,7 +798,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
}
let requiredLevel = 50;
if (utils.isNumber(powerLevels[action])) {
if (isNumber(powerLevels[action])) {
requiredLevel = powerLevels[action]!;
}
@@ -928,7 +928,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
powerLevelsEvent &&
powerLevelsEvent.getContent() &&
powerLevelsEvent.getContent().notifications &&
utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
) {
notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey];
}
@@ -1058,7 +1058,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
// We clobber the user_id > name lookup but the name -> [user_id] lookup
// means we need to remove that user ID from that array rather than nuking
// the lot.
const strippedOldName = utils.removeHiddenChars(oldName);
const strippedOldName = removeHiddenChars(oldName);
const existingUserIds = this.displayNameToUserIds.get(strippedOldName);
if (existingUserIds) {
@@ -1070,7 +1070,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.userIdsToDisplayNames[userId] = displayName;
const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
const strippedDisplayname = displayName && removeHiddenChars(displayName);
// an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
if (strippedDisplayname) {
const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? [];
+146 -31
View File
@@ -24,7 +24,7 @@ import {
} from "./event-timeline-set";
import { Direction, EventTimeline } from "./event-timeline";
import { getHttpUriForMxc } from "../content-repo";
import * as utils from "../utils";
import { compare, removeElement } from "../utils";
import { normalize, noUnsafeEventProps } from "../utils";
import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event";
import { EventStatus } from "./event-status";
@@ -39,6 +39,7 @@ import {
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
EVENT_VISIBILITY_CHANGE_TYPE,
RelationType,
UNSIGNED_THREAD_ID_FIELD,
} from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
@@ -72,8 +73,8 @@ import { isPollEvent, Poll, PollEvent } from "./poll";
// room versions which are considered okay for people to run without being asked
// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers
// return an m.room_versions capability.
export const KNOWN_SAFE_ROOM_VERSION = "9";
const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
export const KNOWN_SAFE_ROOM_VERSION = "10";
const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10"];
interface IOpts {
/**
@@ -368,22 +369,25 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* The room summary.
*/
public summary: RoomSummary | null = null;
// legacy fields
/**
* The live event timeline for this room, with the oldest event at index 0.
* Present for backwards compatibility - prefer getLiveTimeline().getEvents()
*
* @deprecated Present for backwards compatibility.
* Use getLiveTimeline().getEvents() instead
*/
public timeline!: MatrixEvent[];
/**
* oldState The state of the room at the time of the oldest
* event in the live timeline. Present for backwards compatibility -
* prefer getLiveTimeline().getState(EventTimeline.BACKWARDS).
* oldState The state of the room at the time of the oldest event in the live timeline.
*
* @deprecated Present for backwards compatibility.
* Use getLiveTimeline().getState(EventTimeline.BACKWARDS) instead
*/
public oldState!: RoomState;
/**
* currentState The state of the room at the time of the
* newest event in the timeline. Present for backwards compatibility -
* prefer getLiveTimeline().getState(EventTimeline.FORWARDS).
* currentState The state of the room at the time of the newest event in the timeline.
*
* @deprecated Present for backwards compatibility.
* Use getLiveTimeline().getState(EventTimeline.FORWARDS) instead.
*/
public currentState!: RoomState;
public readonly relations = new RelationsContainer(this.client, this);
@@ -393,6 +397,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* This is not a comprehensive list of the threads that exist in this room
*/
private threads = new Map<string, Thread>();
/**
* @deprecated This value is unreliable. It may not contain the last thread.
* Use {@link Room.getLastThread} instead.
*/
public lastThread?: Thread;
/**
@@ -725,7 +734,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
);
}
const removed = utils.removeElement(
const removed = removeElement(
this.pendingEventList,
function (ev) {
return ev.getId() == eventId;
@@ -782,6 +791,54 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
/**
* Returns the last live event of this room.
* "last" means latest timestamp.
* Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
* Unfortunately, this information is currently not available in the client.
* See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
* "live of this room" means from all live timelines: the room and the threads.
*
* @returns MatrixEvent if there is a last event; else undefined.
*/
public getLastLiveEvent(): MatrixEvent | undefined {
const roomEvents = this.getLiveTimeline().getEvents();
const lastRoomEvent = roomEvents[roomEvents.length - 1] as MatrixEvent | undefined;
const lastThread = this.getLastThread();
if (!lastThread) return lastRoomEvent;
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
return (lastRoomEvent?.getTs() ?? 0) > (lastThreadEvent?.getTs() ?? 0) ? lastRoomEvent : lastThreadEvent;
}
/**
* Returns the last thread of this room.
* "last" means latest timestamp of the last thread event.
* Instead of using timestamps, it would be better to do the comparison based on the order of the homeserver DAG.
* Unfortunately, this information is currently not available in the client.
* See {@link https://github.com/matrix-org/matrix-js-sdk/issues/3325}.
*
* @returns the thread with the most recent event in its live time line. undefined if there is no thread.
*/
public getLastThread(): Thread | undefined {
return this.getThreads().reduce<Thread | undefined>((lastThread: Thread | undefined, thread: Thread) => {
if (!lastThread) return thread;
const threadEvent = thread.events[thread.events.length - 1];
const lastThreadEvent = lastThread.events[lastThread.events.length - 1];
if ((threadEvent?.getTs() ?? 0) >= (lastThreadEvent?.getTs() ?? 0)) {
// Last message of current thread is newer → new last thread.
// Equal also means newer, because it was added to the thread map later.
return thread;
}
return lastThread;
}, undefined);
}
/**
* @returns the membership type (join | leave | invite) for the logged in user
*/
@@ -2076,6 +2133,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return this.eventShouldLiveIn(parentEvent, events, roots);
}
if (!event.isRelation()) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
};
}
// Edge case where we know the event is a relation but don't have the parentEvent
if (roots?.has(event.relationEventId!)) {
return {
@@ -2085,9 +2149,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
};
}
// We've exhausted all scenarios, can safely assume that this event should live in the room timeline only
const unsigned = event.getUnsigned();
if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD.name] === "string") {
return {
shouldLiveInRoom: false,
shouldLiveInThread: true,
threadId: unsigned[UNSIGNED_THREAD_ID_FIELD.name],
};
}
// We've exhausted all scenarios,
// we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread
// adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts
return {
shouldLiveInRoom: true,
shouldLiveInRoom: false,
shouldLiveInThread: false,
};
}
@@ -2100,14 +2175,13 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void {
let thread = this.getThread(threadId);
if (!thread) {
const thread = this.getThread(threadId);
if (thread) {
thread.addEvents(events, toStartOfTimeline);
} else {
const rootEvent = this.findEventById(threadId) ?? events.find((e) => e.getId() === threadId);
thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline);
this.createThread(threadId, rootEvent, events, toStartOfTimeline);
}
thread.addEvents(events, toStartOfTimeline);
}
/**
@@ -2644,16 +2718,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @param addLiveEventOptions - addLiveEvent options
* @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'.
*/
public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void;
public async addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): Promise<void>;
/**
* @deprecated In favor of the overload with `IAddLiveEventOptions`
*/
public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void;
public addLiveEvents(
public async addLiveEvents(
events: MatrixEvent[],
duplicateStrategy?: DuplicateStrategy,
fromCache?: boolean,
): Promise<void>;
public async addLiveEvents(
events: MatrixEvent[],
duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
fromCache = false,
): void {
): Promise<void> {
let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy;
let timelineWasEmpty: boolean | undefined = false;
if (typeof duplicateStrategyOrOpts === "object") {
@@ -2704,6 +2782,9 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
timelineWasEmpty,
};
// List of extra events to check for being parents of any relations encountered
const neighbouringEvents = [...events];
for (const event of events) {
// TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
this.processLiveEvent(event);
@@ -2717,12 +2798,35 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
let { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
event,
events,
neighbouringEvents,
threadRoots,
);
if (!shouldLiveInThread && !shouldLiveInRoom && event.isRelation()) {
try {
const parentEvent = new MatrixEvent(
await this.client.fetchRoomEvent(this.roomId, event.relationEventId!),
);
neighbouringEvents.push(parentEvent);
if (parentEvent.threadRootId) {
threadRoots.add(parentEvent.threadRootId);
const unsigned = event.getUnsigned();
unsigned[UNSIGNED_THREAD_ID_FIELD.name] = parentEvent.threadRootId;
event.setUnsigned(unsigned);
}
({ shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
event,
neighbouringEvents,
threadRoots,
));
} catch (e) {
logger.error("Failed to load parent event of unhandled relation", e);
}
}
if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) {
eventsByThread[threadId ?? ""] = [];
}
@@ -2730,6 +2834,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
if (shouldLiveInRoom) {
this.addLiveEvent(event, options);
} else if (!shouldLiveInThread && event.isRelation()) {
this.relations.aggregateChildEvent(event);
}
}
@@ -2740,13 +2846,14 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
public partitionThreadedEvents(
events: MatrixEvent[],
): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[]] {
): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[], unknownRelations: MatrixEvent[]] {
// Indices to the events array, for readability
const ROOM = 0;
const THREAD = 1;
const UNKNOWN_RELATION = 2;
if (this.client.supportsThreads()) {
const threadRoots = this.findThreadRoots(events);
return events.reduce(
return events.reduce<[MatrixEvent[], MatrixEvent[], MatrixEvent[]]>(
(memo, event: MatrixEvent) => {
const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
event,
@@ -2763,13 +2870,17 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
memo[THREAD].push(event);
}
if (!shouldLiveInThread && !shouldLiveInRoom) {
memo[UNKNOWN_RELATION].push(event);
}
return memo;
},
[[] as MatrixEvent[], [] as MatrixEvent[]],
[[], [], []],
);
} else {
// When `experimentalThreadSupport` is disabled treat all events as timelineEvents
return [events as MatrixEvent[], [] as MatrixEvent[]];
return [events as MatrixEvent[], [] as MatrixEvent[], [] as MatrixEvent[]];
}
}
@@ -2782,6 +2893,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
threadRoots.add(event.relationEventId ?? "");
}
const unsigned = event.getUnsigned();
if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD.name] === "string") {
threadRoots.add(unsigned[UNSIGNED_THREAD_ID_FIELD.name]!);
}
}
return threadRoots;
}
@@ -3211,7 +3326,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return true;
});
// make sure members have stable order
otherMembers.sort((a, b) => utils.compare(a.userId, b.userId));
otherMembers.sort((a, b) => compare(a.userId, b.userId));
// only 5 first members, immitate summaryHeroes
otherMembers = otherMembers.slice(0, 5);
otherNames = otherMembers.map((m) => m.name);
+94 -20
View File
@@ -28,6 +28,7 @@ import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
import { ReadReceipt } from "./read-receipt";
import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts";
import { Feature, ServerSupport } from "../feature";
export enum ThreadEvent {
New = "Thread.new",
@@ -202,11 +203,33 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
): void => {
// Add a synthesized receipt when paginating forward in the timeline
if (!toStartOfTimeline) {
room!.addLocalEchoReceipt(event.getSender()!, event, ReceiptType.Read);
const sender = event.getSender();
if (sender && room && this.shouldSendLocalEchoReceipt(sender, event)) {
room.addLocalEchoReceipt(sender, event, ReceiptType.Read);
}
}
this.onEcho(event, toStartOfTimeline ?? false);
};
private shouldSendLocalEchoReceipt(sender: string, event: MatrixEvent): boolean {
const recursionSupport = this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
if (recursionSupport === ServerSupport.Unsupported) {
// Normally we add a local receipt, but if we don't have
// recursion support, then events may arrive out of order, so we
// only create a receipt if it's after our existing receipt.
const oldReceiptEventId = this.getReadReceiptForUserId(sender)?.eventId;
if (oldReceiptEventId) {
const receiptEvent = this.findEventById(oldReceiptEventId);
if (receiptEvent && receiptEvent.getTs() > event.getTs()) {
return false;
}
}
}
return true;
}
private onLocalEcho = (event: MatrixEvent): void => {
this.onEcho(event, false);
};
@@ -235,6 +258,34 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
}
}
/**
* TEMPORARY. Only call this when MSC3981 is not available, and we have some
* late-arriving events to insert, because we recursively found them as part
* of populating a thread. When we have MSC3981 we won't need it, because
* they will all be supplied by the homeserver in one request, and they will
* already be in the right order in that response.
* This is a copy of addEventToTimeline above, modified to call
* insertEventIntoTimeline so this event is inserted into our best guess of
* the right place based on timestamp. (We should be using Sync Order but we
* don't have it.)
*
* @internal
*/
public insertEventIntoTimeline(event: MatrixEvent): void {
const eventId = event.getId();
if (!eventId) {
return;
}
// If the event is already in this thread, bail out
if (this.findEventById(eventId)) {
return;
}
this.timelineSet.insertEventIntoTimeline(event, this.liveTimeline, this.roomState);
// As far as we know, timeline should always be the same as events
this.timeline = this.events;
}
public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach((ev) => this.addEvent(ev, toStartOfTimeline, false));
this.updateThreadMetadata();
@@ -280,7 +331,14 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
*/
this.replayEvents?.push(event);
} else {
this.addEventToTimeline(event, toStartOfTimeline);
const recursionSupport =
this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
if (recursionSupport === ServerSupport.Unsupported) {
this.insertEventIntoTimeline(event);
} else {
this.addEventToTimeline(event, toStartOfTimeline);
}
}
// Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.relations?.aggregateParentEvent(event);
@@ -458,25 +516,31 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
// XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
return Promise.all(
events
.filter((e) => e.isEncrypted())
.map((event: MatrixEvent) => {
if (event.isRelation()) return; // skip - relations don't get edits
return this.client
.relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), {
limit: 1,
})
.then((relations) => {
if (relations.events.length) {
event.makeReplaced(relations.events[0]);
}
})
.catch((e) => {
logger.error("Failed to load edits for encrypted thread event", e);
});
const recursionSupport = this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported;
if (recursionSupport === ServerSupport.Unsupported) {
return Promise.all(
events.filter(isAnEncryptedThreadMessage).map(async (event: MatrixEvent) => {
try {
const relations = await this.client.relations(
this.roomId,
event.getId()!,
RelationType.Replace,
event.getType(),
{
limit: 1,
},
);
if (relations.events.length) {
const editEvent = relations.events[0];
event.makeReplaced(editEvent);
this.insertEventIntoTimeline(editEvent);
}
} catch (e) {
logger.error("Failed to load edits for encrypted thread event", e);
}
}),
);
);
}
}
public setEventMetadata(event: Optional<MatrixEvent>): void {
@@ -644,6 +708,16 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
}
}
/**
* Decide whether an event deserves to have its potential edits fetched.
*
* @returns true if this event is encrypted and is a message that is part of a
* thread - either inside it, or a root.
*/
function isAnEncryptedThreadMessage(event: MatrixEvent): boolean {
return event.isEncrypted() && (event.isRelation(THREAD_RELATION_TYPE.name) || event.isThreadRoot);
}
export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(
"related_by_senders",
"io.element.relation_senders",
+116 -2
View File
@@ -17,6 +17,7 @@ limitations under the License.
// eslint-disable-next-line no-restricted-imports
import { EventEmitter } from "events";
/** Events emitted by EventEmitter itself */
export enum EventEmitterEvents {
NewListener = "newListener",
RemoveListener = "removeListener",
@@ -24,10 +25,22 @@ export enum EventEmitterEvents {
}
type AnyListener = (...args: any) => any;
/** Base class for types mapping from event name to the type of listeners to that event */
export type ListenerMap<E extends string> = { [eventName in E]: AnyListener };
type EventEmitterEventListener = (eventName: string, listener: AnyListener) => void;
type EventEmitterErrorListener = (error: Error) => void;
/**
* The expected type of a listener function for a particular event.
*
* Type parameters:
* * `E` - List of all events emitted by the `TypedEventEmitter`. Normally an enum type.
* * `A` - A type providing mappings from event names to listener types.
* * `T` - The name of the actual event that this listener is for. Normally one of the types in `E` or
* {@link EventEmitterEvents}.
*/
export type Listener<E extends string, A extends ListenerMap<E>, T extends E | EventEmitterEvents> = T extends E
? A[T]
: T extends EventEmitterEvents
@@ -40,12 +53,20 @@ export type Listener<E extends string, A extends ListenerMap<E>, T extends E | E
* This makes it much easier for us to distinguish between events, as we now need
* to properly type this, so that our events are not stringly-based and prone
* to silly typos.
*
* Type parameters:
* * `Events` - List of all events emitted by this `TypedEventEmitter`. Normally an enum type.
* * `Arguments` - A {@link ListenerMap} type providing mappings from event names to listener types.
* * `SuperclassArguments` - TODO: not really sure. Alternative listener mappings, I think? But only honoured for `.emit`?
*/
export class TypedEventEmitter<
Events extends string,
Arguments extends ListenerMap<Events>,
SuperclassArguments extends ListenerMap<any> = Arguments,
> extends EventEmitter {
/**
* Alias for {@link TypedEventEmitter#on}.
*/
public addListener<T extends Events | EventEmitterEvents>(
event: T,
listener: Listener<Events, Arguments, T>,
@@ -53,32 +74,97 @@ export class TypedEventEmitter<
return super.addListener(event, listener);
}
/**
* Synchronously calls each of the listeners registered for the event named
* `event`, in the order they were registered, passing the supplied arguments
* to each.
*
* @param event - The name of the event to emit
* @param args - Arguments to pass to the listener
* @returns `true` if the event had listeners, `false` otherwise.
*/
public emit<T extends Events>(event: T, ...args: Parameters<SuperclassArguments[T]>): boolean;
public emit<T extends Events>(event: T, ...args: Parameters<Arguments[T]>): boolean;
public emit<T extends Events>(event: T, ...args: any[]): boolean {
return super.emit(event, ...args);
}
/**
* Returns the number of listeners listening to the event named `event`.
*
* @param event - The name of the event being listened for
*/
public listenerCount(event: Events | EventEmitterEvents): number {
return super.listenerCount(event);
}
public listeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["listeners"]> {
/**
* Returns a copy of the array of listeners for the event named `event`.
*/
public listeners(event: Events | EventEmitterEvents): Function[] {
return super.listeners(event);
}
/**
* Alias for {@link TypedEventEmitter#removeListener}
*/
public off<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
return super.off(event, listener);
}
/**
* Adds the `listener` function to the end of the listeners array for the
* event named `event`.
*
* No checks are made to see if the `listener` has already been added. Multiple calls
* passing the same combination of `event` and `listener` will result in the `listener`
* being added, and called, multiple times.
*
* By default, event listeners are invoked in the order they are added. The
* {@link TypedEventEmitter#prependListener} method can be used as an alternative to add the
* event listener to the beginning of the listeners array.
*
* @param event - The name of the event.
* @param listener - The callback function
*
* @returns a reference to the `EventEmitter`, so that calls can be chained.
*/
public on<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
return super.on(event, listener);
}
/**
* Adds a **one-time** `listener` function for the event named `event`. The
* next time `event` is triggered, this listener is removed and then invoked.
*
* Returns a reference to the `EventEmitter`, so that calls can be chained.
*
* By default, event listeners are invoked in the order they are added.
* The {@link TypedEventEmitter#prependOnceListener} method can be used as an alternative to add the
* event listener to the beginning of the listeners array.
*
* @param event - The name of the event.
* @param listener - The callback function
*
* @returns a reference to the `EventEmitter`, so that calls can be chained.
*/
public once<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
return super.once(event, listener);
}
/**
* Adds the `listener` function to the _beginning_ of the listeners array for the
* event named `event`.
*
* No checks are made to see if the `listener` has already been added. Multiple calls
* passing the same combination of `event` and `listener` will result in the `listener`
* being added, and called, multiple times.
*
* @param event - The name of the event.
* @param listener - The callback function
*
* @returns a reference to the `EventEmitter`, so that calls can be chained.
*/
public prependListener<T extends Events | EventEmitterEvents>(
event: T,
listener: Listener<Events, Arguments, T>,
@@ -86,6 +172,15 @@ export class TypedEventEmitter<
return super.prependListener(event, listener);
}
/**
* Adds a **one-time**`listener` function for the event named `event` to the _beginning_ of the listeners array.
* The next time `event` is triggered, this listener is removed, and then invoked.
*
* @param event - The name of the event.
* @param listener - The callback function
*
* @returns a reference to the `EventEmitter`, so that calls can be chained.
*/
public prependOnceListener<T extends Events | EventEmitterEvents>(
event: T,
listener: Listener<Events, Arguments, T>,
@@ -93,10 +188,25 @@ export class TypedEventEmitter<
return super.prependOnceListener(event, listener);
}
/**
* Removes all listeners, or those of the specified `event`.
*
* It is bad practice to remove listeners added elsewhere in the code,
* particularly when the `EventEmitter` instance was created by some other
* component or module (e.g. sockets or file streams).
*
* @param event - The name of the event. If undefined, all listeners everywhere are removed.
* @returns a reference to the `EventEmitter`, so that calls can be chained.
*/
public removeAllListeners(event?: Events | EventEmitterEvents): this {
return super.removeAllListeners(event);
}
/**
* Removes the specified `listener` from the listener array for the event named `event`.
*
* @returns a reference to the `EventEmitter`, so that calls can be chained.
*/
public removeListener<T extends Events | EventEmitterEvents>(
event: T,
listener: Listener<Events, Arguments, T>,
@@ -104,7 +214,11 @@ export class TypedEventEmitter<
return super.removeListener(event, listener);
}
public rawListeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["rawListeners"]> {
/**
* Returns a copy of the array of listeners for the event named `eventName`,
* including any wrappers (such as those created by `.once()`).
*/
public rawListeners(event: Events | EventEmitterEvents): Function[] {
return super.rawListeners(event);
}
}
+189
View File
@@ -0,0 +1,189 @@
/*
Copyright 2023 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 { IMinimalEvent } from "./sync-accumulator";
import { EventType } from "./@types/event";
import { isSupportedReceiptType, MapWithDefault, recursiveMapToObject } from "./utils";
import { IContent } from "./models/event";
import { ReceiptContent, ReceiptType } from "./@types/read_receipts";
interface AccumulatedReceipt {
data: IMinimalEvent;
type: ReceiptType;
eventId: string;
}
/**
* Summarises the read receipts within a room. Used by the sync accumulator.
*
* Given receipts for users, picks the most recently-received one and provides
* the results in a new fake receipt event returned from
* buildAccumulatedReceiptEvent().
*
* Handles unthreaded receipts and receipts in each thread separately, so the
* returned event contains the most recently received unthreaded receipt, and
* the most recently received receipt in each thread.
*/
export class ReceiptAccumulator {
/** user_id -\> most-recently-received unthreaded receipt */
private unthreadedReadReceipts: Map<string, AccumulatedReceipt> = new Map();
/** thread_id -\> user_id -\> most-recently-received receipt for this thread */
private threadedReadReceipts: MapWithDefault<string, Map<string, AccumulatedReceipt>> = new MapWithDefault(
() => new Map(),
);
/**
* Provide an unthreaded receipt for this user. Overwrites any other
* unthreaded receipt we have for this user.
*/
private setUnthreaded(userId: string, receipt: AccumulatedReceipt): void {
this.unthreadedReadReceipts.set(userId, receipt);
}
/**
* Provide a receipt for this user in this thread. Overwrites any other
* receipt we have for this user in this thread.
*/
private setThreaded(threadId: string, userId: string, receipt: AccumulatedReceipt): void {
this.threadedReadReceipts.getOrCreate(threadId).set(userId, receipt);
}
/**
* @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
* most recently-received unthreaded receipts for each user.
*/
private allUnthreaded(): IterableIterator<[string, AccumulatedReceipt]> {
return this.unthreadedReadReceipts.entries();
}
/**
* @returns an iterator of pairs of [userId, AccumulatedReceipt] - all the
* most recently-received threaded receipts for each user, in all
* threads.
*/
private *allThreaded(): IterableIterator<[string, AccumulatedReceipt]> {
for (const receiptsForThread of this.threadedReadReceipts.values()) {
for (const e of receiptsForThread.entries()) {
yield e;
}
}
}
/**
* Given a list of ephemeral events, find the receipts and store the
* relevant ones to be returned later from buildAccumulatedReceiptEvent().
*/
public consumeEphemeralEvents(events: IMinimalEvent[] | undefined): void {
events?.forEach((e) => {
if (e.type !== EventType.Receipt || !e.content) {
// This means we'll drop unknown ephemeral events but that
// seems okay.
return;
}
// Handle m.receipt events. They clobber based on:
// (user_id, receipt_type)
// but they are keyed in the event as:
// content:{ $event_id: { $receipt_type: { $user_id: {json} }}}
// so store them in the former so we can accumulate receipt deltas
// quickly and efficiently (we expect a lot of them). Fold the
// receipt type into the key name since we only have 1 at the
// moment (m.read) and nested JSON objects are slower and more
// of a hassle to work with. We'll inflate this back out when
// getJSON() is called.
Object.keys(e.content).forEach((eventId) => {
Object.entries<ReceiptContent>(e.content[eventId]).forEach(([key, value]) => {
if (!isSupportedReceiptType(key)) return;
for (const userId of Object.keys(value)) {
const data = e.content[eventId][key][userId];
const receipt = {
data: e.content[eventId][key][userId],
type: key as ReceiptType,
eventId,
};
// In a world that supports threads, read receipts normally have
// a `thread_id` which is either the thread they belong in or
// `MAIN_ROOM_TIMELINE`, so we normally use `setThreaded(...)`
// here. The `MAIN_ROOM_TIMELINE` is just treated as another
// thread.
//
// We still encounter read receipts that are "unthreaded"
// (missing the `thread_id` property). These come from clients
// that don't support threads, and from threaded clients that
// are doing a "Mark room as read" operation. Unthreaded
// receipts mark everything "before" them as read, in all
// threads, where "before" means in Sync Order i.e. the order
// the events were received from the homeserver in a sync.
// [Note: we have some bugs where we use timestamp order instead
// of Sync Order, because we don't correctly remember the Sync
// Order. See #3325.]
//
// Calling the wrong method will cause incorrect behavior like
// messages re-appearing as "new" when you already read them
// previously.
if (!data.thread_id) {
this.setUnthreaded(userId, receipt);
} else {
this.setThreaded(data.thread_id, userId, receipt);
}
}
});
});
});
}
/**
* Build a receipt event that contains all relevant information for this
* room, taking the most recently received receipt for each user in an
* unthreaded context, and in each thread.
*/
public buildAccumulatedReceiptEvent(roomId: string): IMinimalEvent | null {
const receiptEvent: IMinimalEvent = {
type: EventType.Receipt,
room_id: roomId,
content: {
// $event_id: { "m.read": { $user_id: $json } }
} as IContent,
};
const receiptEventContent: MapWithDefault<
string,
MapWithDefault<ReceiptType, Map<string, object>>
> = new MapWithDefault(() => new MapWithDefault(() => new Map()));
for (const [userId, receiptData] of this.allUnthreaded()) {
receiptEventContent
.getOrCreate(receiptData.eventId)
.getOrCreate(receiptData.type)
.set(userId, receiptData.data);
}
for (const [userId, receiptData] of this.allThreaded()) {
receiptEventContent
.getOrCreate(receiptData.eventId)
.getOrCreate(receiptData.type)
.set(userId, receiptData.data);
}
receiptEvent.content = recursiveMapToObject(receiptEventContent);
return receiptEventContent.size > 0 ? receiptEvent : null;
}
}
+101
View File
@@ -0,0 +1,101 @@
/*
Copyright 2023 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 { OlmMachine, CrossSigningStatus } from "@matrix-org/matrix-sdk-crypto-js";
import { BootstrapCrossSigningOpts } from "../crypto-api";
import { logger } from "../logger";
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { UIAuthCallback } from "../interactive-auth";
/** Manages the cross-signing keys for our own user.
*/
export class CrossSigningIdentity {
public constructor(
private readonly olmMachine: OlmMachine,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
) {}
/**
* Initialise our cross-signing keys by creating new keys if they do not exist, and uploading to the server
*/
public async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
if (opts.setupNewCrossSigning) {
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
return;
}
const olmDeviceStatus: CrossSigningStatus = await this.olmMachine.crossSigningStatus();
const privateKeysInSecretStorage = false; // TODO
const olmDeviceHasKeys =
olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
// Log all relevant state for easier parsing of debug logs.
logger.log("bootStrapCrossSigning: starting", {
setupNewCrossSigning: opts.setupNewCrossSigning,
olmDeviceHasMaster: olmDeviceStatus.hasMaster,
olmDeviceHasUserSigning: olmDeviceStatus.hasUserSigning,
olmDeviceHasSelfSigning: olmDeviceStatus.hasSelfSigning,
privateKeysInSecretStorage,
});
if (!olmDeviceHasKeys && !privateKeysInSecretStorage) {
logger.log(
"bootStrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys",
);
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
} else if (olmDeviceHasKeys) {
logger.log("bootStrapCrossSigning: Olm device has private keys: exporting to secret storage");
await this.exportCrossSigningKeysToStorage();
} else if (privateKeysInSecretStorage) {
logger.log(
"bootStrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
"in secret storage, reading storage and caching locally",
);
throw new Error("TODO");
}
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
// server -- in which case we should call OlmDevice.bootstrap_cross_signing. How do we know?
logger.log("bootStrapCrossSigning: complete");
}
/** Reset our cross-signing keys
*
* This method will:
* * Tell the OlmMachine to create new keys
* * Upload the new public keys and the device signature to the server
* * Upload the private keys to SSSS, if it is set up
*/
private async resetCrossSigning(authUploadDeviceSigningKeys?: UIAuthCallback<void>): Promise<void> {
const outgoingRequests: Array<OutgoingRequest> = await this.olmMachine.bootstrapCrossSigning(true);
logger.log("bootStrapCrossSigning: publishing keys to server");
for (const req of outgoingRequests) {
await this.outgoingRequestProcessor.makeOutgoingRequest(req, authUploadDeviceSigningKeys);
}
await this.exportCrossSigningKeysToStorage();
}
/**
* Extract the cross-signing keys from the olm machine and save them to secret storage, if it is configured
*
* (If secret storage is *not* configured, we assume that the export will happen when it is set up)
*/
private async exportCrossSigningKeysToStorage(): Promise<void> {
// TODO
}
}
+37 -1
View File
@@ -23,11 +23,14 @@ import {
RoomMessageRequest,
SignatureUploadRequest,
ToDeviceRequest,
SigningKeysUploadRequest,
} from "@matrix-org/matrix-sdk-crypto-js";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { QueryDict } from "../utils";
import { IAuthDict, UIAuthCallback } from "../interactive-auth";
import { UIAResponse } from "../@types/uia";
/**
* Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
@@ -53,7 +56,7 @@ export class OutgoingRequestProcessor {
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
) {}
public async makeOutgoingRequest(msg: OutgoingRequest): Promise<void> {
public async makeOutgoingRequest<T>(msg: OutgoingRequest, uiaCallback?: UIAuthCallback<T>): Promise<void> {
let resp: string;
/* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
@@ -79,6 +82,14 @@ export class OutgoingRequestProcessor {
`/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` +
`${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
} else if (msg instanceof SigningKeysUploadRequest) {
resp = await this.makeRequestWithUIA(
Method.Post,
"/_matrix/client/v3/keys/device_signing/upload",
{},
msg.body,
uiaCallback,
);
} else {
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
resp = "";
@@ -89,6 +100,31 @@ export class OutgoingRequestProcessor {
}
}
private async makeRequestWithUIA<T>(
method: Method,
path: string,
queryParams: QueryDict,
body: string,
uiaCallback: UIAuthCallback<T> | undefined,
): Promise<string> {
if (!uiaCallback) {
return await this.rawJsonRequest(method, path, queryParams, body);
}
const parsedBody = JSON.parse(body);
const makeRequest = async (auth: IAuthDict): Promise<UIAResponse<T>> => {
const newBody = {
...parsedBody,
auth,
};
const resp = await this.rawJsonRequest(method, path, queryParams, JSON.stringify(newBody));
return JSON.parse(resp) as T;
};
const resp = await uiaCallback(makeRequest);
return JSON.stringify(resp);
}
private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
const opts = {
// inhibit the JSON stringification and parsing within HttpApi.
+121
View File
@@ -0,0 +1,121 @@
/*
Copyright 2023 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 RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
import { Device, DeviceVerification } from "../models/device";
import { DeviceKeys } from "../client";
/**
* Convert a {@link RustSdkCryptoJs.Device} to a {@link Device}
* @param device - Rust Sdk device
* @param userId - owner of the device
*/
export function rustDeviceToJsDevice(device: RustSdkCryptoJs.Device, userId: RustSdkCryptoJs.UserId): Device {
// Copy rust device keys to Device.keys
const keys = new Map<string, string>();
for (const [keyId, key] of device.keys.entries()) {
keys.set(keyId.toString(), key.toBase64());
}
// Compute verified from device state
let verified: DeviceVerification = DeviceVerification.Unverified;
if (device.isBlacklisted()) {
verified = DeviceVerification.Blocked;
} else if (device.isVerified()) {
verified = DeviceVerification.Verified;
}
// Convert rust signatures to Device.signatures
const signatures = new Map<string, Map<string, string>>();
const mayBeSignatureMap: Map<string, RustSdkCryptoJs.MaybeSignature> | undefined = device.signatures.get(userId);
if (mayBeSignatureMap) {
const convertedSignatures = new Map<string, string>();
// Convert maybeSignatures map to a Map<string, string>
for (const [key, value] of mayBeSignatureMap.entries()) {
if (value.isValid() && value.signature) {
convertedSignatures.set(key, value.signature.toBase64());
}
}
signatures.set(userId.toString(), convertedSignatures);
}
// Convert rust algorithms to algorithms
const rustAlgorithms: RustSdkCryptoJs.EncryptionAlgorithm[] = device.algorithms;
// Use set to ensure that algorithms are not duplicated
const algorithms = new Set<string>();
rustAlgorithms.forEach((algorithm) => {
switch (algorithm) {
case RustSdkCryptoJs.EncryptionAlgorithm.MegolmV1AesSha2:
algorithms.add("m.megolm.v1.aes-sha2");
break;
case RustSdkCryptoJs.EncryptionAlgorithm.OlmV1Curve25519AesSha2:
default:
algorithms.add("m.olm.v1.curve25519-aes-sha2");
break;
}
});
return new Device({
deviceId: device.deviceId.toString(),
userId: userId.toString(),
keys,
algorithms: Array.from(algorithms),
verified,
signatures,
displayName: device.displayName,
});
}
/**
* Convert {@link DeviceKeys} from `/keys/query` request to a `Map<string, Device>`
* @param deviceKeys - Device keys object to convert
*/
export function deviceKeysToDeviceMap(deviceKeys: DeviceKeys): Map<string, Device> {
return new Map(
Object.entries(deviceKeys).map(([deviceId, device]) => [deviceId, downloadDeviceToJsDevice(device)]),
);
}
// Device from `/keys/query` request
type QueryDevice = DeviceKeys[keyof DeviceKeys];
/**
* Convert `/keys/query` {@link QueryDevice} device to {@link Device}
* @param device - Device from `/keys/query` request
*/
export function downloadDeviceToJsDevice(device: QueryDevice): Device {
const keys = new Map(Object.entries(device.keys));
const displayName = device.unsigned?.device_display_name;
const signatures = new Map<string, Map<string, string>>();
if (device.signatures) {
for (const userId in device.signatures) {
signatures.set(userId, new Map(Object.entries(device.signatures[userId])));
}
}
return new Device({
deviceId: device.device_id,
userId: device.user_id,
keys,
algorithms: device.algorithms,
verified: DeviceVerification.Unverified,
signatures,
displayName,
});
}
+12 -1
View File
@@ -20,11 +20,22 @@ import { RustCrypto } from "./rust-crypto";
import { logger } from "../logger";
import { RUST_SDK_STORE_PREFIX } from "./constants";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
import { ServerSideSecretStorage } from "../secret-storage";
/**
* Create a new `RustCrypto` implementation
*
* @param http - Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
* We expect it to set the access token, etc.
* @param userId - The local user's User ID.
* @param deviceId - The local user's Device ID.
* @param secretStorage - Interface to server-side secret storage.
*/
export async function initRustCrypto(
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
userId: string,
deviceId: string,
secretStorage: ServerSideSecretStorage,
): Promise<RustCrypto> {
// initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
await RustSdkCryptoJs.initAsync();
@@ -38,7 +49,7 @@ export async function initRustCrypto(
// TODO: use the pickle key for the passphrase
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass");
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId);
const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId, secretStorage);
await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) =>
rustCrypto.onRoomKeysUpdated(sessions),
);
+191 -6
View File
@@ -24,18 +24,26 @@ import { Room } from "../models/room";
import { RoomMember } from "../models/room-member";
import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
import { logger } from "../logger";
import { IHttpOpts, MatrixHttpApi } from "../http-api";
import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
import { UserTrustLevel } from "../crypto/CrossSigning";
import { RoomEncryptor } from "./RoomEncryptor";
import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
import { KeyClaimManager } from "./KeyClaimManager";
import { MapWithDefault } from "../utils";
import { BootstrapCrossSigningOpts, DeviceVerificationStatus } from "../crypto-api";
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter";
import { IDownloadKeyResult, IQueryKeysRequest } from "../client";
import { Device, DeviceMap } from "../models/device";
import { ServerSideSecretStorage } from "../secret-storage";
import { CrossSigningKey } from "../crypto/api";
import { CrossSigningIdentity } from "./CrossSigningIdentity";
/**
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
*/
export class RustCrypto implements CryptoBackend {
public globalErrorOnUnknownDevices = false;
private _trustCrossSignedDevices = true;
/** whether {@link stop} has been called */
private stopped = false;
@@ -49,16 +57,32 @@ export class RustCrypto implements CryptoBackend {
private eventDecryptor: EventDecryptor;
private keyClaimManager: KeyClaimManager;
private outgoingRequestProcessor: OutgoingRequestProcessor;
private crossSigningIdentity: CrossSigningIdentity;
public constructor(
/** The `OlmMachine` from the underlying rust crypto sdk. */
private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
/**
* Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
*
* We expect it to set the access token, etc.
*/
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
/** The local user's User ID. */
_userId: string,
/** The local user's Device ID. */
_deviceId: string,
/** Interface to server-side secret storage */
_secretStorage: ServerSideSecretStorage,
) {
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
this.eventDecryptor = new EventDecryptor(olmMachine);
this.crossSigningIdentity = new CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -130,9 +154,30 @@ export class RustCrypto implements CryptoBackend {
return new UserTrustLevel(false, false, false);
}
public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
/**
* Finds a DM verification request that is already in progress for the given room id
*
* @param roomId - the room to use for verification
*
* @returns the VerificationRequest that is in progress, if any
*/
public findVerificationRequestDMInProgress(roomId: string): undefined {
// TODO
return new DeviceTrustLevel(false, false, false, false);
return;
}
/**
* Get the cross signing information for a given user.
*
* The cross-signing API is currently UNSTABLE and may change without notice.
*
* @param userId - the user ID to get the cross-signing info for.
*
* @returns the cross signing information for the user.
*/
public getStoredCrossSigningForUser(userId: string): null {
// TODO
return null;
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -165,6 +210,146 @@ export class RustCrypto implements CryptoBackend {
return [];
}
/**
* Get the device information for the given list of users.
*
* @param userIds - The users to fetch.
* @param downloadUncached - If true, download the device list for users whose device list we are not
* currently tracking. Defaults to false, in which case such users will not appear at all in the result map.
*
* @returns A map `{@link DeviceMap}`.
*/
public async getUserDeviceInfo(userIds: string[], downloadUncached = false): Promise<DeviceMap> {
const deviceMapByUserId = new Map<string, Map<string, Device>>();
const rustTrackedUsers: Set<RustSdkCryptoJs.UserId> = await this.olmMachine.trackedUsers();
// Convert RustSdkCryptoJs.UserId to a `Set<string>`
const trackedUsers = new Set<string>();
rustTrackedUsers.forEach((rustUserId) => trackedUsers.add(rustUserId.toString()));
// Keep untracked user to download their keys after
const untrackedUsers: Set<string> = new Set();
for (const userId of userIds) {
// if this is a tracked user, we can just fetch the device list from the rust-sdk
// (NB: this is probably ok even if we race with a leave event such that we stop tracking the user's
// devices: the rust-sdk will return the last-known device list, which will be good enough.)
if (trackedUsers.has(userId)) {
deviceMapByUserId.set(userId, await this.getUserDevices(userId));
} else {
untrackedUsers.add(userId);
}
}
// for any users whose device lists we are not tracking, fall back to downloading the device list
// over HTTP.
if (downloadUncached && untrackedUsers.size >= 1) {
const queryResult = await this.downloadDeviceList(untrackedUsers);
Object.entries(queryResult.device_keys).forEach(([userId, deviceKeys]) =>
deviceMapByUserId.set(userId, deviceKeysToDeviceMap(deviceKeys)),
);
}
return deviceMapByUserId;
}
/**
* Get the device list for the given user from the olm machine
* @param userId - Rust SDK UserId
*/
private async getUserDevices(userId: string): Promise<Map<string, Device>> {
const rustUserId = new RustSdkCryptoJs.UserId(userId);
const devices: RustSdkCryptoJs.UserDevices = await this.olmMachine.getUserDevices(rustUserId);
return new Map(
devices
.devices()
.map((device: RustSdkCryptoJs.Device) => [
device.deviceId.toString(),
rustDeviceToJsDevice(device, rustUserId),
]),
);
}
/**
* Download the given user keys by calling `/keys/query` request
* @param untrackedUsers - download keys of these users
*/
private async downloadDeviceList(untrackedUsers: Set<string>): Promise<IDownloadKeyResult> {
const queryBody: IQueryKeysRequest = { device_keys: {} };
untrackedUsers.forEach((user) => (queryBody.device_keys[user] = []));
return await this.http.authedRequest(Method.Post, "/_matrix/client/v3/keys/query", undefined, queryBody, {
prefix: "",
});
}
/**
* Implementation of {@link CryptoApi#getTrustCrossSignedDevices}.
*/
public getTrustCrossSignedDevices(): boolean {
return this._trustCrossSignedDevices;
}
/**
* Implementation of {@link CryptoApi#setTrustCrossSignedDevices}.
*/
public setTrustCrossSignedDevices(val: boolean): void {
this._trustCrossSignedDevices = val;
// TODO: legacy crypto goes through the list of known devices and emits DeviceVerificationChanged
// events. Maybe we need to do the same?
}
/**
* Implementation of {@link CryptoApi#getDeviceVerificationStatus}.
*/
public async getDeviceVerificationStatus(
userId: string,
deviceId: string,
): Promise<DeviceVerificationStatus | null> {
const device: RustSdkCryptoJs.Device | undefined = await this.olmMachine.getDevice(
new RustSdkCryptoJs.UserId(userId),
new RustSdkCryptoJs.DeviceId(deviceId),
);
if (!device) return null;
return new DeviceVerificationStatus({
signedByOwner: device.isCrossSignedByOwner(),
crossSigningVerified: device.isCrossSigningTrusted(),
localVerified: device.isLocallyTrusted(),
trustCrossSignedDevices: this._trustCrossSignedDevices,
});
}
/**
* Implementation of {@link CryptoApi#isCrossSigningReady}
*/
public async isCrossSigningReady(): Promise<boolean> {
return false;
}
/**
* Implementation of {@link CryptoApi#getCrossSigningKeyId}
*/
public async getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
// TODO
return null;
}
/**
* Implementation of {@link CryptoApi#boostrapCrossSigning}
*/
public async bootstrapCrossSigning(opts: BootstrapCrossSigningOpts): Promise<void> {
await this.crossSigningIdentity.bootstrapCrossSigning(opts);
}
/**
* Implementation of {@link CryptoApi#isSecretStorageReady}
*/
public async isSecretStorageReady(): Promise<boolean> {
return false;
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// SyncCryptoCallbacks implementation
@@ -182,7 +367,7 @@ export class RustCrypto implements CryptoBackend {
private async receiveSyncChanges({
events,
oneTimeKeysCounts = new Map<string, number>(),
unusedFallbackKeys = new Set<string>(),
unusedFallbackKeys,
devices = new RustSdkCryptoJs.DeviceLists(),
}: {
events?: IToDeviceEvent[];

Some files were not shown because too many files have changed in this diff Show More