Compare commits

..

430 Commits

Author SHA1 Message Date
RiotRobot 3702ac56f4 v21.2.0 2022-11-22 11:24:19 +00:00
RiotRobot af4811b327 Prepare changelog for v21.2.0 2022-11-22 11:24:19 +00:00
RiotRobot d4601d9910 v21.2.0-rc.1 2022-11-15 17:41:32 +00:00
RiotRobot 2ced5e1aa4 Prepare changelog for v21.2.0-rc.1 2022-11-15 17:41:32 +00:00
David Baker 45e19e51c1 Merge pull request #2880 from matrix-org/dbkr/revert_to_connecting
Make calls go back to 'connecting' state when media lost
2022-11-15 17:36:50 +00:00
David Baker 3f1c3392d4 Make calls go back to 'connecting' state when media lost
This is a change in how the state machine works: technically it's
a breaking change. Calls will now now go back into the connecting
state if the media connection is lost (they'll try to re-establish
the connection).
2022-11-15 16:10:45 +00:00
Richard van der Hoff f86f67f5a5 Improve logs for decrypting events (#2879)
Some attempts to make debugging UISIs a bit easier
2022-11-15 15:11:13 +00:00
Mahdi Bagvand 4dcf54f448 fix registration add phone number not working (#2876)
Co-authored-by: Michael Weimann <michaelw@matrix.org>
2022-11-15 10:42:37 +01:00
Germain d43e664594 Add ability to send unthreaded receipt (#2878) 2022-11-14 16:14:07 +00:00
Michael Telatynski 0e322848f9 Add way to abort search requests (#2877) 2022-11-14 16:11:53 +00:00
Šimon Brandner b454318684 Use an underride rule for Element Call notifications (#2873) 2022-11-14 11:42:51 +01:00
Richard van der Hoff 692f1d49b9 Deprecate/remove some get/set methods on Crypto (#2874)
* Deprecate Crypto.{get,set}GlobalBlacklistUnverifiedDevices

... in favour of just exposing the properties.

* Remove Crypto.{get,set}GlobalErrorOnUnknownDevices

... in favour of exposing the property.

These methods are UNSTABLE so we can safely remove them, right?
2022-11-14 10:03:12 +00:00
Richard van der Hoff b40cf75c9d Remove Crypto.start() (#2871)
This isn't really needed, and its semantics are poorly defined. (Contrary to
the comment, it dos *not* set background processes running).
2022-11-14 10:01:05 +00:00
kegsay ba6a001d67 Merge pull request #2834 from matrix-org/kegan/custom-room-subs
sliding sync: add custom room subscriptions support
2022-11-14 09:19:40 +00:00
kegsay d0c71ec516 Merge branch 'develop' into kegan/custom-room-subs 2022-11-14 09:11:37 +00:00
Richard van der Hoff 67f343d6f0 Switch to typedoc for documentation (#2869)
This seems to give *much* better results than jsdoc, and means that we can
start to get rid of all the duplicated type information.
2022-11-11 11:38:04 +00:00
Germain a7f0ba97cd Fixes unwanted highlight notifications with encrypted threads (#2862) 2022-11-11 09:27:16 +00:00
Michael Telatynski 54d11e1745 Upload docs artifact for PRs (#2868) 2022-11-10 18:01:34 +00:00
David Baker 14744fd4dc Merge pull request #2865 from matrix-org/dbkr/log_call_id
Log the call ID when receiving a call
2022-11-09 16:03:51 +00:00
David Baker 9b0919350c Log the call ID when receiving a call 2022-11-09 15:47:37 +00:00
David Baker b53ad2c081 Merge pull request #2864 from matrix-org/dbkr/log_on_hangup
Add logging saying why we hung up calls
2022-11-09 15:42:11 +00:00
David Baker 6222d238e4 Add logging saying why we hung up calls 2022-11-09 15:20:10 +00:00
Michael Telatynski c6ee258789 Remove patch-package and update matrix-events-sdk (#2863) 2022-11-08 17:01:38 +00:00
László Várady a584324a0d webrtc: add advanced audio settings (#2434) 2022-11-08 16:02:31 +00:00
renovate[bot] 059b07cfa0 Lock file maintenance (#2857)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-08 14:40:33 +00:00
RiotRobot b628cabe58 Resetting package fields for development 2022-11-08 14:36:10 +00:00
RiotRobot b7d925f5ec Merge branch 'master' into develop
# Conflicts:
#	package.json
2022-11-08 14:36:02 +00:00
Michael Telatynski 1c901e3137 Fix Node 19 compatibility and run CI against it (#2842) 2022-11-08 14:01:06 +00:00
Germain bd4589fcc4 Hide pending events in thread timelines (#2843) 2022-11-08 10:04:20 +00:00
Robin 0fbd0b3685 Merge pull request #2860 from robintown/patch-package-dependency
Move patch-package out of dev dependencies
2022-11-07 15:36:20 -05:00
Eric Eastwood 1646ea05bc Extra insurance that we don't mix events in the wrong timelines - v2 (#2856)
Add checks to `addEventToTimeline` as extra insurance that we don't mix events in the wrong timelines (main timeline vs thread timeline).

Split out from https://github.com/matrix-org/matrix-js-sdk/pull/2521

This PR is a v2 of https://github.com/matrix-org/matrix-js-sdk/pull/2848 since it was reverted in https://github.com/matrix-org/matrix-js-sdk/pull/2853

Previously, we just relied on the callers to make sure they're doing the right thing and since it's easy to get it wrong, we mixed and bugs happened.

Call stacks for how events get added to a timeline:

 - `TimelineSet.addEventsToTimeline` -> `TimelineSet.addEventToTimeline` -> `Timeline.addEvent`
 - `TimelineSet.addEventToTimeline` -> `Timeline.addEvent`
 - `TimelineSet.addLiveEvent` -> `TimelineSet.addEventToTimeline` -> `Timeline.addEvent`
2022-11-07 14:24:43 -06:00
Robin Townsend 885ec1fc73 Move patch-package out of dev dependencies
patch-package is used as a postinstall hook, but since it was in devDependencies, upstream packages would not install it. Moving it to dependencies isn't ideal since it's not needed at runtime, but the patch-package approach is only a temporary workaround for https://github.com/matrix-org/matrix-events-sdk/pull/16#pullrequestreview-1166721652 anyways.
2022-11-07 15:24:04 -05:00
David Baker df2b65f111 Merge pull request #2553 from matrix-org/robertlong/group-call
Add support for group calls using MSC3401
2022-11-07 17:24:31 +00:00
David Baker f09853ccb1 Merge remote-tracking branch 'origin/develop' into robertlong/group-call 2022-11-07 17:04:15 +00:00
Michael Telatynski 6c543382e6 Make SonarCloud happier (#2850)
* Make SonarCloud happier

* Revert one change due to lack of strict mode upstream

* Fix typo
2022-11-07 12:16:48 +00:00
Michael Telatynski 52932f59ab Fix pagination token tracking for mixed room timelines (#2855) 2022-11-05 12:36:20 +00:00
David Baker 4f63ff21ea Merge branch 'develop' into robertlong/group-call 2022-11-04 16:05:04 +00:00
David Baker c8dc71eb69 Merge pull request #2854 from matrix-org/dbkr/gcmerge_20221104
Merge changes from develop
2022-11-04 16:04:23 +00:00
David Baker 2dda837db6 Fix strict mode errors 2022-11-04 14:44:21 +00:00
David Baker fff4cdab7c Merge remote-tracking branch 'origin/develop' into dbkr/gcmerge_20221104 2022-11-04 14:05:48 +00:00
Šimon Brandner 4f00566b9f Do not freeze state in initialiseState() (#2846) 2022-11-04 14:48:42 +01:00
Michael Telatynski c1a3b95073 Revert "Extra insurance that we don't mix events in the wrong timelines" (#2853)
This reverts commit 433b7afd71.
2022-11-04 12:21:04 +00:00
Eric Eastwood 38adbaf923 Ignore random macOS cruft (.DS_Store) (#2851) 2022-11-04 06:25:10 -05:00
Travis Ralston 9459a95134 Delete .DS_Store 2022-11-04 10:59:23 +00:00
Eric Eastwood 433b7afd71 Extra insurance that we don't mix events in the wrong timelines (#2848)
Add checks to `addEventToTimeline` as extra insurance that we don't mix events in the wrong timelines (main timeline vs thread timeline).

Split out from https://github.com/matrix-org/matrix-js-sdk/pull/2521

Previously, we just relied on the callers to make sure they're doing the right thing and since it's easy to get it wrong, we mixed and bugs happened.

Call stacks for how events get added to a timeline:

 - `TimelineSet.addEventsToTimeline` -> `TimelineSet.addEventToTimeline` -> `Timeline.addEvent`
 - `TimelineSet.addEventToTimeline` -> `Timeline.addEvent`
 - `TimelineSet.addLiveEvent` -> `TimelineSet.addEventToTimeline` -> `Timeline.addEvent`
2022-11-04 05:54:23 -05:00
Michael Telatynski 777cf1f135 Remove tsc-strict ci, it has served its purpose (#2847) 2022-11-04 09:12:07 +00:00
Robin 8235b65d71 Merge pull request #2844 from robintown/dont-remove-self
Don't remove our own member for a split second when entering a call
2022-11-03 14:38:39 -04:00
Hubert Chathi 4a33e584b0 Add unit test for device de-/rehydration (#2821) 2022-11-03 13:12:57 -04:00
Michael Telatynski 6ee185e93f Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	.github/workflows/sonarqube.yml
2022-11-03 14:13:25 +00:00
Michael Telatynski 5df9705bae Update sonarqube.yml 2022-11-03 14:12:42 +00:00
Michael Telatynski 76458d3a40 Update sonarqube.yml 2022-11-03 13:58:50 +00:00
Michael Telatynski 27bb79a29a Switch to @casualbot/jest-sonar-reporter 2022-11-03 13:47:36 +00:00
Michael Telatynski 82d942dcc5 Update sonarqube.yml 2022-11-03 13:17:48 +00:00
Michael Telatynski ce6d0e2cb1 Update sonarqube.yml 2022-11-03 12:59:16 +00:00
Michael Telatynski db49cd8d13 Make the js-sdk conform to tsc --strict (#2835)
Co-authored-by: Faye Duxovni <fayed@matrix.org>
2022-11-03 12:50:05 +00:00
Michael Telatynski 42b08eca57 Update sonarqube.yml 2022-11-03 12:49:38 +00:00
Michael Telatynski a92c148f15 Update sonarqube.yml 2022-11-03 12:23:04 +00:00
Michael Telatynski 6cd60e32dc Update sonarqube.yml 2022-11-03 12:14:16 +00:00
Michael Telatynski b6633ad4b0 Update sonarqube.yml 2022-11-03 12:06:52 +00:00
Michael Telatynski e4dd7bcc87 Update sonarcloud.yml 2022-11-03 12:01:33 +00:00
Michael Telatynski b6e97fcecb Update sonarqube.yml 2022-11-03 12:01:20 +00:00
Michael Telatynski dee2b60c3d Update sonarqube.yml 2022-11-03 11:59:54 +00:00
Michael Telatynski a07fe44565 Update matrix-org/sonarcloud-workflow-action (#2845) 2022-11-03 11:46:31 +00:00
Michael Telatynski 0a35f2e2c7 Fix queueToDevice.spec.ts test flakiness (#2841) 2022-11-03 11:45:35 +00:00
Robin Townsend 32d535c2b1 Don't remove our own member for a split second when entering a call 2022-11-02 22:43:54 -04:00
David Baker 7fb313c17c Merge remote-tracking branch 'origin/develop' into robertlong/group-call 2022-11-02 10:38:05 +00:00
renovate[bot] d8f6449422 Update jest monorepo to v29.2.1 (#2837)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-02 08:53:09 +00:00
renovate[bot] c043e36f50 Update all (#2836)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-02 08:52:32 +00:00
renovate[bot] e6524239bd Update dependency @babel/runtime to v7.20.1 (#2838)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-02 02:20:24 -04:00
renovate[bot] daed4b9dcc Update typescript-eslint monorepo to v5.42.0 (#2839)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2022-11-01 20:29:45 +00:00
Kegan Dougal 135d2da143 Maybe satisfy the strict ts checker 2022-11-01 16:35:40 +00:00
Kegan Dougal fef53be5b4 Use Map not Objects 2022-11-01 16:31:17 +00:00
David Baker 7ec726e10b Give everything that isn't web rtc back to element-web 2022-11-01 16:18:14 +00:00
David Baker 476f6f78b1 Add more access modifiers 2022-11-01 16:14:48 +00:00
David Baker cb8123dec7 Add public/private modifiers 2022-11-01 16:07:07 +00:00
David Baker 6729c7d421 Merge remote-tracking branch 'origin/develop' into robertlong/group-call 2022-11-01 14:38:16 +00:00
Kegan Dougal 7ddd198df8 Linting 2022-11-01 11:08:47 +00:00
Kegan Dougal 81681f4090 Add custom room subscriptions support
This is mostly useful when you need to change the subscription depending
on the room. For example, unencrypted rooms have lazy-loaded members, but
encrypted rooms do not.
2022-11-01 11:04:40 +00:00
Robin 94072a096d Merge pull request #2826 from robintown/init-leave-race
Resolve races between `initLocalCallFeed` and `leave`
2022-10-31 13:46:30 -04:00
Robin Townsend b9cccf9109 Resolve races between initLocalCallFeed and leave
Unfortunately there are still other methods that could race with leave and result in broken group call state, such as enter and terminate. For the future, should consider writing a more careful specification of how the whole group call state machine is meant to work.
2022-10-31 12:09:06 -04:00
David Baker f0d4ef7f99 Merge remote-tracking branch 'origin/develop' into robertlong/group-call 2022-10-31 15:36:30 +00:00
Robin 849e3d67c2 Merge pull request #2815 from robintown/keepalive-leave
Let leave requests outlive the window
2022-10-27 08:35:30 -04:00
Robin Townsend 4c6e1e5c21 Replace the keepAlive flag with request options 2022-10-27 08:19:41 -04:00
David Baker 9ff6b357fc Merge branch 'develop' into robertlong/group-call 2022-10-27 09:56:36 +01:00
David Baker 77ef8558bd Merge pull request #2816 from matrix-org/dbkr/groupcall_more_strict
A few more strict mode fixes
2022-10-27 09:55:17 +01:00
David Baker d979302e9b A few more strict mode fixes 2022-10-27 09:37:41 +01:00
Robin Townsend dbdaa1540a Let leave requests outlive the window 2022-10-26 17:50:20 -04:00
David Baker 13c751c060 Merge pull request #2808 from matrix-org/dbkr/groupcall_more_strict
More TS strict mode fixes
2022-10-26 13:44:00 +01:00
David Baker 87115d181d Don't commit the strict mode flag 2022-10-26 12:34:18 +01:00
David Baker 5679c86ca6 More TS strict mode fixes 2022-10-26 12:33:06 +01:00
David Baker 4cd50e4871 Merge pull request #2807 from matrix-org/dbkr/gcmerge_26oct22
Merge changes from develop again
2022-10-26 12:08:28 +01:00
David Baker 0f1012278a Fix types 2022-10-26 12:01:53 +01:00
David Baker 0d211dfbad Clean up group call tests (#2806) 2022-10-26 11:57:16 +01:00
David Baker 384116c8f5 Merge remote-tracking branch 'origin/develop' into dbkr/gcmerge_26oct22 2022-10-26 11:56:41 +01:00
David Baker c374ba2367 TS strict mode compliance in the call / groupcall code (#2805)
* TS strict mode compliance in the call / groupcall code

* Also the test

* Fix initOpponentCrypto

to not panic if it doesn't actually need to init crypto
2022-10-26 11:45:03 +01:00
David Baker 450ff00c3e Merge pull request #2800 from matrix-org/dbkr/gcmerge_oct22_3
Merge develop into group-call branch
2022-10-24 19:03:06 +01:00
David Baker b4ab7fc0b3 Merge branch 'robertlong/group-call' into dbkr/gcmerge_oct22_3 2022-10-24 18:53:54 +01:00
Robin 35f697a04b Merge pull request #2797 from robintown/matryoshka-events
Add event and message capabilities to RoomWidgetClient
2022-10-24 13:50:09 -04:00
David Baker 193d8a429a Merge remote-tracking branch 'origin/develop' into dbkr/gcmerge_oct22_3 2022-10-24 18:38:46 +01:00
Robin Townsend 8cd5aac128 Add event and message capabilities to RoomWidgetClient 2022-10-24 12:18:02 -04:00
David Baker eddd0cafe8 Add throwOnFail to groupCall.setScreensharingEnabled (#2787)
* Add throwOnFail to groupCall.setScreensharingEnabled

For https://github.com/vector-im/element-call/pull/652

* Update mediaHandler.ts
2022-10-24 10:20:04 +01:00
David Baker 5a0787349d Fix connectivity regressions (#2780)
* Fix connectivity regressions

Switches back to addTrack, digging the transceivers out manually
to re-use, because the only way to group tracks into streams re-using
trasceivers from the offer is to use setStreams which FF doesn't
implement.

* Remove comments
2022-10-19 18:03:12 +01:00
David Baker c57c8978cf Fix screenshare failing after several attempts (#2771)
* Fix screenshare failing after several attempts

Re-use any existing transceivers when screen sharing. This prevents
transceivers accumulating and making the SDP too big: see linked bug.

This also switches from `addTrack()` to `addTransceiver ()` which is
not that large of a change, other than having to explicitly find the
transceivers after an offer has arrived rather than just adding tracks
and letting WebRTC take care of it.

Fixes https://github.com/vector-im/element-call/issues/625

* Fix tests

* Unused import

* Use a map instead of an array

* Add comment

* more comment

* Remove commented code

* Remove unintentional debugging

* Add test for screenshare transceiver re-use

* Type alias for transceiver map
2022-10-19 16:00:54 +01:00
David Baker dfe535bc07 More debugging for multiple group calls (#2766) 2022-10-17 20:14:44 +01:00
Robin Townsend 3c33c422e6 Merge branch 'develop' into robertlong/group-call 2022-10-13 20:21:51 -04:00
Robin d521f97411 Merge pull request #2754 from robintown/unblock-mute
Don't block muting/unmuting on network requests
2022-10-13 19:54:57 -04:00
Robin Townsend c0a5299704 Don't block muting/unmuting on network requests
(PTT mode will still block on them, as expected)
2022-10-13 11:56:46 -04:00
Robin ce3b72c850 Merge pull request #2712 from robintown/merge
Merge develop
2022-09-29 08:03:36 -04:00
Robin Townsend 935517746a Merge branch 'develop' into robertlong/group-call 2022-09-28 14:13:45 -04:00
David Baker e48d919cd4 Fix ICE restarts (#2702)
We didn't reset the 'seen end of candidates' flag when doign an ICE
restart, so we would have ignored all locally gathered candidates
on an ICE restart.
2022-09-27 17:25:04 +01:00
Šimon Brandner ab39ee37d6 Add more MatrixCall tests (#2697)
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-09-26 12:02:41 +02:00
Šimon Brandner af6f9d49f4 Add CallEventHandler tests (#2696)
* Add `CallEventHandler` tests

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Avoid tests hanging

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-09-26 12:02:19 +02:00
Šimon Brandner a2981efac3 Add MatrixClient group call tests (#2692)
Co-authored-by: Robin <robin@robin.town>
2022-09-23 18:33:31 +02:00
Šimon Brandner 4625ed73cf Merge pull request #2695 from matrix-org/SimonBrandner/task/gc-merge 2022-09-23 17:43:23 +02:00
Šimon Brandner 6f7a72d69e Merge remote-tracking branch 'upstream/develop' into SimonBrandner/task/gc-merge
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-09-23 15:36:44 +02:00
Šimon Brandner 2a0ffe1223 Fix group call tests getting stuck (#2689) 2022-09-22 17:06:01 +02:00
Šimon Brandner 72a6ec0dd3 Add a few group call event handler tests (#2679) 2022-09-22 17:05:51 +02:00
Šimon Brandner 72b89fde6e Add test for call transfers (#2677) 2022-09-20 19:41:03 +02:00
Šimon Brandner c400dd4ff8 Add a few new GroupCall tests (#2678)
Co-authored-by: Robin <robin@robin.town>
2022-09-20 19:40:47 +02:00
Robin f41b7706e4 Upgrade matrix-widget-api (and fix the lockfile) (#2676) 2022-09-16 11:08:13 -04:00
Robin de694459be Target widget actions at a specific room (#2670)
Otherwise, the RoomWidgetClient class can end up accidentally sending and receiving events from rooms it didn't intend to, if it's an always-on-screen widget.
2022-09-16 10:26:03 -04:00
David Baker 6fc9827b10 Add tests for ice candidate sending (#2674) 2022-09-16 09:26:37 +01:00
David Baker f52c5eb667 Unused imports from merge 2022-09-14 09:53:07 +01:00
David Baker c05cb3ad2b Merge branch 'develop' into robertlong/group-call 2022-09-14 09:51:43 +01:00
David Baker 586a313c8d Add tests for call answering / candidate sending (#2666)
* Add tests for call answering / candidate sending

* Remopve unused stuff

* Capitalise

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Capitalisation

* Capitalise

* Fix typescript strict error

* Actually fix TS strict error(?)

* TS strict mode try 3

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-09-14 09:42:57 +01:00
David Baker c605310b87 Prevent exception when muting (#2667)
Fixes https://github.com/vector-im/element-call/issues/578
2022-09-13 20:02:14 +01:00
David Baker 41cee6f1cc Fix race in creating calls (#2662)
* Fix race in creating calls

We ran an async function between checking for an existing call and
adding the new one to the map, so it would have been possible to
start creating another call while we were placing the first call.
This changes the code to add the call to the map as soon as we've
created it.

Also adds more logging.

* Switch to logger.debug

* Fix unit tests
2022-09-13 16:30:34 +01:00
David Baker 3e1e99f8e5 Fix import in failed merge 2022-09-12 10:34:35 +01:00
David Baker 276849f068 Merge branch 'develop' into robertlong/group-call 2022-09-12 10:03:48 +01:00
David Baker 37118991f5 Add test for removing RTX codec (#2660)
* Add test for removing RTX codec

* Use mocked to cast
2022-09-12 09:40:28 +01:00
David Baker 00629e6dc9 Test fallback screensharing (#2659)
* Test fallback screensharing

* Test replacetrack is called

* Unused import

* Return type

* Fix other test after new track IDs
2022-09-09 21:15:34 +01:00
David Baker 02f6a09bcf Test active speaker events (#2658)
Fixes https://github.com/vector-im/element-call/issues/527
2022-09-09 18:57:25 +01:00
Robin 36a6117ee2 Misc fixes for group call widgets (#2657)
* Fix GroupCallEventHandler in matryoshka mode

GroupCallEventHandler needs to see a 'Syncing' event before it starts handling any events, so emit one immediately in matryoshka mode.

* Implement joinRoom on RoomWidgetClient

Element Call has undergone some changes to how it loads rooms, meaning that this method must be implemented for the app to work in matryoshka mode.

* Allow audio and video to be muted before local call feed exists

This is desirable for the Element Web integration of Element Call, because we need to be able to mute our devices before ever joining the call or creating a call feed, if the users requests it.

* Fix a strict mode error
2022-09-09 09:53:27 -04:00
David Baker aebe26db96 GroupCallEventhandler Tests (#2654)
* GroupCallEventhandler Tests

Fixes https://github.com/vector-im/element-call/issues/545

* Fix long line

* Fix strict mode error

Co-authored-by: Robin <robin@robin.town>

* Fix typo

Co-authored-by: Robin <robin@robin.town>

Co-authored-by: Robin <robin@robin.town>
2022-09-08 21:46:28 +01:00
David Baker 60e175a0e0 Merge branch 'develop' into robertlong/group-call 2022-09-08 20:52:08 +01:00
David Baker d950cda05c Merge branch 'develop' into robertlong/group-call 2022-09-08 15:03:55 +01:00
David Baker 83c848093f MediaHandler Tests (#2646)
* MediaHandler Tests, part 1

Haven't got through all the methods yet

For https://github.com/vector-im/element-call/issues/544

* Didn't need these in the end

* Rest of the media handler tests

* getUserMediaStream takes args

* use mockResolvedValue

* Add .off & reuse the mock we already made

* Re-use mock handler again

* Move updateLocalUsermediaStream to beforeEach

* add .off

* Add types

* Add more .offs
2022-09-07 15:56:38 +01:00
David Baker fa6f70f708 Log ID instead of object (#2643)
as otherwise it recurses and logs the entire client + store
2022-09-06 18:09:34 +01:00
David Baker 98d119d6e1 Add client.waitUntilRoomReadyForGroupCalls() (#2641)
See comment, although this still feels like a poor solution to the
problem. Might be better if the js-sdk processed everything internally
before emitting the 'Room' event (or indeed before joinRoom resolved)
so the app knows everything is ready when it gets that event.
2022-09-06 13:54:48 +01:00
David Baker aca51fd8a3 Test call mute status set on call state chnage (#2638) 2022-09-05 17:06:49 +01:00
David Baker c78631bdee Test that calls in a group call are retried (#2637)
* Test that calls in a group call are retried

* Add new flushpromises file
2022-09-05 09:45:32 +01:00
David Baker 0d6a93b5f6 Refactor the group call placing calls test (#2636)
Add some types & use mock-typed versions directly - it's clearer which
client we're making assertions about.
2022-09-02 15:33:22 +01:00
David Baker 40ecfa7932 Test disabling screenshare in group calls (#2634)
Also add a few more types
2022-09-02 12:57:29 +01:00
David Baker d656b848f8 Wait for client to start syncing before making group calls (#2632)
As hopefully explained in comment

Fixes https://github.com/matrix-org/matrix-js-sdk/issues/2589
2022-09-01 17:57:38 +01:00
David Baker 0981652de4 Add GroupCallEventHandlerEvent.Room (#2631)
* Add GroupCalEventHandlerEvent.Room

Emit an event when the group call event handler has processed all
pending group calls.

* Remove unused return value

* Add void return type

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-09-01 16:18:37 +01:00
David Baker db32420d16 Add logging to diagnose connection issue (#2629)
For https://github.com/vector-im/element-call/issues/559
2022-08-31 13:40:55 +01:00
David Baker d5b82e343a Add types to the call unit test suites (#2627)
* Add types to the call unit test suites

Still involves quite a few casts to any unfortunately as it turns
out we access quite a few private methods on the Call class in these
tests.

* Remove commented line & use better expect syntax

* Replace more calls.length with toHaveBeenCalled

* Remove mistakenly added id field
2022-08-31 11:15:13 +01:00
David Baker 965f4fb13b Fix ICE end-of-candidates messages (#2622)
* Fix ICE end-of-candidates messages

We were casting a POJO to an RTCIceCandidate for the dummy
end-of-candidates candidate, but https://github.com/matrix-org/matrix-js-sdk/pull/2473
started calling .toJSON() on these objects.

Store separately whether we've seen the end of candidates rather than
adding on a dummy candidate object.

A test for this will follow, but a) I want to get this fix out and
b) I'm currently rewriting the call test file to add typing.

Fixes https://github.com/vector-im/element-call/issues/553

* Remove hacks for testing

* Switch if branches
2022-08-26 10:04:07 +01:00
David Baker 9e1b126854 1:1 screenshare tests (#2617)
* 1:1 screenshare tests

Fixes https://github.com/vector-im/element-call/issues/548

* Always hang up calls after tests

to prevent hanging tests

Also fix a null dereference as we may not have an invitee or opponent
member when sending voip events if not using to-device messages.

* use mockImplementationOnce

Co-authored-by: Robin <robin@robin.town>

* use mockImplementationOnce

Co-authored-by: Robin <robin@robin.town>

* Add type on mock

* Add corresponding call.off

* Merge enable & disable screenshare tests

Co-authored-by: Robin <robin@robin.town>
2022-08-24 15:45:53 +01:00
David Baker c527f85fb1 Revert 4a294c9dd3
Pushed to wrong branch
2022-08-23 22:03:25 +01:00
David Baker 4a294c9dd3 1:1 screenshare tests
Fixes https://github.com/vector-im/element-call/issues/548
2022-08-23 22:02:32 +01:00
David Baker be94f5ea93 Fix imports 2022-08-22 20:30:27 +01:00
David Baker 5f9369abee Merge branch 'develop' into robertlong/group-call 2022-08-22 20:19:53 +01:00
David Baker e7a7ec0673 Test call timeouts (#2611)
Fixes https://github.com/vector-im/element-call/issues/547
2022-08-22 20:17:19 +01:00
David Baker 92cd84fc0c Add unit tests for hangup / reject (#2606)
* Add unit tests for hangup / reject

Fixes https://github.com/vector-im/element-call/issues/537

* Fix some bugs where we carried on with the call after it had been ended
2022-08-22 13:29:54 +01:00
Šimon Brandner 45e56f8cc3 Add disposed to CallFeed (#2604) 2022-08-19 17:33:55 +02:00
Robin e95947dc73 Update lockfile (#2603) 2022-08-19 08:22:37 -04:00
Šimon Brandner 448a5c9a77 Add screensharing tests (#2598) 2022-08-18 10:42:03 +02:00
David Baker 9589a97952 Test muting in PTT mode (#2599)
Fixes https://github.com/vector-im/element-call/issues/523
2022-08-17 18:09:46 +01:00
David Baker 2566c40e96 Add tests for incoming calls in group calls (#2597)
* Add tests for incoming calls in group calls

Inspiration wwlecome for the renamed describe group which we're
really abusing for a bunch of things that happen to have the same
dependencies.

Fixes https://github.com/vector-im/element-call/issues/532

* Extract incoming call tests out into their own describe

and get the lexicographical ordering to match who should be calling who

* Trailing space
2022-08-17 15:10:03 +01:00
David Baker 099cac0162 Merge branch 'develop' into robertlong/group-call 2022-08-17 12:34:25 +01:00
David Baker e4cf5b26ee Add test for updateLocalUsermediaStream (#2596) 2022-08-17 12:33:11 +01:00
Šimon Brandner c698317f3f Add group call tests for muting (#2590) 2022-08-17 10:59:54 +02:00
David Baker e8f682f452 Test placing a call in a group call (#2593)
* Test placing a call in a group call

Refactors a bit of the call testing stuff

Fixes https://github.com/vector-im/element-call/issues/521

* Unused imports

* Use expect.toHaveBeenCalledWith()

* Types

* More types

* Add comment on mock typing

* Use toHaveBeenCalledWith()

* Initialise groupcall & room in beforeEach

* Initialise mockMediahandler sensibly

* Add type params to mock

* Rename mute tests

* Move comment

* Join / leave in parallel

* Remove leftover expect
2022-08-16 18:22:36 +01:00
David Baker 020743141b Tidy up imports (#2584)
Duplicate 'call' imports
2022-08-10 18:07:49 +01:00
David Baker 5f5a9b1a43 Merge branch 'develop' into robertlong/group-call 2022-08-10 14:31:39 +01:00
Robin 3334c01191 Support nested Matrix clients via the widget API (#2473)
* WIP RoomWidgetClient

* Wait for the widget API to become ready before backfilling

* Add support for sending user-defined encrypted to-device messages

This is a port of the same change from the robertlong/group-call branch.

* Fix tests

* Emit an event when the client receives TURN servers

* Expose the method in MatrixClient

* Override the encryptAndSendToDevices method

* Add support for TURN servers in embedded mode and make calls mostly work

* Don't put unclonable objects into VoIP events

RoomWidget clients were unable to send m.call.candidate events, because
the candidate objects were not clonable for use with postMessage.
Converting such objects to their canonical JSON form before attempting
to send them over the wire solves this.

* Fix types

* Fix more types

* Fix lint

* Upgrade matrix-widget-api

* Save lockfile

* Untangle dependencies to fix tests

* Add some preliminary tests

* Fix tests

* Fix indirect export

* Add more tests

* Resolve TODOs

* Add queueToDevice to RoomWidgetClient
2022-08-09 09:45:58 -04:00
David Baker 0b8de251bf Add basic creation / entering tests for group calls (#2575)
* Add basic creation / entering tests for group calls

* Missing space

Co-authored-by: Robin <robin@robin.town>

* Assert more of the group call member event

and also move call leaving to a finally so it doesn't leaving a call
hagning if it fails.

Co-authored-by: Robin <robin@robin.town>
2022-08-09 13:43:02 +01:00
David Baker 88ce017333 Fix return types of event sending functions (#2576)
These had somehow got mixed up so the type check was failing.
Nothing uses the response return type, so just return void.
2022-08-09 13:11:59 +01:00
David Baker 471f174889 Merge remote-tracking branch 'origin/develop' into robertlong/group-call 2022-08-05 15:59:01 +01:00
David Baker c0dacb5037 Merge remote-tracking branch 'origin/develop' into robertlong/group-call 2022-08-05 11:06:10 +01:00
David Baker 2cc51e0db7 Merge changes from develop (#2563)
* Prepare changelog for v19.2.0-rc.1

* v19.2.0-rc.1

* Sliding sync: add missing filters from latest MSC

* Gracefully handle missing room_ids

* Prepare changelog for v19.2.0

* v19.2.0

* Resetting package fields for development

* Use EventType enum values instead of hardcoded strings (#2557)

* Retry to-device messages (#2549)

* Retry to-device messages

This adds a queueToDevice API alongside sendToDevice which is a
much higher-level API that adds the messages to a queue, stored in
persistent storage, and retries them periodically. Also converts
sending of megolm keys to use the new API.

Other uses of sendToDevice are nopt converted in this PR, but could
be later.

Requires https://github.com/matrix-org/matrix-mock-request/pull/17

* Bump matrix-mock-request

* Add more waits to make indexeddb tests pass

* Switch some test expectations to queueToDevice

* Stop straight away if the client has been stopped

Hopefully will fix tests being flakey and logging after tests have
finished.

* Add return types & fix constant usage

* Fix return type

Co-authored-by: Germain <germains@element.io>

* Fix return type

Co-authored-by: Germain <germains@element.io>

* Fix return type

Co-authored-by: Germain <germains@element.io>

* Stop the client in all test cases

Co-authored-by: Germain <germains@element.io>

* Add support for sending user-defined encrypted to-device messages (#2528)

* Add support for sending user-defined encrypted to-device messages

This is a port of the same change from the robertlong/group-call branch.

* Fix tests

* Expose the method in MatrixClient

* Fix a code smell

* Fix types

* Test the MatrixClient method

* Fix some types in Crypto test suite

* Test the Crypto method

* Fix tests

* Upgrade matrix-mock-request

* Move useRealTimers to afterEach

* Remove stream-replacement (#2551)

* Reintroduce setNewStream method, fix test, update yarn.lock

Co-authored-by: RiotRobot <releases@riot.im>
Co-authored-by: Kegan Dougal <kegan@matrix.org>
Co-authored-by: Germain <germains@element.io>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-08-04 17:28:54 +01:00
Šimon Brandner 22c5999fed Delint group calls (#2554) 2022-08-01 18:45:14 +02:00
David Baker b711781f16 Merge remote-tracking branch 'origin/develop' into robertlong/group-call 2022-07-29 10:56:01 +01:00
Šimon Brandner 8ba2d257ae Add support for audio sharing (#2530) 2022-07-28 18:12:11 +02:00
David Baker 9e2e144530 Make SDP munging media type specific (#2526)
* Make SDP munging media type specific

We were trying to apply modifications to all media types which led
to confusing warning messages saying opus wasn't present (when it
was for the video stream). Make the modifications media-type specific
to avoid this.

* Make codec * mediatype into enums
2022-07-28 09:38:57 +01:00
Timo 38a6949e5d add missing events from reemitter to GroupCall (#2527) 2022-07-25 09:44:37 -07:00
Šimon Brandner e876482e62 Add local volume control (#2525) 2022-07-25 15:51:06 +02:00
David Baker 544b1c6742 Merge develop into group call branch again (#2513)
* Send call version `1` as a string (#2471)

* test typescriptification - backup.spec (#2468)

* renamed:    spec/unit/crypto/crypto-utils.js -> spec/unit/crypto/crypto-utils.ts

* ts fixes in crypto-utils

* renamed:    spec/unit/crypto/backup.spec.js -> spec/unit/crypto/backup.spec.ts

* ts fixes in backup.spec

* remove fit

* remove debug

* Prepare changelog for v19.0.0-rc.1

* v19.0.0-rc.1

* Update jest monorepo (#2476)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update all (#2475)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency @types/jest to v28 (#2478)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Fix call.collectCallStats() (#2480)

Regressed by https://github.com/matrix-org/matrix-js-sdk/pull/2352
(you can just use RTCStatsReport as an iterator directly (which
was was what that code was doing before) which uses entries(
which gives you key/value pairs, but using forEach gives you just
the value.

* Go back to forEach in collectcallstats (#2481)

Older typescript library doesn't know about .values() on the stats
object, so it was failing in react sdk which had an older typescript.
https://github.com/matrix-org/matrix-react-sdk/pull/8935 was an
attempt to upgrade it but did not seem to be helping on CI, despite
being fine locally.

* Update babel monorepo to v7.18.6 (#2477)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Expose KNOWN_SAFE_ROOM_VERSION (#2474)

* Fix return type on funcs in matrixClient to be optionally null (#2488)

* Update pull_request.yaml (#2490)

* Lock file maintenance (#2491)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Prepare changelog for v19.0.0

* v19.0.0

* Resetting package fields for development

* Improve VoIP integrations testing (#2495)

* Remove MSC3244 support (#2504)

* Actually store the identity server in the client when given as an option (#2503)

* Actually store the identity server in the client when given as an option

* Update requestRegisterEmailToken to a modern spec version too

* Properly re-insert room ID in bundled thread relation messages from sync (#2505)

Events returned by the `/sync` endpoint, including relations bundled with other events, may have their `room_id`s stripped out. This causes decryption errors if the IDs aren't repopulated.

Fixes vector-im/element-web#22094.

* Remove `setNow` from `realtime-callbacks.ts` (#2509)

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Remove dead code (#2510)

* Don't crash with undefined room in `processBeaconEvents()` (#2500)

* Add a basic PR checklist for all PRs (#2511)

It'll be mildly annoying for core developers who have to constantly remove or edit this, but it'll also serve as a good reminder to do these things.

Note that signoff is not required for core developers.

* Fix tests

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
Co-authored-by: Kerry <kerrya@element.io>
Co-authored-by: RiotRobot <releases@riot.im>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Weimann <michaelw@matrix.org>
Co-authored-by: texuf <texuf.eth@gmail.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Travis Ralston <travisr@matrix.org>
Co-authored-by: Faye Duxovni <fayed@element.io>
2022-07-12 19:27:41 +01:00
David Baker 984dd26a13 Prevent double mute status changed events (#2502)
Audio & video mute status were set in separate calls but share a
mute status changed event, so you'd always get two mute status
changed events emitted. We could suppress events where the mute
status didn't change, but this would still get two events saying
the same thing when they both changed. Instead, merge setAudioMuted
& setVideoMuted into a single call that sets either or both.
2022-07-08 09:45:02 +01:00
David Baker bdb91b3806 Set max average bitrate on PTT calls (#2499)
* Set max average bitrate on PTT calls

Via SDP munging. Also makes the SDP munging a bit more generic and
codec-specific (we were previously adding usedtx to any codec that had an fmtp
line already, which was probably not really the intention).

* Make SDP munging for codecs that don't already have fmtp lines

* Use sensible typescript syntax

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>

Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
2022-07-06 22:04:41 +01:00
David Baker 9a15094374 Add config option for e2e group call signalling (#2492) 2022-07-05 13:17:23 +01:00
Robin e980c88901 Don't mute the remote side immediately in PTT calls (#2487)
This clause was causing all PTT calls to mute the remote side
immediately upon ICE connection status changing to 'checking'.
2022-07-02 09:19:07 -04:00
David Baker 6ea2885796 Remove empty decryption listener (#2486)
* Remove empty decryption listener

This listener looks like it was left over from something as it just
did nothing at all. The todevice event gets put into the call
event buffer which awaits on decryption for each event before
processing, so it should already wait for decryption.

More info: https://github.com/vector-im/element-call/issues/428

* Unused import

* Unused function!
2022-07-01 17:44:00 +01:00
David Baker ca5ac79927 Revert hack to only clone streams on safari (#2485)
Reverts https://github.com/matrix-org/matrix-js-sdk/pull/2450

Looks like this wasn't really the problem (although may have made it
happens faster) and the actual problem was multiple audio contexts
and/or leaking peer connections as fixed in https://github.com/matrix-org/matrix-js-sdk/pull/2484
2022-07-01 17:32:17 +01:00
Robin f9672cf307 Fix some MatrixCall leaks and use a shared AudioContext (#2484)
* Fix some MatrixCall leaks and use a shared AudioContext

These leaks, combined with the dozens of AudioContexts floating around in memory across different CallFeeds, could cause some really bad performance issues and audio crashes on Chrome.

* Fully release the AudioContext in CallFeed's dispose method

* Fix tests
2022-07-01 11:58:00 -04:00
Robin e7493fd417 Enable DTX on audio tracks in calls (#2482)
This greatly reduces the amount of bandwidth used when transmitting
silence.
2022-06-29 16:06:17 -04:00
David Baker f553854730 Remove the feature to disable audio from muted members (#2479)
At the moment it looks like its more valuable to get the audio from
people even if they're not actually shown as speaking. We can always
re-introduce it later.
2022-06-29 18:47:01 +01:00
David Baker c89bbf4bf5 Fix call.collectCallStats() (#2480)
Regressed by https://github.com/matrix-org/matrix-js-sdk/pull/2352
(you can just use RTCStatsReport as an iterator directly (which
was was what that code was doing before) which uses entries(
which gives you key/value pairs, but using forEach gives you just
the value.
2022-06-29 12:39:36 +01:00
Robin Townsend ebcb26f1b3 Merge branch 'develop' into robertlong/group-call 2022-06-21 11:21:03 -04:00
Robin 5b4263bf55 Don't block muting on determining whether the device exists (#2461)
* Don't block muting on determining whether the device exists

* Add comments
2022-06-16 13:51:32 -04:00
Robin df9ffdc408 Don't ignore call member events with a distant future expiration date (#2466)
to match updates to MSC3401
2022-06-16 12:55:37 -04:00
Robin 70449ea003 Expire call member state events after 1 hour (#2446)
* Expire call member state events after 1 hour

* Fix lints

* Avoid a possible race
2022-06-16 11:31:13 -04:00
David Baker 9192b876d2 Disable playback of audio for muted users (#2456)
* Disable playback of audio for muted users

As hopefully explained by the comment

* forEach instead of map
2022-06-13 20:46:34 +01:00
Travis Ralston 04d0d61a0e Change CODEOWNERS for element-call feature branch (#2457)
To reduce review requests going to the "wrong" team. The team has been mirrored from the vector-im side.
2022-06-13 12:23:50 -06:00
David Baker 404f8e130e Only clone streams on Safari (#2450)
Only enable the stream cloning behaviour on Safari: it was causing
the audio renderer on Chrome (both desktop and Android) to hang,
causing audio to fail sometimes in Element Call and other Chrome
tabs (eg. YouTube) to fail to play audio.

Fixes https://github.com/vector-im/element-call/issues/267
2022-06-10 21:02:04 +01:00
David Baker b97b862fb6 Emit unknown device errors for group call participants without e2e (#2447)
* Emit unknown device errors for group call participants without e2e

There are a number of different cases here: there were some before
when dealing with versions that didn't send deviceId. This catches
all of them and makes all these cases emit the same error.

* Add type
2022-06-10 12:01:40 +01:00
David Baker 5e766978b8 Set PTT mode on call correctly (#2445)
And not always to true. This was causing audio & video to start muted
sometimes on normal calls because the ICE connection state would change
to 'checking', causing the feeds to be muted.
2022-06-08 19:28:53 +01:00
David Baker 34ef7bc64a Mute disconnected peers in PTT mode (#2421)
When we lose ICE connection to peers, set the status of their feeds
to muted so to end their talking session.

For https://github.com/vector-im/element-call/issues/331
2022-05-31 13:43:37 +01:00
David Baker 18e2052af2 Wait for mute event to send in PTT mode (#2401)
This waits until the mute metadata update is sent to all the calls
before telling the user they're unmuted, when in PTT mode (and only
when starting to talk, ie. unmuting). This should help avoid situations
where the signalling connection is slow enough that the unmute event
takes long enough to reach the other side that you hear someone speak
before they've apparently unmuted.

Involves splitting out the method to send the metadata update.
2022-05-26 13:06:57 +01:00
David Baker aa0d3bd1f5 Handle other members having no e2e keys (#2383)
Fetch the device info once at the start of the cal and cache it
rather than fetching every time, and throw if we're supposed to be
using e2e but the other end has no e2e keys.
2022-05-19 19:09:15 +01:00
Robert Long 942a28ddf6 Add support for sending encrypted to-device events with OLM (#2322) 2022-05-18 14:45:26 +01:00
David Baker 87791cd391 Fix races when muting/unmuting (#2370)
await on the async operation so the promise we return resolves once
everything's actuall complete
2022-05-13 19:26:25 +01:00
David Baker 38e54ae7f2 Remove PTT 'other user speaking' logic (#2362)
This was also in Element Call, and whilst js-=sdk might be a more
sensible place, EC has all the information to do it properly (this
impl didn't take admin talk-over into account).
2022-05-11 16:31:14 +01:00
David Baker acef1d7dd0 Merge branch 'dbkr/group-call-merge' of github.com:matrix-org/matrix-js-sdk into dbkr/group-call-merge 2022-05-10 17:24:40 +01:00
David Baker da615fd512 More setTimeout typings 2022-05-10 17:24:21 +01:00
Michael Telatynski f4f05550ef Merge branch 'develop' into dbkr/group-call-merge 2022-05-10 17:11:20 +01:00
David Baker f475251ddd Merge remote-tracking branch 'origin/develop' into dbkr/group-call-merge 2022-05-10 16:50:45 +01:00
David Baker 83f61c96f3 Merge remote-tracking branch 'origin/develop' into dbkr/group-call-merge 2022-05-10 16:39:07 +01:00
David Baker 85a6a552b5 Make tests pass again
Now we know what that bit in the crypto unit test was for...
2022-05-10 16:30:04 +01:00
David Baker 9702e8a5fa Remove test 'fix'
as I can't work out why it was needed, so I can't justify keeping
it in the group calls merge. It should be PRed to develop separately
if needed.
2022-05-09 22:49:32 +01:00
David Baker d82c041b99 Merge remote-tracking branch 'origin/develop' into robertlong/group-call 2022-05-09 22:46:43 +01:00
David Baker 8d9cd0fcb3 Support for PTT group call mode (#2338)
* Add PTT call mode & mute by default in PTT calls (#2311)

No other parts of PTT calls implemented yet

* Make the tests pass again (#2316)

https://github.com/matrix-org/matrix-js-sdk/commit/3280394bf93622c096e3e260296f7f089b97846b
made call use a bunch of methods that weren't mocked in the tests.

* Add maximum trasmit time for PTT (#2312)

on sender side by muting mic after the max transmit time has elapsed.

* Don't allow user to unmute if another user is speaking  (#2313)

* Add maximum trasmit time for PTT

on sender side by muting mic after the max transmit time has elapsed.

* Don't allow user to unmute if another user is speaking

Based on https://github.com/matrix-org/matrix-js-sdk/pull/2312
For https://github.com/vector-im/element-call/issues/298

* Fix createGroupCall arguments (#2325)

Comma instead of a colon...
2022-05-03 13:14:52 +01:00
Robert Long 96ba061732 Fix shouldRequestAudio logging 2022-03-01 16:27:41 -08:00
David Baker ee4cbd1ec9 Don't remove streams that still have tracks (#2104)
If a renogotiation ends up with one track being removed, we removed
the whole stream, which would cause us to lose, for example, audio
rather than just video.
2022-03-01 15:39:36 -08:00
David Baker 2a0dc39eec Fix bug with ine-way audio after a transfer (#2193)
Seems chrome at least will give you a disabled audio track if you
already had another user media audio track and disabled it, so make
sure our tracks are enabled when we add them. We already did this
on one code path but it didn't get moved over when a new code path
was added.

On the plus side, we now know the reason for the ancient code that
had the comment asking what it was for, so update that.
2022-03-01 15:36:48 -08:00
Robert Long 6e25b13312 Send / add end-of-candidates messages 2022-02-28 16:07:36 -08:00
Robert Long 94c5e37570 Fix import 2022-02-25 10:01:17 -08:00
Robert Long 09fee4a2d9 Allow calls to terminate properly when calling stopClient 2022-02-25 09:48:19 -08:00
Robert Long 49994ac4fc Add checks for call/groupCall ended for updateLocalUsermediaStream 2022-02-25 09:42:41 -08:00
Robert Long e68cabc70e Add logging for all stream creation/cloning/muting 2022-02-24 12:55:08 -08:00
Robert Long c819ac634f Fix updating local media streams 2022-02-24 12:10:02 -08:00
Robert Long 17f5ab4191 Move device changes to the application. Add methods to set device ids 2022-02-23 15:07:13 -08:00
Robert Long e270f075a4 Fix call log 2022-02-22 16:57:43 -08:00
Robert Long 0ef6c2e35f Add callId to all logs 2022-02-18 17:47:01 -08:00
Robert Long 7a249e3ef5 Switch media devices on disconnect 2022-02-18 14:35:09 -08:00
Robert Long 353d6bab47 Fix and add a test for toDevice ordering 2022-02-18 11:35:56 -08:00
Robert Long 7f21f569d5 Process toDevice events in order 2022-02-17 14:08:17 -08:00
Robert Long fa5eae70dd Log complete sync errors 2022-02-17 14:07:21 -08:00
David Baker 3db056ad3e Enable max-bundle (#2182)
No particular reason to worry about old user agents here
2022-02-16 11:06:46 -08:00
Robert Long a2a127d9a4 Remove unused isSafari check 2022-02-15 10:53:28 -08:00
Robert Long d12bccd211 Remove safari hack 2022-02-15 10:51:22 -08:00
Robert Long d8e597ccdf Avoid glare 2022-02-11 17:01:22 -08:00
Robert Long c801690e28 Don't reuse local call feeds that have been added to a RTCPeerConnection 2022-02-11 16:56:47 -08:00
Robert Long b4fe00a3a8 Add answer/negotiate response promise chain 2022-02-10 10:31:52 -08:00
Robert Long d42e2fe2c0 Ignore duplicate streams when adding local feeds 2022-02-10 09:32:34 -08:00
Robert Long 4a4465b9fc Don't send candidates after the call has ended 2022-02-07 14:42:07 -08:00
Robert Long 1a78301adb Fix restartIce on FF Android 2022-02-04 12:11:37 -08:00
Robert Long bbf7020755 Remove log 2022-01-18 10:39:02 -08:00
Robert Long 592fb0cf10 Re-enable retries 2022-01-18 10:37:49 -08:00
Robert Long 015eb5d5c4 Add sender/dest session ids 2022-01-14 13:40:42 -08:00
Robert Long 42fef0e7aa Add user id to all send voip events 2022-01-13 14:10:39 -08:00
Robert Long 28f3169a28 Use replace error code when replacing incoming calls 2022-01-12 13:17:19 -08:00
Robert Long d8285aad00 Remove call from callEventHandler after hangup 2022-01-12 13:17:03 -08:00
Robert Long eeacf8c22c Dont filter unstable call events 2022-01-11 17:47:01 -08:00
Robert Long ee995cb39b Ensure call events are processed once and in order 2022-01-11 16:54:40 -08:00
Robert Long 7529af43e4 Add NewSession CallErrorCode 2022-01-11 16:54:12 -08:00
Robert Long 3fac6d7180 Replace outbound calls only 2022-01-10 16:46:55 -08:00
Robert Long 487bfc88ef Merge branch 'robertlong/group-call-session-id' into robertlong/group-call-disable-retries 2022-01-10 16:25:39 -08:00
Robert Long c91617a799 Force hangup replaced calls 2022-01-10 16:22:52 -08:00
Robert Long 87bf115967 Use session ids to resolve refresh during invite/answer 2022-01-10 15:57:40 -08:00
Robert Long 18bb5c3079 Log opponentDeviceId 2022-01-07 11:14:44 -08:00
Robert Long f3f9e41787 Emit sent voip events 2022-01-07 11:14:27 -08:00
Robert Long 7993dd7630 Log opponentDeviceId 2022-01-06 15:46:55 -08:00
Robert Long bef557976b Emit sent voip events 2022-01-06 15:24:59 -08:00
Robert Long 549f9b7e29 Disable retries 2022-01-06 14:44:16 -08:00
Robert Long 06d9d6207c Send device id along with to device signaling messages 2021-11-30 13:39:43 -08:00
Robert Long e336aceaba Expose webrtc related types/props 2021-11-30 13:01:35 -08:00
Robert Long fcc4b71f06 Add LocalStreamsChanged event to MediaHandler 2021-11-29 14:34:43 -08:00
Robert Long d1a62eddfc Set initial audio/video input ids 2021-11-24 12:43:50 -08:00
Robert Long ffbd10a7b8 Make updateLocalUsermediaStreams stop tracks 2021-11-22 13:26:29 -08:00
Robert Long d0e37ee323 Hopefully resolve a race condition with missing device ids 2021-11-19 16:49:20 -08:00
Robert Long 96ef535ebb Make unknown device error more useful 2021-11-19 16:06:29 -08:00
Robert Long 0683133d5b Dont start retry loop until weve sent the member state event 2021-11-19 16:02:26 -08:00
Robert Long 64c3ac55a4 Stop screenshare when screensharing track ended 2021-11-19 13:56:50 -08:00
Robert Long 5f06df8a87 Properly stop screensharing feed 2021-11-18 13:53:30 -08:00
Robert Long 3291846714 Merge branch 'robertlong/abort-sync-error' into robertlong/group-call 2021-11-17 16:10:20 -08:00
Robert Long 139904f297 Update sync state to error when aborting 2021-11-17 16:05:02 -08:00
Robert Long c2fe2ab270 Add additional logging for removing feeds/tracks 2021-11-15 14:20:55 -08:00
Robert Long 4e26f29032 Add unknown device errors 2021-11-15 12:05:34 -08:00
Robert Long 31391121dc Clean up logging 2021-11-15 11:38:47 -08:00
Robert Long 7d48a8394d Don't immediately start retry call loop 2021-11-15 10:48:24 -08:00
Robert Long 28da62c01c Add retry call loop 2021-11-09 15:31:27 -08:00
Robert Long e880cece93 Add restart ICE 2021-11-09 14:40:29 -08:00
Robert Long 97e8fcea75 Clean up replacing calls for Safari 2021-11-09 14:01:16 -08:00
Robert Long f28cb48fe1 Re-enable safari hack 2021-11-08 13:07:22 -08:00
Robert Long a2e255c2c9 Merge branch 'robertlong/group-call' of github.com:matrix-org/matrix-js-sdk into robertlong/group-call 2021-11-08 12:58:54 -08:00
Robert Long 74c5a20371 Temporarily disable safari hack 2021-11-08 12:57:38 -08:00
Robert Long 4b87907b92 Update local usermedia streams serially 2021-11-08 12:30:56 -08:00
Robert Long f76f708c96 Ad a longer wait to safari media stream hack 2021-11-05 14:31:38 -07:00
Robert Long 17f7dc5463 Keep track of original stream id for sdp stream metadata 2021-11-04 17:44:47 -07:00
Robert Long b253ad9e81 Preserve the disabled tracks when updating local usermedia stream 2021-11-04 17:22:37 -07:00
Robert Long c1f56ba3c4 Fix indentation 2021-11-04 11:46:20 -07:00
Robert Long 7998817f7e Send candidate queue again on finish to flush out queue 2021-11-04 11:44:11 -07:00
Robert Long bdc12a2544 Revert changes to gotCallFeedsForAnswer 2021-11-02 17:25:41 -07:00
Robert Long 5a92597abd Check if call ended before getting user media 2021-11-02 15:33:58 -07:00
Robert Long 6f695c1b82 Ignore call call state in glare resolution 2021-11-02 15:30:38 -07:00
Robert Long d99428f2c1 Remove duplicate call answer 2021-11-02 11:39:45 -07:00
Robert Long 4c9648a23b Sanitize call member state 2021-10-28 13:46:27 -07:00
Robert Long 8c5f88c4a7 Fix handling null call 2021-10-28 13:27:35 -07:00
Robert Long 923e9c4ada Ensure that member call state is set correctly 2021-10-28 12:25:00 -07:00
Robert Long 13d62e71b6 Fix stopping all media streams 2021-10-26 16:50:56 -07:00
Robert Long 32aca09f47 Merge branch 'to-device-olm' into robertlong/group-call 2021-10-26 15:27:44 -07:00
Matthew Hodgson 067ac62271 lint 2021-10-26 15:15:30 -07:00
Matthew Hodgson 841e6e999d handle promises normally now tests are fixed 2021-10-26 15:15:16 -07:00
Matthew Hodgson a48546f60d fix the tests (thanks @turt2live!!!) 2021-10-26 15:15:02 -07:00
Matthew Hodgson 2f09e9641c chain promises correctly; log rejects 2021-10-26 15:14:52 -07:00
Matthew Hodgson f46355e7c0 don't choke on missing promise 2021-10-26 15:14:42 -07:00
Matthew Hodgson 53397ee0d1 lint 2021-10-26 15:14:30 -07:00
Matthew Hodgson 5a83635ef5 switch encryptAndSendToDevices to return a promise rather than use a cb
and assert that olm sessions are open to the destination devices
2021-10-26 15:14:13 -07:00
Matthew Hodgson 56c0c9be4d fix example in readme 2021-10-26 15:14:01 -07:00
Matthew Hodgson 24406d2411 make it build 2021-10-26 15:13:46 -07:00
Matthew Hodgson aeeed6ecd7 clarify the factoring 2021-10-26 15:13:27 -07:00
Matthew Hodgson 9f3f9990ef untested first cut at factoring out a encryptAndSendToDevices method 2021-10-26 15:13:13 -07:00
Robert Long 119ce2e46f Fix inbound calls in Safari 2021-10-26 12:44:37 -07:00
Šimon Brandner fc8a867e8e Start processing member state events only after we've set out own (#2000)
This avoids a race condition where the other side would first receive the to-device messages and only then the member state event which would result in the call being ignored

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-10-25 14:02:28 -07:00
Robert Long b4d8c0b603 Fix updating member state with no existing calls 2021-10-22 11:27:02 -07:00
Robert Long 3b0d1b2696 Add check for existing group call session 2021-10-21 14:23:27 -07:00
Robert Long 5110e0b91e Merge branch 'develop' into robertlong/group-call 2021-10-21 13:10:05 -07:00
Robert Long 305de54106 Fix screensharing and webrtc races 2021-10-21 12:57:30 -07:00
Robert Long 0555f9db1c Only send to device messages to a single device 2021-10-19 17:05:21 -07:00
Robert Long 159e825877 Fix unnecessary param to placeCallWithCallFeeds 2021-10-19 15:31:59 -07:00
Robert Long 8131b3900d Use glare resolution to manage group call setup 2021-10-19 15:30:20 -07:00
Robert Long 431d7a0933 Merge branch 'develop' into robertlong/group-call 2021-10-19 12:37:39 -07:00
Robert Long e9b52e23d2 Rermove session id 2021-10-19 10:57:09 -07:00
Šimon Brandner 0148ad0766 Group call improvements (#1985)
* Add group call events to EventType

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Use EventType instead of a const

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Make logging around sending group call member state event a bit better

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>

* Fix m.calls elements being null

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-10-19 10:25:47 -07:00
Robert Long 213f1134b6 Reduce logging for group calls 2021-10-18 11:49:15 -07:00
Robert Long 50e6a8f6b1 Add session_id check to group calls 2021-10-18 11:49:04 -07:00
Robert Long 4a82e1bf05 Merge pull request #1983 from SimonBrandner/robertlong/group-call
Remove left-over from old screen-sharing code
2021-10-15 16:39:21 -07:00
Šimon Brandner 843973c4da Remove left-over from old screen-sharing code
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-10-15 17:19:22 +02:00
Robert Long debeb66d6f Initialize speakingVolumeSamples for screenshare feeds 2021-10-14 17:24:05 -07:00
Robert Long 015d0f9135 Don't set local user as active speaker 2021-10-14 17:14:12 -07:00
Robert Long 5c8e7f2be0 Improve speaking detection using history 2021-10-14 13:34:20 -07:00
Robert Long 411b5f111c Fix talking indicator 2021-10-13 13:42:40 -07:00
Robert Long 2d231c0ae2 Fix how streams are stopped 2021-10-13 12:05:14 -07:00
Robert Long ec37eb8b6f Add support for switching media devices 2021-10-12 15:20:14 -07:00
Robert Long 1cdcebb5db Merge branch 'robertlong/change-media-device' into robertlong/group-call 2021-10-12 15:15:21 -07:00
Robert Long a0f6eea363 Add support for replacing existing sender tracks 2021-10-12 14:48:59 -07:00
Robert Long 18b1a44df7 Merge branch 'robertlong/change-media-device' into robertlong/group-call 2021-10-12 14:24:02 -07:00
Robert Long 4b6b1599a2 Change media devices mid-call 2021-10-12 14:11:57 -07:00
Robert Long a582b19435 Merge branch 'robertlong/group-call' of github.com:matrix-org/matrix-js-sdk into robertlong/group-call 2021-10-12 12:14:55 -07:00
Robert Long 4a8c3d273f Merge branch 'feature/answer-no-cam' into robertlong/group-call 2021-10-12 12:11:32 -07:00
Robert Long 8dc608d917 Fix connecting to a call without a webcam 2021-10-07 13:47:50 -07:00
Robert Long 7ef38ed1b2 Fix speaking threshold 2021-10-06 16:20:07 -07:00
Robert Long 593f62c1c4 Move to correct event types 2021-10-05 16:51:28 -07:00
Robert Long 04d674b8c7 Merge branch 'SimonBrandner-robertlong/group-call' into robertlong/group-call 2021-10-05 11:42:00 -07:00
Robert Long 27eb88f4a1 Update GroupCall to use new CallFeed constructor 2021-10-05 11:38:03 -07:00
Robert Long 1409a4f814 Merge branch 'develop' into robertlong/group-call 2021-10-05 11:34:55 -07:00
Šimon Brandner 8232896c85 Don't run screen-sharing code for each 1:1 call, share one call feed between them instead
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-10-05 06:17:01 +02:00
Šimon Brandner e2ed80ffa0 Add removeLocalFeed()
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-10-05 06:17:01 +02:00
Šimon Brandner 8ac3841a2f Handle joining a call after someone has started screen-sharing
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-10-05 06:16:59 +02:00
Robert Long ba57736bf6 Remove log that's stalling FF 2021-10-04 15:43:56 -07:00
Robert Long 8be4ca909e Add participants to GroupCall 2021-10-04 15:36:36 -07:00
Robert Long 0d964523a9 Add screensharing to GroupCall 2021-09-30 16:04:15 -07:00
Robert Long bb504bc001 Handle getUserMedia permissions blocked 2021-09-30 11:39:34 -07:00
Robert Long 326aec9f9e Fix getUserMediaStream 2021-09-29 17:30:34 -07:00
Robert Long 688327dab5 Handle new group call rooms 2021-09-29 17:00:17 -07:00
Robert Long 3f4522ba88 Add more verbose logging for testing. Create group calls on first sync. 2021-09-29 16:22:44 -07:00
Robert Long 625983a2b2 Revert changes to package.json and .npmignore 2021-09-29 14:37:07 -07:00
Robert Long 137fd2bd40 Export group call enums 2021-09-29 14:35:42 -07:00
Robert Long 1e65bfd316 Fix initLocalCallFeed state 2021-09-29 14:35:32 -07:00
Robert Long 5da072712d Use prepack instead of prepare 2021-09-29 11:47:09 -07:00
Robert Long 529d61b5f4 Add .npmignore 2021-09-29 11:44:12 -07:00
Robert Long 5111ca622a Change main path 2021-09-29 11:36:11 -07:00
Robert Long f627507b86 Test prepare script for git installs 2021-09-29 11:03:42 -07:00
Robert Long aee4459201 Merge pull request #1955 from SimonBrandner/robertlong/group-call
De-duplication for group calls
2021-09-28 10:31:55 -07:00
Šimon Brandner 1a824750dd Don't emit the same thing twice
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-28 19:11:53 +02:00
Šimon Brandner 73cb5e1ee9 Fix order of execution
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-28 19:10:42 +02:00
Šimon Brandner 96bde1f706 Fix field keys
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-28 18:17:36 +02:00
Šimon Brandner 5251dcf67f De-duplicate pushNewLocalFeed
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-28 08:58:01 +02:00
Šimon Brandner ce0b0ea182 De-duplicate invite/answer code
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-28 08:57:44 +02:00
Robert Long 7a142e9102 Implement new group call state events 2021-09-27 17:06:09 -07:00
Robert Long f85aa44f28 Remove duplicate FeedChanged event 2021-09-27 15:02:59 -07:00
Robert Long efbf252e22 Merge pull request #1951 from SimonBrandner/robertlong/group-call
Use to-device events for group calls and other improvements
2021-09-27 14:50:39 -07:00
Robert Long d873f14b6d Merge branch 'develop' into robertlong/group-call 2021-09-27 12:10:31 -07:00
Šimon Brandner cf1ba12232 Use arrays of CallFeeds
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:22 +02:00
Šimon Brandner df208e4de8 Avoid having duplicate call feeds
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:22 +02:00
Šimon Brandner d8ef7f9f63 pushLocalFeed() -> pushNewLocalFeed()
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:22 +02:00
Šimon Brandner 2515ba31a0 Use createNewMatrixCall()
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:21 +02:00
Šimon Brandner 715c4577d0 Add a prop for not stopping local feeds on end
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:21 +02:00
Šimon Brandner a2f23900c9 Use groupCallId isntead of roomId in to-device messages
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:21 +02:00
Šimon Brandner e9e65cf484 Change type key
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:21 +02:00
Šimon Brandner 205c80ea28 Add groupCallId
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:20 +02:00
Šimon Brandner 678023717b Add a setState() method
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:20 +02:00
Šimon Brandner b535969845 Use to-device messages in group calls
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:20 +02:00
Šimon Brandner 027bc6bfc9 Handle incoming to-device call signalling
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:19 +02:00
Šimon Brandner 71ca424712 Allow for sending toDevice messages
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:19 +02:00
Šimon Brandner 3280394bf9 Figure out opponentMember from the userId rather than the sender
This is because to-device messages don't have a sender

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:19 +02:00
Šimon Brandner fc07530434 Add useToDevice to CallOpts
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-27 18:07:18 +02:00
Robert Long f592d4dbc5 Merge branch 'develop' into robertlong/group-call 2021-09-26 11:30:55 -07:00
Robert Long 96f48929ac Cleaning up group call state 2021-09-24 15:41:05 -07:00
Robert Long 454da84f6e Initialize activeSpeakerSamples 2021-09-24 13:29:23 -07:00
Robert Long 89bda6c2e5 Move from groupCallsParticipants to calls 2021-09-24 12:39:43 -07:00
Robert Long ac70dcfc91 Expose call feed getters on call 2021-09-23 16:23:32 -07:00
Robert Long 9c7cb3cbea Handle more edge cases around creating/ending group calls 2021-09-22 15:03:48 -07:00
Robert Long d8d7bd548f Merge branch 'SimonBrandner-robertlong/group-call' into robertlong/group-call 2021-09-22 12:18:53 -07:00
Šimon Brandner 55ef57ead8 Add GroupCallEventHandler
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-22 21:00:19 +02:00
Šimon Brandner 9996afed03 Throw with no room
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-22 21:00:18 +02:00
Šimon Brandner 61a80a11c9 Export CONF_ROOM
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-09-22 21:00:18 +02:00
Robert Long 6a8e8ed0a6 Merge branch 'develop' into robertlong/group-call 2021-09-22 11:58:42 -07:00
Robert Long 5895ce32fa Revert unintended babelrc edit 2021-09-21 21:00:47 -07:00
Robert Long fe0a268991 Merge branch 'robertlong/group-call' of github.com:matrix-org/matrix-js-sdk into robertlong/group-call 2021-09-21 17:11:12 -07:00
Robert Long 7f189b0abd Add endCall 2021-09-21 17:10:45 -07:00
Robert Long 6e07c9e900 Clean up group call event listeners properly on hangup 2021-09-21 14:48:22 -07:00
Robert Long bbeea51a36 Add callType to room state event 2021-09-21 14:24:51 -07:00
Robert Long 151b54ed65 Clean up GroupCallParticipant listeners on remove 2021-09-21 14:24:37 -07:00
Robert Long 18986cb33a Fix typo 2021-09-20 17:31:02 -07:00
Robert Long aef5d73de4 Fix emitting participants_changed event 2021-09-20 17:30:49 -07:00
Robert Long e4fc1f3628 Merge branch 'develop' into robertlong/group-call 2021-09-17 09:16:05 -07:00
Robert Long 8b1c173659 Avoid changing member on replaced call 2021-09-16 16:35:41 -07:00
Robert Long f0916f14d1 Merge branch 'develop' into robertlong/group-call 2021-09-15 14:26:13 -07:00
Robert Long a291f5ab05 Merge branch 'robertlong/clone-streams' into robertlong/group-call 2021-09-15 13:23:56 -07:00
Robert Long 2d7e07f4ed Update to use latest datachannel / clone media stream PRs 2021-09-15 12:45:42 -07:00
Robert Long 2427f75f98 Merge branch 'robertlong/datachannels' into robertlong/group-call 2021-09-15 12:40:37 -07:00
Robert Long d25fb71eba Merge branch 'robertlong/clone-streams' into robertlong/group-call 2021-09-15 12:39:50 -07:00
Robert Long c81b9d2fd9 Merge branch 'develop' into robertlong/group-call 2021-09-15 11:27:59 -07:00
Robert Long fb3ca90bc9 Fix private method signatures 2021-09-10 16:06:26 -07:00
Robert Long eb2a47623f Fix active speaker 2021-09-10 15:58:44 -07:00
Robert Long f18d8ead08 Fix usermedia feeds 2021-09-10 14:31:39 -07:00
Robert Long 2da14bd6e9 Fix call feed changed event handler 2021-09-10 10:01:54 -07:00
Robert Long 1dbb776e12 Revert register types 2021-09-09 17:07:18 -07:00
Robert Long 07b2c57064 Remove CallFeed export 2021-09-09 16:40:08 -07:00
Robert Long 7021f70a66 Move from constants to configureable public variables 2021-09-09 16:24:26 -07:00
Robert Long 503e954671 Merge branch 'develop' into robertlong/group-call 2021-09-09 11:27:06 -07:00
Robert Long 2add1fcbcb Clean up group call events 2021-09-08 14:37:21 -07:00
Robert Long 4fe115b2c4 Add initial group call logic 2021-09-08 13:27:38 -07:00
Robert Long 60e168806d Properly dispose of call feeds 2021-09-02 13:27:13 -07:00
Robert Long 03dfab1282 Export CallFeed 2021-09-02 13:01:43 -07:00
Robert Long 19302ea4fb Fix initWithInvitePromise 2021-08-31 16:10:37 -07:00
Robert Long d5aaed67ba Merge branch 'develop' into robertlong/full-mesh-voip 2021-08-31 16:08:02 -07:00
Robert Long 8fe6afd9ab Merge branch 'master' into robertlong/full-mesh-voip 2021-08-31 16:02:05 -07:00
Robert Long 782fbb115f Stop all media on hangup 2021-08-20 14:42:41 -07:00
Robert Long 3971bf34ed Merge branch 'master' into robertlong/full-mesh-voip 2021-08-20 12:09:27 -07:00
Robert Long 6dac6e53f7 Merge branch 'develop' into robertlong/full-mesh-voip 2021-08-11 11:49:52 -07:00
Robert Long 7ec84e92a0 Merge branch 'develop' into robertlong/full-mesh-voip 2021-08-11 11:38:15 -07:00
Robert Long 154e5c45a6 Clear localAVStream when stopping local media stream. 2021-08-09 11:02:09 -07:00
Robert Long 2cd5c813ac Add local media stream functions to client 2021-08-05 18:22:29 -07:00
Robert Long 1c5101aa1a Add ice disconnected timeout 2021-08-04 18:23:21 -07:00
Robert Long 76f11bee9e Fix invitee glare detection and incoming call event 2021-07-26 11:38:18 -07:00
Robert Long 91f409e8f4 Add invitee field 2021-07-21 23:29:08 -07:00
97 changed files with 9786 additions and 2072 deletions
+3
View File
@@ -1 +1,4 @@
* @matrix-org/element-web
/src/webrtc @matrix-org/element-call-reviewers
/spec/*/webrtc @matrix-org/element-call-reviewers
+34
View File
@@ -0,0 +1,34 @@
name: Deploy documentation PR preview
on:
workflow_run:
workflows: [ "Static Analysis" ]
types:
- completed
jobs:
netlify:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
# 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@b12b127cf24433d14b4f93cee62f5465076ba82a # v2.24.1
with:
workflow: static_analysis.yml
run_id: ${{ github.event.workflow_run.id }}
name: docs
path: docs
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v1
with:
path: docs
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ secrets.NETLIFY_SITE_ID }}
desc: Documentation preview
deployment_env: PR Documentation Preview
+5 -5
View File
@@ -24,10 +24,7 @@ jobs:
- name: 📋 Copy to temp
run: |
tag="${{ github.ref_name }}"
version="${tag#v}"
echo "VERSION=$version" >> $GITHUB_ENV
cp -a "./.jsdoc/matrix-js-sdk/$version" $RUNNER_TEMP
cp -a "./_docs" "$RUNNER_TEMP/"
- name: 🧮 Checkout gh-pages
uses: actions/checkout@v3
@@ -36,7 +33,10 @@ jobs:
- name: 🔪 Prepare
run: |
cp -a "$RUNNER_TEMP/$VERSION" .
tag="${{ github.ref_name }}"
VERSION="${tag#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
+8 -3
View File
@@ -5,6 +5,11 @@ on:
secrets:
SONAR_TOKEN:
required: true
inputs:
extra_args:
type: string
required: false
description: "Extra args to pass to SonarCloud"
jobs:
sonarqube:
runs-on: ubuntu-latest
@@ -22,7 +27,7 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v2.2
uses: matrix-org/sonarcloud-workflow-action@v2.3
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
@@ -33,8 +38,8 @@ jobs:
coverage_run_id: ${{ github.event.workflow_run.id }}
coverage_workflow_name: tests.yml
coverage_extract_path: coverage
extra_args: ${{ inputs.extra_args }}
- uses: Sibz/github-status-action@v1
if: always()
with:
+28
View File
@@ -8,8 +8,36 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
prepare:
name: Prepare
runs-on: ubuntu-latest
outputs:
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
testExecutionReportPaths: ${{ steps.extra_args.outputs.testExecutionReportPaths }}
steps:
# 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@v2
with:
workflow: tests.yaml
run_id: ${{ github.event.workflow_run.id }}
name: coverage
path: coverage
- id: extra_args
run: |
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
sonarqube:
name: 🩻 SonarQube
needs: prepare
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
with:
extra_args: -Dsonar.javascript.lcov.reportPaths=${{ needs.prepare.outputs.reportPaths }} -Dsonar.testExecutionReportPaths=${{ needs.prepare.outputs.testExecutionReportPaths }}
+7 -34
View File
@@ -64,38 +64,11 @@ jobs:
- name: Generate Docs
run: "yarn run gendoc"
tsc-strict:
name: Typescript Strict Error Checker
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: read
checks: write
steps:
- uses: actions/checkout@v3
- name: Get diff lines
id: diff
uses: Equip-Collaboration/diff-line-numbers@v1.0.0
- name: Upload Artifact
uses: actions/upload-artifact@v2
with:
include: '["\\.tsx?$"]'
- name: Detecting files changed
id: files
uses: futuratrepadeira/changed-files@v4.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pattern: '^.*\.tsx?$'
- uses: t3chguy/typescript-check-action@main
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
use-check: false
check-fail-mode: added
output-behaviour: annotate
ts-extra-args: '--strict'
files-changed: ${{ steps.files.outputs.files_updated }}
files-added: ${{ steps.files.outputs.files_created }}
files-deleted: ${{ steps.files.outputs.files_deleted }}
line-numbers: ${{ steps.diff.outputs.lineNumbers }}
name: docs
path: _docs
# We'll only use this in a workflow_run, then we're done with it
retention-days: 1
+18 -3
View File
@@ -8,25 +8,40 @@ concurrency:
cancel-in-progress: true
jobs:
jest:
name: Jest
name: 'Jest [${{ matrix.specs }}] (Node ${{ matrix.node }})'
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
specs: [browserify, integ, unit]
node: [16, 18, latest]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Yarn cache
- name: Setup Node
uses: actions/setup-node@v3
with:
cache: 'yarn'
node-version: ${{ matrix.node }}
- name: Install dependencies
run: "yarn install"
- name: Build
if: matrix.specs == 'browserify'
run: "yarn build"
- name: Get number of CPU cores
id: cpu-cores
uses: SimenB/github-actions-cpu-cores@v1
- name: Run tests with coverage
run: "yarn coverage --ci --reporters github-actions"
run: |
yarn coverage --ci --reporters github-actions --max-workers ${{ steps.cpu-cores.outputs.count }} ./spec/${{ matrix.specs }}
mv coverage/lcov.info coverage/${{ matrix.node }}-${{ matrix.specs }}.lcov.info
env:
JEST_SONAR_UNIQUE_OUTPUT_NAME: true
- name: Upload Artifact
uses: actions/upload-artifact@v3
+2 -2
View File
@@ -1,5 +1,5 @@
/.jsdocbuild
/.jsdoc
/_docs
.DS_Store
node_modules
/.npmrc
+59
View File
@@ -1,3 +1,62 @@
Changes in [21.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v21.2.0) (2022-11-22)
==================================================================================================
## ✨ Features
* Make calls go back to 'connecting' state when media lost ([\#2880](https://github.com/matrix-org/matrix-js-sdk/pull/2880)).
* Add ability to send unthreaded receipt ([\#2878](https://github.com/matrix-org/matrix-js-sdk/pull/2878)).
* Add way to abort search requests ([\#2877](https://github.com/matrix-org/matrix-js-sdk/pull/2877)).
* sliding sync: add custom room subscriptions support ([\#2834](https://github.com/matrix-org/matrix-js-sdk/pull/2834)).
* webrtc: add advanced audio settings ([\#2434](https://github.com/matrix-org/matrix-js-sdk/pull/2434)). Contributed by @MrAnno.
* Add support for group calls using MSC3401 ([\#2553](https://github.com/matrix-org/matrix-js-sdk/pull/2553)).
* Make the js-sdk conform to tsc --strict ([\#2835](https://github.com/matrix-org/matrix-js-sdk/pull/2835)). Fixes #2112 #2116 and #2124.
* Let leave requests outlive the window ([\#2815](https://github.com/matrix-org/matrix-js-sdk/pull/2815)). Fixes vector-im/element-call#639.
* Add event and message capabilities to RoomWidgetClient ([\#2797](https://github.com/matrix-org/matrix-js-sdk/pull/2797)).
* Misc fixes for group call widgets ([\#2657](https://github.com/matrix-org/matrix-js-sdk/pull/2657)).
* Support nested Matrix clients via the widget API ([\#2473](https://github.com/matrix-org/matrix-js-sdk/pull/2473)).
* Set max average bitrate on PTT calls ([\#2499](https://github.com/matrix-org/matrix-js-sdk/pull/2499)). Fixes vector-im/element-call#440.
* Add config option for e2e group call signalling ([\#2492](https://github.com/matrix-org/matrix-js-sdk/pull/2492)).
* Enable DTX on audio tracks in calls ([\#2482](https://github.com/matrix-org/matrix-js-sdk/pull/2482)).
* Don't ignore call member events with a distant future expiration date ([\#2466](https://github.com/matrix-org/matrix-js-sdk/pull/2466)).
* Expire call member state events after 1 hour ([\#2446](https://github.com/matrix-org/matrix-js-sdk/pull/2446)).
* Emit unknown device errors for group call participants without e2e ([\#2447](https://github.com/matrix-org/matrix-js-sdk/pull/2447)).
* Mute disconnected peers in PTT mode ([\#2421](https://github.com/matrix-org/matrix-js-sdk/pull/2421)).
* Add support for sending encrypted to-device events with OLM ([\#2322](https://github.com/matrix-org/matrix-js-sdk/pull/2322)). Contributed by @robertlong.
* Support for PTT group call mode ([\#2338](https://github.com/matrix-org/matrix-js-sdk/pull/2338)).
## 🐛 Bug Fixes
* Fix registration add phone number not working ([\#2876](https://github.com/matrix-org/matrix-js-sdk/pull/2876)). Contributed by @bagvand.
* Use an underride rule for Element Call notifications ([\#2873](https://github.com/matrix-org/matrix-js-sdk/pull/2873)). Fixes vector-im/element-web#23691.
* Fixes unwanted highlight notifications with encrypted threads ([\#2862](https://github.com/matrix-org/matrix-js-sdk/pull/2862)).
* Extra insurance that we don't mix events in the wrong timelines - v2 ([\#2856](https://github.com/matrix-org/matrix-js-sdk/pull/2856)). Contributed by @MadLittleMods.
* Hide pending events in thread timelines ([\#2843](https://github.com/matrix-org/matrix-js-sdk/pull/2843)). Fixes vector-im/element-web#23684.
* Fix pagination token tracking for mixed room timelines ([\#2855](https://github.com/matrix-org/matrix-js-sdk/pull/2855)). Fixes vector-im/element-web#23695.
* Extra insurance that we don't mix events in the wrong timelines ([\#2848](https://github.com/matrix-org/matrix-js-sdk/pull/2848)). Contributed by @MadLittleMods.
* Do not freeze state in `initialiseState()` ([\#2846](https://github.com/matrix-org/matrix-js-sdk/pull/2846)).
* Don't remove our own member for a split second when entering a call ([\#2844](https://github.com/matrix-org/matrix-js-sdk/pull/2844)).
* Resolve races between `initLocalCallFeed` and `leave` ([\#2826](https://github.com/matrix-org/matrix-js-sdk/pull/2826)).
* Add throwOnFail to groupCall.setScreensharingEnabled ([\#2787](https://github.com/matrix-org/matrix-js-sdk/pull/2787)).
* Fix connectivity regressions ([\#2780](https://github.com/matrix-org/matrix-js-sdk/pull/2780)).
* Fix screenshare failing after several attempts ([\#2771](https://github.com/matrix-org/matrix-js-sdk/pull/2771)). Fixes vector-im/element-call#625.
* Don't block muting/unmuting on network requests ([\#2754](https://github.com/matrix-org/matrix-js-sdk/pull/2754)). Fixes vector-im/element-call#592.
* Fix ICE restarts ([\#2702](https://github.com/matrix-org/matrix-js-sdk/pull/2702)).
* Target widget actions at a specific room ([\#2670](https://github.com/matrix-org/matrix-js-sdk/pull/2670)).
* Add tests for ice candidate sending ([\#2674](https://github.com/matrix-org/matrix-js-sdk/pull/2674)).
* Prevent exception when muting ([\#2667](https://github.com/matrix-org/matrix-js-sdk/pull/2667)). Fixes vector-im/element-call#578.
* Fix race in creating calls ([\#2662](https://github.com/matrix-org/matrix-js-sdk/pull/2662)).
* Add client.waitUntilRoomReadyForGroupCalls() ([\#2641](https://github.com/matrix-org/matrix-js-sdk/pull/2641)).
* Wait for client to start syncing before making group calls ([\#2632](https://github.com/matrix-org/matrix-js-sdk/pull/2632)). Fixes #2589.
* Add GroupCallEventHandlerEvent.Room ([\#2631](https://github.com/matrix-org/matrix-js-sdk/pull/2631)).
* Add missing events from reemitter to GroupCall ([\#2527](https://github.com/matrix-org/matrix-js-sdk/pull/2527)). Contributed by @toger5.
* Prevent double mute status changed events ([\#2502](https://github.com/matrix-org/matrix-js-sdk/pull/2502)).
* Don't mute the remote side immediately in PTT calls ([\#2487](https://github.com/matrix-org/matrix-js-sdk/pull/2487)). Fixes vector-im/element-call#425.
* Fix some MatrixCall leaks and use a shared AudioContext ([\#2484](https://github.com/matrix-org/matrix-js-sdk/pull/2484)). Fixes vector-im/element-call#412.
* Don't block muting on determining whether the device exists ([\#2461](https://github.com/matrix-org/matrix-js-sdk/pull/2461)).
* Only clone streams on Safari ([\#2450](https://github.com/matrix-org/matrix-js-sdk/pull/2450)). Fixes vector-im/element-call#267.
* Set PTT mode on call correctly ([\#2445](https://github.com/matrix-org/matrix-js-sdk/pull/2445)). Fixes vector-im/element-call#382.
* Wait for mute event to send in PTT mode ([\#2401](https://github.com/matrix-org/matrix-js-sdk/pull/2401)).
* Handle other members having no e2e keys ([\#2383](https://github.com/matrix-org/matrix-js-sdk/pull/2383)). Fixes vector-im/element-call#338.
* Fix races when muting/unmuting ([\#2370](https://github.com/matrix-org/matrix-js-sdk/pull/2370)).
Changes in [21.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v21.1.0) (2022-11-08)
==================================================================================================
+2 -2
View File
@@ -295,12 +295,12 @@ API Reference
A hosted reference can be found at
http://matrix-org.github.io/matrix-js-sdk/index.html
This SDK uses JSDoc3 style comments. You can manually build and
This SDK uses [Typedoc](https://typedoc.org/guides/doccomments) doc comments. You can manually build and
host the API reference from the source files like this:
```
$ yarn gendoc
$ cd .jsdoc
$ cd _docs
$ python -m http.server 8005
```
-30
View File
@@ -1,30 +0,0 @@
{
"tags": {
"allowUnknownTags": true
},
"plugins": [
"node_modules/better-docs/category",
"node_modules/better-docs/typescript"
],
"source": {
"include": [
"src"
],
"includePattern": ".(ts|js)$"
},
"opts": {
"encoding": "utf8",
"destination": ".jsdoc",
"readme": "README.md",
"recurse": true,
"verbose": true,
"template": "node_modules/docdash"
},
"docdash": {
"static": true,
"private": false,
"search": true,
"collapse": true,
"typedefs": true
}
}
+16 -10
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "21.1.0",
"version": "21.2.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=16.0.0"
@@ -16,7 +16,7 @@
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
"build:compile-browser": "mkdirp dist && browserify -d src/browser-index.js -p [ tsify -p ./tsconfig-build.json ] -t [ babelify --sourceMaps=inline --presets [ @babel/preset-env @babel/preset-typescript ] ] | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js",
"build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js",
"gendoc": "jsdoc -c jsdoc.json -P package.json",
"gendoc": "typedoc",
"lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 0 src spec",
"lint:js-fix": "eslint --fix src spec",
@@ -54,13 +54,16 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/sdp-transform": "^2.4.5",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
"loglevel": "^1.7.1",
"matrix-events-sdk": "^0.0.1-beta.7",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.0.0",
"p-retry": "4",
"qs": "^6.9.6",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6"
},
"devDependencies": {
@@ -76,6 +79,7 @@
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10",
"@casualbot/jest-sonar-reporter": "^2.2.5",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.13.tgz",
"@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5",
@@ -91,7 +95,7 @@
"browserify": "^17.0.0",
"docdash": "^1.2.0",
"domexception": "^4.0.0",
"eslint": "8.25.0",
"eslint": "8.26.0",
"eslint-config-google": "^0.14.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
@@ -100,14 +104,15 @@
"exorcist": "^2.0.0",
"fake-indexeddb": "^4.0.0",
"jest": "^29.0.0",
"jest-environment-jsdom": "^28.1.3",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"jest-sonar-reporter": "^2.0.0",
"jsdoc": "^3.6.6",
"matrix-mock-request": "^2.5.0",
"rimraf": "^3.0.2",
"terser": "^5.5.1",
"tsify": "^5.0.2",
"typedoc": "^0.23.20",
"typedoc-plugin-missing-exports": "^1.0.0",
"typescript": "^4.5.3"
},
"jest": {
@@ -125,11 +130,12 @@
"text-summary",
"lcov"
],
"testResultsProcessor": "jest-sonar-reporter"
"testResultsProcessor": "@casualbot/jest-sonar-reporter"
},
"jestSonar": {
"reportPath": "coverage",
"sonar56x": true
"@casualbot/jest-sonar-reporter": {
"outputDirectory": "coverage",
"outputName": "jest-sonar-report.xml",
"relativePaths": true
},
"typings": "./lib/index.d.ts"
}
+1 -1
View File
@@ -11,6 +11,6 @@ sonar.exclusions=docs,examples,git-hooks
sonar.typescript.tsconfigPath=./tsconfig.json
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.coverage.exclusions=spec/**/*
sonar.testExecutionReportPaths=coverage/test-report.xml
sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml
sonar.lang.patterns.ts=**/*.ts,**/*.tsx
+21
View File
@@ -14,6 +14,21 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import "../../dist/browser-matrix"; // uses browser-matrix instead of the src
import type { MatrixClient, ClientEvent } from "../../src";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface Global {
matrixcs: {
MatrixClient: typeof MatrixClient;
ClientEvent: typeof ClientEvent;
};
}
}
}
// stub for browser-matrix browserify tests
// @ts-ignore
global.XMLHttpRequest = jest.fn();
@@ -23,3 +38,9 @@ afterAll(() => {
// @ts-ignore
global.XMLHttpRequest = undefined;
});
// Akin to spec/setupTests.ts - but that won't affect the browser-matrix bundle
global.matrixcs = {
...global.matrixcs,
timeoutSignal: () => new AbortController().signal,
};
+2 -15
View File
@@ -16,27 +16,14 @@ limitations under the License.
import HttpBackend from "matrix-mock-request";
import "./setupTests";
import "../../dist/browser-matrix"; // uses browser-matrix instead of the src
import type { MatrixClient, ClientEvent } from "../../src";
import "./setupTests";// uses browser-matrix instead of the src
import type { MatrixClient } from "../../src";
const USER_ID = "@user:test.server";
const DEVICE_ID = "device_id";
const ACCESS_TOKEN = "access_token";
const ROOM_ID = "!room_id:server.test";
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface Global {
matrixcs: {
MatrixClient: typeof MatrixClient;
ClientEvent: typeof ClientEvent;
};
}
}
}
describe("Browserify Test", function() {
let client: MatrixClient;
let httpBackend: HttpBackend;
@@ -18,12 +18,14 @@ import * as utils from "../test-utils/test-utils";
import {
ClientEvent,
Direction,
EventStatus,
EventTimeline,
EventTimelineSet,
Filter,
IEvent,
MatrixClient,
MatrixEvent,
PendingEventOrdering,
Room,
} from "../../src/matrix";
import { logger } from "../../src/logger";
@@ -1162,6 +1164,25 @@ describe("MatrixClient event timelines", function() {
httpBackend.when("GET", "/sync").respond(200, INITIAL_SYNC_DATA);
await flushHttp(room.fetchRoomThreads());
});
it("should prevent displaying pending events", async function() {
const room = new Room("room123", client, "john", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
const event = utils.mkMessage({
room: roomId, user: userId, msg: "a body", event: true,
});
event.status = EventStatus.SENDING;
room.addPendingEvent(event, "txn");
const [allThreads, myThreads] = timelineSets!;
expect(allThreads.getPendingEvents()).toHaveLength(0);
expect(myThreads.getPendingEvents()).toHaveLength(0);
expect(room.getPendingEvents()).toHaveLength(1);
});
});
describe("without server compatibility", function() {
+2 -6
View File
@@ -707,15 +707,11 @@ describe("MatrixClient syncing", () => {
awaitSyncEvent(2),
]).then(() => {
const room = client!.getRoom(roomOne)!;
const stateAtStart = room.getLiveTimeline().getState(
EventTimeline.BACKWARDS,
);
const stateAtStart = room.getLiveTimeline().getState(EventTimeline.BACKWARDS)!;
const startRoomNameEvent = stateAtStart.getStateEvents('m.room.name', '');
expect(startRoomNameEvent.getContent().name).toEqual('Old room name');
const stateAtEnd = room.getLiveTimeline().getState(
EventTimeline.FORWARDS,
);
const stateAtEnd = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const endRoomNameEvent = stateAtEnd.getStateEvents('m.room.name', '');
expect(endRoomNameEvent.getContent().name).toEqual('A new room name');
});
+150
View File
@@ -1112,6 +1112,156 @@ describe("SlidingSync", () => {
});
});
describe("custom room subscriptions", () => {
beforeAll(setupClient);
afterAll(teardownClient);
const roomA = "!a";
const roomB = "!b";
const roomC = "!c";
const roomD = "!d";
const defaultSub = {
timeline_limit: 1,
required_state: [["m.room.create", ""]],
};
const customSubName1 = "sub1";
const customSub1 = {
timeline_limit: 2,
required_state: [["*", "*"]],
};
const customSubName2 = "sub2";
const customSub2 = {
timeline_limit: 3,
required_state: [["*", "*"]],
};
it("should be possible to use custom subscriptions on startup", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
// the intention is for clients to set this up at startup
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName2, customSub2);
// then call these depending on the kind of room / context
slidingSync.useCustomSubscription(roomA, customSubName1);
slidingSync.useCustomSubscription(roomB, customSubName1);
slidingSync.useCustomSubscription(roomC, customSubName2);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA, roomB, roomC, roomD]));
httpBackend!.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(customSub1);
expect(body.room_subscriptions[roomB]).toEqual(customSub1);
expect(body.room_subscriptions[roomC]).toEqual(customSub2);
expect(body.room_subscriptions[roomD]).toEqual(defaultSub);
}).respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
it("should be possible to use custom subscriptions mid-connection", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
// the intention is for clients to set this up at startup
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.addCustomSubscription(customSubName2, customSub2);
// initially no subs
httpBackend!.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeFalsy();
}).respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
// now the user clicks on a room which uses the default sub
httpBackend!.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(defaultSub);
}).respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
await httpBackend!.flushAllExpected();
// now the user clicks on a room which uses a custom sub
httpBackend!.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomB]).toEqual(customSub1);
expect(body.unsubscribe_rooms).toEqual([roomA]);
}).respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.useCustomSubscription(roomB, customSubName1);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomB]));
await httpBackend!.flushAllExpected();
// now the user uses a different sub for the same room: we don't unsub but just resend
httpBackend!.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomB]).toEqual(customSub2);
expect(body.unsubscribe_rooms).toBeFalsy();
}).respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.useCustomSubscription(roomB, customSubName2);
slidingSync.modifyRoomSubscriptions(new Set<string>([roomB]));
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
it("uses the default subscription for unknown subscription names", async () => {
const slidingSync = new SlidingSync(proxyBaseUrl, [], defaultSub, client!, 1);
slidingSync.addCustomSubscription(customSubName1, customSub1);
slidingSync.useCustomSubscription(roomA, "unknown name");
slidingSync.modifyRoomSubscriptions(new Set<string>([roomA]));
httpBackend!.when("POST", syncUrl).check(function(req) {
const body = req.data;
logger.log("custom subs", body);
expect(body.room_subscriptions).toBeTruthy();
expect(body.room_subscriptions[roomA]).toEqual(defaultSub);
}).respond(200, {
pos: "b",
lists: [],
extensions: {},
rooms: {},
});
slidingSync.start();
await httpBackend!.flushAllExpected();
slidingSync.stop();
});
});
describe("extensions", () => {
beforeAll(setupClient);
afterAll(teardownClient);
+6
View File
@@ -17,3 +17,9 @@ limitations under the License.
import DOMException from "domexception";
global.DOMException = DOMException;
jest.mock("../src/http-api/utils", () => ({
...jest.requireActual("../src/http-api/utils"),
// We mock timeoutSignal otherwise it causes tests to leave timers running
timeoutSignal: () => new AbortController().signal,
}));
+28
View File
@@ -0,0 +1,28 @@
/*
Copyright 2022 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.
*/
// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of
// other async methods which break the event loop, letting scheduled promise
// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do
// it manually (this is what sinon does under the hood). We do both in a loop
// until the thing we expect happens: hopefully this is the least flakey way
// and avoids assuming anything about the app's behaviour.
const realSetTimeout = setTimeout;
export function flushPromises() {
return new Promise(r => {
realSetTimeout(r, 1);
});
}
+10 -2
View File
@@ -5,7 +5,7 @@ import EventEmitter from "events";
import '../olm-loader';
import { logger } from '../../src/logger';
import { IContent, IEvent, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { IContent, IEvent, IEventRelation, IUnsigned, MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { ClientEvent, EventType, IPusher, MatrixClient, MsgType } from "../../src";
import { SyncState } from "../../src/sync";
import { eventMapperFor } from "../../src/event-mapper";
@@ -78,6 +78,7 @@ interface IEventOpts {
user?: string;
unsigned?: IUnsigned;
redacts?: string;
ts?: number;
}
let testEventIndex = 1; // counter for events, easier for comparison of randomly generated events
@@ -109,6 +110,7 @@ export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixC
event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(),
txn_id: "~" + Math.random(),
redacts: opts.redacts,
origin_server_ts: opts.ts ?? 0,
};
if (opts.skey !== undefined) {
event.state_key = opts.skey;
@@ -237,11 +239,13 @@ export function mkMembershipCustom<T>(
});
}
interface IMessageOpts {
export interface IMessageOpts {
room?: string;
user: string;
msg?: string;
event?: boolean;
relatesTo?: IEventRelation;
ts?: number;
}
/**
@@ -269,6 +273,10 @@ export function mkMessage(
},
};
if (opts.relatesTo) {
eventOpts.content["m.relates_to"] = opts.relatesTo;
}
if (!eventOpts.content.body) {
eventOpts.content.body = "Random->" + Math.random();
}
+134
View File
@@ -0,0 +1,134 @@
/*
Copyright 2022 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 { RelationType } from "../../src/@types/event";
import { MatrixClient } from "../../src/client";
import { MatrixEvent, MatrixEventEvent } from "../../src/models/event";
import { Room } from "../../src/models/room";
import { Thread } from "../../src/models/thread";
import { mkMessage } from "./test-utils";
export const makeThreadEvent = ({ rootEventId, replyToEventId, ...props }: any & {
rootEventId: string; replyToEventId: string; event?: boolean;
}): MatrixEvent => mkMessage({
...props,
relatesTo: {
event_id: rootEventId,
rel_type: "m.thread",
['m.in_reply_to']: {
event_id: replyToEventId,
},
},
});
type MakeThreadEventsProps = {
roomId: Room["roomId"];
// root message user id
authorId: string;
// user ids of thread replies
// cycled through until thread length is fulfilled
participantUserIds: string[];
// number of messages in the thread, root message included
// optional, default 2
length?: number;
ts?: number;
// provide to set current_user_participated accurately
currentUserId?: string;
};
export const makeThreadEvents = ({
roomId, authorId, participantUserIds, length = 2, ts = 1, currentUserId,
}: MakeThreadEventsProps): { rootEvent: MatrixEvent, events: MatrixEvent[] } => {
const rootEvent = mkMessage({
user: authorId,
room: roomId,
msg: 'root event message ' + Math.random(),
ts,
event: true,
});
const rootEventId = rootEvent.getId();
const events = [rootEvent];
for (let i = 1; i < length; i++) {
const prevEvent = events[i - 1];
const replyToEventId = prevEvent.getId();
const user = participantUserIds[i % participantUserIds.length];
events.push(makeThreadEvent({
user,
room: roomId,
event: true,
msg: `reply ${i} by ${user}`,
rootEventId,
replyToEventId,
// replies are 1ms after each other
ts: ts + i,
}));
}
rootEvent.setUnsigned({
"m.relations": {
[RelationType.Thread]: {
latest_event: events[events.length - 1],
count: length,
current_user_participated: [...participantUserIds, authorId].includes(currentUserId ?? ""),
},
},
});
return { rootEvent, events };
};
type MakeThreadProps = {
room: Room;
client: MatrixClient;
authorId: string;
participantUserIds: string[];
length?: number;
ts?: number;
};
export const mkThread = ({
room,
client,
authorId,
participantUserIds,
length = 2,
ts = 1,
}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent, events: MatrixEvent[] } => {
const { rootEvent, events } = makeThreadEvents({
roomId: room.roomId,
authorId,
participantUserIds,
length,
ts,
currentUserId: client.getUserId() ?? "",
});
expect(rootEvent).toBeTruthy();
for (const evt of events) {
room?.reEmitter.reEmit(evt, [
MatrixEventEvent.BeforeRedaction,
]);
}
const thread = room.createThread(rootEvent.getId() ?? "", rootEvent, events, true);
// So that we do not have to mock the thread loading
thread.initialEventsFetched = true;
thread.addEvents(events, true);
return { thread, rootEvent, events };
};
+386 -29
View File
@@ -14,6 +14,32 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {
ClientEvent,
ClientEventHandlerMap,
EventType,
GroupCall,
GroupCallIntent,
GroupCallType,
IContent,
ISendEventResponse,
MatrixClient,
MatrixEvent,
Room,
RoomState,
RoomStateEvent,
RoomStateEventHandlerMap,
} from "../../src";
import { TypedEventEmitter } from "../../src/models/typed-event-emitter";
import { ReEmitter } from "../../src/ReEmitter";
import { SyncState } from "../../src/sync";
import { CallEvent, CallEventHandlerMap, MatrixCall } from "../../src/webrtc/call";
import { CallEventHandlerEvent, CallEventHandlerEventHandlerMap } from "../../src/webrtc/callEventHandler";
import { CallFeed } from "../../src/webrtc/callFeed";
import { GroupCallEventHandlerMap } from "../../src/webrtc/groupCall";
import { GroupCallEventHandlerEvent } from "../../src/webrtc/groupCallEventHandler";
import { IScreensharingOpts, MediaHandler } from "../../src/webrtc/mediaHandler";
export const DUMMY_SDP = (
"v=0\r\n" +
"o=- 5022425983810148698 2 IN IP4 127.0.0.1\r\n" +
@@ -54,8 +80,50 @@ export const DUMMY_SDP = (
"a=ssrc:3619738545 cname:2RWtmqhXLdoF4sOi\r\n"
);
export const USERMEDIA_STREAM_ID = "mock_stream_from_media_handler";
export const SCREENSHARE_STREAM_ID = "mock_screen_stream_from_media_handler";
class MockMediaStreamAudioSourceNode {
public connect() {}
}
class MockAnalyser {
public getFloatFrequencyData() { return 0.0; }
}
export class MockAudioContext {
constructor() {}
public createAnalyser() { return new MockAnalyser(); }
public createMediaStreamSource() { return new MockMediaStreamAudioSourceNode(); }
public close() {}
}
export class MockRTCPeerConnection {
localDescription: RTCSessionDescription;
private static instances: MockRTCPeerConnection[] = [];
private negotiationNeededListener?: () => void;
public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void;
public onTrackListener?: (e: RTCTrackEvent) => void;
public needsNegotiation = false;
public readyToNegotiate: Promise<void>;
private onReadyToNegotiate?: () => void;
public localDescription: RTCSessionDescription;
public signalingState: RTCSignalingState = "stable";
public transceivers: MockRTCRtpTransceiver[] = [];
public static triggerAllNegotiations(): void {
for (const inst of this.instances) {
inst.doNegotiation();
}
}
public static hasAnyPendingNegotiations(): boolean {
return this.instances.some(i => i.needsNegotiation);
}
public static resetInstances() {
this.instances = [];
}
constructor() {
this.localDescription = {
@@ -63,34 +131,133 @@ export class MockRTCPeerConnection {
type: 'offer',
toJSON: function() { },
};
this.readyToNegotiate = new Promise<void>(resolve => {
this.onReadyToNegotiate = resolve;
});
MockRTCPeerConnection.instances.push(this);
}
addEventListener() { }
createDataChannel(label: string, opts: RTCDataChannelInit) { return { label, ...opts }; }
createOffer() {
return Promise.resolve({});
public addEventListener(type: string, listener: () => void) {
if (type === 'negotiationneeded') {
this.negotiationNeededListener = listener;
} else if (type == 'icecandidate') {
this.iceCandidateListener = listener;
} else if (type == 'track') {
this.onTrackListener = listener;
}
}
setRemoteDescription() {
public createDataChannel(label: string, opts: RTCDataChannelInit) { return { label, ...opts }; }
public createOffer() {
return Promise.resolve({
type: 'offer',
sdp: DUMMY_SDP,
});
}
public createAnswer() {
return Promise.resolve({
type: 'answer',
sdp: DUMMY_SDP,
});
}
public setRemoteDescription() {
return Promise.resolve();
}
setLocalDescription() {
public setLocalDescription() {
return Promise.resolve();
}
close() { }
getStats() { return []; }
addTrack(track: MockMediaStreamTrack) { return new MockRTCRtpSender(track); }
public close() { }
public getStats() { return []; }
public addTransceiver(track: MockMediaStreamTrack): MockRTCRtpTransceiver {
this.needsNegotiation = true;
if (this.onReadyToNegotiate) this.onReadyToNegotiate();
const newSender = new MockRTCRtpSender(track);
const newReceiver = new MockRTCRtpReceiver(track);
const newTransceiver = new MockRTCRtpTransceiver(this);
newTransceiver.sender = newSender as unknown as RTCRtpSender;
newTransceiver.receiver = newReceiver as unknown as RTCRtpReceiver;
this.transceivers.push(newTransceiver);
return newTransceiver;
}
public addTrack(track: MockMediaStreamTrack): MockRTCRtpSender {
return this.addTransceiver(track).sender as unknown as MockRTCRtpSender;
}
public removeTrack() {
this.needsNegotiation = true;
if (this.onReadyToNegotiate) this.onReadyToNegotiate();
}
public getTransceivers(): MockRTCRtpTransceiver[] { return this.transceivers; }
public getSenders(): MockRTCRtpSender[] {
return this.transceivers.map(t => t.sender as unknown as MockRTCRtpSender);
}
public doNegotiation() {
if (this.needsNegotiation && this.negotiationNeededListener) {
this.needsNegotiation = false;
this.negotiationNeededListener();
}
}
}
export class MockRTCRtpSender {
constructor(public track: MockMediaStreamTrack) { }
replaceTrack(track: MockMediaStreamTrack) { this.track = track; }
public replaceTrack(track: MockMediaStreamTrack) { this.track = track; }
}
export class MockRTCRtpReceiver {
constructor(public track: MockMediaStreamTrack) { }
}
export class MockRTCRtpTransceiver {
constructor(private peerConn: MockRTCPeerConnection) {}
public sender?: RTCRtpSender;
public receiver?: RTCRtpReceiver;
public set direction(_: string) {
this.peerConn.needsNegotiation = true;
}
public setCodecPreferences = jest.fn<void, RTCRtpCodecCapability[]>();
}
export class MockMediaStreamTrack {
constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) { }
stop() { }
public stop = jest.fn<void, []>();
public listeners: [string, (...args: any[]) => any][] = [];
public isStopped = false;
public settings?: MediaTrackSettings;
public getSettings(): MediaTrackSettings { return this.settings!; }
// XXX: Using EventTarget in jest doesn't seem to work, so we write our own
// implementation
public dispatchEvent(eventType: string) {
this.listeners.forEach(([t, c]) => {
if (t !== eventType) return;
c();
});
}
public addEventListener(eventType: string, callback: (...args: any[]) => any) {
this.listeners.push([eventType, callback]);
}
public removeEventListener(eventType: string, callback: (...args: any[]) => any) {
this.listeners.filter(([t, c]) => {
return t !== eventType || c !== callback;
});
}
public typed(): MediaStreamTrack { return this as unknown as MediaStreamTrack; }
}
// XXX: Using EventTarget in jest doesn't seem to work, so we write our own
@@ -101,46 +268,236 @@ export class MockMediaStream {
private tracks: MockMediaStreamTrack[] = [],
) {}
listeners: [string, (...args: any[]) => any][] = [];
public listeners: [string, (...args: any[]) => any][] = [];
public isStopped = false;
dispatchEvent(eventType: string) {
public dispatchEvent(eventType: string) {
this.listeners.forEach(([t, c]) => {
if (t !== eventType) return;
c();
});
}
getTracks() { return this.tracks; }
getAudioTracks() { return this.tracks.filter((track) => track.kind === "audio"); }
getVideoTracks() { return this.tracks.filter((track) => track.kind === "video"); }
addEventListener(eventType: string, callback: (...args: any[]) => any) {
public getTracks() { return this.tracks; }
public getAudioTracks() { return this.tracks.filter((track) => track.kind === "audio"); }
public getVideoTracks() { return this.tracks.filter((track) => track.kind === "video"); }
public addEventListener(eventType: string, callback: (...args: any[]) => any) {
this.listeners.push([eventType, callback]);
}
removeEventListener(eventType: string, callback: (...args: any[]) => any) {
public removeEventListener(eventType: string, callback: (...args: any[]) => any) {
this.listeners.filter(([t, c]) => {
return t !== eventType || c !== callback;
});
}
addTrack(track: MockMediaStreamTrack) {
public addTrack(track: MockMediaStreamTrack) {
this.tracks.push(track);
this.dispatchEvent("addtrack");
}
removeTrack(track: MockMediaStreamTrack) { this.tracks.splice(this.tracks.indexOf(track), 1); }
public removeTrack(track: MockMediaStreamTrack) { this.tracks.splice(this.tracks.indexOf(track), 1); }
public clone(): MediaStream {
return new MockMediaStream(this.id + ".clone", this.tracks).typed();
}
public isCloneOf(stream: MediaStream) {
return this.id === stream.id + ".clone";
}
// syntactic sugar for typing
public typed(): MediaStream {
return this as unknown as MediaStream;
}
}
export class MockMediaDeviceInfo {
constructor(
public kind: "audio" | "video",
public kind: "audioinput" | "videoinput" | "audiooutput",
) { }
public typed(): MediaDeviceInfo { return this as unknown as MediaDeviceInfo; }
}
export class MockMediaHandler {
getUserMediaStream(audio: boolean, video: boolean) {
const tracks: MockMediaStreamTrack[] = [];
if (audio) tracks.push(new MockMediaStreamTrack("audio_track", "audio"));
if (video) tracks.push(new MockMediaStreamTrack("video_track", "video"));
public userMediaStreams: MockMediaStream[] = [];
public screensharingStreams: MockMediaStream[] = [];
return new MockMediaStream("mock_stream_from_media_handler", tracks);
public getUserMediaStream(audio: boolean, video: boolean) {
const tracks: MockMediaStreamTrack[] = [];
if (audio) tracks.push(new MockMediaStreamTrack("usermedia_audio_track", "audio"));
if (video) tracks.push(new MockMediaStreamTrack("usermedia_video_track", "video"));
const stream = new MockMediaStream(USERMEDIA_STREAM_ID, tracks);
this.userMediaStreams.push(stream);
return stream;
}
stopUserMediaStream() { }
hasAudioDevice() { return true; }
public stopUserMediaStream(stream: MockMediaStream) {
stream.isStopped = true;
}
public getScreensharingStream = jest.fn((opts?: IScreensharingOpts) => {
const tracks = [new MockMediaStreamTrack("screenshare_video_track", "video")];
if (opts?.audio) tracks.push(new MockMediaStreamTrack("screenshare_audio_track", "audio"));
const stream = new MockMediaStream(SCREENSHARE_STREAM_ID, tracks);
this.screensharingStreams.push(stream);
return stream;
});
public stopScreensharingStream(stream: MockMediaStream) {
stream.isStopped = true;
}
public hasAudioDevice() { return true; }
public hasVideoDevice() { return true; }
public stopAllStreams() {}
public typed(): MediaHandler { return this as unknown as MediaHandler; }
}
export class MockMediaDevices {
public enumerateDevices = jest.fn<Promise<MediaDeviceInfo[]>, []>().mockResolvedValue([
new MockMediaDeviceInfo("audioinput").typed(),
new MockMediaDeviceInfo("videoinput").typed(),
]);
public getUserMedia = jest.fn<Promise<MediaStream>, [MediaStreamConstraints]>().mockReturnValue(
Promise.resolve(new MockMediaStream("local_stream").typed()),
);
public getDisplayMedia = jest.fn<Promise<MediaStream>, [DisplayMediaStreamConstraints]>().mockReturnValue(
Promise.resolve(new MockMediaStream("local_display_stream").typed()),
);
public typed(): MediaDevices { return this as unknown as MediaDevices; }
}
type EmittedEvents = CallEventHandlerEvent | CallEvent | ClientEvent | RoomStateEvent | GroupCallEventHandlerEvent;
type EmittedEventMap = CallEventHandlerEventHandlerMap &
CallEventHandlerMap &
ClientEventHandlerMap &
RoomStateEventHandlerMap &
GroupCallEventHandlerMap;
export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, EmittedEventMap> {
public mediaHandler = new MockMediaHandler();
constructor(public userId: string, public deviceId: string, public sessionId: string) {
super();
}
public groupCallEventHandler = {
groupCalls: new Map<string, GroupCall>(),
};
public callEventHandler = {
calls: new Map<string, MatrixCall>(),
};
public sendStateEvent = jest.fn<Promise<ISendEventResponse>, [
roomId: string, eventType: EventType, content: any, statekey: string,
]>();
public sendToDevice = jest.fn<Promise<{}>, [
eventType: string,
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
txnId?: string,
]>();
public getMediaHandler(): MediaHandler { return this.mediaHandler.typed(); }
public getUserId(): string { return this.userId; }
public getDeviceId(): string { return this.deviceId; }
public getSessionId(): string { return this.sessionId; }
public getTurnServers = () => [];
public isFallbackICEServerAllowed = () => false;
public reEmitter = new ReEmitter(new TypedEventEmitter());
public getUseE2eForGroupCall = () => false;
public checkTurnServers = () => null;
public getSyncState = jest.fn<SyncState | null, []>().mockReturnValue(SyncState.Syncing);
public getRooms = jest.fn<Room[], []>().mockReturnValue([]);
public getRoom = jest.fn();
public typed(): MatrixClient { return this as unknown as MatrixClient; }
public emitRoomState(event: MatrixEvent, state: RoomState): void {
this.emit(
RoomStateEvent.Events,
event,
state,
null,
);
}
}
export class MockCallFeed {
constructor(
public userId: string,
public stream: MockMediaStream,
) {}
public measureVolumeActivity(val: boolean) {}
public dispose() {}
public typed(): CallFeed {
return this as unknown as CallFeed;
}
}
export function installWebRTCMocks() {
global.navigator = {
mediaDevices: new MockMediaDevices().typed(),
} as unknown as Navigator;
global.window = {
// @ts-ignore Mock
RTCPeerConnection: MockRTCPeerConnection,
// @ts-ignore Mock
RTCSessionDescription: {},
// @ts-ignore Mock
RTCIceCandidate: {},
getUserMedia: () => new MockMediaStream("local_stream"),
};
// @ts-ignore Mock
global.document = {};
// @ts-ignore Mock
global.AudioContext = MockAudioContext;
// @ts-ignore Mock
global.RTCRtpReceiver = {
getCapabilities: jest.fn<RTCRtpCapabilities, [string]>().mockReturnValue({
codecs: [],
headerExtensions: [],
}),
};
// @ts-ignore Mock
global.RTCRtpSender = {
getCapabilities: jest.fn<RTCRtpCapabilities, [string]>().mockReturnValue({
codecs: [],
headerExtensions: [],
}),
};
}
export function makeMockGroupCallStateEvent(roomId: string, groupCallId: string, content: IContent = {
"m.type": GroupCallType.Video,
"m.intent": GroupCallIntent.Prompt,
}): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(EventType.GroupCallPrefix),
getRoomId: jest.fn().mockReturnValue(roomId),
getTs: jest.fn().mockReturnValue(0),
getContent: jest.fn().mockReturnValue(content),
getStateKey: jest.fn().mockReturnValue(groupCallId),
} as unknown as MatrixEvent;
}
export function makeMockGroupCallMemberStateEvent(roomId: string, groupCallId: string): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getRoomId: jest.fn().mockReturnValue(roomId),
getTs: jest.fn().mockReturnValue(0),
getContent: jest.fn().mockReturnValue({}),
getStateKey: jest.fn().mockReturnValue(groupCallId),
} as unknown as MatrixEvent;
}
+18
View File
@@ -1122,4 +1122,22 @@ describe("Crypto", function() {
expect(free).toHaveBeenCalled();
});
});
describe("start", () => {
let client: TestClient;
beforeEach(async () => {
client = new TestClient("@alice:example.org", "aliceweb");
await client.client.initCrypto();
});
afterEach(async function() {
await client!.stop();
});
// start() is a no-op nowadays, so there's not much to test here.
it("should complete successfully", async () => {
await client!.client.crypto!.start();
});
});
});
+143
View File
@@ -0,0 +1,143 @@
/*
Copyright 2022 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 '../../olm-loader';
import { TestClient } from '../../TestClient';
import { logger } from '../../../src/logger';
import { DEHYDRATION_ALGORITHM } from '../../../src/crypto/dehydration';
const Olm = global.Olm;
describe("Dehydration", () => {
if (!global.Olm) {
logger.warn('Not running dehydration unit tests: libolm not present');
return;
}
beforeAll(function() {
return global.Olm.init();
});
it("should rehydrate a dehydrated device", async () => {
const key = new Uint8Array([1, 2, 3]);
const alice = new TestClient(
"@alice:example.com", "Osborne2", undefined, undefined,
{
cryptoCallbacks: {
getDehydrationKey: async t => key,
},
},
);
const dehydratedDevice = new Olm.Account();
dehydratedDevice.create();
alice.httpBackend.when("GET", "/dehydrated_device").respond(200, {
device_id: "ABCDEFG",
device_data: {
algorithm: DEHYDRATION_ALGORITHM,
account: dehydratedDevice.pickle(new Uint8Array(key)),
},
});
alice.httpBackend.when("POST", "/dehydrated_device/claim").respond(200, {
success: true,
});
expect((await Promise.all([
alice.client.rehydrateDevice(),
alice.httpBackend.flushAllExpected(),
]))[0])
.toEqual("ABCDEFG");
expect(alice.client.getDeviceId()).toEqual("ABCDEFG");
});
it("should dehydrate a device", async () => {
const key = new Uint8Array([1, 2, 3]);
const alice = new TestClient(
"@alice:example.com", "Osborne2", undefined, undefined,
{
cryptoCallbacks: {
getDehydrationKey: async t => key,
},
},
);
await alice.client.initCrypto();
alice.httpBackend.when("GET", "/room_keys/version").respond(404, {
errcode: "M_NOT_FOUND",
});
let pickledAccount = "";
alice.httpBackend.when("PUT", "/dehydrated_device")
.check((req) => {
expect(req.data.device_data).toMatchObject({
algorithm: DEHYDRATION_ALGORITHM,
account: expect.any(String),
});
pickledAccount = req.data.device_data.account;
})
.respond(200, {
device_id: "ABCDEFG",
});
alice.httpBackend.when("POST", "/keys/upload/ABCDEFG")
.check((req) => {
expect(req.data).toMatchObject({
"device_keys": expect.objectContaining({
algorithms: expect.any(Array),
device_id: "ABCDEFG",
user_id: "@alice:example.com",
keys: expect.objectContaining({
"ed25519:ABCDEFG": expect.any(String),
"curve25519:ABCDEFG": expect.any(String),
}),
signatures: expect.objectContaining({
"@alice:example.com": expect.objectContaining({
"ed25519:ABCDEFG": expect.any(String),
}),
}),
}),
"one_time_keys": expect.any(Object),
"org.matrix.msc2732.fallback_keys": expect.any(Object),
});
})
.respond(200, {});
try {
const deviceId =
(await Promise.all([
alice.client.createDehydratedDevice(new Uint8Array(key), {}),
alice.httpBackend.flushAllExpected(),
]))[0];
expect(deviceId).toEqual("ABCDEFG");
expect(deviceId).not.toEqual("");
// try to rehydrate the dehydrated device
const rehydrated = new Olm.Account();
try {
rehydrated.unpickle(new Uint8Array(key), pickledAccount);
} finally {
rehydrated.free();
}
} finally {
alice.client?.crypto?.dehydrationManager?.stop();
alice.client?.crypto?.deviceList.stop();
}
});
});
@@ -18,7 +18,7 @@ import "../../../olm-loader";
import { CryptoEvent, verificationMethods } from "../../../../src/crypto";
import { logger } from "../../../../src/logger";
import { SAS } from "../../../../src/crypto/verification/SAS";
import { makeTestClients, setupWebcrypto, teardownWebcrypto } from './util';
import { makeTestClients } from './util';
const Olm = global.Olm;
@@ -31,14 +31,9 @@ describe("verification request integration tests with crypto layer", function()
}
beforeAll(function() {
setupWebcrypto();
return Olm.init();
});
afterAll(() => {
teardownWebcrypto();
});
it("should request and accept a verification", async function() {
const [[alice, bob], clearTestClientTimeouts] = await makeTestClients(
[
+1 -6
View File
@@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import "../../../olm-loader";
import { makeTestClients, setupWebcrypto, teardownWebcrypto } from './util';
import { makeTestClients } from './util';
import { MatrixEvent } from "../../../../src/models/event";
import { ISasEvent, SAS, SasEvent } from "../../../../src/crypto/verification/SAS";
import { DeviceInfo } from "../../../../src/crypto/deviceinfo";
@@ -41,14 +41,9 @@ describe("SAS verification", function() {
}
beforeAll(function() {
setupWebcrypto();
return Olm.init();
});
afterAll(() => {
teardownWebcrypto();
});
it("should error on an unexpected event", async function() {
//channel, baseApis, userId, deviceId, startEvent, request
const request = {
@@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { CrossSigningInfo } from '../../../../src/crypto/CrossSigning';
import '../../../olm-loader';
import { MatrixClient, MatrixEvent } from '../../../../src/matrix';
import { encodeBase64 } from "../../../../src/crypto/olmlib";
import { setupWebcrypto, teardownWebcrypto } from './util';
import { VerificationBase } from '../../../../src/crypto/verification/Base';
import { MatrixClient, MatrixEvent } from '../../../../src';
import "../../../../src/crypto"; // import this to cycle-break
import { CrossSigningInfo } from '../../../../src/crypto/CrossSigning';
import { VerificationRequest } from '../../../../src/crypto/verification/request/VerificationRequest';
import { IVerificationChannel } from '../../../../src/crypto/verification/request/Channel';
import { VerificationBase } from '../../../../src/crypto/verification/Base';
jest.useFakeTimers();
@@ -35,14 +36,9 @@ const testKeyPub = "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk";
describe("self-verifications", () => {
beforeAll(function() {
setupWebcrypto();
return global.Olm.init();
});
afterAll(() => {
teardownWebcrypto();
});
it("triggers a request for key sharing upon completion", async () => {
const userId = "@test:localhost";
-15
View File
@@ -15,8 +15,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import nodeCrypto from "crypto";
import { TestClient } from '../../../TestClient';
import { MatrixEvent } from "../../../../src/models/event";
import { IRoomTimelineData } from "../../../../src/models/event-timeline-set";
@@ -118,16 +116,3 @@ export async function makeTestClients(userInfos, options): Promise<[TestClient[]
return [clients, destroy];
}
export function setupWebcrypto() {
global.crypto = {
getRandomValues: (buf) => {
return nodeCrypto.randomFillSync(buf as any);
},
} as unknown as Crypto;
}
export function teardownWebcrypto() {
// @ts-ignore undefined != Crypto
global.crypto = undefined;
}
@@ -20,7 +20,6 @@ import { ToDeviceChannel } from
"../../../../src/crypto/verification/request/ToDeviceChannel";
import { MatrixEvent } from "../../../../src/models/event";
import { MatrixClient } from "../../../../src/client";
import { setupWebcrypto, teardownWebcrypto } from "./util";
import { IVerificationChannel } from "../../../../src/crypto/verification/request/Channel";
import { VerificationBase } from "../../../../src/crypto/verification/Base";
@@ -147,14 +146,6 @@ async function distributeEvent(
jest.useFakeTimers();
describe("verification request unit tests", function() {
beforeAll(function() {
setupWebcrypto();
});
afterAll(() => {
teardownWebcrypto();
});
it("transition from UNSENT to DONE through happy path", async function() {
const alice = makeMockClient("@alice:matrix.tld", "device1");
const bob = makeMockClient("@bob:matrix.tld", "device1");
+331
View File
@@ -0,0 +1,331 @@
/**
* @jest-environment jsdom
*/
/*
Copyright 2022 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.
*/
// We have to use EventEmitter here to mock part of the matrix-widget-api
// project, which doesn't know about our TypeEventEmitter implementation at all
// eslint-disable-next-line no-restricted-imports
import { EventEmitter } from "events";
import { MockedObject } from "jest-mock";
import {
WidgetApi,
WidgetApiToWidgetAction,
MatrixCapabilities,
ITurnServer,
IRoomEvent,
} from "matrix-widget-api";
import { createRoomWidgetClient, MsgType } from "../../src/matrix";
import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "../../src/client";
import { SyncState } from "../../src/sync";
import { ICapabilities } from "../../src/embedded";
import { MatrixEvent } from "../../src/models/event";
import { ToDeviceBatch } from "../../src/models/ToDeviceMessage";
import { DeviceInfo } from "../../src/crypto/deviceinfo";
class MockWidgetApi extends EventEmitter {
public start = jest.fn();
public requestCapability = jest.fn();
public requestCapabilities = jest.fn();
public requestCapabilityForRoomTimeline = jest.fn();
public requestCapabilityToSendEvent = jest.fn();
public requestCapabilityToReceiveEvent = jest.fn();
public requestCapabilityToSendMessage = jest.fn();
public requestCapabilityToReceiveMessage = jest.fn();
public requestCapabilityToSendState = jest.fn();
public requestCapabilityToReceiveState = jest.fn();
public requestCapabilityToSendToDevice = jest.fn();
public requestCapabilityToReceiveToDevice = jest.fn();
public sendRoomEvent = jest.fn(() => ({ event_id: `$${Math.random()}` }));
public sendStateEvent = jest.fn();
public sendToDevice = jest.fn();
public readStateEvents = jest.fn(() => []);
public getTurnServers = jest.fn(() => []);
public transport = { reply: jest.fn() };
}
describe("RoomWidgetClient", () => {
let widgetApi: MockedObject<WidgetApi>;
let client: MatrixClient;
beforeEach(() => {
widgetApi = new MockWidgetApi() as unknown as MockedObject<WidgetApi>;
});
afterEach(() => {
client.stopClient();
});
const makeClient = async (capabilities: ICapabilities): Promise<void> => {
const baseUrl = "https://example.org";
client = createRoomWidgetClient(widgetApi, capabilities, "!1:example.org", { baseUrl });
expect(widgetApi.start).toHaveBeenCalled(); // needs to have been called early in order to not miss messages
widgetApi.emit("ready");
await client.startClient();
};
describe("events", () => {
it("sends", async () => {
await makeClient({ sendEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToSendEvent).toHaveBeenCalledWith("org.matrix.rageshake_request");
await client.sendEvent("!1:example.org", "org.matrix.rageshake_request", { request_id: 123 });
expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith(
"org.matrix.rageshake_request", { request_id: 123 }, "!1:example.org",
);
});
it("receives", async () => {
const event = new MatrixEvent({
type: "org.matrix.rageshake_request",
event_id: "$pduhfiidph",
room_id: "!1:example.org",
sender: "@alice:example.org",
content: { request_id: 123 },
}).getEffectiveEvent();
await makeClient({ receiveEvent: ["org.matrix.rageshake_request"] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveEvent).toHaveBeenCalledWith("org.matrix.rageshake_request");
const emittedEvent = new Promise<MatrixEvent>(resolve => client.once(ClientEvent.Event, resolve));
const emittedSync = new Promise<SyncState>(resolve => client.once(ClientEvent.Sync, resolve));
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendEvent}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
);
// The client should've emitted about the received event
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
expect(await emittedSync).toEqual(SyncState.Syncing);
// It should've also inserted the event into the room object
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.getLiveTimeline().getEvents().map(e => e.getEffectiveEvent())).toEqual([event]);
});
});
describe("messages", () => {
it("requests permissions for specific message types", async () => {
await makeClient({ sendMessage: [MsgType.Text], receiveMessage: [MsgType.Text] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToSendMessage).toHaveBeenCalledWith(MsgType.Text);
expect(widgetApi.requestCapabilityToReceiveMessage).toHaveBeenCalledWith(MsgType.Text);
});
it("requests permissions for all message types", async () => {
await makeClient({ sendMessage: true, receiveMessage: true });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToSendMessage).toHaveBeenCalledWith();
expect(widgetApi.requestCapabilityToReceiveMessage).toHaveBeenCalledWith();
});
// No point in testing sending and receiving since it's done exactly the
// same way as non-message events
});
describe("state events", () => {
const event = new MatrixEvent({
type: "org.example.foo",
event_id: "$sfkjfsksdkfsd",
room_id: "!1:example.org",
sender: "@alice:example.org",
state_key: "bar",
content: { hello: "world" },
}).getEffectiveEvent();
it("sends", async () => {
await makeClient({ sendState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToSendState).toHaveBeenCalledWith("org.example.foo", "bar");
await client.sendStateEvent("!1:example.org", "org.example.foo", { hello: "world" }, "bar");
expect(widgetApi.sendStateEvent).toHaveBeenCalledWith(
"org.example.foo", "bar", { hello: "world" }, "!1:example.org",
);
});
it("receives", async () => {
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
const emittedEvent = new Promise<MatrixEvent>(resolve => client.once(ClientEvent.Event, resolve));
const emittedSync = new Promise<SyncState>(resolve => client.once(ClientEvent.Sync, resolve));
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendEvent}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendEvent}`, { detail: { data: event } }),
);
// The client should've emitted about the received event
expect((await emittedEvent).getEffectiveEvent()).toEqual(event);
expect(await emittedSync).toEqual(SyncState.Syncing);
// It should've also inserted the event into the room object
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar").getEffectiveEvent()).toEqual(event);
});
it("backfills", async () => {
widgetApi.readStateEvents.mockImplementation(async (eventType, limit, stateKey) =>
eventType === "org.example.foo" && (limit ?? Infinity) > 0 && stateKey === "bar"
? [event as IRoomEvent]
: [],
);
await makeClient({ receiveState: [{ eventType: "org.example.foo", stateKey: "bar" }] });
expect(widgetApi.requestCapabilityForRoomTimeline).toHaveBeenCalledWith("!1:example.org");
expect(widgetApi.requestCapabilityToReceiveState).toHaveBeenCalledWith("org.example.foo", "bar");
const room = client.getRoom("!1:example.org");
expect(room).not.toBeNull();
expect(room!.currentState.getStateEvents("org.example.foo", "bar").getEffectiveEvent()).toEqual(event);
});
});
describe("to-device messages", () => {
const unencryptedContentMap = {
"@alice:example.org": { "*": { hello: "alice!" } },
"@bob:example.org": { bobDesktop: { hello: "bob!" } },
};
it("sends unencrypted (sendToDevice)", async () => {
await makeClient({ sendToDevice: ["org.example.foo"] });
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
await client.sendToDevice("org.example.foo", unencryptedContentMap);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
});
it("sends unencrypted (queueToDevice)", async () => {
await makeClient({ sendToDevice: ["org.example.foo"] });
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
const batch: ToDeviceBatch = {
eventType: "org.example.foo",
batch: [
{ userId: "@alice:example.org", deviceId: "*", payload: { hello: "alice!" } },
{ userId: "@bob:example.org", deviceId: "bobDesktop", payload: { hello: "bob!" } },
],
};
await client.queueToDevice(batch);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, unencryptedContentMap);
});
it("sends encrypted (encryptAndSendToDevices)", async () => {
await makeClient({ sendToDevice: ["org.example.foo"] });
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
const payload = { type: "org.example.foo", hello: "world" };
await client.encryptAndSendToDevices(
[
{ userId: "@alice:example.org", deviceInfo: new DeviceInfo("aliceWeb") },
{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobDesktop") },
],
payload,
);
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", true, {
"@alice:example.org": { aliceWeb: payload },
"@bob:example.org": { bobDesktop: payload },
});
});
it.each([
{ encrypted: false, title: "unencrypted" },
{ encrypted: true, title: "encrypted" },
])("receives $title", async ({ encrypted }) => {
await makeClient({ receiveToDevice: ["org.example.foo"] });
expect(widgetApi.requestCapabilityToReceiveToDevice).toHaveBeenCalledWith("org.example.foo");
const event = {
type: "org.example.foo",
sender: "@alice:example.org",
encrypted,
content: { hello: "world" },
};
const emittedEvent = new Promise<MatrixEvent>(resolve => client.once(ClientEvent.ToDeviceEvent, resolve));
const emittedSync = new Promise<SyncState>(resolve => client.once(ClientEvent.Sync, resolve));
widgetApi.emit(
`action:${WidgetApiToWidgetAction.SendToDevice}`,
new CustomEvent(`action:${WidgetApiToWidgetAction.SendToDevice}`, { detail: { data: event } }),
);
expect((await emittedEvent).getEffectiveEvent()).toEqual({
type: event.type,
sender: event.sender,
content: event.content,
});
expect((await emittedEvent).isEncrypted()).toEqual(encrypted);
expect(await emittedSync).toEqual(SyncState.Syncing);
});
});
it("gets TURN servers", async () => {
const server1: ITurnServer = {
uris: [
"turn:turn.example.com:3478?transport=udp",
"turn:10.20.30.40:3478?transport=tcp",
"turns:10.20.30.40:443?transport=tcp",
],
username: "1443779631:@user:example.com",
password: "JlKfBy1QwLrO20385QyAtEyIv0=",
};
const server2: ITurnServer = {
uris: [
"turn:turn.example.com:3478?transport=udp",
"turn:10.20.30.40:3478?transport=tcp",
"turns:10.20.30.40:443?transport=tcp",
],
username: "1448999322:@user:example.com",
password: "hunter2",
};
const clientServer1: IClientTurnServer = {
urls: server1.uris,
username: server1.username,
credential: server1.password,
};
const clientServer2: IClientTurnServer = {
urls: server2.uris,
username: server2.username,
credential: server2.password,
};
let emitServer2: () => void;
const getServer2 = new Promise<ITurnServer>(resolve => emitServer2 = () => resolve(server2));
widgetApi.getTurnServers.mockImplementation(async function* () {
yield server1;
yield await getServer2;
});
await makeClient({ turnServers: true });
expect(widgetApi.requestCapability).toHaveBeenCalledWith(MatrixCapabilities.MSC3846TurnServers);
// The first server should've arrived immediately
expect(client.getTurnServers()).toEqual([clientServer1]);
// Subsequent servers arrive asynchronously and should emit an event
const emittedServer = new Promise<IClientTurnServer[]>(resolve =>
client.once(ClientEvent.TurnServers, resolve),
);
emitServer2!();
expect(await emittedServer).toEqual([clientServer2]);
expect(client.getTurnServers()).toEqual([clientServer2]);
});
});
+76
View File
@@ -55,6 +55,23 @@ describe('EventTimelineSet', () => {
});
};
const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: userA,
room: roomId,
content: {
"body": "Thread response :: " + Math.random(),
"m.relates_to": {
"event_id": root.getId(),
"m.in_reply_to": {
"event_id": root.getId(),
},
"rel_type": "m.thread",
},
},
}, room.client);
beforeEach(() => {
client = utils.mock(MatrixClient, 'MatrixClient');
client.reEmitter = utils.mock(ReEmitter, 'ReEmitter');
@@ -117,6 +134,13 @@ describe('EventTimelineSet', () => {
});
describe('addEventToTimeline', () => {
let thread: Thread;
beforeEach(() => {
(client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true);
thread = new Thread("!thread_id:server", messageEvent, { room, client });
});
it("Adds event to timeline", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
@@ -144,6 +168,58 @@ describe('EventTimelineSet', () => {
);
}).not.toThrow();
});
it("should not add an event to a timeline that does not belong to the timelineSet", () => {
const eventTimelineSet2 = new EventTimelineSet(room);
const liveTimeline2 = eventTimelineSet2.getLiveTimeline();
expect(liveTimeline2.getEvents().length).toStrictEqual(0);
expect(() => {
eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline2, {
toStartOfTimeline: true,
});
}).toThrowError();
});
it("should not add a threaded reply to the main room timeline", () => {
const liveTimeline = eventTimelineSet.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
const threadedReplyEvent = mkThreadResponse(messageEvent);
eventTimelineSet.addEventToTimeline(threadedReplyEvent, liveTimeline, {
toStartOfTimeline: true,
});
expect(liveTimeline.getEvents().length).toStrictEqual(0);
});
it("should not add a normal message to the timelineSet representing a thread", () => {
const eventTimelineSetForThread = new EventTimelineSet(room, {}, client, thread);
const liveTimeline = eventTimelineSetForThread.getLiveTimeline();
expect(liveTimeline.getEvents().length).toStrictEqual(0);
eventTimelineSetForThread.addEventToTimeline(messageEvent, liveTimeline, {
toStartOfTimeline: true,
});
expect(liveTimeline.getEvents().length).toStrictEqual(0);
});
describe('non-room timeline', () => {
it('Adds event to timeline', () => {
const nonRoomEventTimelineSet = new EventTimelineSet(
// This is what we're specifically testing against, a timeline
// without a `room` defined
undefined,
);
const nonRoomEventTimeline = new EventTimeline(nonRoomEventTimelineSet);
expect(nonRoomEventTimeline.getEvents().length).toStrictEqual(0);
nonRoomEventTimelineSet.addEventToTimeline(messageEvent, nonRoomEventTimeline, {
toStartOfTimeline: true,
});
expect(nonRoomEventTimeline.getEvents().length).toStrictEqual(1);
});
});
});
describe('aggregateRelations', () => {
+32 -17
View File
@@ -1,15 +1,13 @@
import { mocked } from 'jest-mock';
import * as utils from "../test-utils/test-utils";
import { EventTimeline } from "../../src/models/event-timeline";
import { Direction, EventTimeline } from "../../src/models/event-timeline";
import { RoomState } from "../../src/models/room-state";
import { MatrixClient } from "../../src/matrix";
import { Room } from "../../src/models/room";
import { RoomMember } from "../../src/models/room-member";
import { EventTimelineSet } from "../../src/models/event-timeline-set";
jest.mock("../../src/models/room-state");
describe("EventTimeline", function() {
const roomId = "!foo:bar";
const userA = "@alice:bar";
@@ -23,7 +21,14 @@ describe("EventTimeline", function() {
const timelineSet = new EventTimelineSet(room);
jest.spyOn(room, 'getUnfilteredTimelineSet').mockReturnValue(timelineSet);
return new EventTimeline(timelineSet);
const timeline = new EventTimeline(timelineSet);
// We manually stub the methods we'll be mocking out later instead of mocking the whole module
// otherwise the default member property values (e.g. paginationToken) will be incorrect
timeline.getState(Direction.Backward)!.setStateEvents = jest.fn();
timeline.getState(Direction.Backward)!.getSentinelMember = jest.fn();
timeline.getState(Direction.Forward)!.setStateEvents = jest.fn();
timeline.getState(Direction.Forward)!.getSentinelMember = jest.fn();
return timeline;
};
beforeEach(function() {
@@ -55,13 +60,13 @@ describe("EventTimeline", function() {
];
timeline.initialiseState(events);
// @ts-ignore private prop
const timelineStartState = timeline.startState;
const timelineStartState = timeline.startState!;
expect(mocked(timelineStartState).setStateEvents).toHaveBeenCalledWith(
events,
{ timelineWasEmpty: undefined },
);
// @ts-ignore private prop
const timelineEndState = timeline.endState;
const timelineEndState = timeline.endState!;
expect(mocked(timelineEndState).setStateEvents).toHaveBeenCalledWith(
events,
{ timelineWasEmpty: undefined },
@@ -98,7 +103,17 @@ describe("EventTimeline", function() {
expect(timeline.getPaginationToken(EventTimeline.FORWARDS)).toBe(null);
});
it("setPaginationToken should set token", function() {
it("setPaginationToken should set token", function() {
timeline.setPaginationToken("back", EventTimeline.BACKWARDS);
timeline.setPaginationToken("fwd", EventTimeline.FORWARDS);
expect(timeline.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("back");
expect(timeline.getPaginationToken(EventTimeline.FORWARDS)).toEqual("fwd");
});
it("should be able to store pagination tokens for mixed room timelines", () => {
const timelineSet = new EventTimelineSet(undefined);
const timeline = new EventTimeline(timelineSet);
timeline.setPaginationToken("back", EventTimeline.BACKWARDS);
timeline.setPaginationToken("fwd", EventTimeline.FORWARDS);
expect(timeline.getPaginationToken(EventTimeline.BACKWARDS)).toEqual("back");
@@ -185,14 +200,14 @@ describe("EventTimeline", function() {
sentinel.name = "Old Alice";
sentinel.membership = "join";
mocked(timeline.getState(EventTimeline.FORWARDS)).getSentinelMember
mocked(timeline.getState(EventTimeline.FORWARDS)!).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
mocked(timeline.getState(EventTimeline.BACKWARDS)).getSentinelMember
mocked(timeline.getState(EventTimeline.BACKWARDS)!).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return oldSentinel;
@@ -225,14 +240,14 @@ describe("EventTimeline", function() {
sentinel.name = "Old Alice";
sentinel.membership = "join";
mocked(timeline.getState(EventTimeline.FORWARDS)).getSentinelMember
mocked(timeline.getState(EventTimeline.FORWARDS)!).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return sentinel;
}
return null;
});
mocked(timeline.getState(EventTimeline.BACKWARDS)).getSentinelMember
mocked(timeline.getState(EventTimeline.BACKWARDS)!).getSentinelMember
.mockImplementation(function(uid) {
if (uid === userA) {
return oldSentinel;
@@ -269,15 +284,15 @@ describe("EventTimeline", function() {
timeline.addEvent(events[0], { toStartOfTimeline: false });
timeline.addEvent(events[1], { toStartOfTimeline: false });
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).
toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).
toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
expect(events[0].forwardLooking).toBe(true);
expect(events[1].forwardLooking).toBe(true);
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).
not.toHaveBeenCalled();
});
@@ -298,15 +313,15 @@ describe("EventTimeline", function() {
timeline.addEvent(events[0], { toStartOfTimeline: true });
timeline.addEvent(events[1], { toStartOfTimeline: true });
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).
toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined });
expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents).
expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).
toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined });
expect(events[0].forwardLooking).toBe(false);
expect(events[1].forwardLooking).toBe(false);
expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents).
expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).
not.toHaveBeenCalled();
});
+2
View File
@@ -28,6 +28,8 @@ import {
import { sleep } from "../../../src/utils";
jest.mock("../../../src/utils");
// setupTests mocks `timeoutSignal` due to hanging timers
jest.unmock("../../../src/http-api/utils");
describe("timeoutSignal", () => {
jest.useFakeTimers();
+35
View File
@@ -1703,4 +1703,39 @@ describe("MatrixClient", function() {
expect(newSourceRoom._state.get(PolicyScope.User)?.[eventId]).toBeFalsy();
});
});
describe("using E2EE in group calls", () => {
const opts = {
baseUrl: "https://my.home.server",
idBaseUrl: identityServerUrl,
accessToken: "my.access.token",
store: store,
scheduler: scheduler,
userId: userId,
};
it("enables E2EE by default", () => {
const client = new MatrixClient(opts);
expect(client.getUseE2eForGroupCall()).toBe(true);
});
it("enables E2EE when enabled explicitly", () => {
const client = new MatrixClient({
useE2eForGroupCall: true,
...opts,
});
expect(client.getUseE2eForGroupCall()).toBe(true);
});
it("disables E2EE if disabled explicitly", () => {
const client = new MatrixClient({
useE2eForGroupCall: false,
...opts,
});
expect(client.getUseE2eForGroupCall()).toBe(false);
});
});
});
+52
View File
@@ -14,7 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "../../../src/client";
import { Room } from "../../../src/models/room";
import { Thread } from "../../../src/models/thread";
import { mkThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
describe('Thread', () => {
describe("constructor", () => {
@@ -25,4 +29,52 @@ describe('Thread', () => {
}).toThrow("element-web#22141: A thread requires a room in order to function");
});
});
describe("hasUserReadEvent", () => {
const myUserId = "@bob:example.org";
let client: MatrixClient;
let room: Room;
beforeEach(() => {
const testClient = new TestClient(
myUserId,
"DEVICE",
"ACCESS_TOKEN",
undefined,
{ timelineSupport: false },
);
client = testClient.client;
room = new Room("123", client, myUserId);
jest.spyOn(client, "getRoom").mockReturnValue(room);
});
afterAll(() => {
jest.resetAllMocks();
});
it("considers own events with no RR as read", () => {
const { thread, events } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: [myUserId],
length: 2,
});
expect(thread.hasUserReadEvent(myUserId, events.at(-1)!.getId() ?? "")).toBeTruthy();
});
it("considers other events with no RR as unread", () => {
const { thread, events } = mkThread({
room,
client,
authorId: myUserId,
participantUserIds: ["@alice:example.org"],
length: 2,
});
expect(thread.hasUserReadEvent("@alice:example.org", events.at(-1)!.getId() ?? "")).toBeFalsy();
});
});
});
+7 -16
View File
@@ -22,6 +22,8 @@ import { MatrixClient } from "../../src/client";
import { ToDeviceBatch } from '../../src/models/ToDeviceMessage';
import { logger } from '../../src/logger';
import { IStore } from '../../src/store';
import { flushPromises } from '../test-utils/flushPromises';
import { removeElement } from "../../src/utils";
const FAKE_USER = "@alice:example.org";
const FAKE_DEVICE_ID = "AAAAAAAA";
@@ -47,19 +49,6 @@ enum StoreType {
IndexedDB = 'IndexedDB',
}
// Jest now uses @sinonjs/fake-timers which exposes tickAsync() and a number of
// other async methods which break the event loop, letting scheduled promise
// callbacks run. Unfortunately, Jest doesn't expose these, so we have to do
// it manually (this is what sinon does under the hood). We do both in a loop
// until the thing we expect happens: hopefully this is the least flakey way
// and avoids assuming anything about the app's behaviour.
const realSetTimeout = setTimeout;
function flushPromises() {
return new Promise(r => {
realSetTimeout(r, 1);
});
}
async function flushAndRunTimersUntil(cond: () => boolean) {
while (!cond()) {
await flushPromises();
@@ -75,6 +64,8 @@ describe.each([
let client: MatrixClient;
beforeEach(async function() {
jest.runOnlyPendingTimers();
jest.useRealTimers();
httpBackend = new MockHttpBackend();
let store: IStore;
@@ -300,7 +291,7 @@ describe.each([
],
});
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
await flushPromises();
const dummyEvent = new MatrixEvent({
@@ -328,12 +319,12 @@ describe.each([
});
}
const expectedCounts = [20, 1];
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
expect(Object.keys(request.data.messages).length).toEqual(20);
expect(removeElement(expectedCounts, c => c === Object.keys(request.data.messages).length)).toBeTruthy();
}).respond(200, {});
httpBackend.when(
"PUT", "/sendToDevice/org.example.foo/",
).check((request) => {
+25 -13
View File
@@ -18,6 +18,7 @@ import MockHttpBackend from 'matrix-mock-request';
import { ReceiptType } from '../../src/@types/read_receipts';
import { MatrixClient } from "../../src/client";
import { Feature, ServerSupport } from '../../src/feature';
import { EventType } from '../../src/matrix';
import { MAIN_ROOM_TIMELINE } from '../../src/models/read-receipt';
import { encodeUri } from '../../src/utils';
@@ -69,15 +70,8 @@ const roomEvent = utils.mkEvent({
},
});
function mockServerSideSupport(client, hasServerSideSupport) {
const doesServerSupportUnstableFeature = client.doesServerSupportUnstableFeature;
client.doesServerSupportUnstableFeature = (unstableFeature) => {
if (unstableFeature === "org.matrix.msc3771") {
return Promise.resolve(hasServerSideSupport);
} else {
return doesServerSupportUnstableFeature(unstableFeature);
}
};
function mockServerSideSupport(client, serverSideSupport: ServerSupport) {
client.canSupport.set(Feature.ThreadUnreadNotifications, serverSideSupport);
}
describe("Read receipt", () => {
@@ -103,13 +97,31 @@ describe("Read receipt", () => {
expect(request.data.thread_id).toEqual(THREAD_ID);
}).respond(200, {});
mockServerSideSupport(client, true);
mockServerSideSupport(client, ServerSupport.Stable);
client.sendReceipt(threadEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
await flushPromises();
});
it("sends an unthreaded receipt", async () => {
httpBackend.when(
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
$roomId: ROOM_ID,
$receiptType: ReceiptType.Read,
$eventId: threadEvent.getId()!,
}),
).check((request) => {
expect(request.data.thread_id).toBeUndefined();
}).respond(200, {});
mockServerSideSupport(client, ServerSupport.Stable);
client.sendReadReceipt(threadEvent, ReceiptType.Read, true);
await httpBackend.flushAllExpected();
await flushPromises();
});
it("sends a room read receipt", async () => {
httpBackend.when(
"POST", encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
@@ -121,7 +133,7 @@ describe("Read receipt", () => {
expect(request.data.thread_id).toEqual(MAIN_ROOM_TIMELINE);
}).respond(200, {});
mockServerSideSupport(client, true);
mockServerSideSupport(client, ServerSupport.Stable);
client.sendReceipt(roomEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
@@ -139,7 +151,7 @@ describe("Read receipt", () => {
expect(request.data.thread_id).toBeUndefined();
}).respond(200, {});
mockServerSideSupport(client, false);
mockServerSideSupport(client, ServerSupport.Unsupported);
client.sendReceipt(threadEvent, ReceiptType.Read, {});
await httpBackend.flushAllExpected();
@@ -157,7 +169,7 @@ describe("Read receipt", () => {
expect(request.data).toEqual({});
}).respond(200, {});
mockServerSideSupport(client, false);
mockServerSideSupport(client, ServerSupport.Unsupported);
client.sendReceipt(threadEvent, ReceiptType.Read, undefined);
await httpBackend.flushAllExpected();
File diff suppressed because it is too large Load Diff
+214 -6
View File
@@ -20,22 +20,117 @@ import {
EventTimeline,
EventTimelineSet,
EventType,
GroupCallIntent,
GroupCallType,
IRoomTimelineData,
MatrixCall,
MatrixEvent,
Room,
RoomEvent,
RoomMember,
} from "../../../src";
import { MatrixClient } from "../../../src/client";
import { CallEventHandler, CallEventHandlerEvent } from "../../../src/webrtc/callEventHandler";
import { GroupCallEventHandler } from "../../../src/webrtc/groupCallEventHandler";
import { SyncState } from "../../../src/sync";
import { installWebRTCMocks, MockRTCPeerConnection } from "../../test-utils/webrtc";
import { sleep } from "../../../src/utils";
describe("callEventHandler", () => {
it("should ignore a call if invite & hangup come within a single sync", () => {
const testClient = new TestClient();
const client = testClient.client;
const room = new Room("!room:id", client, "@user:id");
const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
describe("CallEventHandler", () => {
let client: MatrixClient;
beforeEach(() => {
installWebRTCMocks();
client = new TestClient("@alice:foo", "somedevice", "token", undefined, {}).client;
client.callEventHandler = new CallEventHandler(client);
client.callEventHandler.start();
client.groupCallEventHandler = new GroupCallEventHandler(client);
client.groupCallEventHandler.start();
client.sendStateEvent = jest.fn().mockResolvedValue({});
});
afterEach(() => {
client.callEventHandler!.stop();
client.groupCallEventHandler!.stop();
});
const sync = async () => {
client.getSyncState = jest.fn().mockReturnValue(SyncState.Syncing);
client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Prepared);
// We can't await the event processing
await sleep(10);
};
it("should enforce inbound toDevice message ordering", async () => {
const callEventHandler = client.callEventHandler!;
const event1 = new MatrixEvent({
type: EventType.CallInvite,
content: {
call_id: "123",
seq: 0,
},
});
callEventHandler["onToDeviceEvent"](event1);
expect(callEventHandler.callEventBuffer.length).toBe(1);
expect(callEventHandler.callEventBuffer[0]).toBe(event1);
const event2 = new MatrixEvent({
type: EventType.CallCandidates,
content: {
call_id: "123",
seq: 1,
},
});
callEventHandler["onToDeviceEvent"](event2);
expect(callEventHandler.callEventBuffer.length).toBe(2);
expect(callEventHandler.callEventBuffer[1]).toBe(event2);
const event3 = new MatrixEvent({
type: EventType.CallCandidates,
content: {
call_id: "123",
seq: 3,
},
});
callEventHandler["onToDeviceEvent"](event3);
expect(callEventHandler.callEventBuffer.length).toBe(2);
expect(callEventHandler.nextSeqByCall.get("123")).toBe(2);
expect(callEventHandler.toDeviceEventBuffers.get("123")?.length).toBe(1);
const event4 = new MatrixEvent({
type: EventType.CallCandidates,
content: {
call_id: "123",
seq: 4,
},
});
callEventHandler["onToDeviceEvent"](event4);
expect(callEventHandler.callEventBuffer.length).toBe(2);
expect(callEventHandler.nextSeqByCall.get("123")).toBe(2);
expect(callEventHandler.toDeviceEventBuffers.get("123")?.length).toBe(2);
const event5 = new MatrixEvent({
type: EventType.CallCandidates,
content: {
call_id: "123",
seq: 2,
},
});
callEventHandler["onToDeviceEvent"](event5);
expect(callEventHandler.callEventBuffer.length).toBe(5);
expect(callEventHandler.nextSeqByCall.get("123")).toBe(5);
expect(callEventHandler.toDeviceEventBuffers.get("123")?.length).toBe(0);
});
it("should ignore a call if invite & hangup come within a single sync", () => {
const room = new Room("!room:id", client, "@user:id");
const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
// Fire off call invite then hangup within a single sync
const callInvite = new MatrixEvent({
@@ -62,4 +157,117 @@ describe("callEventHandler", () => {
expect(incomingCallEmitted).not.toHaveBeenCalled();
});
it("should ignore non-call events", async () => {
// @ts-ignore Mock handleCallEvent is private
jest.spyOn(client.callEventHandler, "handleCallEvent");
jest.spyOn(client, "checkTurnServers").mockReturnValue(Promise.resolve(true));
const room = new Room("!room:id", client, "@user:id");
const timelineData: IRoomTimelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
client.emit(RoomEvent.Timeline, new MatrixEvent({
type: EventType.RoomMessage,
room_id: "!room:id",
content: {
text: "hello",
},
}), room, false, false, timelineData);
await sync();
// @ts-ignore Mock handleCallEvent is private
expect(client.callEventHandler.handleCallEvent).not.toHaveBeenCalled();
});
describe("handleCallEvent()", () => {
const incomingCallListener = jest.fn();
let timelineData: IRoomTimelineData;
let room: Room;
beforeEach(() => {
room = new Room("!room:id", client, client.getUserId()!);
timelineData = { timeline: new EventTimeline(new EventTimelineSet(room, {})) };
jest.spyOn(client, "checkTurnServers").mockReturnValue(Promise.resolve(true));
jest.spyOn(client, "getRoom").mockReturnValue(room);
jest.spyOn(room, "getMember").mockReturnValue({ user_id: client.getUserId() } as unknown as RoomMember);
client.on(CallEventHandlerEvent.Incoming, incomingCallListener);
});
afterEach(() => {
MockRTCPeerConnection.resetInstances();
jest.resetAllMocks();
});
it("should create a call when receiving an invite", async () => {
client.emit(RoomEvent.Timeline, new MatrixEvent({
type: EventType.CallInvite,
room_id: "!room:id",
content: {
call_id: "123",
},
}), room, false, false, timelineData);
await sync();
expect(incomingCallListener).toHaveBeenCalled();
});
it("should handle group call event", async () => {
let call: MatrixCall;
const groupCall = await client.createGroupCall(
room.roomId,
GroupCallType.Voice,
false,
GroupCallIntent.Ring,
);
const SESSION_ID = "sender_session_id";
const GROUP_CALL_ID = "group_call_id";
const DEVICE_ID = "device_id";
incomingCallListener.mockImplementation((c) => call = c);
jest.spyOn(client.groupCallEventHandler!, "getGroupCallById").mockReturnValue(groupCall);
// @ts-ignore Mock onIncomingCall is private
jest.spyOn(groupCall, "onIncomingCall");
await groupCall.enter();
client.emit(RoomEvent.Timeline, new MatrixEvent({
type: EventType.CallInvite,
room_id: "!room:id",
content: {
call_id: "123",
conf_id: GROUP_CALL_ID,
device_id: DEVICE_ID,
sender_session_id: SESSION_ID,
dest_session_id: client.getSessionId(),
},
}), room, false, false, timelineData);
await sync();
expect(incomingCallListener).toHaveBeenCalled();
expect(call!.groupCallId).toBe(GROUP_CALL_ID);
// @ts-ignore Mock opponentDeviceId is private
expect(call.opponentDeviceId).toBe(DEVICE_ID);
expect(call!.getOpponentSessionId()).toBe(SESSION_ID);
// @ts-ignore Mock onIncomingCall is private
expect(groupCall.onIncomingCall).toHaveBeenCalledWith(call);
groupCall.terminate(false);
});
it("ignores a call with a different invitee than us", async () => {
client.emit(RoomEvent.Timeline, new MatrixEvent({
type: EventType.CallInvite,
room_id: "!room:id",
content: {
call_id: "123",
invitee: "@bob:bar",
},
}), room, false, false, timelineData);
await sync();
expect(incomingCallListener).not.toHaveBeenCalled();
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,316 @@
/*
Copyright 2022 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 {
ClientEvent,
GroupCall,
GroupCallIntent,
GroupCallState,
GroupCallType,
IContent,
MatrixEvent,
Room,
RoomState,
} from "../../../src";
import { SyncState } from "../../../src/sync";
import { GroupCallTerminationReason } from "../../../src/webrtc/groupCall";
import { GroupCallEventHandler, GroupCallEventHandlerEvent } from "../../../src/webrtc/groupCallEventHandler";
import { flushPromises } from "../../test-utils/flushPromises";
import {
makeMockGroupCallMemberStateEvent,
makeMockGroupCallStateEvent,
MockCallMatrixClient,
} from "../../test-utils/webrtc";
const FAKE_USER_ID = "@alice:test.dummy";
const FAKE_DEVICE_ID = "AAAAAAA";
const FAKE_SESSION_ID = "session1";
const FAKE_ROOM_ID = "!roomid:test.dummy";
const FAKE_GROUP_CALL_ID = "fakegroupcallid";
describe('Group Call Event Handler', function() {
let groupCallEventHandler: GroupCallEventHandler;
let mockClient: MockCallMatrixClient;
let mockRoom: Room;
beforeEach(() => {
mockClient = new MockCallMatrixClient(
FAKE_USER_ID, FAKE_DEVICE_ID, FAKE_SESSION_ID,
);
groupCallEventHandler = new GroupCallEventHandler(mockClient.typed());
mockRoom = {
roomId: FAKE_ROOM_ID,
currentState: {
getStateEvents: jest.fn().mockReturnValue([makeMockGroupCallStateEvent(
FAKE_ROOM_ID, FAKE_GROUP_CALL_ID,
)]),
},
} as unknown as Room;
(mockClient as any).getRoom = jest.fn().mockReturnValue(mockRoom);
});
describe("reacts to state changes", () => {
it("terminates call", async () => {
await groupCallEventHandler.start();
mockClient.emitRoomState(
makeMockGroupCallStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID),
{ roomId: FAKE_ROOM_ID } as unknown as RoomState,
);
const groupCall = groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)!;
expect(groupCall.state).toBe(GroupCallState.LocalCallFeedUninitialized);
mockClient.emitRoomState(
makeMockGroupCallStateEvent(
FAKE_ROOM_ID, FAKE_GROUP_CALL_ID, {
"m.type": GroupCallType.Video,
"m.intent": GroupCallIntent.Prompt,
"m.terminated": GroupCallTerminationReason.CallEnded,
},
),
{
roomId: FAKE_ROOM_ID,
} as unknown as RoomState,
);
expect(groupCall.state).toBe(GroupCallState.Ended);
});
});
it("waits until client starts syncing", async () => {
mockClient.getSyncState.mockReturnValue(null);
let isStarted = false;
(async () => {
await groupCallEventHandler.start();
isStarted = true;
})();
const setSyncState = async (newState: SyncState) => {
const oldState = mockClient.getSyncState();
mockClient.getSyncState.mockReturnValue(newState);
mockClient.emit(ClientEvent.Sync, newState, oldState, undefined);
await flushPromises();
};
await flushPromises();
expect(isStarted).toEqual(false);
await setSyncState(SyncState.Prepared);
expect(isStarted).toEqual(false);
await setSyncState(SyncState.Syncing);
expect(isStarted).toEqual(true);
});
it("finds existing group calls when started", async () => {
const mockClientEmit = mockClient.emit = jest.fn();
mockClient.getRooms.mockReturnValue([mockRoom]);
await groupCallEventHandler.start();
expect(mockClientEmit).toHaveBeenCalledWith(
GroupCallEventHandlerEvent.Incoming,
expect.objectContaining({
groupCallId: FAKE_GROUP_CALL_ID,
}),
);
groupCallEventHandler.stop();
});
it("can wait until a room is ready for group calls", async () => {
await groupCallEventHandler.start();
const prom = groupCallEventHandler.waitUntilRoomReadyForGroupCalls(FAKE_ROOM_ID);
let resolved = false;
(async () => {
await prom;
resolved = true;
})();
expect(resolved).toEqual(false);
mockClient.emit(ClientEvent.Room, mockRoom);
await prom;
expect(resolved).toEqual(true);
groupCallEventHandler.stop();
});
it("fires events for incoming calls", async () => {
const onIncomingGroupCall = jest.fn();
mockClient.on(GroupCallEventHandlerEvent.Incoming, onIncomingGroupCall);
await groupCallEventHandler.start();
mockClient.emitRoomState(
makeMockGroupCallStateEvent(
FAKE_ROOM_ID, FAKE_GROUP_CALL_ID,
),
{
roomId: FAKE_ROOM_ID,
} as unknown as RoomState,
);
expect(onIncomingGroupCall).toHaveBeenCalledWith(expect.objectContaining({
groupCallId: FAKE_GROUP_CALL_ID,
}));
mockClient.off(GroupCallEventHandlerEvent.Incoming, onIncomingGroupCall);
});
it("handles data channel", async () => {
await groupCallEventHandler.start();
const dataChannelOptions = {
"maxPacketLifeTime": "life_time",
"maxRetransmits": "retransmits",
"ordered": "ordered",
"protocol": "protocol",
};
mockClient.emitRoomState(
makeMockGroupCallStateEvent(
FAKE_ROOM_ID,
FAKE_GROUP_CALL_ID,
{
"m.type": GroupCallType.Video,
"m.intent": GroupCallIntent.Prompt,
"dataChannelsEnabled": true,
dataChannelOptions,
},
),
{
roomId: FAKE_ROOM_ID,
} as unknown as RoomState,
);
// @ts-ignore Mock dataChannelsEnabled is private
expect(groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)?.dataChannelsEnabled).toBe(true);
// @ts-ignore Mock dataChannelOptions is private
expect(groupCallEventHandler.groupCalls.get(FAKE_ROOM_ID)?.dataChannelOptions).toStrictEqual(
dataChannelOptions,
);
});
it("sends member events to group calls", async () => {
await groupCallEventHandler.start();
const mockGroupCall = {
onMemberStateChanged: jest.fn(),
};
groupCallEventHandler.groupCalls.set(FAKE_ROOM_ID, mockGroupCall as unknown as GroupCall);
const mockStateEvent = makeMockGroupCallMemberStateEvent(FAKE_ROOM_ID, FAKE_GROUP_CALL_ID);
mockClient.emitRoomState(
mockStateEvent,
{
roomId: FAKE_ROOM_ID,
} as unknown as RoomState,
);
expect(mockGroupCall.onMemberStateChanged).toHaveBeenCalledWith(mockStateEvent);
});
describe("ignoring invalid group call state events", () => {
let mockClientEmit: jest.Func;
beforeEach(() => {
mockClientEmit = mockClient.emit = jest.fn();
});
afterEach(() => {
groupCallEventHandler.stop();
jest.clearAllMocks();
});
const setupCallAndStart = async (content?: IContent) => {
mocked(mockRoom.currentState.getStateEvents).mockReturnValue([
makeMockGroupCallStateEvent(
FAKE_ROOM_ID,
FAKE_GROUP_CALL_ID,
content,
),
] as unknown as MatrixEvent);
mockClient.getRooms.mockReturnValue([mockRoom]);
await groupCallEventHandler.start();
};
it("ignores terminated calls", async () => {
await setupCallAndStart({
"m.type": GroupCallType.Video,
"m.intent": GroupCallIntent.Prompt,
"m.terminated": GroupCallTerminationReason.CallEnded,
});
expect(mockClientEmit).not.toHaveBeenCalledWith(
GroupCallEventHandlerEvent.Incoming,
expect.objectContaining({
groupCallId: FAKE_GROUP_CALL_ID,
}),
);
});
it("ignores calls with invalid type", async () => {
await setupCallAndStart({
"m.type": "fake_type",
"m.intent": GroupCallIntent.Prompt,
});
expect(mockClientEmit).not.toHaveBeenCalledWith(
GroupCallEventHandlerEvent.Incoming,
expect.objectContaining({
groupCallId: FAKE_GROUP_CALL_ID,
}),
);
});
it("ignores calls with invalid intent", async () => {
await setupCallAndStart({
"m.type": GroupCallType.Video,
"m.intent": "fake_intent",
});
expect(mockClientEmit).not.toHaveBeenCalledWith(
GroupCallEventHandlerEvent.Incoming,
expect.objectContaining({
groupCallId: FAKE_GROUP_CALL_ID,
}),
);
});
it("ignores calls without a room", async () => {
mockClient.getRoom.mockReturnValue(undefined);
await setupCallAndStart();
expect(mockClientEmit).not.toHaveBeenCalledWith(
GroupCallEventHandlerEvent.Incoming,
expect.objectContaining({
groupCallId: FAKE_GROUP_CALL_ID,
}),
);
});
});
});
+462
View File
@@ -0,0 +1,462 @@
/*
Copyright 2022 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 { GroupCall, MatrixCall, MatrixClient } from "../../../src";
import { MediaHandler, MediaHandlerEvent } from "../../../src/webrtc/mediaHandler";
import { MockMediaDeviceInfo, MockMediaDevices, MockMediaStream, MockMediaStreamTrack } from "../../test-utils/webrtc";
const FAKE_AUDIO_INPUT_ID = "aaaaaaaa";
const FAKE_VIDEO_INPUT_ID = "vvvvvvvv";
const FAKE_DESKTOP_SOURCE_ID = "ddddddd";
describe('Media Handler', function() {
let mockMediaDevices: MockMediaDevices;
let mediaHandler: MediaHandler;
let calls: Map<string, MatrixCall>;
let groupCalls: Map<string, GroupCall>;
beforeEach(() => {
mockMediaDevices = new MockMediaDevices();
global.navigator = {
mediaDevices: mockMediaDevices.typed(),
} as unknown as Navigator;
calls = new Map();
groupCalls = new Map();
mediaHandler = new MediaHandler({
callEventHandler: {
calls,
},
groupCallEventHandler: {
groupCalls,
},
} as unknown as MatrixClient);
});
it("does not trigger update after restore media settings ", () => {
mediaHandler.restoreMediaSettings(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID);
expect(mockMediaDevices.getUserMedia).not.toHaveBeenCalled();
});
it("sets device IDs on restore media settings", async () => {
mediaHandler.restoreMediaSettings(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID);
await mediaHandler.getUserMediaStream(true, true);
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
}),
}));
});
it("sets audio device ID", async () => {
await mediaHandler.setAudioInput(FAKE_AUDIO_INPUT_ID);
await mediaHandler.getUserMediaStream(true, false);
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
}),
}));
});
it("sets audio settings", async () => {
await mediaHandler.setAudioSettings({
autoGainControl: false,
echoCancellation: true,
noiseSuppression: false,
});
await mediaHandler.getUserMediaStream(true, false);
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
audio: expect.objectContaining({
autoGainControl: { ideal: false },
echoCancellation: { ideal: true },
noiseSuppression: { ideal: false },
}),
}));
});
it("sets video device ID", async () => {
await mediaHandler.setVideoInput(FAKE_VIDEO_INPUT_ID);
await mediaHandler.getUserMediaStream(false, true);
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
}),
}));
});
it("sets media inputs", async () => {
await mediaHandler.setMediaInputs(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID);
await mediaHandler.getUserMediaStream(true, true);
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
audio: expect.objectContaining({
deviceId: { ideal: FAKE_AUDIO_INPUT_ID },
}),
video: expect.objectContaining({
deviceId: { ideal: FAKE_VIDEO_INPUT_ID },
}),
}));
});
describe("updateLocalUsermediaStreams", () => {
let localStreamsChangedHandler: jest.Mock<void, []>;
beforeEach(() => {
localStreamsChangedHandler = jest.fn();
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, localStreamsChangedHandler);
});
afterEach(() => {
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, localStreamsChangedHandler);
});
it("does nothing if it has no streams", async () => {
mediaHandler.updateLocalUsermediaStreams();
expect(mockMediaDevices.getUserMedia).not.toHaveBeenCalled();
});
it("does not emit LocalStreamsChanged if it had no streams", async () => {
await mediaHandler.updateLocalUsermediaStreams();
expect(localStreamsChangedHandler).not.toHaveBeenCalled();
});
describe("with existing streams", () => {
let stopTrack: jest.Mock<void, []>;
let updateLocalUsermediaStream: jest.Mock;
beforeEach(() => {
stopTrack = jest.fn();
mediaHandler.userMediaStreams = [
{
getTracks: () => [{
stop: stopTrack,
} as unknown as MediaStreamTrack],
} as unknown as MediaStream,
];
updateLocalUsermediaStream = jest.fn();
});
it("stops existing streams", async () => {
mediaHandler.updateLocalUsermediaStreams();
expect(stopTrack).toHaveBeenCalled();
});
it("replaces streams on calls", async () => {
calls.set("some_call", {
hasLocalUserMediaAudioTrack: true,
hasLocalUserMediaVideoTrack: true,
callHasEnded: jest.fn().mockReturnValue(false),
updateLocalUsermediaStream,
} as unknown as MatrixCall);
await mediaHandler.updateLocalUsermediaStreams();
expect(updateLocalUsermediaStream).toHaveBeenCalled();
});
it("doesn't replace streams on ended calls", async () => {
calls.set("some_call", {
hasLocalUserMediaAudioTrack: true,
hasLocalUserMediaVideoTrack: true,
callHasEnded: jest.fn().mockReturnValue(true),
updateLocalUsermediaStream,
} as unknown as MatrixCall);
await mediaHandler.updateLocalUsermediaStreams();
expect(updateLocalUsermediaStream).not.toHaveBeenCalled();
});
it("replaces streams on group calls", async () => {
groupCalls.set("some_group_call", {
localCallFeed: {},
updateLocalUsermediaStream,
} as unknown as GroupCall);
await mediaHandler.updateLocalUsermediaStreams();
expect(updateLocalUsermediaStream).toHaveBeenCalled();
});
it("doesn't replace streams on group calls with no localCallFeed", async () => {
groupCalls.set("some_group_call", {
localCallFeed: null,
updateLocalUsermediaStream,
} as unknown as GroupCall);
await mediaHandler.updateLocalUsermediaStreams();
expect(updateLocalUsermediaStream).not.toHaveBeenCalled();
});
it("emits LocalStreamsChanged", async () => {
await mediaHandler.updateLocalUsermediaStreams();
expect(localStreamsChangedHandler).toHaveBeenCalled();
});
});
});
describe("hasAudioDevice", () => {
it("returns true if the system has audio inputs", async () => {
expect(await mediaHandler.hasAudioDevice()).toEqual(true);
});
it("returns false if the system has no audio inputs", async () => {
mockMediaDevices.enumerateDevices.mockReturnValue(Promise.resolve([
new MockMediaDeviceInfo("videoinput").typed(),
]));
expect(await mediaHandler.hasAudioDevice()).toEqual(false);
});
});
describe("hasVideoDevice", () => {
it("returns true if the system has video inputs", async () => {
expect(await mediaHandler.hasVideoDevice()).toEqual(true);
});
it("returns false if the system has no video inputs", async () => {
mockMediaDevices.enumerateDevices.mockReturnValue(Promise.resolve([
new MockMediaDeviceInfo("audioinput").typed(),
]));
expect(await mediaHandler.hasVideoDevice()).toEqual(false);
});
});
describe("getUserMediaStream", () => {
beforeEach(() => {
// replace this with one that returns a new object each time so we can
// tell whether we've ended up with the same stream
mockMediaDevices.getUserMedia.mockImplementation((constraints: MediaStreamConstraints) => {
const stream = new MockMediaStream("local_stream");
if (constraints.audio) {
const track = new MockMediaStreamTrack("audio_track", "audio");
track.settings = { deviceId: FAKE_AUDIO_INPUT_ID };
stream.addTrack(track);
}
if (constraints.video) {
const track = new MockMediaStreamTrack("video_track", "video");
track.settings = { deviceId: FAKE_VIDEO_INPUT_ID };
stream.addTrack(track);
}
return Promise.resolve(stream.typed());
});
mediaHandler.restoreMediaSettings(FAKE_AUDIO_INPUT_ID, FAKE_VIDEO_INPUT_ID);
});
it("returns the same stream for reusable streams", async () => {
const stream1 = await mediaHandler.getUserMediaStream(true, false);
const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream;
expect(stream2.isCloneOf(stream1)).toEqual(true);
});
it("doesn't re-use stream if reusable is false", async () => {
const stream1 = await mediaHandler.getUserMediaStream(true, false, false);
const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream;
expect(stream2.isCloneOf(stream1)).toEqual(false);
});
it("doesn't re-use stream if existing stream lacks audio", async () => {
const stream1 = await mediaHandler.getUserMediaStream(false, true);
const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream;
expect(stream2.isCloneOf(stream1)).toEqual(false);
});
it("doesn't re-use stream if existing stream lacks video", async () => {
const stream1 = await mediaHandler.getUserMediaStream(true, false);
const stream2 = await mediaHandler.getUserMediaStream(false, true) as unknown as MockMediaStream;
expect(stream2.isCloneOf(stream1)).toEqual(false);
});
it("strips unwanted audio tracks from re-used stream", async () => {
const stream1 = await mediaHandler.getUserMediaStream(true, true);
const stream2 = await mediaHandler.getUserMediaStream(false, true) as unknown as MockMediaStream;
expect(stream2.isCloneOf(stream1)).toEqual(true);
expect(stream2.getAudioTracks().length).toEqual(0);
});
it("strips unwanted video tracks from re-used stream", async () => {
const stream1 = await mediaHandler.getUserMediaStream(true, true);
const stream2 = await mediaHandler.getUserMediaStream(true, false) as unknown as MockMediaStream;
expect(stream2.isCloneOf(stream1)).toEqual(true);
expect(stream2.getVideoTracks().length).toEqual(0);
});
});
describe("getScreensharingStream", () => {
it("gets any screen sharing stream when called with no args", async () => {
const stream = await mediaHandler.getScreensharingStream();
expect(stream).toBeTruthy();
expect(stream.getTracks()).toBeTruthy();
});
it("re-uses streams", async () => {
const stream = await mediaHandler.getScreensharingStream(undefined, true);
expect(mockMediaDevices.getDisplayMedia).toHaveBeenCalled();
mockMediaDevices.getDisplayMedia.mockClear();
const stream2 = await mediaHandler.getScreensharingStream() as unknown as MockMediaStream;
expect(mockMediaDevices.getDisplayMedia).not.toHaveBeenCalled();
expect(stream2.isCloneOf(stream)).toEqual(true);
});
it("passes through desktopCapturerSourceId for Electron", async () => {
await mediaHandler.getScreensharingStream({
desktopCapturerSourceId: FAKE_DESKTOP_SOURCE_ID,
});
expect(mockMediaDevices.getUserMedia).toHaveBeenCalledWith(expect.objectContaining({
video: {
mandatory: expect.objectContaining({
chromeMediaSource: "desktop",
chromeMediaSourceId: FAKE_DESKTOP_SOURCE_ID,
}),
},
}));
});
it("emits LocalStreamsChanged", async () => {
const onLocalStreamChanged = jest.fn();
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
await mediaHandler.getScreensharingStream();
expect(onLocalStreamChanged).toHaveBeenCalled();
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
});
});
describe("stopUserMediaStream", () => {
let stream: MediaStream;
beforeEach(async () => {
stream = await mediaHandler.getUserMediaStream(true, false);
});
it("stops tracks on streams", async () => {
const mockTrack = new MockMediaStreamTrack("audio_track", "audio");
stream.addTrack(mockTrack.typed());
mediaHandler.stopUserMediaStream(stream);
expect(mockTrack.stop).toHaveBeenCalled();
});
it("removes stopped streams", async () => {
expect(mediaHandler.userMediaStreams).toContain(stream);
mediaHandler.stopUserMediaStream(stream);
expect(mediaHandler.userMediaStreams).not.toContain(stream);
});
it("emits LocalStreamsChanged", async () => {
const onLocalStreamChanged = jest.fn();
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
mediaHandler.stopUserMediaStream(stream);
expect(onLocalStreamChanged).toHaveBeenCalled();
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
});
});
describe("stopUserMediaStream", () => {
let stream: MediaStream;
beforeEach(async () => {
stream = await mediaHandler.getScreensharingStream();
});
it("stops tracks on streams", async () => {
const mockTrack = new MockMediaStreamTrack("audio_track", "audio");
stream.addTrack(mockTrack.typed());
mediaHandler.stopScreensharingStream(stream);
expect(mockTrack.stop).toHaveBeenCalled();
});
it("removes stopped streams", async () => {
expect(mediaHandler.screensharingStreams).toContain(stream);
mediaHandler.stopScreensharingStream(stream);
expect(mediaHandler.screensharingStreams).not.toContain(stream);
});
it("emits LocalStreamsChanged", async () => {
const onLocalStreamChanged = jest.fn();
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
mediaHandler.stopScreensharingStream(stream);
expect(onLocalStreamChanged).toHaveBeenCalled();
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
});
});
describe("stopAllStreams", () => {
let userMediaStream: MediaStream;
let screenSharingStream: MediaStream;
beforeEach(async () => {
userMediaStream = await mediaHandler.getUserMediaStream(true, false);
screenSharingStream = await mediaHandler.getScreensharingStream();
});
it("stops tracks on streams", async () => {
const mockUserMediaTrack = new MockMediaStreamTrack("audio_track", "audio");
userMediaStream.addTrack(mockUserMediaTrack.typed());
const mockScreenshareTrack = new MockMediaStreamTrack("audio_track", "audio");
screenSharingStream.addTrack(mockScreenshareTrack.typed());
mediaHandler.stopAllStreams();
expect(mockUserMediaTrack.stop).toHaveBeenCalled();
expect(mockScreenshareTrack.stop).toHaveBeenCalled();
});
it("removes stopped streams", async () => {
expect(mediaHandler.userMediaStreams).toContain(userMediaStream);
expect(mediaHandler.screensharingStreams).toContain(screenSharingStream);
mediaHandler.stopAllStreams();
expect(mediaHandler.userMediaStreams).not.toContain(userMediaStream);
expect(mediaHandler.screensharingStreams).not.toContain(screenSharingStream);
});
it("emits LocalStreamsChanged", async () => {
const onLocalStreamChanged = jest.fn();
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
mediaHandler.stopAllStreams();
expect(onLocalStreamChanged).toHaveBeenCalled();
mediaHandler.off(MediaHandlerEvent.LocalStreamsChanged, onLocalStreamChanged);
});
});
});
+5 -4
View File
@@ -30,7 +30,7 @@ export enum TweakName {
export type Tweak<N extends TweakName, V> = {
set_tweak: N;
value: V;
value?: V;
};
export type TweakHighlight = Tweak<TweakName.Highlight, boolean>;
@@ -76,7 +76,8 @@ export interface IPushRuleCondition<N extends ConditionKind | string> {
export interface IEventMatchCondition extends IPushRuleCondition<ConditionKind.EventMatch> {
key: string;
pattern: string;
pattern?: string;
value?: string;
}
export interface IContainsDisplayNameCondition extends IPushRuleCondition<ConditionKind.ContainsDisplayName> {
@@ -168,8 +169,8 @@ export interface IPusher {
lang: string;
profile_tag?: string;
pushkey: string;
enabled?: boolean | null | undefined;
"org.matrix.msc3881.enabled"?: boolean | null | undefined;
enabled?: boolean | null;
"org.matrix.msc3881.enabled"?: boolean | null;
device_id?: string | null;
"org.matrix.msc3881.device_id"?: string | null;
}
+4
View File
@@ -87,6 +87,10 @@ export enum EventType {
RoomKeyRequest = "m.room_key_request",
ForwardedRoomKey = "m.forwarded_room_key",
Dummy = "m.dummy",
// Group call events
GroupCallPrefix = "org.matrix.msc3401.call",
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
}
export enum RelationType {
+1
View File
@@ -114,5 +114,6 @@ export interface ISearchResults {
count?: number;
next_batch?: string;
pendingRequest?: Promise<ISearchResults>;
abortSignal?: AbortSignal;
}
/* eslint-enable camelcase */
+48 -32
View File
@@ -32,6 +32,28 @@ export enum AutoDiscoveryAction {
FAIL_ERROR = "FAIL_ERROR",
}
enum AutoDiscoveryError {
Invalid = "Invalid homeserver discovery response",
GenericFailure = "Failed to get autodiscovery configuration from server",
InvalidHsBaseUrl = "Invalid base_url for m.homeserver",
InvalidHomeserver = "Homeserver URL does not appear to be a valid Matrix homeserver",
InvalidIsBaseUrl = "Invalid base_url for m.identity_server",
InvalidIdentityServer = "Identity server URL does not appear to be a valid identity server",
InvalidIs = "Invalid identity server discovery response",
MissingWellknown = "No .well-known JSON file found",
InvalidJson = "Invalid JSON",
}
interface WellKnownConfig extends Omit<IWellKnownConfig, "error"> {
state: AutoDiscoveryAction;
error?: IWellKnownConfig["error"] | null;
}
interface ClientConfig {
"m.homeserver": WellKnownConfig;
"m.identity_server": WellKnownConfig;
}
/**
* Utilities for automatically discovery resources, such as homeservers
* for users to log in to.
@@ -42,36 +64,25 @@ export class AutoDiscovery {
// translate the meaning of the states in the spec, but also
// support our own if needed.
public static readonly ERROR_INVALID = "Invalid homeserver discovery response";
public static readonly ERROR_INVALID = AutoDiscoveryError.Invalid;
public static readonly ERROR_GENERIC_FAILURE = "Failed to get autodiscovery configuration from server";
public static readonly ERROR_GENERIC_FAILURE = AutoDiscoveryError.GenericFailure;
public static readonly ERROR_INVALID_HS_BASE_URL = "Invalid base_url for m.homeserver";
public static readonly ERROR_INVALID_HS_BASE_URL = AutoDiscoveryError.InvalidHsBaseUrl;
public static readonly ERROR_INVALID_HOMESERVER = "Homeserver URL does not appear to be a valid Matrix homeserver";
public static readonly ERROR_INVALID_HOMESERVER = AutoDiscoveryError.InvalidHomeserver;
public static readonly ERROR_INVALID_IS_BASE_URL = "Invalid base_url for m.identity_server";
public static readonly ERROR_INVALID_IS_BASE_URL = AutoDiscoveryError.InvalidIsBaseUrl;
// eslint-disable-next-line
public static readonly ERROR_INVALID_IDENTITY_SERVER = "Identity server URL does not appear to be a valid identity server";
public static readonly ERROR_INVALID_IDENTITY_SERVER = AutoDiscoveryError.InvalidIdentityServer;
public static readonly ERROR_INVALID_IS = "Invalid identity server discovery response";
public static readonly ERROR_INVALID_IS = AutoDiscoveryError.InvalidIs;
public static readonly ERROR_MISSING_WELLKNOWN = "No .well-known JSON file found";
public static readonly ERROR_MISSING_WELLKNOWN = AutoDiscoveryError.MissingWellknown;
public static readonly ERROR_INVALID_JSON = "Invalid JSON";
public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson;
public static readonly ALL_ERRORS = [
AutoDiscovery.ERROR_INVALID,
AutoDiscovery.ERROR_GENERIC_FAILURE,
AutoDiscovery.ERROR_INVALID_HS_BASE_URL,
AutoDiscovery.ERROR_INVALID_HOMESERVER,
AutoDiscovery.ERROR_INVALID_IS_BASE_URL,
AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER,
AutoDiscovery.ERROR_INVALID_IS,
AutoDiscovery.ERROR_MISSING_WELLKNOWN,
AutoDiscovery.ERROR_INVALID_JSON,
];
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError);
/**
* The auto discovery failed. The client is expected to communicate
@@ -120,13 +131,13 @@ export class AutoDiscovery {
* configuration, which may include error states. Rejects on unexpected
* failure, not when verification fails.
*/
public static async fromDiscoveryConfig(wellknown: any): Promise<IClientWellKnown> {
public static async fromDiscoveryConfig(wellknown: any): Promise<ClientConfig> {
// Step 1 is to get the config, which is provided to us here.
// We default to an error state to make the first few checks easier to
// write. We'll update the properties of this object over the duration
// of this function.
const clientConfig = {
const clientConfig: ClientConfig = {
"m.homeserver": {
state: AutoDiscovery.FAIL_ERROR,
error: AutoDiscovery.ERROR_INVALID,
@@ -197,7 +208,7 @@ export class AutoDiscovery {
if (wellknown["m.identity_server"]) {
// We prepare a failing identity server response to save lines later
// in this branch.
const failingClientConfig = {
const failingClientConfig: ClientConfig = {
"m.homeserver": clientConfig["m.homeserver"],
"m.identity_server": {
state: AutoDiscovery.FAIL_PROMPT,
@@ -279,7 +290,7 @@ export class AutoDiscovery {
* configuration, which may include error states. Rejects on unexpected
* failure, not when discovery fails.
*/
public static async findClientConfig(domain: string): Promise<IClientWellKnown> {
public static async findClientConfig(domain: string): Promise<ClientConfig> {
if (!domain || typeof(domain) !== "string" || domain.length === 0) {
throw new Error("'domain' must be a string of non-zero length");
}
@@ -298,7 +309,7 @@ export class AutoDiscovery {
// We default to an error state to make the first few checks easier to
// write. We'll update the properties of this object over the duration
// of this function.
const clientConfig = {
const clientConfig: ClientConfig = {
"m.homeserver": {
state: AutoDiscovery.FAIL_ERROR,
error: AutoDiscovery.ERROR_INVALID,
@@ -367,18 +378,18 @@ export class AutoDiscovery {
* @return {string|boolean} The sanitized URL or a falsey value if the URL is invalid.
* @private
*/
private static sanitizeWellKnownUrl(url: string): string | boolean {
private static sanitizeWellKnownUrl(url: string): string | false {
if (!url) return false;
try {
let parsed = null;
let parsed: URL | undefined;
try {
parsed = new URL(url);
} catch (e) {
logger.error("Could not parse url", e);
}
if (!parsed || !parsed.hostname) return false;
if (!parsed?.hostname) return false;
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
const port = parsed.port ? `:${parsed.port}` : "";
@@ -448,12 +459,17 @@ export class AutoDiscovery {
};
}
} catch (err) {
const error = err as Error | string | undefined;
const error = err as AutoDiscoveryError | string | undefined;
let reason = "";
if (typeof error === "object") {
reason = (<Error>error)?.message;
}
return {
error,
raw: {},
action: AutoDiscoveryAction.FAIL_PROMPT,
reason: (<Error>error)?.message || "General failure",
reason: reason || "General failure",
};
}
@@ -463,7 +479,7 @@ export class AutoDiscovery {
action: AutoDiscoveryAction.SUCCESS,
};
} catch (err) {
const error = err as Error | string | undefined;
const error = err as Error;
return {
error,
raw: {},
+192 -88
View File
@@ -35,6 +35,7 @@ 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 * as utils from './utils';
import { replaceParam, QueryDict, sleep } from './utils';
import { Direction, EventTimeline } from "./models/event-timeline";
@@ -64,12 +65,14 @@ import {
FileType,
UploadResponse,
HTTPError,
IRequestOpts,
} from "./http-api";
import {
Crypto,
CryptoEvent,
CryptoEventHandlerMap,
fixBackupKey,
ICryptoCallbacks,
IBootstrapCrossSigningOpts,
ICheckOwnCrossSigningTrustOpts,
IMegolmSessionData,
@@ -99,29 +102,9 @@ import {
} from "./crypto/keybackup";
import { IIdentityServerProvider } from "./@types/IIdentityServerProvider";
import { MatrixScheduler } from "./scheduler";
import {
IAuthData,
ICryptoCallbacks,
IMinimalEvent,
IRoomEvent,
IStateEvent,
NotificationCountType,
BeaconEvent,
BeaconEventHandlerMap,
RoomEvent,
RoomEventHandlerMap,
RoomMemberEvent,
RoomMemberEventHandlerMap,
RoomStateEvent,
RoomStateEventHandlerMap,
INotificationsResponse,
IFilterResponse,
ITagsResponse,
IStatusResponse,
IPushRule,
PushRuleActionName,
IAuthDict,
} from "./matrix";
import { BeaconEvent, BeaconEventHandlerMap } from "./models/beacon";
import { IAuthData, IAuthDict } from "./interactive-auth";
import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
import {
CrossSigningKey,
IAddSecretStorageKeyOpts,
@@ -136,7 +119,9 @@ import { VerificationRequest } from "./crypto/verification/request/VerificationR
import { VerificationBase as Verification } from "./crypto/verification/Base";
import * as ContentHelpers from "./content-helpers";
import { CrossSigningInfo, DeviceTrustLevel, ICacheCallbacks, UserTrustLevel } from "./crypto/CrossSigning";
import { Room, RoomNameState } from "./models/room";
import { Room, NotificationCountType, RoomEvent, RoomEventHandlerMap, RoomNameState } from "./models/room";
import { RoomMemberEvent, RoomMemberEventHandlerMap } from "./models/room-member";
import { RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state";
import {
IAddThreePidOnlyBody,
IBindThreePidBody,
@@ -153,6 +138,10 @@ import {
IRoomDirectoryOptions,
ISearchOpts,
ISendEventResponse,
INotificationsResponse,
IFilterResponse,
ITagsResponse,
IStatusResponse,
} from "./@types/requests";
import {
EventType,
@@ -184,10 +173,26 @@ import {
} from "./@types/search";
import { ISynapseAdminDeactivateResponse, ISynapseAdminWhoisResponse } from "./@types/synapse";
import { IHierarchyRoom } from "./@types/spaces";
import { IPusher, IPusherRequest, IPushRules, PushRuleAction, PushRuleKind, RuleId } from "./@types/PushRules";
import {
IPusher,
IPusherRequest,
IPushRule,
IPushRules,
PushRuleAction,
PushRuleActionName,
PushRuleKind,
RuleId,
} from "./@types/PushRules";
import { IThreepid } from "./@types/threepids";
import { CryptoStore } 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 { ReceiptType } from "./@types/read_receipts";
@@ -359,6 +364,12 @@ export interface ICreateClientOpts {
*/
fallbackICEServerAllowed?: boolean;
/**
* If true, to-device signalling for group calls will be encrypted
* with Olm. Default: true.
*/
useE2eForGroupCall?: boolean;
cryptoCallbacks?: ICryptoCallbacks;
/**
@@ -507,7 +518,7 @@ export interface IUploadKeySignaturesResponse {
}
export interface IPreviewUrlResponse {
[key: string]: string | number;
[key: string]: undefined | string | number;
"og:title": string;
"og:type": string;
"og:url": string;
@@ -705,8 +716,9 @@ export interface IMyDevice {
display_name?: string;
last_seen_ip?: string;
last_seen_ts?: number;
[UNSTABLE_MSC3852_LAST_SEEN_UA.stable]?: string;
[UNSTABLE_MSC3852_LAST_SEEN_UA.unstable]?: string;
// UNSTABLE_MSC3852_LAST_SEEN_UA
last_seen_user_agent?: string;
"org.matrix.msc3852.last_seen_user_agent"?: string;
}
export interface Keys {
@@ -824,6 +836,7 @@ export enum ClientEvent {
DeleteRoom = "deleteRoom",
SyncUnexpectedError = "sync.unexpectedError",
ClientWellKnown = "WellKnown.client",
ReceivedVoipEvent = "received_voip_event",
TurnServers = "turnServers",
TurnServersError = "turnServers.error",
}
@@ -883,6 +896,9 @@ export type EmittedEvents = ClientEvent
| UserEvents
| CallEvent // re-emitted by call.ts using Object.values
| CallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Ended
| GroupCallEventHandlerEvent.Participants
| HttpApiEvent.SessionLoggedOut
| HttpApiEvent.NoConsent
| BeaconEvent;
@@ -896,6 +912,7 @@ export type ClientEventHandlerMap = {
[ClientEvent.DeleteRoom]: (roomId: string) => void;
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
[ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
[ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
[ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
[ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void;
} & RoomEventHandlerMap
@@ -905,6 +922,7 @@ export type ClientEventHandlerMap = {
& RoomMemberEventHandlerMap
& UserEventHandlerMap
& CallEventHandlerEventHandlerMap
& GroupCallEventHandlerEventHandlerMap
& CallEventHandlerMap
& HttpApiEventHandlerMap
& BeaconEventHandlerMap;
@@ -935,6 +953,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public crypto?: Crypto; // XXX: Intended private, used in code.
public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
public groupCallEventHandler?: GroupCallEventHandler;
public supportsCallTransfer = false; // XXX: Intended private, used in code.
public forceTURN = false; // XXX: Intended private, used in code.
public iceCandidatePoolSize = 0; // XXX: Intended private, used in code.
@@ -983,8 +1002,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected exportedOlmDeviceToImport?: IExportedOlmDevice;
protected txnCtr = 0;
protected mediaHandler = new MediaHandler(this);
protected sessionId: string;
protected pendingEventEncryption = new Map<string, Promise<void>>();
private useE2eForGroupCall = true;
private toDeviceMessageQueue: ToDeviceMessageQueue;
// A manager for determining which invites should be ignored.
@@ -1003,6 +1024,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.usingExternalCrypto = opts.usingExternalCrypto ?? false;
this.store = opts.store || new StubStore();
this.deviceId = opts.deviceId || null;
this.sessionId = randomString(10);
const userId = opts.userId || null;
this.credentials = { userId };
@@ -1061,6 +1083,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (supportsMatrixCall()) {
this.callEventHandler = new CallEventHandler(this);
this.groupCallEventHandler = new GroupCallEventHandler(this);
this.canSupportVoip = true;
// Start listening for calls after the initial sync is done
// We do not need to backfill the call event buffer
@@ -1079,6 +1102,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.supportsCallTransfer = opts.supportsCallTransfer || false;
this.fallbackICEServerAllowed = opts.fallbackICEServerAllowed || false;
if (opts.useE2eForGroupCall !== undefined) this.useE2eForGroupCall = opts.useE2eForGroupCall;
// List of which rooms have encryption enabled: separate from crypto because
// we still want to know which rooms are encrypted even if crypto is disabled:
// we don't want to start sending unencrypted events to them.
@@ -1175,7 +1200,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (this.crypto) {
this.crypto.uploadDeviceKeys();
this.crypto.start();
}
// periodically poll for turn servers if we support voip
@@ -1251,7 +1275,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.peekSync?.stopPeeking();
this.callEventHandler?.stop();
this.groupCallEventHandler?.stop();
this.callEventHandler = undefined;
this.groupCallEventHandler = undefined;
global.clearInterval(this.checkTurnServersIntervalID);
this.checkTurnServersIntervalID = undefined;
@@ -1482,6 +1508,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.deviceId;
}
/**
* Get the session ID of this client
* @return {string} session ID
*/
public getSessionId(): string {
return this.sessionId;
}
/**
* Check if the runtime environment supports VoIP calling.
* @return {boolean} True if VoIP is supported.
@@ -1515,6 +1549,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.supportsCallTransfer = support;
}
/**
* Returns true if to-device signalling for group calls will be encrypted with Olm.
* If false, it will be sent unencrypted.
* @returns boolean Whether group call signalling will be encrypted
*/
public getUseE2eForGroupCall(): boolean {
return this.useE2eForGroupCall;
}
/**
* Creates a new call.
* The place*Call methods on the returned call can be used to actually place a call
@@ -1526,6 +1569,67 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return createNewMatrixCall(this, roomId);
}
/**
* Creates a new group call and sends the associated state event
* to alert other members that the room now has a group call.
*
* @param {string} roomId The room the call is to be placed in.
* @return {GroupCall}
*/
public async createGroupCall(
roomId: string,
type: GroupCallType,
isPtt: boolean,
intent: GroupCallIntent,
dataChannelsEnabled?: boolean,
dataChannelOptions?: IGroupCallDataChannelOptions,
): Promise<GroupCall> {
if (this.getGroupCallForRoom(roomId)) {
throw new Error(`${roomId} already has an existing group call`);
}
const room = this.getRoom(roomId);
if (!room) {
throw new Error(`Cannot find room ${roomId}`);
}
return new GroupCall(
this,
room,
type,
isPtt,
intent,
undefined,
dataChannelsEnabled,
dataChannelOptions,
).create();
}
/**
* Wait until an initial state for the given room has been processed by the
* client and the client is aware of any ongoing group calls. Awaiting on
* the promise returned by this method before calling getGroupCallForRoom()
* avoids races where getGroupCallForRoom is called before the state for that
* room has been processed. It does not, however, fix other races, eg. two
* clients both creating a group call at the same time.
* @param roomId The room ID to wait for
* @returns A promise that resolves once existing group calls in the room
* have been processed.
*/
public waitUntilRoomReadyForGroupCalls(roomId: string): Promise<void> {
return this.groupCallEventHandler!.waitUntilRoomReadyForGroupCalls(roomId);
}
/**
* Get an existing group call for the provided room.
* @param roomId
* @returns {GroupCall} The group call or null if it doesn't already exist.
*/
public getGroupCallForRoom(roomId: string): GroupCall | null {
return this.groupCallEventHandler!.groupCalls.get(roomId) || null;
}
/**
* Get the current sync state.
* @return {?SyncState} the sync state, which may be null.
@@ -2003,11 +2107,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @param {boolean} value whether to blacklist all unverified devices by default
*/
public setGlobalBlacklistUnverifiedDevices(value: boolean) {
public setGlobalBlacklistUnverifiedDevices(value: boolean): boolean {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.setGlobalBlacklistUnverifiedDevices(value);
this.crypto.globalBlacklistUnverifiedDevices = value;
return value;
}
/**
@@ -2017,7 +2122,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.getGlobalBlacklistUnverifiedDevices();
return this.crypto.globalBlacklistUnverifiedDevices;
}
/**
@@ -2030,11 +2135,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @param {boolean} value whether error on unknown devices
*/
public setGlobalErrorOnUnknownDevices(value: boolean) {
public setGlobalErrorOnUnknownDevices(value: boolean): void {
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.setGlobalErrorOnUnknownDevices(value);
this.crypto.globalErrorOnUnknownDevices = value;
}
/**
@@ -2046,7 +2151,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (!this.crypto) {
throw new Error("End-to-end encryption disabled");
}
return this.crypto.getGlobalErrorOnUnknownDevices();
return this.crypto.globalErrorOnUnknownDevices;
}
/**
@@ -2929,7 +3034,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("End-to-end encryption disabled");
}
const path = this.makeKeyBackupPath(roomId!, sessionId!, version!);
const path = this.makeKeyBackupPath(roomId!, sessionId!, version);
await this.http.authedRequest(
Method.Put, path.path, path.queryData, data,
{ prefix: ClientPrefix.V3 },
@@ -3121,7 +3226,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
opts: IKeyBackupRestoreOpts,
): Promise<IKeyBackupRestoreResult> {
const privKey = decodeRecoveryKey(recoveryKey);
return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts);
return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
}
public async restoreKeyBackupWithCache(
@@ -3283,7 +3388,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("End-to-end encryption disabled");
}
const path = this.makeKeyBackupPath(roomId, sessionId, version);
const path = this.makeKeyBackupPath(roomId!, sessionId!, version);
await this.http.authedRequest(
Method.Delete, path.path, path.queryData, undefined,
{ prefix: ClientPrefix.V3 },
@@ -3775,7 +3880,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
roomId: string,
threadId: string | null,
eventType: string | IContent,
content: IContent | string,
content?: IContent | string,
txnId?: string,
): Promise<ISendEventResponse> {
if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
@@ -3787,10 +3892,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// If we expect that an event is part of a thread but is missing the relation
// we need to add it manually, as well as the reply fallback
if (threadId && !content["m.relates_to"]?.rel_type) {
const isReply = !!content["m.relates_to"]?.["m.in_reply_to"];
content["m.relates_to"] = {
...content["m.relates_to"],
if (threadId && !content!["m.relates_to"]?.rel_type) {
const isReply = !!content!["m.relates_to"]?.["m.in_reply_to"];
content!["m.relates_to"] = {
...content!["m.relates_to"],
"rel_type": THREAD_RELATION_TYPE.name,
"event_id": threadId,
// Set is_falling_back to true unless this is actually intended to be a reply
@@ -3798,7 +3903,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
};
const thread = this.getRoom(roomId)?.getThread(threadId);
if (thread && !isReply) {
content["m.relates_to"]["m.in_reply_to"] = {
content!["m.relates_to"]["m.in_reply_to"] = {
"event_id": thread.lastReply((ev: MatrixEvent) => {
return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status;
})?.getId() ?? threadId,
@@ -3888,9 +3993,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param room
* @param event
* @returns {Promise} returns a promise which resolves with the result of the send request
* @private
*/
private encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> {
protected encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> {
let cancelled = false;
// Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections,
// so that we can handle synchronous and asynchronous exceptions with the
@@ -4018,7 +4122,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.isRoomEncrypted(roomId) ? EventType.RoomMessageEncrypted : eventType;
}
private updatePendingEventStatus(room: Room | null, event: MatrixEvent, newStatus: EventStatus) {
protected updatePendingEventStatus(room: Room | null, event: MatrixEvent, newStatus: EventStatus) {
if (room) {
room.updatePendingEvent(event, newStatus);
} else {
@@ -4097,7 +4201,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (!eventId?.startsWith(EVENT_ID_PREFIX)) {
opts = txnId as IRedactOpts;
txnId = eventId;
eventId = threadId;
eventId = threadId!;
threadId = null;
}
const reason = opts?.reason;
@@ -4130,7 +4234,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public sendMessage(
roomId: string,
threadId: string | null | IContent,
content: IContent | string,
content?: IContent | string,
txnId?: string,
): Promise<ISendEventResponse> {
if (typeof threadId !== "string" && threadId !== null) {
@@ -4180,7 +4284,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.sendEvent(
roomId,
threadId as (string | null),
threadId as string | null,
eventType,
sendContent,
txnId,
@@ -4314,7 +4418,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public sendImageMessage(
roomId: string,
threadId: string | null,
url: string | IImageInfo,
url?: string | IImageInfo,
info?: IImageInfo | string,
text = "Image",
): Promise<ISendEventResponse> {
@@ -4358,7 +4462,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public sendStickerMessage(
roomId: string,
threadId: string | null,
url: string | IImageInfo,
url?: string | IImageInfo,
info?: IImageInfo | string,
text = "Sticker",
): Promise<ISendEventResponse> {
@@ -4484,6 +4588,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {ReceiptType} receiptType The kind of receipt e.g. "m.read". Other than
* ReceiptType.Read are experimental!
* @param {object} body Additional content to send alongside the receipt.
* @param {boolean} unthreaded An unthreaded receipt will clear room+thread notifications
* @return {Promise} Resolves: to an empty object {}
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
@@ -4491,6 +4596,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
event: MatrixEvent,
receiptType: ReceiptType,
body: any,
unthreaded = false,
): Promise<{}> {
if (this.isGuest()) {
return Promise.resolve({}); // guests cannot send receipts so don't bother.
@@ -4502,12 +4608,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
$eventId: event.getId()!,
});
// TODO: Add a check for which spec version this will be released in
if (await this.doesServerSupportUnstableFeature("org.matrix.msc3771")) {
const supportsThreadRR = this.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
if (supportsThreadRR && !unthreaded) {
const isThread = !!event.threadRootId;
body.thread_id = isThread
? event.threadRootId
: MAIN_ROOM_TIMELINE;
body = {
...body,
thread_id: isThread
? event.threadRootId
: MAIN_ROOM_TIMELINE,
};
}
const promise = this.http.authedRequest<{}>(Method.Post, path, undefined, body || {});
@@ -4529,6 +4638,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async sendReadReceipt(
event: MatrixEvent | null,
receiptType = ReceiptType.Read,
unthreaded = false,
): Promise<{} | undefined> {
if (!event) return;
const eventId = event.getId()!;
@@ -4537,7 +4647,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error(`Cannot set read receipt to a pending event (${eventId})`);
}
return this.sendReceipt(event, receiptType, {});
return this.sendReceipt(event, receiptType, {}, unthreaded);
}
/**
@@ -5224,13 +5334,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
];
// Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
let timeline = timelineSet.getTimelineForEvent(events[0].getId()!);
let timeline = timelineSet.getTimelineForEvent(events[0].getId());
if (timeline) {
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper));
timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
} else {
timeline = timelineSet.addTimeline();
timeline.initialiseState(res.state.map(mapper));
timeline.getState(EventTimeline.FORWARDS).paginationToken = res.end;
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
}
const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
@@ -5319,7 +5429,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
let timeline = timelineSet.getTimelineForEvent(event.getId());
if (timeline) {
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper));
timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
} else {
timeline = timelineSet.addTimeline();
timeline.initialiseState(res.state.map(mapper));
@@ -5380,7 +5490,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// Here we handle non-thread timelines only, but still process any thread events to populate thread
// summaries.
const timeline = timelineSet.getLiveTimeline();
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper));
timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
timelineSet.addEventsToTimeline(events, true, timeline, null);
if (!resOlder.next_batch) {
@@ -5683,7 +5793,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventTimeline.getFilter(),
).then((res) => {
if (res.state) {
const roomState = eventTimeline.getState(dir);
const roomState = eventTimeline.getState(dir)!;
const stateEvents = res.state.map(this.getEventMapper());
roomState.setUnknownStateEvents(stateEvents);
}
@@ -5758,7 +5868,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventTimeline.getFilter(),
).then((res) => {
if (res.state) {
const roomState = eventTimeline.getState(dir);
const roomState = eventTimeline.getState(dir)!;
const stateEvents = res.state.map(this.getEventMapper());
roomState.setUnknownStateEvents(stateEvents);
}
@@ -6114,15 +6224,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// There can be only room-kind push rule per room
// and its id is the room id.
if (this.pushRules) {
if (!this.pushRules[scope] || !this.pushRules[scope].room) {
return;
}
for (let i = 0; i < this.pushRules[scope].room.length; i++) {
const rule = this.pushRules[scope].room[i];
if (rule.rule_id === roomId) {
return rule;
}
}
return this.pushRules[scope]?.room?.find(rule => rule.rule_id === roomId);
} else {
throw new Error(
"SyncApi.sync() must be done before accessing to push rules.",
@@ -6293,7 +6395,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
next_batch: searchResults.next_batch,
};
const promise = this.search(searchOpts)
const promise = this.search(searchOpts, searchResults.abortSignal)
.then(res => this.processRoomEventsSearch(searchResults, res))
.finally(() => {
searchResults.pendingRequest = undefined;
@@ -6472,8 +6574,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// create a new filter
const createdFilter = await this.createFilter(filter.getDefinition());
// debuglog("Created new filter ID %s: %s", createdFilter.filterId,
// JSON.stringify(createdFilter.getDefinition()));
this.store.setFilterIdByName(filterName, createdFilter.filterId);
return createdFilter.filterId!;
}
@@ -6495,7 +6595,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
private startCallEventHandler = (): void => {
if (this.isInitialSyncComplete()) {
this.callEventHandler?.start();
this.callEventHandler!.start();
this.groupCallEventHandler!.start();
this.off(ClientEvent.Sync, this.startCallEventHandler);
}
};
@@ -7640,6 +7741,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {string} eventType
* @param {Object} content
* @param {string} stateKey
* @param {IRequestOpts} opts Options for the request function.
* @return {Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
@@ -7648,6 +7750,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventType: string,
content: any,
stateKey = "",
opts: IRequestOpts = {},
): Promise<ISendEventResponse> {
const pathParams = {
$roomId: roomId,
@@ -7658,7 +7761,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (stateKey !== undefined) {
path = utils.encodeUri(path + "/$stateKey", pathParams);
}
return this.http.authedRequest(Method.Put, path, undefined, content);
return this.http.authedRequest(Method.Put, path, undefined, content, opts);
}
/**
@@ -8342,17 +8445,19 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param {Object} opts
* @param {string} opts.next_batch the batch token to pass in the query string
* @param {Object} opts.body the JSON object to pass to the request body.
* @param {AbortSignal=} abortSignal optional signal used to cancel the http request.
* @return {Promise} Resolves: TODO
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public search(
opts: { body: ISearchRequestBody, next_batch?: string }, // eslint-disable-line camelcase
abortSignal?: AbortSignal,
): Promise<ISearchResponse> {
const queryParams: any = {};
if (opts.next_batch) {
queryParams.next_batch = opts.next_batch;
}
return this.http.authedRequest(Method.Post, "/search", queryParams, opts.body);
return this.http.authedRequest(Method.Post, "/search", queryParams, opts.body, { abortSignal });
}
/**
@@ -8433,9 +8538,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
keyAlgorithm = "signed_curve25519";
}
for (let i = 0; i < devices.length; ++i) {
const userId = devices[i][0];
const deviceId = devices[i][1];
for (const [userId, deviceId] of devices) {
const query = queries[userId] || {};
queries[userId] = query;
query[deviceId] = keyAlgorithm;
@@ -8654,11 +8757,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
clientSecret: string,
msisdnToken: string,
): Promise<any> { // TODO: Types
const u = new URL(url);
u.searchParams.set("sid", sid);
u.searchParams.set("client_secret", clientSecret);
u.searchParams.set("token", msisdnToken);
return this.http.requestOtherUrl(Method.Post, u);
const params = {
sid: sid,
client_secret: clientSecret,
token: msisdnToken,
};
return this.http.requestOtherUrl(Method.Post, url, params);
}
/**
+14 -14
View File
@@ -118,12 +118,12 @@ export function makeEmoteMessage(body: string) {
/** Location content helpers */
export const getTextForLocationEvent = (
uri: string,
uri: string | undefined,
assetType: LocationAssetType,
timestamp: number,
description?: string,
timestamp?: number,
description?: string | null,
): string => {
const date = `at ${new Date(timestamp).toISOString()}`;
const date = `at ${new Date(timestamp!).toISOString()}`;
const assetName = assetType === LocationAssetType.Self ? 'User' : undefined;
const quotedDescription = description ? `"${description}"` : undefined;
@@ -147,10 +147,10 @@ export const getTextForLocationEvent = (
export const makeLocationContent = (
// this is first but optional
// to avoid a breaking change
text: string | undefined,
uri: string,
timestamp: number,
description?: string,
text?: string,
uri?: string,
timestamp?: number,
description?: string | null,
assetType?: LocationAssetType,
): LegacyLocationEventContent & MLocationEventContent => {
const defaultedText = text ??
@@ -187,7 +187,7 @@ export const parseLocationEvent = (wireEventContent: LocationEventWireContent):
const assetType = asset?.type ?? LocationAssetType.Self;
const fallbackText = text ?? wireEventContent.body;
return makeLocationContent(fallbackText, geoUri, timestamp, description, assetType);
return makeLocationContent(fallbackText, geoUri, timestamp ?? undefined, description, assetType);
};
/**
@@ -201,7 +201,7 @@ export type MakeTopicContent = (
export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
const renderings = [{ body: topic, mimetype: "text/plain" }];
if (isProvided(htmlTopic)) {
renderings.push({ body: htmlTopic, mimetype: "text/html" });
renderings.push({ body: htmlTopic!, mimetype: "text/html" });
}
return { topic, [M_TOPIC.name]: renderings };
};
@@ -247,14 +247,14 @@ export const makeBeaconInfoContent: MakeBeaconInfoContent = (
export type BeaconInfoState = MBeaconInfoContent & {
assetType?: LocationAssetType;
timestamp: number;
timestamp?: number;
};
/**
* Flatten beacon info event content
*/
export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => {
const { description, timeout, live } = content;
const timestamp = M_TIMESTAMP.findIn<number>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content) ?? undefined;
const asset = M_ASSET.findIn<MAssetContent>(content);
return {
@@ -290,14 +290,14 @@ export const makeBeaconContent: MakeBeaconContent = (
},
});
export type BeaconLocationState = MLocationContent & {
export type BeaconLocationState = Omit<MLocationContent, "uri"> & {
uri?: string; // override from MLocationContent to allow optionals
timestamp?: number;
};
export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => {
const location = M_LOCATION.findIn<MLocationContent>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content);
const timestamp = M_TIMESTAMP.findIn<number>(content) ?? undefined;
return {
description: location?.description,
+1 -1
View File
@@ -29,7 +29,7 @@ import { DeviceInfo } from "./deviceinfo";
import { SecretStorage } from "./SecretStorage";
import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
import { OlmDevice } from "./OlmDevice";
import { ICryptoCallbacks } from "../matrix";
import { ICryptoCallbacks } from ".";
import { ISignatures } from "../@types/signed";
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
import { ISecretStorageKeyInfo } from "./api";
+1 -1
View File
@@ -230,7 +230,7 @@ export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHand
}, delay);
}
return savePromise!;
return savePromise;
}
/**
+3 -4
View File
@@ -19,16 +19,15 @@ import { IContent, MatrixEvent } from "../models/event";
import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning";
import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store';
import { Method, ClientPrefix } from "../http-api";
import { Crypto, IBootstrapCrossSigningOpts } from "./index";
import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index";
import {
ClientEvent,
CrossSigningKeys,
ClientEventHandlerMap,
CrossSigningKeys,
ICrossSigningKey,
ICryptoCallbacks,
ISignedKey,
KeySignatures,
} from "../matrix";
} from "../client";
import { ISecretStorageKeyInfo } from "./api";
import { IKeyBackupInfo } from "./keybackup";
import { TypedEventEmitter } from "../models/typed-event-emitter";
+1 -1
View File
@@ -804,7 +804,7 @@ export class OlmDevice {
log,
);
return info!;
return info;
}
/**
+1 -8
View File
@@ -100,7 +100,7 @@ export class OutgoingRoomKeyRequestManager {
// of sendOutgoingRoomKeyRequests
private sendOutgoingRoomKeyRequestsRunning = false;
private clientRunning = false;
private clientRunning = true;
constructor(
private readonly baseApis: MatrixClient,
@@ -108,13 +108,6 @@ export class OutgoingRoomKeyRequestManager {
private readonly cryptoStore: CryptoStore,
) {}
/**
* Called when the client is started. Sets background processes running.
*/
public start(): void {
this.clientRunning = true;
}
/**
* Called when the client is stopped. Stops any running background processes.
*/
+3 -2
View File
@@ -19,8 +19,9 @@ import * as olmlib from './olmlib';
import { encodeBase64 } from './olmlib';
import { randomString } from '../randomstring';
import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from './aes';
import { ClientEvent, IContent, ICryptoCallbacks, MatrixEvent } from '../matrix';
import { ClientEventHandlerMap, MatrixClient } from "../client";
import { ICryptoCallbacks } from ".";
import { IContent, MatrixEvent } from "../models/event";
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from "../client";
import { IAddSecretStorageKeyOpts, ISecretStorageKeyInfo } from './api';
import { TypedEventEmitter } from '../models/typed-event-emitter';
import { defer, IDeferred } from "../utils";
+10 -10
View File
@@ -38,7 +38,7 @@ import { IOlmSessionResult } from "../olmlib";
import { DeviceInfoMap } from "../DeviceList";
import { MatrixEvent } from "../../models/event";
import { EventType, MsgType } from '../../@types/event';
import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index";
import { IEncryptedContent, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index";
import { RoomKeyRequestState } from '../OutgoingRoomKeyRequestManager';
import { OlmGroupSessionExtraData } from "../../@types/crypto";
import { MatrixError } from "../../http-api";
@@ -105,12 +105,6 @@ interface IPayload extends Partial<IMessage> {
algorithm?: string;
sender_key?: string;
}
interface IEncryptedContent {
algorithm: string;
sender_key: string;
ciphertext: Record<string, string>;
}
/* eslint-enable camelcase */
interface SharedWithData {
@@ -981,7 +975,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
logger.debug(`Getting devices in ${this.roomId}`);
const [devicesInRoom, blocked] = await this.getDevicesInRoom(room);
if (this.crypto.getGlobalErrorOnUnknownDevices()) {
if (this.crypto.globalErrorOnUnknownDevices) {
// Drop unknown devices for now. When the message gets sent, we'll
// throw an error, but we'll still be prepared to send to the known
// devices.
@@ -1034,7 +1028,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
// check if any of these devices are not yet known to the user.
// if so, warn the user so they can verify or ignore.
if (this.crypto.getGlobalErrorOnUnknownDevices()) {
if (this.crypto.globalErrorOnUnknownDevices) {
this.checkForUnknownDevices(devicesInRoom);
}
@@ -1169,7 +1163,7 @@ class MegolmEncryption extends EncryptionAlgorithm {
});
// The global value is treated as a default for when rooms don't specify a value.
let isBlacklisting = this.crypto.getGlobalBlacklistUnverifiedDevices();
let isBlacklisting = this.crypto.globalBlacklistUnverifiedDevices;
const isRoomBlacklisting = room.getBlacklistUnverifiedDevices();
if (typeof isRoomBlacklisting === 'boolean') {
isBlacklisting = isRoomBlacklisting;
@@ -1660,6 +1654,9 @@ class MegolmDecryption extends DecryptionAlgorithm {
return;
}
}
// XXX: switch this to use encryptAndSendToDevices() rather than duplicating it?
await olmlib.ensureOlmSessionsForDevices(
this.olmDevice, this.baseApis, { [sender]: [device] }, false,
);
@@ -1717,6 +1714,8 @@ class MegolmDecryption extends DecryptionAlgorithm {
const deviceInfo = this.crypto.getStoredDevice(userId, deviceId)!;
const body = keyRequest.requestBody;
// XXX: switch this to use encryptAndSendToDevices()?
this.olmlib.ensureOlmSessionsForDevices(
this.olmDevice, this.baseApis, {
[userId]: [deviceInfo],
@@ -1910,6 +1909,7 @@ class MegolmDecryption extends DecryptionAlgorithm {
for (const [senderKey, sessionId] of sharedHistorySessions) {
const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId);
// FIXME: use encryptAndSendToDevices() rather than duplicating it here.
const promises: Promise<unknown>[] = [];
const contentMap: Record<string, Record<string, IEncryptedContent>> = {};
for (const [userId, devices] of Object.entries(devicesByUser)) {
+3 -6
View File
@@ -119,12 +119,10 @@ class OlmEncryption extends EncryptionAlgorithm {
const promises: Promise<void>[] = [];
for (let i = 0; i < users.length; ++i) {
const userId = users[i];
for (const userId of users) {
const devices = this.crypto.getStoredDevicesForUser(userId) || [];
for (let j = 0; j < devices.length; ++j) {
const deviceInfo = devices[j];
for (const deviceInfo of devices) {
const key = deviceInfo.getIdentityKey();
if (key == this.olmDevice.deviceCurve25519Key) {
// don't bother sending to ourself
@@ -304,8 +302,7 @@ class OlmDecryption extends DecryptionAlgorithm {
// try each session in turn.
const decryptionErrors: Record<string, string> = {};
for (let i = 0; i < sessionIds.length; i++) {
const sessionId = sessionIds[i];
for (const sessionId of sessionIds) {
try {
const payload = await this.olmDevice.decryptMessage(
theirDeviceIdentityKey, sessionId, message.type, message.body,
+2 -3
View File
@@ -302,7 +302,7 @@ export class BackupManager {
|| now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT
) {
this.sessionLastCheckAttemptedTime[targetSessionId!] = now;
await this.baseApis.restoreKeyBackupWithCache(targetRoomId, targetSessionId, this.backupInfo, {});
await this.baseApis.restoreKeyBackupWithCache(targetRoomId!, targetSessionId!, this.backupInfo, {});
}
}
@@ -680,8 +680,7 @@ export class Curve25519 implements BackupAlgorithm {
const backupPubKey = decryption.init_with_private_key(privKey);
if (backupPubKey !== this.authData.public_key) {
// eslint-disable-next-line no-throw-literal
throw { errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY };
throw new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY });
}
const keys: IMegolmSessionData[] = [];
+39 -35
View File
@@ -127,6 +127,29 @@ export interface IBootstrapCrossSigningOpts {
authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>;
}
export interface ICryptoCallbacks {
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
shouldUpgradeDeviceVerifications?: (
users: Record<string, any>
) => Promise<string[]>;
getSecretStorageKey?: (
keys: {keys: Record<string, ISecretStorageKeyInfo>}, name: string
) => Promise<[string, Uint8Array] | null>;
cacheSecretStorageKey?: (
keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array
) => void;
onSecretRequested?: (
userId: string, deviceId: string,
requestId: string, secretName: string, deviceTrust: DeviceTrustLevel
) => Promise<string>;
getDehydrationKey?: (
keyInfo: ISecretStorageKeyInfo,
checkFunc: (key: Uint8Array) => void,
) => Promise<Uint8Array>;
getBackupKey?: () => Promise<Uint8Array>;
}
/* eslint-disable camelcase */
interface IRoomKey {
room_id: string;
@@ -285,8 +308,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
private deviceKeys: Record<string, string> = {}; // type: key
private globalBlacklistUnverifiedDevices = false;
private globalErrorOnUnknownDevices = true;
public globalBlacklistUnverifiedDevices = false;
public globalErrorOnUnknownDevices = true;
// list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations
// we received in the current sync.
@@ -1752,9 +1775,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
eventEmitter.on(MatrixEventEvent.Decrypted, this.onTimelineEvent);
}
/** Start background processes related to crypto */
/**
* @deprecated this does nothing and will be removed in a future version
*/
public start(): void {
this.outgoingRoomKeyRequestManager.start();
logger.warn("MatrixClient.crypto.start() is deprecated");
}
/** Stop background processes related to crypto */
@@ -1788,6 +1813,9 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
* do not specify a value.
*
* @param {boolean} value whether to blacklist all unverified devices by default
*
* @deprecated For external code, use {@link MatrixClient#setGlobalBlacklistUnverifiedDevices}. For
* internal code, set {@link #globalBlacklistUnverifiedDevices} directly.
*/
public setGlobalBlacklistUnverifiedDevices(value: boolean): void {
this.globalBlacklistUnverifiedDevices = value;
@@ -1795,34 +1823,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* @return {boolean} whether to blacklist all unverified devices by default
*
* @deprecated For external code, use {@link MatrixClient#getGlobalBlacklistUnverifiedDevices}. For
* internal code, reference {@link #globalBlacklistUnverifiedDevices} directly.
*/
public getGlobalBlacklistUnverifiedDevices(): boolean {
return this.globalBlacklistUnverifiedDevices;
}
/**
* Set whether sendMessage in a room with unknown and unverified devices
* should throw an error and not send them message. This has 'Global' for
* symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
* no room-level equivalent for this setting.
*
* This API is currently UNSTABLE and may change or be removed without notice.
*
* @param {boolean} value whether error on unknown devices
*/
public setGlobalErrorOnUnknownDevices(value: boolean): void {
this.globalErrorOnUnknownDevices = value;
}
/**
* @return {boolean} whether to error on unknown devices
*
* This API is currently UNSTABLE and may change or be removed without notice.
*/
public getGlobalErrorOnUnknownDevices(): boolean {
return this.globalErrorOnUnknownDevices;
}
/**
* Upload the device keys to the homeserver.
* @return {object} A promise that will resolve when the keys are uploaded.
@@ -2379,9 +2387,8 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*/
public async getOlmSessionsForUser(userId: string): Promise<Record<string, IUserOlmSession>> {
const devices = this.getStoredDevicesForUser(userId) || [];
const result = {};
for (let j = 0; j < devices.length; ++j) {
const device = devices[j];
const result: { [deviceId: string]: IUserOlmSession } = {};
for (const device of devices) {
const deviceKey = device.getIdentityKey();
const sessions = await this.olmDevice.getSessionInfoForDevice(deviceKey);
@@ -2682,14 +2689,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
): Promise<Record<string, Record<string, olmlib.IOlmSessionResult>>> {
const devicesByUser: Record<string, DeviceInfo[]> = {};
for (let i = 0; i < users.length; ++i) {
const userId = users[i];
for (const userId of users) {
devicesByUser[userId] = [];
const devices = this.getStoredDevicesForUser(userId) || [];
for (let j = 0; j < devices.length; ++j) {
const deviceInfo = devices[j];
for (const deviceInfo of devices) {
const key = deviceInfo.getIdentityKey();
if (key == this.olmDevice.deviceCurve25519Key) {
// don't bother setting up session to ourself
+7 -4
View File
@@ -88,12 +88,16 @@ export async function encryptMessageForDevice(
if (sessionId === null) {
// If we don't have a session for a device then
// we can't encrypt a message for it.
logger.log(
`[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` +
`${recipientUserId}:${recipientDevice.deviceId}`,
);
return;
}
logger.log(
"Using sessionid " + sessionId + " for device " +
recipientUserId + ":" + recipientDevice.deviceId,
`[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` +
`${recipientUserId}:${recipientDevice.deviceId}`,
);
const payload = {
@@ -335,8 +339,7 @@ export async function ensureOlmSessionsForDevices(
const promises: Promise<void>[] = [];
for (const [userId, devices] of Object.entries(devicesByUser)) {
const userRes = otkResult[userId] || {};
for (let j = 0; j < devices.length; j++) {
const deviceInfo = devices[j];
for (const deviceInfo of devices) {
const deviceId = deviceInfo.deviceId;
const key = deviceInfo.getIdentityKey();
@@ -201,7 +201,7 @@ export class Backend implements CryptoStore {
let stateIndex = 0;
let result: OutgoingRoomKeyRequest;
function onsuccess(this: IDBRequest<IDBCursorWithValue>) {
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>) {
const cursor = this.result;
if (cursor) {
// got a match
@@ -256,7 +256,7 @@ export class Backend implements CryptoStore {
let stateIndex = 0;
const results: OutgoingRoomKeyRequest[] = [];
function onsuccess(this: IDBRequest<IDBCursorWithValue>) {
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>) {
const cursor = this.result;
if (cursor) {
const keyReq = cursor.value;
@@ -309,7 +309,7 @@ export class Backend implements CryptoStore {
): Promise<OutgoingRoomKeyRequest | null> {
let result: OutgoingRoomKeyRequest | null = null;
function onsuccess(this: IDBRequest<IDBCursorWithValue>) {
function onsuccess(this: IDBRequest<IDBCursorWithValue | null>) {
const cursor = this.result;
if (!cursor) {
return;
+357
View File
@@ -0,0 +1,357 @@
/*
Copyright 2022 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 {
WidgetApi,
WidgetApiToWidgetAction,
MatrixCapabilities,
IWidgetApiRequest,
IWidgetApiAcknowledgeResponseData,
ISendEventToWidgetActionRequest,
ISendToDeviceToWidgetActionRequest,
ISendEventFromWidgetResponseData,
} from "matrix-widget-api";
import { 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 } 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 } from "./models/ToDeviceMessage";
import { DeviceInfo } from "./crypto/deviceinfo";
import { IOlmDevice } from "./crypto/algorithms/megolm";
interface IStateEventRequest {
eventType: string;
stateKey?: string;
}
export interface ICapabilities {
/**
* Event types that this client expects to send.
*/
sendEvent?: string[];
/**
* Event types that this client expects to receive.
*/
receiveEvent?: string[];
/**
* Message types that this client expects to send, or true for all message
* types.
*/
sendMessage?: string[] | true;
/**
* Message types that this client expects to receive, or true for all
* message types.
*/
receiveMessage?: string[] | true;
/**
* Types of state events that this client expects to send.
*/
sendState?: IStateEventRequest[];
/**
* Types of state events that this client expects to receive.
*/
receiveState?: IStateEventRequest[];
/**
* To-device event types that this client expects to send.
*/
sendToDevice?: string[];
/**
* To-device event types that this client expects to receive.
*/
receiveToDevice?: string[];
/**
* Whether this client needs access to TURN servers.
* @default false
*/
turnServers?: boolean;
}
/**
* A MatrixClient that routes its requests through the widget API instead of the
* real CS API.
* @experimental This class is considered unstable!
*/
export class RoomWidgetClient extends MatrixClient {
private room?: Room;
private widgetApiReady = new Promise<void>(resolve => this.widgetApi.once("ready", resolve));
private lifecycle?: AbortController;
private syncState: SyncState | null = null;
constructor(
private readonly widgetApi: WidgetApi,
private readonly capabilities: ICapabilities,
private readonly roomId: string,
opts: IMatrixClientCreateOpts,
) {
super(opts);
// Request capabilities for the functionality this client needs to support
if (
capabilities.sendEvent?.length
|| capabilities.receiveEvent?.length
|| capabilities.sendMessage === true
|| (Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length)
|| capabilities.receiveMessage === true
|| (Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length)
|| capabilities.sendState?.length
|| capabilities.receiveState?.length
) {
widgetApi.requestCapabilityForRoomTimeline(roomId);
}
capabilities.sendEvent?.forEach(eventType =>
widgetApi.requestCapabilityToSendEvent(eventType),
);
capabilities.receiveEvent?.forEach(eventType =>
widgetApi.requestCapabilityToReceiveEvent(eventType),
);
if (capabilities.sendMessage === true) {
widgetApi.requestCapabilityToSendMessage();
} else if (Array.isArray(capabilities.sendMessage)) {
capabilities.sendMessage.forEach(msgType =>
widgetApi.requestCapabilityToSendMessage(msgType),
);
}
if (capabilities.receiveMessage === true) {
widgetApi.requestCapabilityToReceiveMessage();
} else if (Array.isArray(capabilities.receiveMessage)) {
capabilities.receiveMessage.forEach(msgType =>
widgetApi.requestCapabilityToReceiveMessage(msgType),
);
}
capabilities.sendState?.forEach(({ eventType, stateKey }) =>
widgetApi.requestCapabilityToSendState(eventType, stateKey),
);
capabilities.receiveState?.forEach(({ eventType, stateKey }) =>
widgetApi.requestCapabilityToReceiveState(eventType, stateKey),
);
capabilities.sendToDevice?.forEach(eventType =>
widgetApi.requestCapabilityToSendToDevice(eventType),
);
capabilities.receiveToDevice?.forEach(eventType =>
widgetApi.requestCapabilityToReceiveToDevice(eventType),
);
if (capabilities.turnServers) {
widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
}
widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
// Open communication with the host
widgetApi.start();
}
public async startClient(opts: IStartClientOpts = {}): Promise<void> {
this.lifecycle = new AbortController();
// Create our own user object artificially (instead of waiting for sync)
// so it's always available, even if the user is not in any rooms etc.
const userId = this.getUserId();
if (userId) {
this.store.storeUser(new User(userId));
}
// Even though we have no access token and cannot sync, the sync class
// still has some valuable helper methods that we make use of, so we
// instantiate it anyways
if (opts.slidingSync) {
this.syncApi = new SlidingSyncSdk(opts.slidingSync, this, opts);
} else {
this.syncApi = new SyncApi(this, opts);
}
this.room = this.syncApi.createRoom(this.roomId);
this.store.storeRoom(this.room);
await this.widgetApiReady;
// Backfill the requested events
// We only get the most recent event for every type + state key combo,
// so it doesn't really matter what order we inject them in
await Promise.all(
this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]);
const events = rawEvents.map(rawEvent => new MatrixEvent(rawEvent as Partial<IEvent>));
await this.syncApi!.injectRoomEvents(this.room!, [], events);
events.forEach(event => {
this.emit(ClientEvent.Event, event);
logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
});
}) ?? [],
);
this.setSyncState(SyncState.Syncing);
logger.info("Finished backfilling events");
// Watch for TURN servers, if requested
if (this.capabilities.turnServers) this.watchTurnServers();
}
public stopClient() {
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
super.stopClient();
this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped
}
public async joinRoom(roomIdOrAlias: string): Promise<Room> {
if (roomIdOrAlias === this.roomId) return this.room!;
throw new Error(`Unknown room: ${roomIdOrAlias}`);
}
protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise<ISendEventResponse> {
let response: ISendEventFromWidgetResponseData;
try {
response = await this.widgetApi.sendRoomEvent(event.getType(), event.getContent(), room.roomId);
} catch (e) {
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
throw e;
}
room.updatePendingEvent(event, EventStatus.SENT, response.event_id);
return { event_id: response.event_id };
}
public async sendStateEvent(
roomId: string,
eventType: string,
content: any,
stateKey = "",
): Promise<ISendEventResponse> {
return await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId);
}
public async sendToDevice(
eventType: string,
contentMap: { [userId: string]: { [deviceId: string]: Record<string, any> } },
): Promise<{}> {
await this.widgetApi.sendToDevice(eventType, false, contentMap);
return {};
}
public async queueToDevice({ eventType, batch }: ToDeviceBatch): Promise<void> {
const contentMap: { [userId: string]: { [deviceId: string]: object } } = {};
for (const { userId, deviceId, payload } of batch) {
if (!contentMap[userId]) contentMap[userId] = {};
contentMap[userId][deviceId] = payload;
}
await this.widgetApi.sendToDevice(eventType, false, contentMap);
}
public async encryptAndSendToDevices(
userDeviceInfoArr: IOlmDevice<DeviceInfo>[],
payload: object,
): Promise<void> {
const contentMap: { [userId: string]: { [deviceId: string]: object } } = {};
for (const { userId, deviceInfo: { deviceId } } of userDeviceInfoArr) {
if (!contentMap[userId]) contentMap[userId] = {};
contentMap[userId][deviceId] = payload;
}
await this.widgetApi.sendToDevice((payload as { type: string }).type, true, contentMap);
}
// Overridden since we get TURN servers automatically over the widget API,
// and this method would otherwise complain about missing an access token
public async checkTurnServers(): Promise<boolean> {
return this.turnServers.length > 0;
}
// Overridden since we 'sync' manually without the sync API
public getSyncState(): SyncState | null {
return this.syncState;
}
private setSyncState(state: SyncState) {
const oldState = this.syncState;
this.syncState = state;
this.emit(ClientEvent.Sync, state, oldState);
}
private async ack(ev: CustomEvent<IWidgetApiRequest>): Promise<void> {
await this.widgetApi.transport.reply<IWidgetApiAcknowledgeResponseData>(ev.detail, {});
}
private onEvent = async (ev: CustomEvent<ISendEventToWidgetActionRequest>) => {
ev.preventDefault();
// Verify the room ID matches, since it's possible for the client to
// send us events from other rooms if this widget is always on screen
if (ev.detail.data.room_id === this.roomId) {
const event = new MatrixEvent(ev.detail.data as Partial<IEvent>);
await this.syncApi!.injectRoomEvents(this.room!, [], [event]);
this.emit(ClientEvent.Event, event);
this.setSyncState(SyncState.Syncing);
logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
} else {
const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`);
}
await this.ack(ev);
};
private onToDevice = async (ev: CustomEvent<ISendToDeviceToWidgetActionRequest>) => {
ev.preventDefault();
const event = new MatrixEvent({
type: ev.detail.data.type,
sender: ev.detail.data.sender,
content: ev.detail.data.content as IContent,
});
// Mark the event as encrypted if it was, using fake contents and keys since those are unknown to us
if (ev.detail.data.encrypted) event.makeEncrypted(EventType.RoomMessageEncrypted, {}, "", "");
this.emit(ClientEvent.ToDeviceEvent, event);
this.setSyncState(SyncState.Syncing);
await this.ack(ev);
};
private async watchTurnServers() {
const servers = this.widgetApi.getTurnServers();
const onClientStopped = () => servers.return(undefined);
this.lifecycle!.signal.addEventListener("abort", onClientStopped);
try {
for await (const server of servers) {
this.turnServers = [{
urls: server.uris,
username: server.username,
credential: server.password,
}];
this.emit(ClientEvent.TurnServers, this.turnServers);
logger.log(`Received TURN server: ${server.uris}`);
}
} catch (e) {
logger.warn("Error watching TURN servers", e);
} finally {
this.lifecycle!.signal.removeEventListener("abort", onClientStopped);
}
}
}
+1 -2
View File
@@ -150,8 +150,7 @@ export class FilterComponent {
},
};
for (let n = 0; n < Object.keys(literalKeys).length; n++) {
const name = Object.keys(literalKeys)[n];
for (const name in literalKeys) {
const matchFunc = literalKeys[name];
const notName = "not_" + name;
const disallowedValues: string[] = this.filterJson[notName];
+1 -1
View File
@@ -49,7 +49,7 @@ export class HTTPError extends Error {
*/
export class MatrixError extends HTTPError {
public readonly errcode?: string;
public readonly data: IErrorJson;
public data: IErrorJson;
constructor(
errorJson: IErrorJson = {},
+3 -1
View File
@@ -236,7 +236,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
method: Method,
url: URL | string,
body?: Body,
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "abortSignal"> = {},
opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal"> = {},
): Promise<ResponseType<T, O>> {
const headers = Object.assign({}, opts.headers || {});
const json = opts.json ?? true;
@@ -254,6 +254,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
}
const timeout = opts.localTimeoutMs ?? this.opts.localTimeoutMs;
const keepAlive = opts.keepAlive ?? false;
const signals = [
this.abortController.signal,
];
@@ -286,6 +287,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
referrerPolicy: "no-referrer",
cache: "no-cache",
credentials: "omit", // we send credentials via headers
keepalive: keepAlive,
});
} catch (e) {
if ((<Error>e).name === "AbortError") {
+1
View File
@@ -38,6 +38,7 @@ export interface IRequestOpts {
headers?: Record<string, string>;
abortSignal?: AbortSignal;
localTimeoutMs?: number;
keepAlive?: boolean; // defaults to false
json?: boolean; // defaults to true
// Set to true to prevent the request function from emitting a Session.logged_out event.
+19 -20
View File
@@ -21,6 +21,7 @@ limitations under the License.
import { logger } from './logger';
import { MatrixClient } from "./client";
import { defer, IDeferred } from "./utils";
import { MatrixError } from "./http-api";
const EMAIL_STAGE_TYPE = "m.login.email.identity";
const MSISDN_STAGE_TYPE = "m.login.msisdn";
@@ -111,7 +112,7 @@ interface IOpts {
sessionId?: string;
clientSecret?: string;
emailSid?: string;
doRequest(auth: IAuthData, background: boolean): Promise<IAuthData>;
doRequest(auth: IAuthData | null, background: boolean): Promise<IAuthData>;
stateUpdated(nextStage: AuthType, status: IStageStatus): void;
requestEmailToken(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
busyChanged?(busy: boolean): void;
@@ -328,7 +329,7 @@ export class InteractiveAuth {
* @param {string} loginType login type for the stage
* @return {object?} any parameters from the server for this stage
*/
public getStageParams(loginType: string): Record<string, any> {
public getStageParams(loginType: string): Record<string, any> | undefined {
return this.data.params?.[loginType];
}
@@ -428,10 +429,10 @@ export class InteractiveAuth {
this.requestingEmailToken = true;
try {
const requestTokenResult = await this.requestEmailTokenCallback(
this.inputs.emailAddress,
this.inputs.emailAddress!,
this.clientSecret,
this.emailAttempt++,
this.data.session,
this.data.session!,
);
this.emailSid = requestTokenResult.sid;
logger.trace("Email token request succeeded");
@@ -454,16 +455,16 @@ 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, background = false): Promise<void> {
private async doRequest(auth: IAuthData | null, background = false): Promise<void> {
try {
const result = await this.requestCallback(auth, background);
this.attemptAuthDeferred!.resolve(result);
this.attemptAuthDeferred = null;
} catch (error) {
// sometimes UI auth errors don't come with flows
const errorFlows = error.data?.flows ?? null;
const errorFlows = (<MatrixError>error).data?.flows ?? null;
const haveFlows = this.data.flows || Boolean(errorFlows);
if (error.httpStatus !== 401 || !error.data || !haveFlows) {
if ((<MatrixError>error).httpStatus !== 401 || !(<MatrixError>error).data || !haveFlows) {
// doesn't look like an interactive-auth failure.
if (!background) {
this.attemptAuthDeferred?.reject(error);
@@ -474,20 +475,23 @@ export class InteractiveAuth {
logger.log("Background poll request failed doing UI auth: ignoring", error);
}
}
if (!error.data) {
error.data = {};
if (!(<MatrixError>error).data) {
(<MatrixError>error).data = {};
}
// if the error didn't come with flows, completed flows or session ID,
// copy over the ones we have. Synapse sometimes sends responses without
// any UI auth data (eg. when polling for email validation, if the email
// has not yet been validated). This appears to be a Synapse bug, which
// we workaround here.
if (!error.data.flows && !error.data.completed && !error.data.session) {
error.data.flows = this.data.flows;
error.data.completed = this.data.completed;
error.data.session = this.data.session;
if (!(<MatrixError>error).data.flows &&
!(<MatrixError>error).data.completed &&
!(<MatrixError>error).data.session
) {
(<MatrixError>error).data.flows = this.data.flows;
(<MatrixError>error).data.completed = this.data.completed;
(<MatrixError>error).data.session = this.data.session;
}
this.data = error.data;
this.data = (<MatrixError>error).data;
try {
this.startNextAuthStage();
} catch (e) {
@@ -627,11 +631,6 @@ export class InteractiveAuth {
*/
private firstUncompletedStage(flow: IFlow): AuthType | undefined {
const completed = this.data.completed || [];
for (let i = 0; i < flow.stages.length; ++i) {
const stageType = flow.stages[i];
if (completed.indexOf(stageType) === -1) {
return stageType;
}
}
return flow.stages.find(stageType => !completed.includes(stageType));
}
}
+33 -56
View File
@@ -1,5 +1,5 @@
/*
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2015-2022 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.
@@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { WidgetApi } from "matrix-widget-api";
import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store";
import { MemoryStore } from "./store/memory";
import { MatrixScheduler } from "./scheduler";
import { MatrixClient, ICreateClientOpts } from "./client";
import { DeviceTrustLevel } from "./crypto/CrossSigning";
import { ISecretStorageKeyInfo } from "./crypto/api";
import { RoomWidgetClient, ICapabilities } from "./embedded";
export * from "./client";
export * from "./embedded";
export * from "./http-api";
export * from "./autodiscovery";
export * from "./sync-accumulator";
@@ -51,9 +53,16 @@ export * from './@types/requests';
export * from './@types/search';
export * from './models/room-summary';
export * as ContentHelpers from "./content-helpers";
export type { ICryptoCallbacks } from "./crypto"; // used to be located here
export { createNewMatrixCall } from "./webrtc/call";
export type { MatrixCall } from "./webrtc/call";
export {
createNewMatrixCall,
} from "./webrtc/call";
GroupCallEvent,
GroupCallIntent,
GroupCallState,
GroupCallType,
} from "./webrtc/groupCall";
export type { GroupCall } from "./webrtc/groupCall";
let cryptoStoreFactory = () => new MemoryCryptoStore;
@@ -67,34 +76,20 @@ export function setCryptoStoreFactory(fac) {
cryptoStoreFactory = fac;
}
export interface ICryptoCallbacks {
getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
shouldUpgradeDeviceVerifications?: (
users: Record<string, any>
) => Promise<string[]>;
getSecretStorageKey?: (
keys: {keys: Record<string, ISecretStorageKeyInfo>}, name: string
) => Promise<[string, Uint8Array] | null>;
cacheSecretStorageKey?: (
keyId: string, keyInfo: ISecretStorageKeyInfo, key: Uint8Array
) => void;
onSecretRequested?: (
userId: string, deviceId: string,
requestId: string, secretName: string, deviceTrust: DeviceTrustLevel
) => Promise<string>;
getDehydrationKey?: (
keyInfo: ISecretStorageKeyInfo,
checkFunc: (key: Uint8Array) => void,
) => Promise<Uint8Array>;
getBackupKey?: () => Promise<Uint8Array>;
function amendClientOpts(opts: ICreateClientOpts): ICreateClientOpts {
opts.store = opts.store ?? new MemoryStore({
localStorage: global.localStorage,
});
opts.scheduler = opts.scheduler ?? new MatrixScheduler();
opts.cryptoStore = opts.cryptoStore ?? cryptoStoreFactory();
return opts;
}
/**
* Construct a Matrix Client. Similar to {@link module:client.MatrixClient}
* except that the 'request', 'store' and 'scheduler' dependencies are satisfied.
* @param {(Object)} opts The configuration options for this client. If
* this is a string, it is assumed to be the base URL. These configuration
* @param {Object} opts The configuration options for this client. These configuration
* options will be passed directly to {@link module:client.MatrixClient}.
* @param {Object} opts.store If not set, defaults to
* {@link module:store/memory.MemoryStore}.
@@ -111,33 +106,15 @@ export interface ICryptoCallbacks {
* @see {@link module:client.MatrixClient} for the full list of options for
* <code>opts</code>.
*/
export function createClient(opts: ICreateClientOpts) {
opts.store = opts.store || new MemoryStore({
localStorage: global.localStorage,
});
opts.scheduler = opts.scheduler || new MatrixScheduler();
opts.cryptoStore = opts.cryptoStore || cryptoStoreFactory();
return new MatrixClient(opts);
export function createClient(opts: ICreateClientOpts): MatrixClient {
return new MatrixClient(amendClientOpts(opts));
}
/**
* A wrapper for the request function interface.
* @callback requestWrapperFunction
* @param {requestFunction} origRequest The underlying request function being
* wrapped
* @param {Object} opts The options for this HTTP request, given in the same
* form as {@link requestFunction}.
* @param {requestCallback} callback The request callback.
*/
/**
* The request callback interface for performing HTTP requests. This matches the
* API for the {@link https://github.com/request/request#requestoptions-callback|
* request NPM module}. The SDK will implement a callback which meets this
* interface in order to handle the HTTP response.
* @callback requestCallback
* @param {Error} err The error if one occurred, else falsey.
* @param {Object} response The HTTP response which consists of
* <code>{statusCode: {Number}, headers: {Object}}</code>
* @param {Object} body The parsed HTTP response body.
*/
export function createRoomWidgetClient(
widgetApi: WidgetApi,
capabilities: ICapabilities,
roomId: string,
opts: ICreateClientOpts,
): MatrixClient {
return new RoomWidgetClient(widgetApi, capabilities, roomId, amendClientOpts(opts));
}
+1 -1
View File
@@ -145,7 +145,7 @@ export class MSC3089Branch {
let event: MatrixEvent | undefined = room.getUnfilteredTimelineSet().findEventById(this.id);
// keep scrolling back if needed until we find the event or reach the start of the room:
while (!event && room.getLiveTimeline().getState(EventTimeline.BACKWARDS).paginationToken) {
while (!event && room.getLiveTimeline().getState(EventTimeline.BACKWARDS)!.paginationToken) {
await this.client.scrollback(room, 100);
event = room.getUnfilteredTimelineSet().findEventById(this.id);
}
+8 -7
View File
@@ -16,7 +16,7 @@ limitations under the License.
import { MBeaconEventContent } from "../@types/beacon";
import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers";
import { MatrixEvent } from "../matrix";
import { MatrixEvent } from "./event";
import { sortEventsByLatestContentTimestamp } from "../utils";
import { TypedEventEmitter } from "./typed-event-emitter";
@@ -132,19 +132,19 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
this.checkLiveness();
if (!this.beaconInfo) return;
if (this.isLive) {
const expiryInMs = (this.beaconInfo.timestamp + this.beaconInfo.timeout) - Date.now();
const expiryInMs = (this.beaconInfo.timestamp! + this.beaconInfo.timeout) - Date.now();
if (expiryInMs > 1) {
this.livenessWatchTimeout = setTimeout(
() => { this.monitorLiveness(); },
expiryInMs,
);
}
} else if (this.beaconInfo.timestamp > Date.now()) {
} else if (this.beaconInfo.timestamp! > Date.now()) {
// beacon start timestamp is in the future
// check liveness again then
this.livenessWatchTimeout = setTimeout(
() => { this.monitorLiveness(); },
this.beaconInfo.timestamp - Date.now(),
this.beaconInfo.timestamp! - Date.now(),
);
}
}
@@ -165,6 +165,7 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
if (!parsed.uri || !parsed.timestamp) return false; // we won't be able to process these
const { timestamp } = parsed;
return (
this._beaconInfo!.timestamp &&
// only include positions that were taken inside the beacon's live period
isTimestampInDuration(this._beaconInfo!.timestamp, this._beaconInfo!.timeout, timestamp) &&
// ignore positions older than our current latest location
@@ -197,10 +198,10 @@ export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.N
// may have a start timestamp in the future from Bob's POV
// handle this by adding 6min of leniency to the start timestamp when it is in the future
if (!this.beaconInfo) return;
const startTimestamp = this.beaconInfo.timestamp > Date.now() ?
this.beaconInfo.timestamp - 360000 /* 6min */ :
const startTimestamp = this.beaconInfo.timestamp! > Date.now() ?
this.beaconInfo.timestamp! - 360000 /* 6min */ :
this.beaconInfo.timestamp;
this._isLive = !!this._beaconInfo?.live &&
this._isLive = !!this._beaconInfo?.live && !!startTimestamp &&
isTimestampInDuration(startTimestamp, this._beaconInfo?.timeout, Date.now());
if (prevLiveness !== this.isLive) {
+24 -3
View File
@@ -457,8 +457,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
let didUpdate = false;
let lastEventWasNew = false;
for (let i = 0; i < events.length; i++) {
const event = events[i];
for (const event of events) {
const eventId = event.getId()!;
const existingTimeline = this._eventIdToTimeline.get(eventId);
@@ -625,7 +624,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
}
EventTimeline.setEventMetadata(
event,
roomState,
roomState!,
false,
);
tlEvents[j] = event;
@@ -702,6 +701,28 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
);
}
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;
}
const eventId = event.getId()!;
timeline.addEvent(event, {
toStartOfTimeline,
+39 -43
View File
@@ -19,7 +19,7 @@ limitations under the License.
*/
import { logger } from '../logger';
import { RoomState, IMarkerFoundOptions } from "./room-state";
import { IMarkerFoundOptions, RoomState } from "./room-state";
import { EventTimelineSet } from "./event-timeline-set";
import { MatrixEvent } from "./event";
import { Filter } from "../filter";
@@ -95,8 +95,14 @@ export class EventTimeline {
private readonly name: string;
private events: MatrixEvent[] = [];
private baseIndex = 0;
private startState: RoomState;
private endState: RoomState;
private startState?: RoomState;
private endState?: RoomState;
// If we have a roomId then we delegate pagination token storage to the room state objects `startState` and
// `endState`, but for things like the notification timeline which mix multiple rooms we store the tokens ourselves.
private startToken: string | null = null;
private endToken: string | null = null;
private prevTimeline: EventTimeline | null = null;
private nextTimeline: EventTimeline | null = null;
public paginationRequests: Record<Direction, Promise<boolean> | null> = {
@@ -126,10 +132,10 @@ export class EventTimeline {
*/
constructor(private readonly eventTimelineSet: EventTimelineSet) {
this.roomId = eventTimelineSet.room?.roomId ?? null;
this.startState = new RoomState(this.roomId);
this.startState.paginationToken = null;
this.endState = new RoomState(this.roomId);
this.endState.paginationToken = null;
if (this.roomId) {
this.startState = new RoomState(this.roomId);
this.endState = new RoomState(this.roomId);
}
// this is used by client.js
this.paginationRequests = { 'b': null, 'f': null };
@@ -151,28 +157,8 @@ export class EventTimeline {
throw new Error("Cannot initialise state after events are added");
}
// We previously deep copied events here and used different copies in
// the oldState and state events: this decision seems to date back
// quite a way and was apparently made to fix a bug where modifications
// made to the start state leaked through to the end state.
// This really shouldn't be possible though: the events themselves should
// not change. Duplicating the events uses a lot of extra memory,
// so we now no longer do it. To assert that they really do never change,
// freeze them! Note that we can't do this for events in general:
// although it looks like the only things preventing us are the
// 'status' flag, forwardLooking (which is only set once when adding to the
// timeline) and possibly the sender (which seems like it should never be
// reset but in practice causes a lot of the tests to break).
for (const e of stateEvents) {
Object.freeze(e);
}
this.startState.setStateEvents(stateEvents, {
timelineWasEmpty,
});
this.endState.setStateEvents(stateEvents, {
timelineWasEmpty,
});
this.startState?.setStateEvents(stateEvents, { timelineWasEmpty });
this.endState?.setStateEvents(stateEvents, { timelineWasEmpty });
}
/**
@@ -190,7 +176,7 @@ export class EventTimeline {
public forkLive(direction: Direction): EventTimeline {
const forkState = this.getState(direction);
const timeline = new EventTimeline(this.eventTimelineSet);
timeline.startState = forkState.clone();
timeline.startState = forkState?.clone();
// Now clobber the end state of the new live timeline with that from the
// previous live timeline. It will be identical except that we'll keep
// using the same RoomMember objects for the 'live' set of members with any
@@ -198,7 +184,7 @@ export class EventTimeline {
timeline.endState = forkState;
// Firstly, we just stole the current timeline's end state, so it needs a new one.
// Make an immutable copy of the state so back pagination will get the correct sentinels.
this.endState = forkState.clone();
this.endState = forkState?.clone();
return timeline;
}
@@ -214,8 +200,8 @@ export class EventTimeline {
public fork(direction: Direction): EventTimeline {
const forkState = this.getState(direction);
const timeline = new EventTimeline(this.eventTimelineSet);
timeline.startState = forkState.clone();
timeline.endState = forkState.clone();
timeline.startState = forkState?.clone();
timeline.endState = forkState?.clone();
return timeline;
}
@@ -276,7 +262,7 @@ export class EventTimeline {
*
* @return {RoomState} state at the start/end of the timeline
*/
public getState(direction: Direction): RoomState {
public getState(direction: Direction): RoomState | undefined {
if (direction == EventTimeline.BACKWARDS) {
return this.startState;
} else if (direction == EventTimeline.FORWARDS) {
@@ -296,7 +282,13 @@ export class EventTimeline {
* @return {?string} pagination token
*/
public getPaginationToken(direction: Direction): string | null {
return this.getState(direction).paginationToken;
if (this.roomId) {
return this.getState(direction)!.paginationToken;
} else if (direction === Direction.Backward) {
return this.startToken;
} else {
return this.endToken;
}
}
/**
@@ -304,12 +296,18 @@ export class EventTimeline {
*
* @param {?string} token pagination token
*
* @param {string} direction EventTimeline.BACKWARDS to set the paginatio
* @param {string} direction EventTimeline.BACKWARDS to set the pagination
* token for going backwards in time; EventTimeline.FORWARDS to set the
* pagination token for going forwards in time.
*/
public setPaginationToken(token: string | null, direction: Direction): void {
this.getState(direction).paginationToken = token;
if (this.roomId) {
this.getState(direction)!.paginationToken = token;
} else if (direction === Direction.Backward) {
this.startToken = token;
} else {
this.endToken = token;
}
}
/**
@@ -408,16 +406,14 @@ export class EventTimeline {
const timelineSet = this.getTimelineSet();
if (timelineSet.room) {
EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
EventTimeline.setEventMetadata(event, roomState!, toStartOfTimeline);
// modify state but only on unfiltered timelineSets
if (
event.isState() &&
timelineSet.room.getUnfilteredTimelineSet() === timelineSet
) {
roomState.setStateEvents([event], {
timelineWasEmpty,
});
roomState?.setStateEvents([event], { timelineWasEmpty });
// 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
@@ -428,8 +424,8 @@ export class EventTimeline {
// 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() === "m.room.member" && !toStartOfTimeline)) {
EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline);
if (!event.sender || (event.getType() === EventType.RoomMember && !toStartOfTimeline)) {
EventTimeline.setEventMetadata(event, roomState!, toStartOfTimeline);
}
}
}
+44 -22
View File
@@ -107,7 +107,7 @@ export interface IEventRelation {
event_id?: string;
is_falling_back?: boolean;
"m.in_reply_to"?: {
event_id: string;
event_id?: string;
};
key?: string;
}
@@ -413,7 +413,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
if (this.clearEvent) {
return this.clearEvent.type;
}
return this.event.type;
return this.event.type!;
}
/**
@@ -423,7 +423,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @return {string} The event type.
*/
public getWireType(): EventType | string {
return this.event.type;
return this.event.type!;
}
/**
@@ -441,7 +441,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @return {Number} The event timestamp, e.g. <code>1433502692297</code>
*/
public getTs(): number {
return this.event.origin_server_ts;
return this.event.origin_server_ts!;
}
/**
@@ -452,6 +452,26 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
return this.event.origin_server_ts ? new Date(this.event.origin_server_ts) : null;
}
/**
* Get a string containing details of this event
*
* This is intended for logging, to help trace errors. Example output:
*
* id=$HjnOHV646n0SjLDAqFrgIjim7RCpB7cdMXFrekWYAn type=m.room.encrypted sender=@user:example.com room=!room:example.com ts=2022-10-25T17:30:28.404Z
*/
public getDetails(): string {
let details = `id=${this.getId()} type=${this.getWireType()} sender=${this.getSender()}`;
const room = this.getRoomId();
if (room) {
details += ` room=${room}`;
}
const date = this.getDate();
if (date) {
details += ` ts=${date.toISOString()}`;
}
return details;
}
/**
* Get the (decrypted, if necessary) event content JSON, even if the event
* was replaced by another event.
@@ -625,8 +645,8 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
): void {
// keep the plain-text data for 'view source'
this.clearEvent = {
type: this.event.type,
content: this.event.content,
type: this.event.type!,
content: this.event.content!,
};
this.event.type = cryptoType;
this.event.content = cryptoContent;
@@ -730,7 +750,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
const wireContent = this.getWireContent();
return crypto.requestRoomKey({
algorithm: wireContent.algorithm,
room_id: this.getRoomId(),
room_id: this.getRoomId()!,
session_id: wireContent.session_id,
sender_key: wireContent.sender_key,
}, this.getKeyRequestRecipients(userId), true);
@@ -780,7 +800,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
} else {
res = await crypto.decryptEvent(this);
if (options.isRetry === true) {
logger.info(`Decrypted event on retry (id=${this.getId()})`);
logger.info(`Decrypted event on retry (${this.getDetails()})`);
}
}
} catch (e) {
@@ -790,10 +810,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
const re = options.isRetry ? 're' : '';
// For find results: this can produce "Error decrypting event (id=$ev)" and
// "Error redecrypting event (id=$ev)".
logger.error(
`Error ${re}decrypting event ` +
`(id=${this.getId()}): ${e.stack || e}`,
);
logger.error(`Error ${re}decrypting event (${this.getDetails()})`, e);
this.decryptionPromise = null;
this.retryDecryption = false;
return;
@@ -817,16 +834,21 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
//
if (this.retryDecryption) {
// decryption error, but we have a retry queued.
logger.log(`Got error decrypting event (id=${this.getId()}: ` +
`${(<DecryptionError>e).detailedString}), but retrying`, e);
logger.log(
`Error decrypting event (${this.getDetails()}), but retrying: ` +
(<DecryptionError>e).detailedString,
);
continue;
}
// decryption error, no retries queued. Warn about the error and
// set it to m.bad.encrypted.
//
// the detailedString already includes the name and message of the error, and the stack isn't much use,
// so we don't bother to log `e` separately.
logger.warn(
`Got error decrypting event (id=${this.getId()}: ${(<DecryptionError>e).detailedString})`,
e,
`Error decrypting event (${this.getDetails()}): ` +
(<DecryptionError>e).detailedString,
);
res = this.badEncryptedMessage((<DecryptionError>e).message);
@@ -1007,7 +1029,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
const value = this._localRedactionEvent;
this._localRedactionEvent = null;
if (this.event.unsigned) {
this.event.unsigned.redacted_because = null;
this.event.unsigned.redacted_because = undefined;
}
return !!value;
}
@@ -1194,8 +1216,8 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
if (!this.isRedacted()) return null;
if (this.clearEvent?.unsigned) {
return this.clearEvent?.unsigned.redacted_because;
} else if (this.event.unsigned.redacted_because) {
return this.clearEvent?.unsigned.redacted_because ?? null;
} else if (this.event.unsigned?.redacted_because) {
return this.event.unsigned.redacted_because;
} else {
return {};
@@ -1246,7 +1268,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
this.emit(MatrixEventEvent.LocalEventIdReplaced, this);
}
this.localTimestamp = Date.now() - this.getAge();
this.localTimestamp = Date.now() - this.getAge()!;
}
/**
@@ -1290,7 +1312,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// State events cannot be m.replace relations
return false;
}
return relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true);
return !!(relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true));
}
/**
@@ -1302,7 +1324,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
if (!this.isRelation()) {
return null;
}
return this.getWireContent()["m.relates_to"];
return this.getWireContent()["m.relates_to"] ?? null;
}
/**
+1 -1
View File
@@ -164,7 +164,7 @@ export class IgnoredInvites {
const senderServer = sender.split(":")[1];
const roomServer = roomId.split(":")[1];
for (const room of policyRooms) {
const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(EventTimeline.FORWARDS);
const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(EventTimeline.FORWARDS)!;
for (const { scope, entities } of [
{ scope: PolicyScope.Room, entities: [roomId] },
+3 -1
View File
@@ -12,9 +12,11 @@ limitations under the License.
*/
import { ReceiptType } from "../@types/read_receipts";
import { EventTimelineSet, EventType, MatrixEvent } from "../matrix";
import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
import * as utils from "../utils";
import { MatrixEvent } from "./event";
import { EventType } from "../@types/event";
import { EventTimelineSet } from "./event-timeline-set";
export const MAIN_ROOM_TIMELINE = "main";
+29 -32
View File
@@ -1008,8 +1008,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Removing just the old live timeline whilst preserving previous ones is not supported.
*/
public resetLiveTimeline(backPaginationToken?: string | null, forwardPaginationToken?: string | null): void {
for (let i = 0; i < this.timelineSets.length; i++) {
this.timelineSets[i].resetLiveTimeline(
for (const timelineSet of this.timelineSets) {
timelineSet.resetLiveTimeline(
backPaginationToken ?? undefined,
forwardPaginationToken ?? undefined,
);
@@ -1032,10 +1032,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// state at the start and end of that timeline. These are more
// for backwards-compatibility than anything else.
this.timeline = this.getLiveTimeline().getEvents();
this.oldState = this.getLiveTimeline()
.getState(EventTimeline.BACKWARDS);
this.currentState = this.getLiveTimeline()
.getState(EventTimeline.FORWARDS);
this.oldState = this.getLiveTimeline().getState(EventTimeline.BACKWARDS)!;
this.currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
// Let people know to register new listeners for the new state
// references. The reference won't necessarily change every time so only
@@ -1564,8 +1562,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
pendingEvents = true,
}: ICreateFilterOpts = {},
): EventTimelineSet {
if (this.filteredTimelineSets[filter.filterId]) {
return this.filteredTimelineSets[filter.filterId];
if (this.filteredTimelineSets[filter.filterId!]) {
return this.filteredTimelineSets[filter.filterId!];
}
const opts = Object.assign({ filter, pendingEvents }, this.opts);
const timelineSet = new EventTimelineSet(this, opts);
@@ -1574,7 +1572,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
RoomEvent.TimelineReset,
]);
if (useSyncEvents) {
this.filteredTimelineSets[filter.filterId] = timelineSet;
this.filteredTimelineSets[filter.filterId!] = timelineSet;
this.timelineSets.push(timelineSet);
}
@@ -1623,7 +1621,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
private async getThreadListFilter(filterType = ThreadFilterType.All): Promise<Filter> {
const myUserId = this.client.getUserId();
const myUserId = this.client.getUserId()!;
const filter = new Filter(myUserId);
const definition: IFilterDefinition = {
@@ -1635,7 +1633,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
};
if (filterType === ThreadFilterType.My) {
definition.room.timeline[FILTER_RELATED_BY_SENDERS.name] = [myUserId];
definition!.room!.timeline![FILTER_RELATED_BY_SENDERS.name] = [myUserId];
}
filter.setDefinition(definition);
@@ -1653,7 +1651,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
let timelineSet: EventTimelineSet;
if (Thread.hasServerSideListSupport) {
timelineSet =
new EventTimelineSet(this, this.opts, undefined, undefined, filterType ?? ThreadFilterType.All);
new EventTimelineSet(this, {
...this.opts,
pendingEvents: false,
}, undefined, undefined, filterType ?? ThreadFilterType.All);
this.reEmitter.reEmit(timelineSet, [
RoomEvent.Timeline,
RoomEvent.TimelineReset,
@@ -1681,7 +1682,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return event.getSender() === this.client.getUserId();
});
if (filterType !== ThreadFilterType.My || currentUserParticipated) {
timelineSet.getLiveTimeline().addEvent(thread.rootEvent, {
timelineSet.getLiveTimeline().addEvent(thread.rootEvent!, {
toStartOfTimeline: false,
});
}
@@ -1851,8 +1852,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @param {Filter} filter the filter whose timelineSet is to be forgotten
*/
public removeFilteredTimelineSet(filter: Filter): void {
const timelineSet = this.filteredTimelineSets[filter.filterId];
delete this.filteredTimelineSets[filter.filterId];
const timelineSet = this.filteredTimelineSets[filter.filterId!];
delete this.filteredTimelineSets[filter.filterId!];
const i = this.timelineSets.indexOf(timelineSet);
if (i > -1) {
this.timelineSets.splice(i, 1);
@@ -1864,7 +1865,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
shouldLiveInThread: boolean;
threadId?: string;
} {
if (!this.client.supportsExperimentalThreads()) {
if (!this.client?.supportsExperimentalThreads()) {
return {
shouldLiveInRoom: true,
shouldLiveInThread: false,
@@ -2130,8 +2131,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const { duplicateStrategy, timelineWasEmpty, fromCache } = addLiveEventOptions;
// add to our timeline sets
for (let i = 0; i < this.timelineSets.length; i++) {
this.timelineSets[i].addLiveEvent(event, {
for (const timelineSet of this.timelineSets) {
timelineSet.addLiveEvent(event, {
duplicateStrategy,
fromCache,
timelineWasEmpty,
@@ -2187,7 +2188,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// call setEventMetadata to set up event.sender etc
// as event is shared over all timelineSets, we set up its metadata based
// on the unfiltered timelineSet.
EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS), false);
EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS)!, false);
this.txnToEvent[txnId] = event;
if (this.pendingEventList) {
@@ -2216,8 +2217,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
} else {
for (let i = 0; i < this.timelineSets.length; i++) {
const timelineSet = this.timelineSets[i];
for (const timelineSet of this.timelineSets) {
if (timelineSet.getFilter()) {
if (timelineSet.getFilter()!.filterRoomTimeline([event]).length) {
timelineSet.addEventToTimeline(event,
@@ -2324,9 +2324,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
if (shouldLiveInRoom) {
for (let i = 0; i < this.timelineSets.length; i++) {
const timelineSet = this.timelineSets[i];
for (const timelineSet of this.timelineSets) {
// if it's already in the timeline, update the timeline map. If it's not, add it.
timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
}
@@ -2409,8 +2407,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// if the event was already in the timeline (which will be the case if
// opts.pendingEventOrdering==chronological), we need to update the
// timeline map.
for (let i = 0; i < this.timelineSets.length; i++) {
this.timelineSets[i].replaceEventId(oldEventId, newEventId!);
for (const timelineSet of this.timelineSets) {
timelineSet.replaceEventId(oldEventId, newEventId!);
}
}
} else if (newStatus == EventStatus.CANCELLED) {
@@ -2645,8 +2643,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @param {String[]} eventIds A list of eventIds to remove.
*/
public removeEvents(eventIds: string[]): void {
for (let i = 0; i < eventIds.length; ++i) {
this.removeEvent(eventIds[i]);
for (const eventId of eventIds) {
this.removeEvent(eventId);
}
}
@@ -2659,8 +2657,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/
public removeEvent(eventId: string): boolean {
let removedAny = false;
for (let i = 0; i < this.timelineSets.length; i++) {
const removed = this.timelineSets[i].removeEvent(eventId);
for (const timelineSet of this.timelineSets) {
const removed = timelineSet.removeEvent(eventId);
if (removed) {
if (removed.isRedaction()) {
this.revertRedactionLocalEcho(removed);
@@ -2742,8 +2740,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @param {Array<MatrixEvent>} events an array of account_data events to add
*/
public addAccountData(events: MatrixEvent[]): void {
for (let i = 0; i < events.length; i++) {
const event = events[i];
for (const event of events) {
if (event.getType() === "m.tag") {
this.addTags(event);
}
+24 -4
View File
@@ -16,16 +16,18 @@ limitations under the License.
import { Optional } from "matrix-events-sdk";
import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix";
import { MatrixClient } from "../client";
import { TypedReEmitter } from "../ReEmitter";
import { IThreadBundledRelationship, MatrixEvent } from "./event";
import { RelationType } from "../@types/event";
import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "./event";
import { EventTimeline } from "./event-timeline";
import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set';
import { Room } from './room';
import { NotificationCountType, Room, RoomEvent } from './room';
import { RoomState } from "./room-state";
import { ServerControlledNamespacedValue } from "../NamespacedValue";
import { logger } from "../logger";
import { ReadReceipt } from "./read-receipt";
import { ReceiptType } from "../@types/read_receipts";
export enum ThreadEvent {
New = "Thread.new",
@@ -200,7 +202,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
};
public get roomState(): RoomState {
return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
}
private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void {
@@ -416,6 +418,24 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
public addReceipt(event: MatrixEvent, synthetic: boolean): void {
throw new Error("Unsupported function on the thread model");
}
public hasUserReadEvent(userId: string, eventId: string): boolean {
if (userId === this.client.getUserId()) {
const publicReadReceipt = this.getReadReceiptForUserId(userId, false, ReceiptType.Read);
const privateReadReceipt = this.getReadReceiptForUserId(userId, false, ReceiptType.ReadPrivate);
const hasUnreads = this.room.getThreadUnreadNotificationCount(this.id, NotificationCountType.Total) > 0;
if (!publicReadReceipt && !privateReadReceipt && !hasUnreads) {
// Consider an event read if it's part of a thread that has no
// read receipts and has no notifications. It is likely that it is
// part of a thread that was created before read receipts for threads
// were supported (via MSC3771)
return true;
}
}
return super.hasUserReadEvent(userId, eventId);
}
}
export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(
+2 -2
View File
@@ -134,8 +134,8 @@ export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
this.updateModifiedTime();
for (let i = 0; i < eventsToFire.length; i++) {
this.emit(eventsToFire[i], event, this);
for (const eventToFire of eventsToFire) {
this.emit(eventToFire, event, this);
}
}
+35 -22
View File
@@ -93,6 +93,9 @@ const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
],
actions: [],
},
];
const DEFAULT_UNDERRIDE_RULES: IPushRule[] = [
{
// For homeservers which don't support MSC3914 yet
rule_id: ".org.matrix.msc3914.rule.room.call",
@@ -135,8 +138,7 @@ export class PushProcessor {
*/
public static actionListToActionsObject(actionList: PushRuleAction[]): IActionsObject {
const actionObj: IActionsObject = { notify: false, tweaks: {} };
for (let i = 0; i < actionList.length; ++i) {
const action = actionList[i];
for (const action of actionList) {
if (action === PushRuleActionName.Notify) {
actionObj.notify = true;
} else if (typeof action === 'object') {
@@ -164,6 +166,7 @@ export class PushProcessor {
if (!newRules) newRules = {} as IPushRules;
if (!newRules.global) newRules.global = {} as PushRuleSet;
if (!newRules.global.override) newRules.global.override = [];
if (!newRules.global.override) newRules.global.underride = [];
// Merge the client-level defaults with the ones from the server
const globalOverrides = newRules.global.override;
@@ -184,21 +187,37 @@ export class PushProcessor {
}
}
const globalUnderrides = newRules.global.underride ?? [];
for (const underride of DEFAULT_UNDERRIDE_RULES) {
const existingRule = globalUnderrides
.find((r) => r.rule_id === underride.rule_id);
if (existingRule) {
// Copy over the actions, default, and conditions. Don't touch the user's preference.
existingRule.default = underride.default;
existingRule.conditions = underride.conditions;
existingRule.actions = underride.actions;
} else {
// Add the rule
const ruleId = underride.rule_id;
logger.warn(`Adding default global underride for ${ruleId}`);
globalUnderrides.push(underride);
}
}
return newRules;
}
private static cachedGlobToRegex: Record<string, RegExp> = {}; // $glob: RegExp
private matchingRuleFromKindSet(ev: MatrixEvent, kindset: PushRuleSet): IAnnotatedPushRule | null {
for (let ruleKindIndex = 0; ruleKindIndex < RULEKINDS_IN_ORDER.length; ++ruleKindIndex) {
const kind = RULEKINDS_IN_ORDER[ruleKindIndex];
for (const kind of RULEKINDS_IN_ORDER) {
const ruleset = kindset[kind];
if (!ruleset) {
continue;
}
for (let ruleIndex = 0; ruleIndex < ruleset.length; ++ruleIndex) {
const rule = ruleset[ruleIndex];
for (const rule of ruleset) {
if (!rule.enabled) {
continue;
}
@@ -219,8 +238,11 @@ export class PushProcessor {
return null;
}
private templateRuleToRaw(kind: PushRuleKind, tprule: any): any {
const rawrule = {
private templateRuleToRaw(
kind: PushRuleKind,
tprule: IPushRule,
): Pick<IPushRule, "rule_id" | "actions" | "conditions"> | null {
const rawrule: Pick<IPushRule, "rule_id" | "actions" | "conditions"> = {
'rule_id': tprule.rule_id,
'actions': tprule.actions,
'conditions': [],
@@ -234,7 +256,7 @@ export class PushProcessor {
if (!tprule.rule_id) {
return null;
}
rawrule.conditions.push({
rawrule.conditions!.push({
'kind': ConditionKind.EventMatch,
'key': 'room_id',
'value': tprule.rule_id,
@@ -244,7 +266,7 @@ export class PushProcessor {
if (!tprule.rule_id) {
return null;
}
rawrule.conditions.push({
rawrule.conditions!.push({
'kind': ConditionKind.EventMatch,
'key': 'user_id',
'value': tprule.rule_id,
@@ -254,7 +276,7 @@ export class PushProcessor {
if (!tprule.pattern) {
return null;
}
rawrule.conditions.push({
rawrule.conditions!.push({
'kind': ConditionKind.EventMatch,
'key': 'content.body',
'pattern': tprule.pattern,
@@ -474,17 +496,8 @@ export class PushProcessor {
return actionObj;
}
public ruleMatchesEvent(rule: IPushRule, ev: MatrixEvent): boolean {
if (!rule.conditions?.length) return true;
let ret = true;
for (let i = 0; i < rule.conditions.length; ++i) {
const cond = rule.conditions[i];
// @ts-ignore
ret &= this.eventFulfillsCondition(cond, ev);
}
//console.log("Rule "+rule.rule_id+(ret ? " matches" : " doesn't match"));
return ret;
public ruleMatchesEvent(rule: Partial<IPushRule> & Pick<IPushRule, "conditions">, ev: MatrixEvent): boolean {
return !rule.conditions?.some(cond => !this.eventFulfillsCondition(cond, ev));
}
/**
+3 -5
View File
@@ -101,7 +101,7 @@ export function clearTimeout(key: number): void {
}
// remove the element from the list
let i;
let i: number;
for (i = 0; i < callbackList.length; i++) {
const cb = callbackList[i];
if (cb.key == key) {
@@ -137,7 +137,6 @@ function scheduleRealCallback(): void {
}
function runCallbacks(): void {
let cb: Callback;
const timestamp = Date.now();
debuglog("runCallbacks: now:", timestamp);
@@ -149,7 +148,7 @@ function runCallbacks(): void {
if (!first || first.runAt > timestamp) {
break;
}
cb = callbackList.shift()!;
const cb = callbackList.shift()!;
debuglog("runCallbacks: popping", cb.key);
callbacksToRun.push(cb);
}
@@ -159,8 +158,7 @@ function runCallbacks(): void {
// register their own setTimeouts.
scheduleRealCallback();
for (let i = 0; i < callbacksToRun.length; i++) {
cb = callbacksToRun[i];
for (const cb of callbacksToRun) {
try {
cb.func.apply(global, cb.params);
} catch (e) {
+1 -2
View File
@@ -16,14 +16,13 @@ limitations under the License.
import { UnstableValue } from "matrix-events-sdk";
import { RendezvousChannel } from ".";
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
import { MatrixClient } from "../client";
import { CrossSigningInfo } from "../crypto/CrossSigning";
import { DeviceInfo } from "../crypto/deviceinfo";
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
import { logger } from "../logger";
import { sleep } from "../utils";
import { RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
enum PayloadType {
Start = 'm.login.start',
+63 -5
View File
@@ -33,8 +33,11 @@ import {
SlidingSyncEvent,
SlidingSyncState,
} from "./sliding-sync";
import { EventType, IPushRules } from "./matrix";
import { EventType } from "./@types/event";
import { IPushRules } from "./@types/PushRules";
import { PushProcessor } from "./pushprocessor";
import { RoomStateEvent } from "./models/room-state";
import { RoomMemberEvent } from "./models/room-member";
// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed
// to RECONNECTING. This is needed to inform the client of server issues when the
@@ -389,6 +392,60 @@ export class SlidingSyncSdk {
return this.syncStateData ?? null;
}
// Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts
public createRoom(roomId: string): Room { // XXX cargoculted from sync.ts
const { timelineSupport } = this.client;
const room = new Room(roomId, this.client, this.client.getUserId()!, {
lazyLoadMembers: this.opts.lazyLoadMembers,
pendingEventOrdering: this.opts.pendingEventOrdering,
timelineSupport,
});
this.client.reEmitter.reEmit(room, [
RoomEvent.Name,
RoomEvent.Redaction,
RoomEvent.RedactionCancelled,
RoomEvent.Receipt,
RoomEvent.Tags,
RoomEvent.LocalEchoUpdated,
RoomEvent.AccountData,
RoomEvent.MyMembership,
RoomEvent.Timeline,
RoomEvent.TimelineReset,
]);
this.registerStateListeners(room);
return room;
}
private registerStateListeners(room: Room): void { // XXX cargoculted from sync.ts
// we need to also re-emit room state and room member events, so hook it up
// to the client now. We need to add a listener for RoomState.members in
// order to hook them correctly.
this.client.reEmitter.reEmit(room.currentState, [
RoomStateEvent.Events,
RoomStateEvent.Members,
RoomStateEvent.NewMember,
RoomStateEvent.Update,
]);
room.currentState.on(RoomStateEvent.NewMember, (event, state, member) => {
member.user = this.client.getUser(member.userId) ?? undefined;
this.client.reEmitter.reEmit(member, [
RoomMemberEvent.Name,
RoomMemberEvent.Typing,
RoomMemberEvent.PowerLevel,
RoomMemberEvent.Membership,
]);
});
}
/*
private deregisterStateListeners(room: Room): void { // XXX cargoculted from sync.ts
// could do with a better way of achieving this.
room.currentState.removeAllListeners(RoomStateEvent.Events);
room.currentState.removeAllListeners(RoomStateEvent.Members);
room.currentState.removeAllListeners(RoomStateEvent.NewMember);
} */
private shouldAbortSync(error: MatrixError): boolean {
if (error.errcode === "M_UNKNOWN_TOKEN") {
// The logout already happened, we just need to stop.
@@ -484,7 +541,7 @@ export class SlidingSyncSdk {
if (roomData.invite_state) {
const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state);
this.processRoomEvents(room, inviteStateEvents);
this.injectRoomEvents(room, inviteStateEvents);
if (roomData.initial) {
room.recalculate();
this.client.store.storeRoom(room);
@@ -552,10 +609,11 @@ export class SlidingSyncSdk {
// reason to stop incrementally tracking notifications and
// reset the timeline.
this.client.resetNotifTimelineSet();
this.registerStateListeners(room);
}
} */
this.processRoomEvents(room, stateEvents, timelineEvents, false);
this.injectRoomEvents(room, stateEvents, timelineEvents, false);
// we deliberately don't add ephemeral events to the timeline
room.addEphemeralEvents(ephemeralEvents);
@@ -594,6 +652,7 @@ export class SlidingSyncSdk {
}
/**
* Injects events into a room's model.
* @param {Room} room
* @param {MatrixEvent[]} stateEventList A list of state events. This is the state
* at the *START* of the timeline list if it is supplied.
@@ -601,7 +660,7 @@ export class SlidingSyncSdk {
* @param {boolean} fromCache whether the sync response came from cache
* is earlier in time. Higher index is later.
*/
private processRoomEvents(
public injectRoomEvents(
room: Room,
stateEventList: MatrixEvent[],
timelineEventList?: MatrixEvent[],
@@ -822,7 +881,6 @@ function ensureNameEvent(client: MatrixClient, roomId: string, roomData: MSC3575
// Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts,
// just outside the class.
function mapEvents(client: MatrixClient, roomId: string | undefined, events: object[], decrypt = true): MatrixEvent[] {
const mapper = client.getEventMapper({ decrypt });
return (events as Array<IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent>).map(function(e) {
+35 -1
View File
@@ -353,6 +353,11 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
private desiredRoomSubscriptions = new Set<string>(); // the *desired* room subscriptions
private confirmedRoomSubscriptions = new Set<string>();
// map of custom subscription name to the subscription
private customSubscriptions: Map<string, MSC3575RoomSubscription> = new Map();
// map of room ID to custom subscription name
private roomIdToCustomSubscription: Map<string, string> = new Map();
private pendingReq?: Promise<MSC3575SlidingSyncResponse>;
private abortController?: AbortController;
@@ -375,6 +380,30 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
this.lists = lists.map((l) => new SlidingList(l));
}
/**
* Add a custom room subscription, referred to by an arbitrary name. If a subscription with this
* name already exists, it is replaced. No requests are sent by calling this method.
* @param name The name of the subscription. Only used to reference this subscription in
* useCustomSubscription.
* @param sub The subscription information.
*/
public addCustomSubscription(name: string, sub: MSC3575RoomSubscription) {
this.customSubscriptions.set(name, sub);
}
/**
* Use a custom subscription previously added via addCustomSubscription. No requests are sent
* by calling this method. Use modifyRoomSubscriptions to resend subscription information.
* @param roomId The room to use the subscription in.
* @param name The name of the subscription. If this name is unknown, the default subscription
* will be used.
*/
public useCustomSubscription(roomId: string, name: string) {
this.roomIdToCustomSubscription.set(roomId, name);
// unconfirm this subscription so a resend() will send it up afresh.
this.confirmedRoomSubscriptions.delete(roomId);
}
/**
* Get the length of the sliding lists.
* @returns The number of lists in the sync request
@@ -806,7 +835,12 @@ export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSync
if (newSubscriptions.size > 0) {
reqBody.room_subscriptions = {};
for (const roomId of newSubscriptions) {
reqBody.room_subscriptions[roomId] = this.roomSubscriptionInfo;
const customSubName = this.roomIdToCustomSubscription.get(roomId);
let sub = this.roomSubscriptionInfo;
if (customSubName && this.customSubscriptions.has(customSubName)) {
sub = this.customSubscriptions.get(customSubName)!;
}
reqBody.room_subscriptions[roomId] = sub;
}
}
if (this.txnId) {
+2 -2
View File
@@ -454,8 +454,8 @@ export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
return utils.promiseTry<void>(() => {
const txn = this.db!.transaction(["accountData"], "readwrite");
const store = txn.objectStore("accountData");
for (let i = 0; i < accountData.length; i++) {
store.put(accountData[i]); // put == UPSERT
for (const event of accountData) {
store.put(event); // put == UPSERT
}
return txnAsPromise(txn).then();
});
+42 -38
View File
@@ -400,47 +400,51 @@ export class SyncAccumulator {
acc[INVITED_COUNT_KEY] = sum[INVITED_COUNT_KEY] || acc[INVITED_COUNT_KEY];
}
if (data.ephemeral && data.ephemeral.events) {
data.ephemeral.events.forEach((e) => {
// We purposefully do not persist m.typing events.
// Technically you could refresh a browser before the timer on a
// typing event is up, so it'll look like you aren't typing when
// you really still are. However, the alternative is worse. If
// we do persist typing events, it will look like people are
// typing forever until someone really does start typing (which
// will prompt Synapse to send down an actual m.typing event to
// clobber the one we persisted).
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(e.content[eventId]).forEach(([key, value]) => {
if (!isSupportedReceiptType(key)) return;
data.ephemeral?.events?.forEach((e) => {
// We purposefully do not persist m.typing events.
// Technically you could refresh a browser before the timer on a
// typing event is up, so it'll look like you aren't typing when
// you really still are. However, the alternative is worse. If
// we do persist typing events, it will look like people are
// typing forever until someone really does start typing (which
// will prompt Synapse to send down an actual m.typing event to
// clobber the one we persisted).
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<{
[eventId: string]: {
[receiptType: string]: {
[userId: string]: IMinimalEvent;
};
};
}>(e.content[eventId]).forEach(([key, value]) => {
if (!isSupportedReceiptType(key)) return;
Object.keys(value!).forEach((userId) => {
// clobber on user ID
currentData._readReceipts[userId] = {
data: e.content[eventId][key][userId],
type: key as ReceiptType,
eventId: eventId,
};
});
Object.keys(value).forEach((userId) => {
// clobber on user ID
currentData._readReceipts[userId] = {
data: e.content[eventId][key][userId],
type: key as ReceiptType,
eventId: eventId,
};
});
});
});
}
});
// if we got a limited sync, we need to remove all timeline entries or else
// we will have gaps in the timeline.
@@ -551,7 +555,7 @@ export class SyncAccumulator {
};
// Add account data
Object.keys(roomData._accountData).forEach((evType) => {
roomJson.account_data.events.push(roomData._accountData[evType] as IMinimalEvent);
roomJson.account_data.events.push(roomData._accountData[evType]);
});
// Add receipt data
+13 -15
View File
@@ -334,7 +334,7 @@ export class SyncApi {
// events so that clients can start back-paginating.
room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS);
await this.processRoomEvents(room, stateEvents, events);
await this.injectRoomEvents(room, stateEvents, events);
room.recalculate();
client.store.storeRoom(room);
@@ -367,7 +367,7 @@ export class SyncApi {
response.messages.chunk = response.messages.chunk || [];
response.state = response.state || [];
// FIXME: Mostly duplicated from processRoomEvents but not entirely
// FIXME: Mostly duplicated from injectRoomEvents but not entirely
// because "state" in this API is at the BEGINNING of the chunk
const oldStateEvents = utils.deepCopy(response.state)
.map(client.getEventMapper());
@@ -821,7 +821,6 @@ export class SyncApi {
let data: ISyncResponse;
try {
//debuglog('Starting sync since=' + syncToken);
if (!this.currentSyncRequest) {
this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken);
}
@@ -834,8 +833,6 @@ export class SyncApi {
this.currentSyncRequest = undefined;
}
//debuglog('Completed sync, next_batch=' + data.next_batch);
// set the sync token NOW *before* processing the events. We do this so
// if something barfs on an event we can skip it rather than constantly
// polling with the same token.
@@ -1210,7 +1207,7 @@ export class SyncApi {
const room = inviteObj.room;
const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room);
await this.processRoomEvents(room, stateEvents);
await this.injectRoomEvents(room, stateEvents);
const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId()!)?.getSender();
@@ -1366,7 +1363,7 @@ export class SyncApi {
}
try {
await this.processRoomEvents(room, stateEvents, events, syncEventData.fromCache);
await this.injectRoomEvents(room, stateEvents, events, syncEventData.fromCache);
} catch (e) {
logger.error(`Failed to process events on room ${room.roomId}:`, e);
}
@@ -1421,7 +1418,7 @@ export class SyncApi {
const events = this.mapSyncEventsFormat(leaveObj.timeline, room);
const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data);
await this.processRoomEvents(room, stateEvents, events);
await this.injectRoomEvents(room, stateEvents, events);
room.addAccountData(accountDataEvents);
room.recalculate();
@@ -1539,6 +1536,7 @@ export class SyncApi {
{
prefix: '',
localTimeoutMs: 15 * 1000,
abortSignal: this.abortController?.signal,
},
).then(() => {
success();
@@ -1659,14 +1657,15 @@ export class SyncApi {
}
/**
* Injects events into a room's model.
* @param {Room} room
* @param {MatrixEvent[]} stateEventList A list of state events. This is the state
* at the *START* of the timeline list if it is supplied.
* @param {MatrixEvent[]} [timelineEventList] A list of timeline events, including threaded. Lower index
* @param {boolean} fromCache whether the sync response came from cache
* is earlier in time. Higher index is later.
* @param {boolean} fromCache whether the sync response came from cache
*/
private async processRoomEvents(
public async injectRoomEvents(
room: Room,
stateEventList: MatrixEvent[],
timelineEventList?: MatrixEvent[],
@@ -1747,11 +1746,10 @@ export class SyncApi {
private processEventsForNotifs(room: Room, timelineEventList: MatrixEvent[]): void {
// gather our notifications into this.notifEvents
if (this.client.getNotifTimelineSet()) {
for (let i = 0; i < timelineEventList.length; i++) {
const pushActions = this.client.getPushActionsForEvent(timelineEventList[i]);
if (pushActions && pushActions.notify &&
pushActions.tweaks && pushActions.tweaks.highlight) {
this.notifEvents.push(timelineEventList[i]);
for (const event of timelineEventList) {
const pushActions = this.client.getPushActionsForEvent(event);
if (pushActions?.notify && pushActions.tweaks?.highlight) {
this.notifEvents.push(event);
}
}
}
+3 -3
View File
@@ -190,9 +190,9 @@ export function isFunction(value: any) {
*/
// note using 'keys' here would shadow the 'keys' function defined above
export function checkObjectHasKeys(obj: object, keys: string[]) {
for (let i = 0; i < keys.length; i++) {
if (!obj.hasOwnProperty(keys[i])) {
throw new Error("Missing required key: " + keys[i]);
for (const key of keys) {
if (!obj.hasOwnProperty(key)) {
throw new Error("Missing required key: " + key);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
/*
Copyright 2022 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.
*/
let audioContext: AudioContext | null = null;
let refCount = 0;
/**
* Acquires a reference to the shared AudioContext.
* It's highly recommended to reuse this AudioContext rather than creating your
* own, because multiple AudioContexts can be problematic in some browsers.
* Make sure to call releaseContext when you're done using it.
* @returns {AudioContext} The shared AudioContext
*/
export const acquireContext = (): AudioContext => {
if (audioContext === null) audioContext = new AudioContext();
refCount++;
return audioContext;
};
/**
* Signals that one of the references to the shared AudioContext has been
* released, allowing the context and associated hardware resources to be
* cleaned up if nothing else is using it.
*/
export const releaseContext = () => {
refCount--;
if (refCount === 0) {
audioContext?.close();
audioContext = null;
}
};
+728 -247
View File
File diff suppressed because it is too large Load Diff
+180 -83
View File
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent, MatrixEventEvent } from '../models/event';
import { MatrixEvent } from '../models/event';
import { logger } from '../logger';
import { CallDirection, CallErrorCode, CallState, createNewMatrixCall, MatrixCall } from './call';
import { CallDirection, CallError, CallErrorCode, CallState, createNewMatrixCall, MatrixCall } from './call';
import { EventType } from '../@types/event';
import { ClientEvent, MatrixClient } from '../client';
import { MCallAnswer, MCallHangupReject } from "./callEventTypes";
import { SyncState } from "../sync";
import { GroupCall, GroupCallErrorCode, GroupCallEvent, GroupCallUnknownDeviceError } from './groupCall';
import { RoomEvent } from "../models/room";
// Don't ring unless we'd be ringing for at least 3 seconds: the user needs some
@@ -36,10 +36,15 @@ export type CallEventHandlerEventHandlerMap = {
};
export class CallEventHandler {
client: MatrixClient;
calls: Map<string, MatrixCall>;
callEventBuffer: MatrixEvent[];
candidateEventsByCall: Map<string, Array<MatrixEvent>>;
// XXX: Most of these are only public because of the tests
public calls: Map<string, MatrixCall>;
public callEventBuffer: MatrixEvent[];
public nextSeqByCall: Map<string, number> = new Map();
public toDeviceEventBuffers: Map<string, Array<MatrixEvent>> = new Map();
private client: MatrixClient;
private candidateEventsByCall: Map<string, Array<MatrixEvent>>;
private eventBufferPromiseChain?: Promise<void>;
constructor(client: MatrixClient) {
this.client = client;
@@ -57,90 +62,165 @@ export class CallEventHandler {
}
public start() {
this.client.on(ClientEvent.Sync, this.evaluateEventBuffer);
this.client.on(ClientEvent.Sync, this.onSync);
this.client.on(RoomEvent.Timeline, this.onRoomTimeline);
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
public stop() {
this.client.removeListener(ClientEvent.Sync, this.evaluateEventBuffer);
this.client.removeListener(ClientEvent.Sync, this.onSync);
this.client.removeListener(RoomEvent.Timeline, this.onRoomTimeline);
this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
private evaluateEventBuffer = async () => {
if (this.client.getSyncState() === SyncState.Syncing) {
await Promise.all(this.callEventBuffer.map(event => {
this.client.decryptEventIfNeeded(event);
}));
private onSync = (): void => {
// Process the current event buffer and start queuing into a new one.
const currentEventBuffer = this.callEventBuffer;
this.callEventBuffer = [];
const ignoreCallIds = new Set<string>();
// inspect the buffer and mark all calls which have been answered
// or hung up before passing them to the call event handler.
for (const ev of this.callEventBuffer) {
if (ev.getType() === EventType.CallAnswer || ev.getType() === EventType.CallHangup) {
ignoreCallIds.add(ev.getContent().call_id);
}
}
// now loop through the buffer chronologically and inject them
for (const e of this.callEventBuffer) {
if (e.getType() === EventType.CallInvite && ignoreCallIds.has(e.getContent().call_id)) {
// This call has previously been answered or hung up: ignore it
continue;
}
try {
await this.handleCallEvent(e);
} catch (e) {
logger.error("Caught exception handling call event", e);
}
}
this.callEventBuffer = [];
// Ensure correct ordering by only processing this queue after the previous one has finished processing
if (this.eventBufferPromiseChain) {
this.eventBufferPromiseChain =
this.eventBufferPromiseChain.then(() => this.evaluateEventBuffer(currentEventBuffer));
} else {
this.eventBufferPromiseChain = this.evaluateEventBuffer(currentEventBuffer);
}
};
private async evaluateEventBuffer(eventBuffer: MatrixEvent[]) {
await Promise.all(eventBuffer.map((event) => this.client.decryptEventIfNeeded(event)));
const callEvents = eventBuffer.filter((event) => {
const eventType = event.getType();
return eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call.");
});
const ignoreCallIds = new Set<string>();
// inspect the buffer and mark all calls which have been answered
// or hung up before passing them to the call event handler.
for (const event of callEvents) {
const eventType = event.getType();
if (eventType=== EventType.CallAnswer || eventType === EventType.CallHangup) {
ignoreCallIds.add(event.getContent().call_id);
}
}
// Process call events in the order that they were received
for (const event of callEvents) {
const eventType = event.getType();
const callId = event.getContent().call_id;
if (eventType === EventType.CallInvite && ignoreCallIds.has(callId)) {
// This call has previously been answered or hung up: ignore it
continue;
}
try {
await this.handleCallEvent(event);
} catch (e) {
logger.error("Caught exception handling call event", e);
}
}
}
private onRoomTimeline = (event: MatrixEvent) => {
this.client.decryptEventIfNeeded(event);
// any call events or ones that might be once they're decrypted
if (this.eventIsACall(event) || event.isBeingDecrypted()) {
// queue up for processing once all events from this sync have been
// processed (see above).
this.callEventBuffer.push(event);
};
private onToDeviceEvent = (event: MatrixEvent): void => {
const content = event.getContent();
if (!content.call_id) {
this.callEventBuffer.push(event);
return;
}
if (event.isBeingDecrypted() || event.isDecryptionFailure()) {
// add an event listener for once the event is decrypted.
event.once(MatrixEventEvent.Decrypted, async () => {
if (!this.eventIsACall(event)) return;
if (!this.nextSeqByCall.has(content.call_id)) {
this.nextSeqByCall.set(content.call_id, 0);
}
if (this.callEventBuffer.includes(event)) {
// we were waiting for that event to decrypt, so recheck the buffer
this.evaluateEventBuffer();
} else {
// This one wasn't buffered so just run the event handler for it
// straight away
try {
await this.handleCallEvent(event);
} catch (e) {
logger.error("Caught exception handling call event", e);
}
}
});
if (content.seq === undefined) {
this.callEventBuffer.push(event);
return;
}
const nextSeq = this.nextSeqByCall.get(content.call_id) || 0;
if (content.seq !== nextSeq) {
if (!this.toDeviceEventBuffers.has(content.call_id)) {
this.toDeviceEventBuffers.set(content.call_id, []);
}
const buffer = this.toDeviceEventBuffers.get(content.call_id)!;
const index = buffer.findIndex((e) => e.getContent().seq > content.seq);
if (index === -1) {
buffer.push(event);
} else {
buffer.splice(index, 0, event);
}
} else {
const callId = content.call_id;
this.callEventBuffer.push(event);
this.nextSeqByCall.set(callId, content.seq + 1);
const buffer = this.toDeviceEventBuffers.get(callId);
let nextEvent = buffer && buffer.shift();
while (nextEvent && nextEvent.getContent().seq === this.nextSeqByCall.get(callId)) {
this.callEventBuffer.push(nextEvent);
this.nextSeqByCall.set(callId, nextEvent.getContent().seq + 1);
nextEvent = buffer!.shift();
}
}
};
private eventIsACall(event: MatrixEvent): boolean {
const type = event.getType();
/**
* Unstable prefixes:
* - org.matrix.call. : MSC3086 https://github.com/matrix-org/matrix-doc/pull/3086
*/
return type.startsWith("m.call.") || type.startsWith("org.matrix.call.");
}
private async handleCallEvent(event: MatrixEvent) {
this.client.emit(ClientEvent.ReceivedVoipEvent, event);
const content = event.getContent();
const callRoomId = (
event.getRoomId() ||
this.client.groupCallEventHandler!.getGroupCallById(content.conf_id)?.room?.roomId
);
const groupCallId = content.conf_id;
const type = event.getType() as EventType;
const weSentTheEvent = event.getSender() === this.client.credentials.userId;
const senderId = event.getSender()!;
const weSentTheEvent = senderId === this.client.credentials.userId;
let call = content.call_id ? this.calls.get(content.call_id) : undefined;
//console.info("RECV %s content=%s", type, JSON.stringify(content));
let opponentDeviceId: string | undefined;
let groupCall: GroupCall | undefined;
if (groupCallId) {
groupCall = this.client.groupCallEventHandler!.getGroupCallById(groupCallId);
if (!groupCall) {
logger.warn(`Cannot find a group call ${groupCallId} for event ${type}. Ignoring event.`);
return;
}
opponentDeviceId = content.device_id;
if (!opponentDeviceId) {
logger.warn(`Cannot find a device id for ${senderId}. Ignoring event.`);
groupCall.emit(
GroupCallEvent.Error,
new GroupCallUnknownDeviceError(senderId),
);
return;
}
if (content.dest_session_id !== this.client.getSessionId()) {
logger.warn("Call event does not match current session id, ignoring.");
return;
}
}
if (!callRoomId) return;
if (type === EventType.CallInvite) {
// ignore invites you send
@@ -157,12 +237,20 @@ export class CallEventHandler {
);
}
const timeUntilTurnCresExpire = this.client.getTurnServersExpiry() - Date.now();
if (content.invitee && content.invitee !== this.client.getUserId()) {
return; // This invite was meant for another user in the room
}
const timeUntilTurnCresExpire = (this.client.getTurnServersExpiry() ?? 0) - Date.now();
logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
call = createNewMatrixCall(
this.client,
event.getRoomId()!,
{ forceTURN: this.client.forceTURN },
callRoomId,
{
forceTURN: this.client.forceTURN, opponentDeviceId,
groupCallId,
opponentSessionId: content.sender_session_id,
},
) ?? undefined;
if (!call) {
logger.log(
@@ -176,7 +264,17 @@ export class CallEventHandler {
}
call.callId = content.call_id;
await call.initWithInvite(event);
try {
await call.initWithInvite(event);
} catch (e) {
if (e instanceof CallError) {
if (e.code === GroupCallErrorCode.UnknownDevice) {
groupCall?.emit(GroupCallEvent.Error, e);
} else {
logger.error(e);
}
}
}
this.calls.set(call.callId, call);
// if we stashed candidate events for that call ID, play them back now
@@ -196,6 +294,7 @@ export class CallEventHandler {
if (
call.roomId === thisCall.roomId &&
thisCall.direction === CallDirection.Outbound &&
call.getOpponentMember()?.userId === thisCall.invitee &&
isCalling
) {
existingCall = thisCall;
@@ -204,21 +303,12 @@ export class CallEventHandler {
}
if (existingCall) {
// If we've only got to wait_local_media or create_offer and
// we've got an invite, pick the incoming call because we know
// we haven't sent our invite yet otherwise, pick whichever
// call has the lowest call ID (by string comparison)
if (
existingCall.state === CallState.WaitLocalMedia ||
existingCall.state === CallState.CreateOffer ||
existingCall.callId > call.callId
) {
if (existingCall.callId > call.callId) {
logger.log(
"Glare detected: answering incoming call " + call.callId +
" and canceling outgoing call " + existingCall.callId,
);
existingCall.replacedBy(call);
call.answer();
} else {
logger.log(
"Glare detected: rejecting incoming call " + call.callId +
@@ -250,7 +340,14 @@ export class CallEventHandler {
// if not live, store the fact that the call has ended because
// we're probably getting events backwards so
// the hangup will come before the invite
call = createNewMatrixCall(this.client, event.getRoomId()!) ?? undefined;
call = createNewMatrixCall(
this.client,
callRoomId,
{
opponentDeviceId,
opponentSessionId: content.sender_session_id,
},
) ?? undefined;
if (call) {
call.callId = content.call_id;
call.initWithHangup(event);
+5
View File
@@ -36,6 +36,8 @@ export interface MCallBase {
call_id: string;
version: string | number;
party_id?: string;
sender_session_id?: string;
dest_session_id?: string;
}
export interface MCallAnswer extends MCallBase {
@@ -53,6 +55,9 @@ export interface MCallInviteNegotiate extends MCallBase {
description: RTCSessionDescription;
lifetime: number;
capabilities?: CallCapabilities;
invitee?: string;
sender_session_id?: string;
dest_session_id?: string;
[SDPStreamMetadataKey]: SDPStreamMetadata;
}
+77 -13
View File
@@ -15,8 +15,10 @@ limitations under the License.
*/
import { SDPStreamMetadataPurpose } from "./callEventTypes";
import { acquireContext, releaseContext } from "./audioContext";
import { MatrixClient } from "../client";
import { RoomMember } from "../models/room-member";
import { logger } from "../logger";
import { TypedEventEmitter } from "../models/typed-event-emitter";
const POLLING_INTERVAL = 200; // ms
@@ -25,7 +27,7 @@ const SPEAKING_SAMPLE_COUNT = 8; // samples
export interface ICallFeedOpts {
client: MatrixClient;
roomId: string;
roomId?: string;
userId: string;
stream: MediaStream;
purpose: SDPStreamMetadataPurpose;
@@ -42,27 +44,33 @@ export interface ICallFeedOpts {
export enum CallFeedEvent {
NewStream = "new_stream",
MuteStateChanged = "mute_state_changed",
LocalVolumeChanged = "local_volume_changed",
VolumeChanged = "volume_changed",
Speaking = "speaking",
Disposed = "disposed",
}
type EventHandlerMap = {
[CallFeedEvent.NewStream]: (stream: MediaStream) => void;
[CallFeedEvent.MuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void;
[CallFeedEvent.LocalVolumeChanged]: (localVolume: number) => void;
[CallFeedEvent.VolumeChanged]: (volume: number) => void;
[CallFeedEvent.Speaking]: (speaking: boolean) => void;
[CallFeedEvent.Disposed]: () => void;
};
export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap> {
public stream: MediaStream;
public sdpMetadataStreamId: string;
public userId: string;
public purpose: SDPStreamMetadataPurpose;
public speakingVolumeSamples: number[];
private client: MatrixClient;
private roomId: string;
private roomId?: string;
private audioMuted: boolean;
private videoMuted: boolean;
private localVolume = 1;
private measuringVolumeActivity = false;
private audioContext?: AudioContext;
private analyser?: AnalyserNode;
@@ -70,6 +78,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
private speakingThreshold = SPEAKING_THRESHOLD;
private speaking = false;
private volumeLooperTimeout?: ReturnType<typeof setTimeout>;
private _disposed = false;
constructor(opts: ICallFeedOpts) {
super();
@@ -81,6 +90,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
this.audioMuted = opts.audioMuted;
this.videoMuted = opts.videoMuted;
this.speakingVolumeSamples = new Array(SPEAKING_SAMPLE_COUNT).fill(-Infinity);
this.sdpMetadataStreamId = opts.stream.id;
this.updateStream(null, opts.stream);
this.stream = opts.stream; // updateStream does this, but this makes TS happier
@@ -115,10 +125,8 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
}
private initVolumeMeasuring(): void {
const AudioContext = window.AudioContext || window.webkitAudioContext;
if (!this.hasAudioTrack || !AudioContext) return;
this.audioContext = new AudioContext();
if (!this.hasAudioTrack) return;
if (!this.audioContext) this.audioContext = acquireContext();
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 512;
@@ -174,6 +182,17 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
return this.speaking;
}
/**
* Replaces the current MediaStream with a new one.
* The stream will be different and new stream as remore parties are
* concerned, but this can be used for convenience locally to set up
* volume listeners automatically on the new stream etc.
* @param newStream new stream with which to replace the current one
*/
public setNewStream(newStream: MediaStream): void {
this.updateStream(this.stream, newStream);
}
/**
* Set one or both of feed's internal audio and video video mute state
* Either value may be null to leave it as-is
@@ -197,7 +216,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
*/
public measureVolumeActivity(enabled: boolean): void {
if (enabled) {
if (!this.audioContext || !this.analyser || !this.frequencyBinCount || !this.hasAudioTrack) return;
if (!this.analyser || !this.frequencyBinCount || !this.hasAudioTrack) return;
this.measuringVolumeActivity = true;
this.volumeLooper();
@@ -220,9 +239,9 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
this.analyser.getFloatFrequencyData(this.frequencyBinCount!);
let maxVolume = -Infinity;
for (let i = 0; i < this.frequencyBinCount!.length; i++) {
if (this.frequencyBinCount![i] > maxVolume) {
maxVolume = this.frequencyBinCount![i];
for (const volume of this.frequencyBinCount!) {
if (volume > maxVolume) {
maxVolume = volume;
}
}
@@ -233,9 +252,7 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
let newSpeaking = false;
for (let i = 0; i < this.speakingVolumeSamples.length; i++) {
const volume = this.speakingVolumeSamples[i];
for (const volume of this.speakingVolumeSamples) {
if (volume > this.speakingThreshold) {
newSpeaking = true;
break;
@@ -250,7 +267,54 @@ export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap>
this.volumeLooperTimeout = setTimeout(this.volumeLooper, POLLING_INTERVAL);
};
public clone(): CallFeed {
const mediaHandler = this.client.getMediaHandler();
const stream = this.stream.clone();
logger.log(`callFeed cloning stream ${this.stream.id} newStream ${stream.id}`);
if (this.purpose === SDPStreamMetadataPurpose.Usermedia) {
mediaHandler.userMediaStreams.push(stream);
} else {
mediaHandler.screensharingStreams.push(stream);
}
return new CallFeed({
client: this.client,
roomId: this.roomId,
userId: this.userId,
stream,
purpose: this.purpose,
audioMuted: this.audioMuted,
videoMuted: this.videoMuted,
});
}
public dispose(): void {
clearTimeout(this.volumeLooperTimeout);
this.stream?.removeEventListener("addtrack", this.onAddTrack);
if (this.audioContext) {
this.audioContext = undefined;
this.analyser = undefined;
releaseContext();
}
this._disposed = true;
this.emit(CallFeedEvent.Disposed);
}
public get disposed(): boolean {
return this._disposed;
}
private set disposed(value: boolean) {
this._disposed = value;
}
public getLocalVolume(): number {
return this.localVolume;
}
public setLocalVolume(localVolume: number): void {
this.localVolume = localVolume;
this.emit(CallFeedEvent.LocalVolumeChanged, localVolume);
}
}
File diff suppressed because it is too large Load Diff
+233
View File
@@ -0,0 +1,233 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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';
import { MatrixClient, ClientEvent } from '../client';
import {
GroupCall,
GroupCallIntent,
GroupCallType,
IGroupCallDataChannelOptions,
} from "./groupCall";
import { Room } from "../models/room";
import { RoomState, RoomStateEvent } from "../models/room-state";
import { RoomMember } from "../models/room-member";
import { logger } from '../logger';
import { EventType } from "../@types/event";
import { SyncState } from '../sync';
export enum GroupCallEventHandlerEvent {
Incoming = "GroupCall.incoming",
Ended = "GroupCall.ended",
Participants = "GroupCall.participants",
}
export type GroupCallEventHandlerEventHandlerMap = {
[GroupCallEventHandlerEvent.Incoming]: (call: GroupCall) => void;
[GroupCallEventHandlerEvent.Ended]: (call: GroupCall) => void;
[GroupCallEventHandlerEvent.Participants]: (participants: RoomMember[], call: GroupCall) => void;
};
interface RoomDeferred {
prom: Promise<void>;
resolve?: () => void;
}
export class GroupCallEventHandler {
public groupCalls = new Map<string, GroupCall>(); // roomId -> GroupCall
// All rooms we know about and whether we've seen a 'Room' event
// for them. The promise will be fulfilled once we've processed that
// event which means we're "up to date" on what calls are in a room
// and get
private roomDeferreds = new Map<string, RoomDeferred>();
constructor(private client: MatrixClient) { }
public async start(): Promise<void> {
// We wait until the client has started syncing for real.
// This is because we only support one call at a time, and want
// the latest. We therefore want the latest state of the room before
// we create a group call for the room so we can be fairly sure that
// the group call we create is really the latest one.
if (this.client.getSyncState() !== SyncState.Syncing) {
logger.debug("Waiting for client to start syncing...");
await new Promise<void>(resolve => {
const onSync = () => {
if (this.client.getSyncState() === SyncState.Syncing) {
this.client.off(ClientEvent.Sync, onSync);
return resolve();
}
};
this.client.on(ClientEvent.Sync, onSync);
});
}
const rooms = this.client.getRooms();
for (const room of rooms) {
this.createGroupCallForRoom(room);
}
this.client.on(ClientEvent.Room, this.onRoomsChanged);
this.client.on(RoomStateEvent.Events, this.onRoomStateChanged);
}
public stop(): void {
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateChanged);
}
private getRoomDeferred(roomId: string): RoomDeferred {
let deferred = this.roomDeferreds.get(roomId);
if (deferred === undefined) {
let resolveFunc: () => void;
deferred = {
prom: new Promise<void>(resolve => {
resolveFunc = resolve;
}),
};
deferred.resolve = resolveFunc!;
this.roomDeferreds.set(roomId, deferred);
}
return deferred;
}
public waitUntilRoomReadyForGroupCalls(roomId: string): Promise<void> {
return this.getRoomDeferred(roomId).prom;
}
public getGroupCallById(groupCallId: string): GroupCall | undefined {
return [...this.groupCalls.values()].find((groupCall) => groupCall.groupCallId === groupCallId);
}
private createGroupCallForRoom(room: Room): void {
const callEvents = room.currentState.getStateEvents(EventType.GroupCallPrefix);
const sortedCallEvents = callEvents.sort((a, b) => b.getTs() - a.getTs());
for (const callEvent of sortedCallEvents) {
const content = callEvent.getContent();
if (content["m.terminated"]) {
continue;
}
logger.debug(
`Choosing group call ${callEvent.getStateKey()} with TS ` +
`${callEvent.getTs()} for room ${room.roomId} from ${callEvents.length} possible calls.`,
);
this.createGroupCallFromRoomStateEvent(callEvent);
break;
}
logger.info("Group call event handler processed room", room.roomId);
this.getRoomDeferred(room.roomId).resolve!();
}
private createGroupCallFromRoomStateEvent(event: MatrixEvent): GroupCall | undefined {
const roomId = event.getRoomId();
const content = event.getContent();
const room = this.client.getRoom(roomId);
if (!room) {
logger.warn(`Couldn't find room ${roomId} for GroupCall`);
return;
}
const groupCallId = event.getStateKey();
const callType = content["m.type"];
if (!Object.values(GroupCallType).includes(callType)) {
logger.warn(`Received invalid group call type ${callType} for room ${roomId}.`);
return;
}
const callIntent = content["m.intent"];
if (!Object.values(GroupCallIntent).includes(callIntent)) {
logger.warn(`Received invalid group call intent ${callType} for room ${roomId}.`);
return;
}
const isPtt = Boolean(content["io.element.ptt"]);
let dataChannelOptions: IGroupCallDataChannelOptions | undefined;
if (content?.dataChannelsEnabled && content?.dataChannelOptions) {
// Pull out just the dataChannelOptions we want to support.
const { ordered, maxPacketLifeTime, maxRetransmits, protocol } = content.dataChannelOptions;
dataChannelOptions = { ordered, maxPacketLifeTime, maxRetransmits, protocol };
}
const groupCall = new GroupCall(
this.client,
room,
callType,
isPtt,
callIntent,
groupCallId,
content?.dataChannelsEnabled,
dataChannelOptions,
);
this.groupCalls.set(room.roomId, groupCall);
this.client.emit(GroupCallEventHandlerEvent.Incoming, groupCall);
return groupCall;
}
private onRoomsChanged = (room: Room) => {
this.createGroupCallForRoom(room);
};
private onRoomStateChanged = (event: MatrixEvent, state: RoomState): void => {
const eventType = event.getType();
if (eventType === EventType.GroupCallPrefix) {
const groupCallId = event.getStateKey();
const content = event.getContent();
const currentGroupCall = this.groupCalls.get(state.roomId);
if (!currentGroupCall && !content["m.terminated"]) {
this.createGroupCallFromRoomStateEvent(event);
} else if (currentGroupCall && currentGroupCall.groupCallId === groupCallId) {
if (content["m.terminated"]) {
currentGroupCall.terminate(false);
} else if (content["m.type"] !== currentGroupCall.type) {
// TODO: Handle the callType changing when the room state changes
logger.warn(`The group call type changed for room: ${
state.roomId}. Changing the group call type is currently unsupported.`);
}
} else if (currentGroupCall && currentGroupCall.groupCallId !== groupCallId) {
// TODO: Handle new group calls and multiple group calls
logger.warn(`Multiple group calls detected for room: ${
state.roomId}. Multiple group calls are currently unsupported.`);
}
} else if (eventType === EventType.GroupCallMemberPrefix) {
const groupCall = this.groupCalls.get(state.roomId);
if (!groupCall) {
return;
}
groupCall.onMemberStateChanged(event);
}
};
}
+158 -30
View File
@@ -17,18 +17,53 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { GroupCallType, GroupCallState } from "../webrtc/groupCall";
import { logger } from "../logger";
import { MatrixClient } from "../client";
import { CallState } from "./call";
export class MediaHandler {
export enum MediaHandlerEvent {
LocalStreamsChanged = "local_streams_changed"
}
export type MediaHandlerEventHandlerMap = {
[MediaHandlerEvent.LocalStreamsChanged]: () => void;
};
export interface IScreensharingOpts {
desktopCapturerSourceId?: string;
audio?: boolean;
// For electron screen capture, there are very few options for detecting electron
// apart from inspecting the user agent or just trying getDisplayMedia() and
// catching the failure, so we do the latter - this flag tells the function to just
// throw an error so we can catch it in this case, rather than logging and emitting.
throwOnFail?: boolean;
}
export interface AudioSettings {
autoGainControl: boolean;
echoCancellation: boolean;
noiseSuppression: boolean;
}
export class MediaHandler extends TypedEventEmitter<
MediaHandlerEvent.LocalStreamsChanged, MediaHandlerEventHandlerMap
> {
private audioInput?: string;
private audioSettings?: AudioSettings;
private videoInput?: string;
private localUserMediaStream?: MediaStream;
public userMediaStreams: MediaStream[] = [];
public screensharingStreams: MediaStream[] = [];
constructor(private client: MatrixClient) { }
constructor(private client: MatrixClient) {
super();
}
public restoreMediaSettings(audioInput: string, videoInput: string) {
this.audioInput = audioInput;
this.videoInput = videoInput;
}
/**
* Set an audio input device to use for MatrixCalls
@@ -36,7 +71,7 @@ export class MediaHandler {
* undefined treated as unset
*/
public async setAudioInput(deviceId: string): Promise<void> {
logger.info("LOG setting audio input to", deviceId);
logger.info("Setting audio input to", deviceId);
if (this.audioInput === deviceId) return;
@@ -44,13 +79,24 @@ export class MediaHandler {
await this.updateLocalUsermediaStreams();
}
/**
* Set audio settings for MatrixCalls
* @param {AudioSettings} opts audio options to set
*/
public async setAudioSettings(opts: AudioSettings): Promise<void> {
logger.info("Setting audio settings to", opts);
this.audioSettings = Object.assign({}, opts) as AudioSettings;
await this.updateLocalUsermediaStreams();
}
/**
* Set a video input device to use for MatrixCalls
* @param {string} deviceId the identifier for the device
* undefined treated as unset
*/
public async setVideoInput(deviceId: string): Promise<void> {
logger.info("LOG setting video input to", deviceId);
logger.info("Setting video input to", deviceId);
if (this.videoInput === deviceId) return;
@@ -59,6 +105,19 @@ export class MediaHandler {
}
/**
* Set media input devices to use for MatrixCalls
* @param {string} audioInput the identifier for the audio device
* @param {string} videoInput the identifier for the video device
* undefined treated as unset
*/
public async setMediaInputs(audioInput: string, videoInput: string): Promise<void> {
logger.log(`mediaHandler setMediaInputs audioInput: ${audioInput} videoInput: ${videoInput}`);
this.audioInput = audioInput;
this.videoInput = videoInput;
await this.updateLocalUsermediaStreams();
}
/*
* Requests new usermedia streams and replace the old ones
*/
public async updateLocalUsermediaStreams(): Promise<void> {
@@ -72,16 +131,53 @@ export class MediaHandler {
});
}
for (const stream of this.userMediaStreams) {
logger.log(`mediaHandler stopping all tracks for stream ${stream.id}`);
for (const track of stream.getTracks()) {
track.stop();
}
}
this.userMediaStreams = [];
this.localUserMediaStream = undefined;
for (const call of this.client.callEventHandler!.calls.values()) {
if (call.state === CallState.Ended || !callMediaStreamParams.has(call.callId)) continue;
if (call.callHasEnded() || !callMediaStreamParams.has(call.callId)) {
continue;
}
const { audio, video } = callMediaStreamParams.get(call.callId)!;
// This stream won't be reusable as we will replace the tracks of the old stream
const stream = await this.getUserMediaStream(audio, video, false);
logger.log(`mediaHandler updateLocalUsermediaStreams getUserMediaStream call ${call.callId}`);
const stream = await this.getUserMediaStream(audio, video);
if (call.callHasEnded()) {
continue;
}
await call.updateLocalUsermediaStream(stream);
}
for (const groupCall of this.client.groupCallEventHandler!.groupCalls.values()) {
if (!groupCall.localCallFeed) {
continue;
}
logger.log(`mediaHandler updateLocalUsermediaStreams getUserMediaStream groupCall ${
groupCall.groupCallId}`);
const stream = await this.getUserMediaStream(
true,
groupCall.type === GroupCallType.Video,
);
if (groupCall.state === GroupCallState.Ended) {
continue;
}
await groupCall.updateLocalUsermediaStream(stream);
}
this.emit(MediaHandlerEvent.LocalStreamsChanged);
}
public async hasAudioDevice(): Promise<boolean> {
@@ -106,16 +202,35 @@ export class MediaHandler {
let stream: MediaStream;
if (
!this.localUserMediaStream ||
(this.localUserMediaStream.getAudioTracks().length === 0 && shouldRequestAudio) ||
(this.localUserMediaStream.getVideoTracks().length === 0 && shouldRequestVideo) ||
(this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput) ||
(this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput)
) {
let canReuseStream = true;
if (this.localUserMediaStream) {
// This code checks that the device ID is the same as the localUserMediaStream stream, but we update
// the localUserMediaStream whenever the device ID changes (apart from when restoring) so it's not
// clear why this would ever be different, unless there's a race.
if (shouldRequestAudio) {
if (
this.localUserMediaStream.getAudioTracks().length === 0 ||
this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput
) {
canReuseStream = false;
}
}
if (shouldRequestVideo) {
if (
this.localUserMediaStream.getVideoTracks().length === 0 ||
this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput) {
canReuseStream = false;
}
}
} else {
canReuseStream = false;
}
if (!canReuseStream) {
const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo);
logger.log("Getting user media with constraints", constraints);
stream = await navigator.mediaDevices.getUserMedia(constraints);
logger.log(`mediaHandler getUserMediaStream streamId ${stream.id} shouldRequestAudio ${
shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`, constraints);
for (const track of stream.getTracks()) {
const settings = track.getSettings();
@@ -131,7 +246,9 @@ export class MediaHandler {
this.localUserMediaStream = stream;
}
} else {
stream = this.localUserMediaStream.clone();
stream = this.localUserMediaStream!.clone();
logger.log(`mediaHandler clone userMediaStream ${this.localUserMediaStream?.id} new stream ${
stream.id} shouldRequestAudio ${shouldRequestAudio} shouldRequestVideo ${shouldRequestVideo}`);
if (!shouldRequestAudio) {
for (const track of stream.getAudioTracks()) {
@@ -150,6 +267,8 @@ export class MediaHandler {
this.userMediaStreams.push(stream);
}
this.emit(MediaHandlerEvent.LocalStreamsChanged);
return stream;
}
@@ -157,7 +276,7 @@ export class MediaHandler {
* Stops all tracks on the provided usermedia stream
*/
public stopUserMediaStream(mediaStream: MediaStream) {
logger.debug("Stopping usermedia stream", mediaStream.id);
logger.log(`mediaHandler stopUserMediaStream stopping stream ${mediaStream.id}`);
for (const track of mediaStream.getTracks()) {
track.stop();
}
@@ -169,6 +288,8 @@ export class MediaHandler {
this.userMediaStreams.splice(index, 1);
}
this.emit(MediaHandlerEvent.LocalStreamsChanged);
if (this.localUserMediaStream === mediaStream) {
this.localUserMediaStream = undefined;
}
@@ -179,23 +300,19 @@ export class MediaHandler {
* @param reusable is allowed to be reused by the MediaHandler
* @returns {MediaStream} based on passed parameters
*/
public async getScreensharingStream(
desktopCapturerSourceId?: string,
reusable = true,
): Promise<MediaStream | null> {
public async getScreensharingStream(opts: IScreensharingOpts = {}, reusable = true): Promise<MediaStream> {
let stream: MediaStream;
if (this.screensharingStreams.length === 0) {
const screenshareConstraints = this.getScreenshareContraints(desktopCapturerSourceId);
if (!screenshareConstraints) return null;
const screenshareConstraints = this.getScreenshareContraints(opts);
if (desktopCapturerSourceId) {
if (opts.desktopCapturerSourceId) {
// We are using Electron
logger.debug("Getting screensharing stream using getUserMedia()", desktopCapturerSourceId);
logger.debug("Getting screensharing stream using getUserMedia()", opts);
stream = await navigator.mediaDevices.getUserMedia(screenshareConstraints);
} else {
// We are not using Electron
logger.debug("Getting screensharing stream using getDisplayMedia()");
logger.debug("Getting screensharing stream using getDisplayMedia()", opts);
stream = await navigator.mediaDevices.getDisplayMedia(screenshareConstraints);
}
} else {
@@ -208,6 +325,8 @@ export class MediaHandler {
this.screensharingStreams.push(stream);
}
this.emit(MediaHandlerEvent.LocalStreamsChanged);
return stream;
}
@@ -226,6 +345,8 @@ export class MediaHandler {
logger.debug("Splicing screensharing stream out stream array", mediaStream.id);
this.screensharingStreams.splice(index, 1);
}
this.emit(MediaHandlerEvent.LocalStreamsChanged);
}
/**
@@ -233,6 +354,7 @@ export class MediaHandler {
*/
public stopAllStreams() {
for (const stream of this.userMediaStreams) {
logger.log(`mediaHandler stopAllStreams stopping stream ${stream.id}`);
for (const track of stream.getTracks()) {
track.stop();
}
@@ -247,6 +369,8 @@ export class MediaHandler {
this.userMediaStreams = [];
this.screensharingStreams = [];
this.localUserMediaStream = undefined;
this.emit(MediaHandlerEvent.LocalStreamsChanged);
}
private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints {
@@ -256,6 +380,9 @@ export class MediaHandler {
audio: audio
? {
deviceId: this.audioInput ? { ideal: this.audioInput } : undefined,
autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined,
echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined,
noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined,
}
: false,
video: video
@@ -273,11 +400,12 @@ export class MediaHandler {
};
}
private getScreenshareContraints(desktopCapturerSourceId?: string): DesktopCapturerConstraints {
private getScreenshareContraints(opts: IScreensharingOpts): DesktopCapturerConstraints {
const { desktopCapturerSourceId, audio } = opts;
if (desktopCapturerSourceId) {
logger.debug("Using desktop capturer source", desktopCapturerSourceId);
return {
audio: false,
audio: audio ?? false,
video: {
mandatory: {
chromeMediaSource: "desktop",
@@ -288,7 +416,7 @@ export class MediaHandler {
} else {
logger.debug("Not using desktop capturer source");
return {
audio: false,
audio: audio ?? false,
video: true,
};
}
+8 -2
View File
@@ -8,10 +8,16 @@
"noImplicitAny": false,
"noUnusedLocals": true,
"noEmit": true,
"declaration": true
"declaration": true,
"strict": true
},
"include": [
"./src/**/*.ts",
"./spec/**/*.ts"
]
],
"typedocOptions": {
"entryPoints": ["src/index.ts"],
"excludeExternals": true,
"out": "_docs"
}
}
+1006 -620
View File
File diff suppressed because it is too large Load Diff