Compare commits

...

360 Commits

Author SHA1 Message Date
Timo K
f518111887 add back docstrings 2025-11-17 14:55:00 +01:00
Timo K
4414fe3c82 remove all /*PUBLIC*/ 2025-11-17 14:39:24 +01:00
Timo K
e9f400e9f1 remove ReturnType 2025-11-17 14:30:16 +01:00
Timo K
7ab90d3c1f Delete unused files 2025-11-17 12:00:28 +01:00
Timo K
348f0dbfa8 CallViewModel structure change. Clear input and outputs. 2025-11-17 11:37:58 +01:00
Robin
cf8298cd69 Fix lints 2025-11-14 18:57:39 -05:00
Robin
bb746dc8dc Fix a resource leak in the track processor code 2025-11-14 18:45:02 -05:00
Robin
53aaa011b7 Remove stray comma
This comma was showing up in the bottom left corner of the UI.
2025-11-14 16:41:18 -05:00
Robin
494ed7c126 Fix the MatrixRTC mode developer controls
They weren't responding properly to clicks, and make more sense as radio buttons.
2025-11-14 14:15:58 -05:00
Timo K
b9ad83bf3b fix a couple of more tests. 2025-11-14 18:45:07 +01:00
Valere
6d8d2d2384 refact: extract withCallViewModel in test utils file 2025-11-14 17:30:02 +01:00
Timo K
b731c2e9fd dont import withCallViewModel in Lifecycle test 2025-11-14 16:53:43 +01:00
Robin
da85e74596 Restore LocalMembership tests 2025-11-14 10:48:24 -05:00
Valere Fedronic
8ce8067956 Adding more logs (#3563)
* Adding more logs

* post merge fix

* fixup merge error

* review

---------

Co-authored-by: Timo K <toger5@hotmail.de>
2025-11-14 16:18:31 +01:00
Timo K
f6d0d7323e fix call lifecycle and add tests. 2025-11-14 16:01:43 +01:00
Timo K
fdce3ec1aa Fix a couple of CallViewModel tests. 2025-11-14 10:44:16 +01:00
Timo K
0115242a2b tests first batch 2025-11-13 11:35:37 +01:00
Robin
c7f50b53f5 Fix decryption errors
The code had regressed to a state where it was attempting to use one encryption worker for all LiveKit rooms, which does not currently work.
2025-11-12 15:41:41 -05:00
Robin
a62d8368a1 Fix and simplify screen sharing 2025-11-12 15:02:19 -05:00
Robin
27b76b4b1d Remove unused Async type 2025-11-12 14:28:26 -05:00
Robin
8d421899a6 Fix formatting of doc comment 2025-11-12 10:16:15 -05:00
Timo K
9f4d954cfa The source of the local participant is the createLocalMembership$ and
not the MatrixLivekitMembers!

Co-authored-by: Valere <bill.carson@valrsoft.com>
2025-11-12 12:09:31 +01:00
Timo K
8671d3fd67 Very bit test overhaul. All displayname tests are now done in the
Metadata file. and not in the CallViewModel anymore.
2025-11-11 15:52:35 +01:00
Timo K
85f659bcc9 Introduce MatrixMemberMetadata and use it to simplify username and
avatar computation This removes member from the tiles entirely!
2025-11-11 15:51:48 +01:00
Timo K
93c4dc5beb make it run 2025-11-10 15:55:01 +01:00
Valere
93659931ca fixup: update integration tests 2025-11-10 11:20:20 +01:00
Valere
5c83e0dce1 test: fixup MatrixLivekitMembers tests 2025-11-10 10:43:53 +01:00
Robin
92ddc4c797 Fix avatar reactivity, simplify display names tracking 2025-11-09 01:16:39 -05:00
Robin
b4c17ed26d Replace generateKeyed$ with a redesigned generateItems operator
And use it to clean up a number of code smells, fix some reactivity bugs, and avoid some resource leaks.
2025-11-09 00:46:16 -05:00
Valere
1f386a1d57 test: fix displayname tests due to Epoch change 2025-11-08 13:24:03 +01:00
Valere
fc842d4be7 test: fixup ConnectionManager tests 2025-11-08 13:02:12 +01:00
Timo K
b8635b52d8 Working (no local feed) 2025-11-07 19:07:45 +01:00
Timo K
cf5c35bccd fix more runtime errors 2025-11-07 17:14:02 +01:00
Timo K
e741285b11 Fix lints, move CallViewModel.test.ts. Fix audio renderer 2025-11-07 14:04:40 +01:00
Timo K
28047217b8 Almost running
- NEVER use undefined as the default for behaviors (FOOTGUN)
2025-11-07 12:33:07 +01:00
Timo K
92fdce33ea pull out all screen share related logic. 2025-11-07 08:44:56 +01:00
Timo K
7c41aef801 Add Epoch<T> and use it in for the memberships$ behavior and its
derivatives.
2025-11-06 22:00:48 +01:00
Valere
2e6b1767b9 Fixup base integration test 2025-11-06 16:48:20 +01:00
Timo K
a55ce19048 cleanup 2025-11-06 15:26:17 +01:00
Valere
6e1a582265 fix tests compilation peer session timo - wip 2025-11-06 12:08:46 +01:00
Timo K
d8e29467f6 rename merger 2025-11-05 18:58:40 +01:00
Timo K
c19e2245c8 use createSomething$ mathods instead of classes
Rename several classes/behaviors to factory-style creators and adapt
call wiring and tests accordingly:
- Replace ConnectionManager class with createConnectionManager$ which
  returns transports$, connectionManagerData$, connections$
- Convert MatrixLivekitMerger to createMatrixLivekitMembers$
  (matrixLivekitMerger$)
- Rename sessionBehaviors$, localMembership$, localTransport$ to
  createSessionMembershipsAndTransports$, createLocalMembership$,
  createLocalTransport$
- Adjust participant types and hook up connectOptions$; expose join via
  localMembership.requestConnect
- Update tests to use the new factory APIs
2025-11-05 18:57:24 +01:00
Timo K
4d0de2fb71 Refactor Matrix/LiveKit session merging
- Replace MatrixLivekitItem with MatrixLivekitMember, add displayName$
  and participantId, and use explicit LiveKit participant types
- Make sessionBehaviors$ accept a props object and return a typed
  RxRtcSession
- Update CallViewModel to use the new session behaviors, rebuild media
  items from matrixLivekitMembers, handle missing connections and use
  participantId-based keys
- Change localMembership/localTransport to accept Behavior-based
  options, read options.value for enterRTCSession, and fix advertised
  transport selection order
- Update tests and minor UI adjustments (settings modal livekitRooms
  stubbed) and fix JSON formatting in locales
2025-11-05 17:55:36 +01:00
Timo K
107ef16d94 Add MatrixRTCMode and refactor local membership
Remove preferStickyEvents and multiSfu in favor of a MatrixRTCMode
enum/setting (Legacy, Compatibil, Matrix_2_0). Move session join/leave,
track pause/resume, and config error handling out of CallViewModel into
the localMembership module. Update developer settings UI, i18n strings,
and related RTC session helpers and wiring accordingly.
2025-11-05 12:56:58 +01:00
Timo K
57bf86fc4c finish up most of our helper classes. there are no lint issues in the
new classes. The CallViewModel is not done yet however
2025-11-04 20:24:15 +01:00
Valere
870b706672 Connection & Livekit integ test WIP 2025-11-04 17:13:28 +01:00
Valere
06734ae086 quick refactor, use object instead of tupple 2025-11-04 17:12:44 +01:00
Valere
5961cb65df test with marbles 2025-11-03 17:19:17 +01:00
Valere
a7d2a3b9db es lint fixes 2025-11-03 13:18:33 +01:00
Valere
4c5f06a8a9 Refactoring to ease testing of ConnectionManager
- Extract a ConnectionFactory
- Change Connection manager allPariticipantWithConnection$ for structure that supports members with no participant
2025-11-03 13:18:21 +01:00
Timo K
a44171da1c changes summary valere timo 2025-10-30 22:15:35 +01:00
Timo K
4f892e358a start onwMemberhsip.ts 2025-10-30 15:15:49 +01:00
Timo K
c8ef8d6a24 start moving over/removing things from the CallViewModel 2025-10-30 01:13:06 +01:00
Timo K
6b513534f1 lots of fixup in the new classes 2025-10-30 00:09:07 +01:00
Timo K
633a0f9290 connection manager 2025-10-29 18:33:58 +01:00
Timo K
62ef49ca05 temp
Co-authored-by: Valere <bill.carson@valrsoft.com>
2025-10-29 15:20:06 +01:00
Timo K
3de0bbcfc9 temp
Co-authored-by: Valere <bill.carson@valrsoft.com>
2025-10-29 12:37:14 +01:00
Timo K
cfe05f1ed9 more temp 2025-10-28 21:58:10 +01:00
Timo K
9cdbb1135f temp 2025-10-28 21:18:47 +01:00
Valere Fedronic
5b21691c21 Merge pull request #3544 from element-hq/valere/fix_hangup
Fix: widget hangup action not working
2025-10-24 13:33:04 +02:00
Valere
23bcb5777c Fix: widget hangup action not working 2025-10-23 19:20:21 +02:00
renovate[bot]
5e0a3f3974 Update LiveKit components (#3533)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 17:31:41 +02:00
renovate[bot]
06337b9ae6 Update dependency vite to v7.1.11 [SECURITY] (#3541)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 17:23:07 +02:00
renovate[bot]
1fa2eba0c0 Update dependency livekit-client to v2.15.11 (#3532)
* Update dependency livekit-client to v2.15.11

* bump livekit/protocol

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-10-23 17:14:41 +02:00
Valere Fedronic
f99d01fc38 Merge pull request #3512 from element-hq/voip-team/rebased-multiSFU
Multi-SFU media transport
2025-10-23 16:46:49 +02:00
Valere
301cf2f4eb Revert "CI try without USE_DOCKER"
This reverts commit d52656c553.
2025-10-23 16:26:02 +02:00
Valere
918d67b2ac CI: revert to synapse latest 2025-10-23 16:23:08 +02:00
Valere
d52656c553 CI try without USE_DOCKER 2025-10-23 16:07:04 +02:00
Valere
0823936ee1 revert CI test changes 2025-10-23 16:05:24 +02:00
Valere
150cdf64c8 CI try some more logs 2025-10-23 15:55:22 +02:00
Valere
552e1215be revert CI test changes 2025-10-23 14:24:15 +02:00
Valere
185d7d1177 CI test 2025-10-23 14:13:50 +02:00
Valere
c572d2e779 test CI 2025-10-23 14:02:17 +02:00
Valere
348a6fe1af test CI changing ports 2025-10-23 13:54:08 +02:00
Valere
a638acde2c CI: Increase job timeout 2025-10-23 13:41:08 +02:00
Valere
e06f288e93 update playwright 2025-10-23 10:41:10 +02:00
Robin
db2004fb90 Remove unused string 2025-10-22 23:29:36 -04:00
Robin
e0b9a85f02 Merge branch 'livekit' into voip-team/rebased-multiSFU 2025-10-22 23:28:26 -04:00
Robin
6be7749091 Remove even more dead code 2025-10-22 23:27:38 -04:00
Robin
5245b22d80 Remove dead test files
While we still ought to eventually port these tests in some way, the presence of these empty test files is causing a Vitest failure, so it's easiest to just let them go and refer to Git history when we do want to reference them next.
2025-10-22 23:18:15 -04:00
Robin
75a8dd21eb Remove some dead code 2025-10-22 23:17:07 -04:00
Robin
2c5afe82c4 Skip GroupCallView tests for now 2025-10-22 23:13:57 -04:00
Robin
58cd12b9b8 Fix a number of remaining test failures 2025-10-22 22:57:29 -04:00
Robin
a1c7255cc6 Restore CallViewModel tests to working order
I've left only one of the tests behind (skipped).
2025-10-22 18:50:16 -04:00
Robin
9ca8962328 Remove unused useECConnectionState React hook
It has been replaced by Connection.
2025-10-22 18:48:28 -04:00
Timo K
8da111d39d remove option to enable/disable room transport
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-22 14:13:31 +02:00
Timo K
19daf5f5f3 update snapshot (in call view)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-22 14:10:10 +02:00
Timo
5526cd74cb Add sticky event support (#3513)
* add sticky event support
 - use new js-sdk
 - use custom synapse
 - don't filter rooms by existing call state events

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

* enable sticky events in the joinSessionConfig

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

* Remove unused useNewMembershipmanager setting

* Add prefer sticky setting]

* Fixup call detection logic to allow sticky events

* lint

* update docker image

* More tidy

* update checksum

* bump js-sdk fix sticky events type

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

* fix demo

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

* always use multi sfu if we are using sticky events.

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

* review

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

* lint

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

* Always consider multi-SFU mode enabled when using sticky events

CallViewModel would pass the wrong transport to enterRtcSession when the user enabled sticky events but didn't manually enable multi-SFU mode as well. This likely would've added some confusion to our attempts to test these modes.

* Fix test type errors

* add todo comment

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Half-Shot <will@half-shot.uk>
Co-authored-by: Robin <robin@robin.town>
2025-10-22 12:53:22 +02:00
Robin
e7e0facaa5 Merge pull request #3536 from robintown/stable-speaker-switches
Avoid gratuitous animations when the speaker changes
2025-10-21 13:36:54 -04:00
Robin
1a26a85a78 Show that we've proved to TypeScript that the media is user media 2025-10-21 13:22:20 -04:00
Robin
4936cdfbf6 Merge pull request #3537 from robintown/connection-leaks
Fix resource leaks when we stop using a connection
2025-10-21 09:56:31 -04:00
Robin
340265a838 Enable the PiP tile in expanded spotlight layout to swap speakers without a layout shift
This was apparently left unimplemented during the first iteration of the TileStore. It's a welcome UI optimization and we can reliably test for it.
2025-10-21 01:08:46 -04:00
Robin
1b3a56427f Document generateKeyed$ more thoroughly 2025-10-21 00:27:18 -04:00
Robin
27f24ca565 Document CallViewModel and some terms used 2025-10-21 00:07:48 -04:00
fkwp
a12b9ccbb4 Add another community guide (#3539)
* add another community guide
2025-10-20 09:35:50 +02:00
Robin
13894aaf3a Simplify some test helpers that no longer need continuations 2025-10-17 12:34:06 -04:00
Robin
d5efba285b Fix resource leaks when we stop using a connection
The execution of certain Observables related to a local or remote connection would continue even after we stopped caring about said connection because we were failing to give these state holders a proper ObservableScope of their own, separate from the CallViewModel's longer-lived scope. With this commit they now have scopes managed by generateKeyed$.
2025-10-17 12:23:53 -04:00
Robin
717c7420f9 Require ObservableScopes of state holders to be specified explicitly
Previously we had a ViewModel class which was responsible for little more than creating an ObservableScope. However, since this ObservableScope would be created implicitly upon view model construction, it became a tad bit harder for callers to remember to eventually end the scope (as you wouldn't just have to remember to end ObservableScopes, but also to destroy ViewModels). Requiring the scope to be specified explicitly by the caller also makes it possible for the caller to reuse the scope for other purposes, reducing the number of scopes mentally in flight that need tending to, and for all state holders (not just view models) to be handled uniformly by helper functions such as generateKeyed$.
2025-10-17 12:23:53 -04:00
Robin
414322e5d9 Fix TileStore's ability to swap spotlight speakers without a layout shift
This was regressed in 79c40f198c because of the overlooked renaming of the 'speaking' field to 'speaking$'.
2025-10-17 11:25:39 -04:00
Robin
1eec7314e8 Remove unnecessary lint suppressions 2025-10-17 11:22:23 -04:00
Robin
2c66e11a0a Fix connection tests
Removing one of these tests because it was doing a lot of work to test something that was made trivial in b0eb566a4f.
2025-10-14 16:04:52 -04:00
Robin
102e581c41 Serialize updates to the call intent
So that the value advertised in your membership can't desync from the actual value if you toggle video too fast.
2025-10-14 15:20:07 -04:00
Robin
b0eb566a4f Simplify local transport connection state tracking 2025-10-14 14:43:13 -04:00
Robin
2dc6134606 Stick to the term 'transport' rather than 'focus' 2025-10-14 14:38:37 -04:00
Robin
ea17ed7253 Rename 'localTransport' to 'transport', since it's often remote 2025-10-14 14:34:51 -04:00
Robin
9f4e99310b Elaborate on a TODO for preloading JWT tokens 2025-10-14 14:32:56 -04:00
Robin
625cfa15ff Move sorting bin calculation into UserMedia 2025-10-14 14:31:35 -04:00
Robin
95069fd3fa Fix joining call with audio and video muted 2025-10-14 12:19:23 -04:00
Robin
13636b78d9 Replace deprecated CallMembership.sender with userId 2025-10-14 12:07:51 -04:00
Robin
bcbf7a90f0 Initialize preferredTransport$ in a less unusual way 2025-10-14 12:00:00 -04:00
Robin
2de7a20d8a Remove outdated comment 2025-10-14 11:54:36 -04:00
Valere
a6c4fb4148 review: count as publishing even if not yet connected to LK 2025-10-14 17:47:38 +02:00
Robin
b030d304df Fix crash during focus switching 2025-10-14 09:29:48 -04:00
Robin
392c51fbf6 Merge branch 'voip-team/rebased-multiSFU' of github.com:element-hq/element-call into voip-team/rebased-multiSFU 2025-10-14 09:22:08 -04:00
Robin
a18700cbcd Avoid updating membership during focus switch 2025-10-14 09:16:46 -04:00
Valere
93d763f58f devtool: quick display of focus URL in stats tile 2025-10-14 14:06:54 +02:00
Valere
58d60b35fd fix CI failing with Invalid value "iife" for option "worker.format"
UMD and IIFE output formats are not supported for code-splitting builds. see https://github.com/vitejs/vite/issues/18585
2025-10-14 12:25:31 +02:00
Valere
60332dc2db fix js-sdk dependency format 2025-10-14 12:16:24 +02:00
Valere
a9db9c8b59 ErrorHandling: publish connection error handling 2025-10-14 10:46:57 +02:00
Valere
a5aba928dd dependency: depends on js-sdk develop 2025-10-13 16:39:14 +02:00
Valere
0e1b61a5e8 refactor: Split out all exports of CallViewModel to their own file 2025-10-13 16:24:55 +02:00
Valere
8823be67c5 refactor extract inner classes to their own files 2025-10-13 15:43:12 +02:00
Valere
8e6eb70e5b refactor: use EnterRTCSessionOptions instead of unnamed bools 2025-10-13 13:52:01 +02:00
Valere Fedronic
c846ea6447 Merge pull request #3521 from element-hq/valere/multi-sfu/connection_states
Connection State: Error Handling + Cleaning / refactoring /
2025-10-13 10:15:55 +02:00
Valere
fc2384e330 post merge fixes (js-sdk changes) 2025-10-10 19:23:24 +02:00
Valere
2fc7f11bea prettier fix 2025-10-10 16:51:58 +02:00
Valere
cca46bd8dd Merge branch 'voip-team/rebased-multiSFU' into valere/multi-sfu/connection_states 2025-10-10 16:51:09 +02:00
Valere
1e75f9adcc test: fix additional test with proper mutestate fix 2025-10-10 15:13:55 +02:00
Valere
4a8f5bccac post merge lint fixes 2025-10-10 15:04:12 +02:00
Valere
4608d68cd9 Merge branch 'voip-team/rebased-multiSFU' into valere/multi-sfu/connection_states 2025-10-10 15:01:49 +02:00
Timo K
0fd414358c Merge branch 'livekit' into voip-team/rebased-multiSFU
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-10 12:26:47 +02:00
Valere
1ab081d636 test: MISSING_MATRIX_RTC_FOCUS renamed as MISSING_MATRIX_RTC_TRANSPORT 2025-10-10 11:41:26 +02:00
Valere
6710f4c72a Test: Fix mocking to fix failing tests 2025-10-10 11:09:41 +02:00
Robin
de5f5198dd Merge branch 'livekit' into voip-team/rebased-multiSFU
Note that this effectively *reverts* 3ac2aa8526 because this branch now has what is a better UX (at least I think so): the in-call view is presented instantly when pressing the join button. Errors that occur during initial connection procedures will be surfaced more uniformly.
2025-10-09 16:44:12 -04:00
Robin
f9f2896a6a Merge branch 'voip-team/rebased-multiSFU' of github.com:element-hq/element-call into voip-team/rebased-multiSFU 2025-10-09 16:37:48 -04:00
Valere
a500915c43 test: Fix mute test, behavior change from setMuted to setAudioEnabled
useCallViewKeyboardShortcuts() changed a param from `setMicrophoneMuted` to `setAudioEnabled`, the boolean arg of the callback is inverse tht it used to be
2025-10-09 19:24:44 +02:00
Valere
7cbb1ec1e8 Simplify AudioRenderer and add more tests 2025-10-09 15:33:25 +02:00
Timo K
39f8cb95ec make compatible with related_event (async CallMembership) branch
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-09 15:29:47 +02:00
Valere
4c6b960da3 fix: use correct TestEachFunction 2025-10-09 11:00:45 +02:00
Robin
85ffe68d98 Remove outdated comment 2025-10-08 19:20:21 -04:00
Robin
2c576a7477 Clean up subscriptions in Connection tests 2025-10-08 19:09:27 -04:00
Robin
64c2e5911c Update outdated comment 2025-10-08 19:09:08 -04:00
Robin
5be3b91509 Fix focus connection state typo, simplify its initialization 2025-10-08 19:09:03 -04:00
Robin
2d7e4247f3 Note a potential resource leak 2025-10-08 18:51:30 -04:00
Robin
00daf834b6 Remove local participant case (now enforced by types) from audio tests 2025-10-08 17:53:11 -04:00
Robin
dcc3ab641f Remove MockedObject from mockMediaDevices type signature 2025-10-08 17:40:03 -04:00
Robin
dee06a4b70 Remove unused useIsEarpiece hook 2025-10-08 17:37:54 -04:00
Robin
3691e7120d Restore a hidden 'null' state for the local transport/connection 2025-10-08 17:35:53 -04:00
Robin
8778be8351 Fix doc comment typo 2025-10-08 17:34:33 -04:00
Robin
e88474452f Correct / document some missing bits in tests 2025-10-08 17:33:17 -04:00
Robin
b1d143720a Add comments to Async 2025-10-08 17:08:51 -04:00
Robin
5da780ed30 Remove dead MuteStates file
It's been replaced by a refactored RxJS version living in src/state.
2025-10-08 16:43:43 -04:00
Robin
c96e81bfd3 Simplify type of audio participants exposed from CallViewModel 2025-10-08 16:42:36 -04:00
Robin
e346c8c148 Re-enable React strict mode 2025-10-08 16:39:27 -04:00
Robin
1a4b38cf93 Document ObservableScope.reconcile 2025-10-08 12:54:24 -04:00
Robin
5835cd423e Merge pull request #3517 from element-hq/renovate/github-actions
Update GitHub Actions
2025-10-08 10:19:22 -04:00
Valere
427a8dd644 test: Fix Audio render tests and added more 2025-10-08 14:48:40 +02:00
Valere
afe004c6e7 Remove un-necessary transport field, already accessible from connection 2025-10-08 14:30:52 +02:00
Valere
f5ea734a5c esLint fix 2025-10-08 14:29:59 +02:00
Valere
13fb46644c test: Fix mediaView test, ,member is not optional anymore
Updated the test because now name will be the userId instead of default display name
2025-10-08 08:50:35 +02:00
Robin
669bc76dd5 Replace calls to deprecated resolveActiveFocus 2025-10-08 01:04:58 -04:00
Valere
05e7b5a7ff fixup MediaView tests 2025-10-07 17:35:25 +02:00
Valere
18ba02c9c2 knip: remove dead code 2025-10-07 16:29:11 +02:00
Valere
529cb8a7ec prettier ! 2025-10-07 16:24:02 +02:00
Valere
7437961195 lint: fix import order 2025-10-07 16:12:23 +02:00
Valere
c820ba3983 build: update lock file 2025-10-07 16:07:46 +02:00
Valere
c3c0516f0d Lint: fix all the lint errors 2025-10-07 16:00:59 +02:00
Valere
597e6782a8 Merge branch 'voip-team/rebased-multiSFU' into valere/multi-sfu/connection_states 2025-10-07 10:33:31 +02:00
renovate[bot]
f08805d3ed Update GitHub Actions 2025-10-07 05:31:36 +00:00
Will Hunt
3ac2aa8526 Disable call button while the call is connecting. (#3531)
* Disable call button while the call is connecting.

* cleanup

* fix

* Update src/room/LobbyView.tsx

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

* fixup

* appease linter

---------

Co-authored-by: Robin <robin@robin.town>
2025-10-06 18:52:58 +00:00
Valere
91a366fa2a tests: Publish connection states 2025-10-06 10:50:10 +02:00
Robin
1fff71ace1 Actually leave the MatrixRTC session again 2025-10-03 21:00:45 -04:00
Robin
1820cac3f6 Create media items for session members not joined to LiveKit 2025-10-03 19:14:48 -04:00
Robin
86fb026be8 Turn multi-SFU media transport into a developer option 2025-10-03 14:43:22 -04:00
Valere
00401ca38a refactor: PublishConnection extract from giant constructor 2025-10-02 15:15:23 +02:00
Valere
84f95be48d test: Ensure scope for publishers observer 2025-10-02 13:08:00 +02:00
Valere
0502f66e21 tests: Add publisher observable tests 2025-10-02 12:53:59 +02:00
Timo K
68aae4a8e3 fix another rename + another js-sdk bump
Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-02 11:23:11 +02:00
Valere
dfaa6a33f4 fix lint errors 2025-10-01 17:24:19 +02:00
Valere
e8bf817f88 tests: end scope tests 2025-10-01 16:39:21 +02:00
Valere
6a1f7dd057 ConnectionState: test livekit connection states 2025-10-01 15:23:24 +02:00
Valere
22900161d6 extract common test setup 2025-10-01 14:47:45 +02:00
Valere
47c876f3df lint fixes 2025-10-01 14:37:03 +02:00
Valere
3d8639df03 Connection states tests 2025-10-01 14:21:37 +02:00
Valere
879a1d4af1 Connection: add Connection state and handle error on start 2025-10-01 11:23:16 +02:00
Robin
71127a4e94 Merge pull request #3516 from element-hq/toger5/voip-team/rebased-multiSFU-bump-to-new-js-sdk-version
Use updated multi sfu js-sdk
2025-09-30 11:24:12 -04:00
Valere
b00f7d5409 refactor: Remote / Publish Connection and constructor 2025-09-30 17:02:48 +02:00
Timo K
2819c79590 use updated multi sfu js-sdk
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-30 16:47:45 +02:00
Valere
edf68d16b7 refactoring: prep work extract to file + documentation 2025-09-30 11:33:45 +02:00
Robin
a4a0a58a72 Remove the option to show non-member ("ghost") participants
As we'd like to get the multi-SFU feature branch shipped, this is not the most important debugging tool to expend effort on at the moment.
2025-09-26 13:27:18 -04:00
Robin
dbdf853d55 Stop connections on view model destroy 2025-09-26 13:20:55 -04:00
Robin
0759f9b27d Don't render audio from participants that aren't meant to be publishing 2025-09-25 21:29:02 -04:00
Robin
4980d8a622 Merge branch 'livekit' into voip-team/rebased-multiSFU 2025-09-25 18:40:59 -04:00
Will Hunt
d24da1859e Add media hints for notification events. (#3493)
* Add media hints for notification events.

* Prevent showing calling view when disconnected from Livekit. (#3491)

* Refactor disconnection handling

* Use "unknown"

* Update signature

* Add tests

* Expose livekitConnectionState directly

* fix whoopsie

* Update dependency livekit-client to v2.15.7 (#3496)

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

* Fix the interactivity of buttons while reconnecting or in earpiece mode (#3486)

* Fix the interactivity of buttons while reconnecting or in earpiece mode

When we're in one of these modes, we need to ensure that everything above the overlay (the header and footer buttons) is interactive, while everything obscured by the overlay (the media tiles) is non-interactive and removed from the accessibility tree. It's not a very easy task to trap focus *outside* an element, so the best solution I could come up with is to set tabindex="-1" manually on all interactive elements belonging to the media tiles.

* Write a Playwright test for reconnecting

* fix lints

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

* fix test

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

* enable http2 for matrx-rtc host to allow the jwt service to talk to the SFU

* remove rate limit for delayed events

* more time to connect to livekit SFU

* Due to a Firefox issue we set the start anchor for the tab test to the Mute microphone button

* adapt to most recent Element Web version

* Use the "End call" button as proofe for a started call

* Currrenty disabled due to recent Element Web
- not indicating the number of participants
- bypassing Lobby

* linting

* disable 'can only interact with header and footer while reconnecting' for firefox

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
Co-authored-by: Timo K <toger5@hotmail.de>
Co-authored-by: fkwp <github-fkwp@w4ve.de>

* Log when a track is unpublished or runs into an error (#3495)

* default mute states (unmuted!) in widget mode (embedded + intent) (#3494)

* default mute states (unmuted!) in widget mode (embedded + intent)

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

* review

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

* introduce a cache for the url params.

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

* Add an option to skip the cache.

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

---------

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

* Apply new hint code

* missed a bit

* fix intent

* Automatically update intent on mute change

* update packages

* lint

* Fix tests

* fix merge fails

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
Co-authored-by: Timo K <toger5@hotmail.de>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-09-25 13:02:43 +01:00
Robin
530fbaf90a Clear up the room membership confusion around reading session members 2025-09-24 21:39:36 -04:00
Robin
6cf020763e Make UI react instantly to hanging up but also wait for leave sound
This ensures that we don't see a mistaken 'reconnecting' toast while we're hanging up (and also that the leave sound gets a chance to play in widgets once again).
2025-09-24 21:26:16 -04:00
Robin
edd3eb8747 Implement screen sharing 2025-09-24 13:54:54 -04:00
Robin
f99a256c86 Reset matrix-js-sdk to multi SFU branch 2025-09-24 13:53:39 -04:00
Robin
8e21ea6d51 Merge branch 'livekit' into voip-team/rebased-multiSFU 2025-09-24 10:12:26 -04:00
Timo K
6b44f3b008 a tiny bit of tests lint fixes.
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-23 12:25:31 +02:00
Timo K
96e96a5e43 fix leaving
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-23 12:25:05 +02:00
Timo K
7777179935 cleanup (delete files useLivekit) now covered by Connection.ts
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-23 11:40:29 +02:00
Timo K
78e9521f22 Make track processor work
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-23 11:38:34 +02:00
fkwp
342dd2e7d7 DevX: Add another Matrix site to docker-compose (#3511)
* remove redis, since we dont use it

* update localhost TLS certificat to add *.othersite.m.localhost wildcard

* allow for federation

* Add services and config files for Matrix site othersite.m.localhost

* add element web instance app.othersite.m.localhost

* update README

* exclude synapse database for othersite.m.localhost

* linting
2025-09-22 21:14:12 +02:00
Timo K
8bf24895ce TODO: settings modal with multiple connections
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-22 14:18:23 +02:00
Timo K
dddda7057f add todo comments and who works on them
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-22 14:17:52 +02:00
Timo K
02f23e25fd remove todo from matrix audio renderer
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-22 14:16:24 +02:00
Timo
b2c594d36c Never skip lobby for huddles (#3508)
* Never skip lobby for huddles

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

* tests

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-22 11:35:20 +02:00
Timo K
d9fe31039f start fixing CallViewModel tests.
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 18:01:45 +02:00
Timo K
41e152f420 dont throw disconnected error at start of the call
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:52:12 +02:00
Timo K
ccfd32c9b2 move leave logic into view model
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:52:12 +02:00
Timo K
38d78ddce4 make audio work
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
cc870c3cc2 enable encryption in per sender case
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
c8098734cd one e2ee worker per session
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Robin
e08f16f889 All my Friday work. Demoable! 2025-09-19 17:50:43 +02:00
Timo K
386dc6c84d temp before holiday
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Robin
d46fe55a67 Import unfinished mute states refactor 2025-09-19 17:50:43 +02:00
Robin
02f4c73759 Add my own local storage SFU config stuff too 2025-09-19 17:50:43 +02:00
Timo K
598371b807 lots of work. noone knows if it works.
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
802ebf828d refactor connection
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
e4a54e3a19 refactor connnection class
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
a617a92e88 make it work
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
33bc78eec1 add logging
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
8ffb360114 add local storage + more readable + remoteParticipants + use publishingParticipants
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
55b46b3f33 introduce publishingParticipants$
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
6bdfd7fbd8 add comment
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
a10489d7df publish audio in remote rooms
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo K
7b88420f6a first video!
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Robin
cb91f1ad4f Make it actually join the session 2025-09-19 17:50:43 +02:00
Robin
c6e8c94fd6 Fix makeFocus 2025-09-19 17:50:43 +02:00
Timo K
376a4b4e4a initial compiling version
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Robin
d9b6302bf7 Fix crash? 2025-09-19 17:50:43 +02:00
Robin
35319dd6b5 Fix some errors in CallViewModel 2025-09-19 17:50:43 +02:00
Timo K
9011ae4e1f temp
Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:50:43 +02:00
Timo
9b9c08ed61 Refactor ring$ observable (#3504)
* Refactor ring$ observable

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

* fix ci

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

* fix regression test

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 17:43:31 +02:00
Timo
317b2dc796 Fix: never stop ring feedback on the sender side. (#3502)
* make ring$ a behavior and add code comments to justify/explain the change.

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

* Add test: reproduce "ring does not stop" race.

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 16:42:47 +02:00
Jorge Martin Espinosa
0774d18f92 Android: allow RCs to be published as non-SNAPSHOT versions (#3501)
* Android: allow RCs to be published as non-SNAPSHOT versions

* Fix broken string in build script
2025-09-18 19:22:24 +02:00
Timo
6e9dc34008 Add ring notification to UserIntent.StartNewCallDM (#3499)
* Add ring notification to UserIntent.StartNewCallDM

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

* Add more tests (refactor to compute + get)

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-18 17:45:49 +02:00
Timo
db5c7cf9c7 default mute states (unmuted!) in widget mode (embedded + intent) (#3494)
* default mute states (unmuted!) in widget mode (embedded + intent)

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

* review

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

* introduce a cache for the url params.

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

* Add an option to skip the cache.

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-18 13:21:12 +02:00
Will Hunt
63122c7f6b Log when a track is unpublished or runs into an error (#3495) 2025-09-18 12:09:54 +01:00
Robin
4be395500f Fix the interactivity of buttons while reconnecting or in earpiece mode (#3486)
* Fix the interactivity of buttons while reconnecting or in earpiece mode

When we're in one of these modes, we need to ensure that everything above the overlay (the header and footer buttons) is interactive, while everything obscured by the overlay (the media tiles) is non-interactive and removed from the accessibility tree. It's not a very easy task to trap focus *outside* an element, so the best solution I could come up with is to set tabindex="-1" manually on all interactive elements belonging to the media tiles.

* Write a Playwright test for reconnecting

* fix lints

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

* fix test

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

* enable http2 for matrx-rtc host to allow the jwt service to talk to the SFU

* remove rate limit for delayed events

* more time to connect to livekit SFU

* Due to a Firefox issue we set the start anchor for the tab test to the Mute microphone button

* adapt to most recent Element Web version

* Use the "End call" button as proofe for a started call

* Currrenty disabled due to recent Element Web
- not indicating the number of participants
- bypassing Lobby

* linting

* disable 'can only interact with header and footer while reconnecting' for firefox

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
Co-authored-by: Timo K <toger5@hotmail.de>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-09-18 12:58:47 +02:00
renovate[bot]
df7bd8ff2b Update dependency livekit-client to v2.15.7 (#3496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-18 11:46:55 +02:00
Will Hunt
2374a3fd33 Prevent showing calling view when disconnected from Livekit. (#3491)
* Refactor disconnection handling

* Use "unknown"

* Update signature

* Add tests

* Expose livekitConnectionState directly

* fix whoopsie
2025-09-16 13:16:11 +00:00
davidegirardi
5811794f31 Add drum/joke reaction (#3217)
* Add drum/joke reaction

* Add license information

* Update test snapshot

* update snapshot test

---------

Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Davide Girardi <davideg@element.io>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-09-16 11:46:25 +02:00
Will Hunt
e201258af3 Add sounds for ringing (#3490)
* add wait for pickup overlay

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

* refactor and leave logic

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

* recursive play sound logic

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

* review

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

* text color

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

* overlay styling and interval fixes

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

* fix permissions and styling

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

* fix always getting pickup sound

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

* Add sound effects for declined,timeout and ringtone

* better ringtone

* Integrate sounds

* Ensure leave sound does not play

* Remove unused blocked sound

* fix test

* Improve tests

* Loop ring sound inside Audio context for better perf.

* lint

* better ringtone

* Update to delay ringtone logic.

* lint + fix test

* Tidy up ring sync and add comments.

* lint

* Refactor onLeave to take a sound so we don't need to repeat the sound

* fix import

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo K <toger5@hotmail.de>
2025-09-15 15:41:15 +01:00
Timo
76465d0e63 Add "wait for pickup" overlay with sound. Leave on decline/timeout (#3489)
* add wait for pickup overlay

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

* refactor and leave logic

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

* recursive play sound logic

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

* review

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

* text color

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

* overlay styling and interval fixes

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

* fix permissions and styling

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

* fix always getting pickup sound

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-15 09:58:16 +02:00
Robin
a2cf4c8139 Merge pull request #3479 from robintown/fix-reconnect
Fix the reconnect button
2025-09-12 15:05:47 +02:00
Will Hunt
65d358df58 Do not use preload mode by default in embedded mode (#3488)
* Set preload=false by default for inApp

* Pull through types for params so it's easy to document

* Cleanup boolean logic

* change test
2025-09-11 14:57:26 +01:00
Robin
32cb8541f4 Actually fix the test flake 2025-09-10 16:42:09 +02:00
Robin
62cfe09c7b Merge pull request #3482 from element-hq/renovate/npm-vite-vulnerability
Update dependency vite to v6.3.6 [SECURITY]
2025-09-10 12:37:16 +02:00
Robin
8e32deaba5 Upgrade to Vite v7
To remove all the duplicate Rollup dependencies.
2025-09-10 12:31:03 +02:00
renovate[bot]
8311b1619c Update dependency vite to v6.3.6 [SECURITY] 2025-09-10 02:28:00 +00:00
Timo
fc449bcfbf Merge pull request #3467 from element-hq/toger5/call-pickup-state-decline-event
View model for decline logic
2025-09-09 15:23:39 +02:00
Robin
34a8977dd1 Increase timeout to hopefully avoid test flakes 2025-09-08 15:24:46 +02:00
Robin
db04cbfbfc Fix type error 2025-09-08 14:58:46 +02:00
Robin
c8d3d58940 Merge pull request #3400 from element-hq/robin/reactions-small
Prefer showing the reaction button at small screen widths
2025-09-08 14:54:51 +02:00
Robin
d85cf5f929 Fix the reconnect button
After a membership manager error, clicking the 'reconnect' button did nothing. This is because we were forgetting to clear the external error state, causing it to transition directly back to the same error state.
2025-09-08 14:21:38 +02:00
Robin
1e32b355ce Ignore decline events from the local user 2025-09-05 21:22:32 +02:00
Robin
1193a22658 Fix tests 2025-09-05 14:49:37 +02:00
Robin
2541f810fa Ensure that non-ringing notifications lead to a null pickup state 2025-09-05 14:36:27 +02:00
Robin
7961bb35be Merge pull request #3466 from element-hq/toger5/waitForNotificationAnswer
Add dialing/ringing state to CallViewModel (`callPickupState$`)
2025-09-05 14:11:37 +02:00
Robin
c599d6eae9 Make the CallEventAudioRenderer test stricter 2025-09-05 14:06:28 +02:00
Andrew Ferrazzutti
075a4cbe0d Merge pull request #3475 from element-hq/af/docker-compose-pull-policy-always
docker compose: always pull latest image
2025-09-04 07:47:45 -04:00
Mohammed Tayeh
cd845b69d2 Add Caddy configuration example and fix ConfigOptions.ts link (#3476)
Signed-off-by: Mohammed Tayeh <m.tayeh94@gmail.com>
2025-09-04 10:19:37 +02:00
Andrew Ferrazzutti
779f792cbf docker compose: always pull latest image
Otherwise, there is a risk of using outdated images for some services.
2025-09-03 15:39:27 -04:00
Robin
e9c43856d0 Merge branch 'toger5/waitForNotificationAnswer' into toger5/call-pickup-state-decline-event 2025-09-03 17:59:16 +02:00
Robin
1e8b87408c Document recently added URL parameters 2025-09-03 16:59:37 +02:00
Robin
1428df8567 Tentatively rename URL parameter to waitForCallPickup 2025-09-03 16:52:40 +02:00
Robin
b8acdc3cec Tidy/correct the dialing/ringing tests 2025-09-03 16:51:35 +02:00
Robin
880e07c07f Tidy some things up, refactor dialing/ringing behaviors 2025-09-03 16:51:31 +02:00
Robin
07522d6704 Remove unnecessary casts 2025-09-03 13:03:48 +02:00
Robin
58ed7ef93e Merge branch 'livekit' into toger5/waitForNotificationAnswer 2025-09-03 13:01:14 +02:00
Robin
c1694fe706 Merge pull request #3470 from element-hq/af/migrate-defer
Migrate from js-sdk's `defer` in test
2025-08-28 18:21:05 +02:00
Andrew Ferrazzutti
a775bcd62a Migrate from js-sdk's defer in test
See: matrix-org/matrix-js-sdk#4959
2025-08-28 10:14:32 -04:00
Timo K
7724cbf9be fix tests
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 19:21:27 +02:00
Timo K
fe65c1f4da fix decline event type
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 19:07:52 +02:00
Timo K
a91c71a8e4 types
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:36:23 +02:00
Timo K
e30142a43b Add decline logic and tests
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:36:23 +02:00
Timo K
c15551c9f5 types
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:29:51 +02:00
Timo K
f410a11934 unused import
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:13:04 +02:00
Timo K
3122ccf4e4 dont use deprecated defer
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:10:08 +02:00
Timo K
03527e9d7a fix participant count test
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:09:50 +02:00
Timo K
36be15436a fix linter
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 17:58:46 +02:00
Timo K
04c03b1e72 bump js-sdk to develop branch
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 17:41:58 +02:00
renovate[bot]
38e60208ef Update dependency livekit-client to v2.15.6 (#3468)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-26 13:54:25 +02:00
Timo K
fb8de061f3 rename to callPickupState
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-25 14:31:14 +02:00
Timo K
acd26c61ef use custom js-sdk from PR: https://github.com/matrix-org/matrix-js-sdk/pull/4976
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-25 14:24:32 +02:00
Timo K
e475f56af5 waitForNotificationAnswer
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-25 14:11:10 +02:00
Timo
9486ed5d77 Merge pull request #3464 from robintown/reconnecting-test
Test sync loop status and membership status in reconnection test as well
2025-08-25 14:03:32 +02:00
Robin
7ba4df7781 Test sync loop status and membership status in reconnection test as well 2025-08-22 18:15:21 +02:00
Robin
db65a5308a Make most params optional for call view model test setup 2025-08-22 18:14:25 +02:00
fkwp
482e2f8f6f Refine MatrixRTC connection wording (#3463) 2025-08-22 12:34:18 +02:00
ElementRobot
cd5b7cdc47 Merge pull request #3461 from element-hq/actions/localazy-download
Localazy Download
2025-08-21 19:09:14 +02:00
fkwp
e097e58212 Translations updates 2025-08-21 15:39:08 +00:00
Timo
31892f46cd Merge pull request #3453 from robintown/reconnecting
Pause media tracks and show a message when reconnecting to MatrixRTC
2025-08-20 22:24:30 +02:00
Timo K
c0dccf89cb lint
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-20 22:07:09 +02:00
Robin
44ddb40eea Iterate in pairing session 2025-08-20 20:47:20 +02:00
Robin
5b8839b577 Pause hand raise updates while in 'reconnecting' state 2025-08-20 19:08:44 +02:00
Robin
33bf63d8d2 Disable a bunch of media/event sources when reconnecting 2025-08-20 18:51:03 +02:00
Robin
a7cc11ac81 Merge branch 'livekit' into reconnecting 2025-08-20 14:13:03 +02:00
Robin
25f04030fc Merge pull request #3336 from element-hq/robin/switch-camera-tile
Move the switch camera button to the local user's tile
2025-08-20 13:38:44 +02:00
Robin
5d0289f3c9 Show 'reconnecting' message when session warns we've hit a timeout
By using the new 'probablyLeft' state provided by MatrixRTCSession.
2025-08-20 13:34:41 +02:00
Robin
3da9013426 Show 'reconnecting' message when disconnected from LiveKit 2025-08-20 13:34:36 +02:00
Robin
1a1e5a9db8 Show 'reconnecting' message when sync loop is disconnected
With this change I'm also taking care to not show the standard "Connection to the server has been lost" banner in the call view, since that is now covered by the 'reconnecting' message.
2025-08-20 13:34:26 +02:00
fkwp
87574451a9 Doc: Use new naming of jwt auth service (#3455)
* use new naming of jwt auth service

* use new name of jwt service

* added links the https://github.com/element-hq/lk-jwt-service
2025-08-20 12:45:00 +02:00
Robin
f8a051ee11 Address review feedback 2025-08-20 12:07:06 +02:00
fkwp
a0d778083d Allow multiple MatrixRTC sessions per room (#3451)
* bump js-sdk to Allow multiple rtc sessions per room (with different sessionDescriptions)

* Allow new state key string-packing format for widget mode

* bump js-sdk to latest version (with merged multi session PR)

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo K <toger5@hotmail.de>
2025-08-19 16:13:56 +02:00
Robin
8de6ddceb0 Test that media tracks are paused while reconnecting to MatrixRTC 2025-08-15 20:20:00 +02:00
Robin
8b3b72dfb4 Consolidate yes/no marbles into one definition 2025-08-15 20:19:18 +02:00
Robin
db59679ad4 Allow the local participant's RTC membership to be absent in tests 2025-08-15 20:18:21 +02:00
Robin
f08ae36f9e Pause media tracks and show a message when reconnecting to MatrixRTC 2025-08-15 18:38:52 +02:00
Robin
dc789e63f2 Avoid using the deprecated 'room' field on MatrixRTCSession 2025-08-15 18:32:37 +02:00
Robin
30024877fc Test that the reaction button is visible at small screen widths 2025-08-14 17:00:56 +02:00
renovate[bot]
94085bff00 Update dependency livekit-client to v2.15.5 (#3452)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 16:47:34 +02:00
Robin
aa0f3a1126 Merge branch 'robin/switch-camera-tile' into robin/reactions-small 2025-08-14 16:39:51 +02:00
Robin
0ed47c2588 Merge branch 'livekit' into robin/switch-camera-tile 2025-08-14 16:39:08 +02:00
Robin
770346676c Merge pull request #3443 from element-hq/toger5/js-to-ts
Use `.ts` for remaining js config files
2025-08-14 15:06:12 +02:00
Robin
8c1fa3fd70 Fix inaccurate copyright headers 2025-08-14 14:57:39 +02:00
Robin
217fa40ddb Only forbid using console in application code 2025-08-14 14:57:20 +02:00
Timo
a733461845 Hangup when last person in call (based on url params) (#3372)
* Introduce condigurable auto leave option

* Read url params for auto leave

* add tests

* rename url param to `autoLeave`

* lint

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

* fix scope in CallViewModel tests

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

* use auto leave in DM case

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

* Make last once leave logic based on matrix user id (was participant id before)

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

* add test for multi device auto leave

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-08 17:15:47 +02:00
Timo
6667fc54c0 Add a fullscreen button that uses the element request Fullscreen browser api (#3447)
* Add a fullscreen button that uses the element request Fullscreen browser api

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

* use body instead of root node

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-08 17:09:25 +02:00
Timo
c0aab96968 Add intents for DM (#3445)
* Add intents for DM

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

* review

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-07 15:16:41 +02:00
Valere Fedronic
610032ad1d Merge pull request #3444 from element-hq/valere/force_jwt_service_before_start
Ensure that the jwt service is called before starting a call
2025-08-06 14:54:24 +02:00
Valere Fedronic
e4d14b4892 Update src/rtcSessionHelpers.ts
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2025-08-06 14:22:10 +02:00
Valere
8509efb48b review 2025-08-06 13:59:53 +02:00
Valere
6dcc44b631 comment 2025-08-06 09:26:07 +02:00
Valere
fce7b6d456 refactor: move warmup to makePreferredLivekitFoci 2025-08-05 17:44:22 +02:00
Valere
ef7c9a166a fix eslint 2025-08-05 15:58:46 +02:00
Valere
dba8a43495 fixup test mock 2025-08-05 15:30:15 +02:00
Valere
1a3e88c19a Ensure that the the jwt service is called before starting a call 2025-08-05 15:13:04 +02:00
renovate[bot]
955c22c8dc Update dependency @vector-im/compound-design-tokens to v6 (#3438)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 11:34:24 +02:00
Timo K
02f9d0822f update knip.ts
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-05 09:17:50 +02:00
Timo K
e133289a7f Make convert remaining js config files to ts
Co-authored-by: hughns
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-04 19:09:37 +02:00
ElementRobot
518039f7c3 Merge pull request #3441 from element-hq/actions/localazy-download
Localazy Download
2025-08-04 17:13:41 +01:00
fkwp
f5094be194 Translations updates 2025-08-04 16:01:34 +00:00
Timo
fd426191af Stop reading deprecated config options (DONT USE: membership_keep_alive_period, membership_server_side_expiry_timeout, key_rotation_on_leave_delay) (#3416)
* Stop reading deprecated config options

* add PR-Breaking-Change as one of the possible PR- prefix labels

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-04 17:59:04 +02:00
renovate[bot]
f9d87ea934 Update dependency livekit-client to v2.15.4 (#3425)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 17:56:33 +02:00
Timo
77ba88b76c Set available devices to empty map on safari. Safari does not allow changing output devices so the output device dialog is confusing. (#3426)
* Set available devices to empty map on safari.

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

* better safari check

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-04 17:46:56 +02:00
fkwp
8615d3adb0 improve web endpoint routing details (#3440) 2025-08-04 17:14:29 +02:00
Timo
7971ea76cd Dont construct logger before rageshake initialization (#3434)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-04 16:43:08 +02:00
Robin
5d992a398d Merge pull request #3437 from element-hq/fkwp/doc/self-hosting_cleanup_backend_announcement
Cleanup self-hosting guide
2025-08-01 17:47:22 +02:00
fkwp
1aa76925f7 removing nextgen_new_foci_type item from foci array since it introduces confusion 2025-08-01 15:39:58 +02:00
Robin
4888efe2f7 Merge pull request #3436 from robintown/default-timeout
Reset LiveKit connection timeouts to their defaults
2025-08-01 15:13:41 +02:00
Robin
879816ac6d Reset LiveKit connection timeouts to their defaults
I believe that the issue we were originally investigating using these increased timeouts was the fault of my earlier ISP in SW Virginia. I can't recall reproducing the exact issue on other networks.
2025-08-01 14:52:47 +02:00
Robin
6de8820590 Merge pull request #3432 from element-hq/renovate/embedded-package-dependencies
Update embedded package dependencies
2025-08-01 14:03:45 +02:00
Valere Fedronic
415f83ace5 Merge pull request #3428 from element-hq/valere/restricted_room_creation_error
Display a custom error for restricted SFU config error
2025-08-01 13:04:27 +02:00
Robin
c9fb80f702 Migrate away from Sonatype repo for embedded builds
It's been shut down and we can no longer publish to it.
2025-08-01 12:38:41 +02:00
Valere
9bf63cce25 fix the test to properly fail 2025-08-01 11:58:05 +02:00
renovate[bot]
16947e7c39 Update all non-major dependencies (#3433)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 11:07:01 +02:00
renovate[bot]
b84c8ab82b Update embedded package dependencies 2025-08-01 02:31:35 +00:00
Valere
078c67bfb2 fix bad copy paste comment 2025-07-31 17:47:13 +02:00
Valere
81555c8d51 add a playwright test 2025-07-31 17:46:09 +02:00
Valere
d294be8bd4 custom error for restricted SFU config error 2025-07-31 17:18:02 +02:00
Timo
51672599ba Patch base-x and undici to versions without known vulnerabilities (#3431)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-07-31 17:16:49 +02:00
Valere Fedronic
2ade9b5c1e Merge pull request #3429 from element-hq/valere/devx/update_dev_config
DevX: update dev backend to support jwt-token v3
2025-07-31 17:15:44 +02:00
Robin
735d63d393 Prefer showing the reaction button at small screen widths
Rather than the 'share screen' button. Small screens are most likely to be mobile devices which wouldn't have the ability to share their screen, anyways.
2025-07-10 16:07:14 -04:00
Robin
31bb46485f Put a switch camera button on the local user's tile 2025-07-10 15:29:59 -04:00
Robin
f53558cb81 Fix bug in drag handler which breaks buttons inside tiles 2025-07-10 15:29:39 -04:00
Robin
0c194617a3 Add camera switching to the media view model 2025-07-10 15:29:39 -04:00
Robin
7c5336fc40 Remove the switch camera button 2025-07-10 15:29:38 -04:00
209 changed files with 13771 additions and 7336 deletions

View File

@@ -28,8 +28,6 @@ module.exports = {
rules: {
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
"jsx-a11y/media-has-caption": "off",
// We should use the js-sdk logger, never console directly.
"no-console": ["error"],
"react/display-name": "error",
// Encourage proper usage of Promises:
"@typescript-eslint/no-floating-promises": "error",
@@ -46,6 +44,15 @@ module.exports = {
"rxjs/no-exposed-subjects": "error",
"rxjs/finnish": ["error", { names: { "^this$": false } }],
},
overrides: [
{
files: ["src/*/**"],
rules: {
// In application code we should use the js-sdk logger, never console directly.
"no-console": ["error"],
},
},
],
settings: {
react: {
version: "detect",

View File

@@ -10,7 +10,7 @@ jobs:
pull-requests: read
steps:
- name: Add notice
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |

View File

@@ -23,7 +23,7 @@ jobs:
packages: write
steps:
- name: Check it out
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: 📥 Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -34,7 +34,7 @@ jobs:
path: dist
- name: Log in to container registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -42,7 +42,7 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: ${{ inputs.docker_tags}}
@@ -50,10 +50,10 @@ jobs:
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build and push Docker image
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Enable Corepack
run: corepack enable
- name: Yarn cache

View File

@@ -9,6 +9,6 @@ jobs:
steps:
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
with:
REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies"
REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies,PR-Breaking-Change"
REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one 'PR-' label"
BANNED_LABELS: "banned"

View File

@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Enable Corepack
run: corepack enable
- name: Yarn cache

View File

@@ -44,6 +44,8 @@ jobs:
run: |
if [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "TAG=latest" >> "$GITHUB_OUTPUT"
elif [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\-rc\.[0-9]+$ ]]; then
echo "TAG=rc" >> "$GITHUB_OUTPUT"
else
echo "TAG=other" >> "$GITHUB_OUTPUT"
fi
@@ -83,7 +85,7 @@ jobs:
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
- name: Upload
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -101,7 +103,7 @@ jobs:
id-token: write # required for the provenance flag on npm publish
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -142,7 +144,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -163,6 +165,8 @@ jobs:
run: |
if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
else
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV"
fi
@@ -195,7 +199,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with:
path: element-call
@@ -208,7 +212,7 @@ jobs:
path: element-call/embedded/ios/Sources/dist
- name: Checkout element-call-swift
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with:
repository: element-hq/element-call-swift
path: element-call-swift
@@ -260,7 +264,7 @@ jobs:
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
- name: Add release notes
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
with:
append_body: true
body: |

View File

@@ -42,7 +42,7 @@ jobs:
- name: Create Checksum
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
- name: Upload
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -68,7 +68,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Add release note
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
with:
append_body: true
body: |

View File

@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
@@ -22,7 +22,7 @@ jobs:
- name: Vitest
run: "yarn run test:coverage"
- name: Upload to codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
@@ -30,10 +30,10 @@ jobs:
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
timeout-minutes: 30
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

View File

@@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Enable Corepack
run: corepack enable

View File

@@ -14,7 +14,7 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Upload
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1

3
.gitignore vendored
View File

@@ -8,6 +8,7 @@ dist-ssr
.idea/
public/config.json
backend/synapse_tmp/*
backend/synapse_tmp_othersite/*
/coverage
config.json
@@ -28,4 +29,4 @@ yarn-error.log
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.cache/

View File

@@ -143,7 +143,8 @@ via the `org.matrix.msc4143.rtc_foci` key, e.g.:
where the format for MatrixRTC using LiveKit backend is defined in
[MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
In the example above Matrix clients do discover a focus of type `livekit` which
points them to a Matrix LiveKit JWT Auth Service via `livekit_service_url`.
points them to a [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
via `livekit_service_url`.
### Backend Selection
@@ -154,9 +155,9 @@ points them to a Matrix LiveKit JWT Auth Service via `livekit_service_url`.
the **first participant who joined the call** defines via the `foci_preferred`
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
will be used for this call.
- During the actual call join flow, the **LiveKit JWT Auth Service** provides
the client with the **LiveKit SFU WebSocket URL** and an **access JWT token**
in order to exchange media via WebRTC.
- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)**
provides the client with the **LiveKit SFU WebSocket URL** and an
**access JWT token** in order to exchange media via WebRTC.
The example below illustrates how backend selection works across **Matrix
federation**, using the setup from sites A, B, and C. It demonstrates backend
@@ -205,22 +206,22 @@ See also:
### Backend
A docker compose file `dev-backend-docker-compose.yml` is provided to start the
whole stack of components which is required for a local development environment:
whole stack of components which is required for a local development environment
including federation:
- Minimum Synapse Setup (servername: `synapse.m.localhost`)
- LiveKit Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Minimum LiveKit SFU Setup using dev defaults for config
- Redis db for completeness
- Minimum Synapse Setup (servernameis: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Minimum LiveKit SFU setup using dev defaults for config
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
- Hostnames: `m.localhost`, `*.m.localhost`
- Hostnames: `m.localhost`, `*.m.localhost`, `*.othersite.m.localhost`
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted
certificates
- Minimum TLS reverse proxy for
- Synapse homeserver: `synapse.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost`
- Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost`
- Local Element Call development `call.m.localhost` via `yarn dev --host `
- Element Web `app.m.localhost`
- Note certificates will expire on Thu, 03 May 2035 10:32:02 GMT
- Element Web `app.m.localhost` and `app.othersite.m.localhost`
- Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST
These use a test 'secret' published in this repository, so this must be used
only for local development and **_never be exposed to the public Internet._**

View File

@@ -0,0 +1,64 @@
server_name: "synapse.othersite.m.localhost"
public_baseurl: https://synapse.othersite.m.localhost/
pid_file: /data/homeserver.pid
listeners:
- port: 18008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation, openid]
compress: false
database:
name: sqlite3
args:
database: /data/homeserver.db
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "synapse.m.localhost"
accept_keys_insecurely: true
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
# - per_second: number of requests a client can send per second.
# - burst_count: number of requests a client can send before being throttled.
rc_message:
# This needs to match at least the heart-beat frequency plus a bit of headroom
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5
burst_count: 30

View File

@@ -19,8 +19,18 @@ database:
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation.
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "matrix.org"
- server_name: "synapse.othersite.m.localhost"
accept_keys_insecurely: true
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
@@ -28,12 +38,21 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# sticky events for matrixRTC user state
msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
@@ -45,10 +64,3 @@ rc_message:
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5
burst_count: 30
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true

View File

@@ -0,0 +1,20 @@
port: 17880
bind_addresses:
- "0.0.0.0"
rtc:
tcp_port: 17881
port_range_start: 50300
port_range_end: 50400
use_external_ip: false
turn:
enabled: false
domain: localhost
cert_file: ""
key_file: ""
tls_port: 5349
udp_port: 443
external_tls: true
keys:
devkey: secret
room:
auto_create: false

View File

@@ -6,11 +6,6 @@ rtc:
port_range_start: 50100
port_range_end: 50200
use_external_ip: false
#redis:
# address: redis:6379
# username: ""
# password: ""
# db: 0
turn:
enabled: false
domain: localhost

View File

@@ -1,4 +1,5 @@
# Synapse reverse proxy including .well-known/matrix/client
# domain synapse.m.localhost
server {
listen 80;
listen [::]:80;
@@ -26,14 +27,53 @@ server {
# This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token
# verification, which requires TLS
location / {
proxy_pass "http://homeserver:8008";
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass "http://homeserver:8008";
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
}
# Synapse reverse proxy including .well-known/matrix/client
# domain synapse.othersite.m.localhost
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen 8448 ssl;
listen [::]:443 ssl;
listen [::]:8448 ssl;
server_name synapse.othersite.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
# well-known config adding rtc_foci backend
# Note well-known is currently not effective due to:
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
# says it must be at https://$server_name/... (implied port 443) Hence, we
# currently rely for local development environment on deprecated config.json
# setting for livekit_service_url
location /.well-known/matrix/client {
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://synapse.othersite.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.othersite.m.localhost/livekit/jwt"}]}';
default_type application/json;
}
# Reverse proxy for Matrix Synapse Homeserver
# This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token
# verification, which requires TLS
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass "http://homeserver-1:18008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
@@ -41,6 +81,7 @@ server {
}
# MatrixRTC reverse proxy
# domain matrix-rtc.m.localhost
# - MatrixRTC Authorization Service
# - LiveKit SFU websocket signaling connection
upstream jwt-auth-services {
@@ -49,16 +90,14 @@ upstream jwt-auth-services {
}
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
listen 8448 ssl;
listen [::]:8448 ssl;
server_name matrix-rtc.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
http2 on;
location ^~ /livekit/jwt/ {
@@ -94,6 +133,54 @@ server {
}
# MatrixRTC reverse proxy
# domain matrix-rtc.othersite.m.localhost
# - MatrixRTC Authorization Service
# - LiveKit SFU websocket signaling connection
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name matrix-rtc.othersite.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
http2 on;
location ^~ /livekit/jwt/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# JWT Service running at port 16080
proxy_pass http://auth-service-1:16080/;
}
location ^~ /livekit/sfu/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_send_timeout 120;
proxy_read_timeout 120;
proxy_buffering off;
proxy_set_header Accept-Encoding gzip;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# LiveKit SFU websocket connection running at port 17880
proxy_pass http://livekit-sfu-1:17880/;
}
error_page 500 502 503 504 /50x.html;
}
# Convenience reverse proxy for the call.m.localhost domain to yarn dev --host
server {
listen 80;
@@ -159,3 +246,36 @@ server {
error_page 500 502 503 504 /50x.html;
}
# Convenience reverse proxy app.othersite.m.localhost for element web
server {
listen 80;
listen [::]:80;
server_name app.othersite.m.localhost;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name app.othersite.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
location ^~ / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://element-web-1:18081;
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}

View File

@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDGjCCAgKgAwIBAgIUGdiFHhH4KL2pqBjMQHQ+PVIkSV8wDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA1MDUxMDMy
MDJaFw0zNTA1MDMxMDMyMDJaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDA2y0hjmNn1vRsVSdy
8IOfo8N1q9UgkhQWpGKXzPh+D5d1fnuJEmHIVwtDEtS/PwQ43LTmegChPtKH9jdT
tG0IihW9Ja5YNG+9xAwaoA/sB3CGCBYsz+2/XjVUpXoBJXIPoFBWsn+K0oeFw9fw
eRO1z9abM4cl+LjKzMNM8CCyu9uI1MaGjYez2YIWvG854VucLxX7HSlMJxZNWnie
Ui7fMakuJhB2+aiIQjdKxy4E5RHNhzYG/LXhvP+wBYBDPNRsP3rtzEaE9HAveL9K
FGqd3R4cBia6r1WIXmpAzyu5RGP5Eou0TZlGkal96/bF0I7q/pKlL23Jt1BLPiQU
KGKrAgMBAAGjUDBOMB0GA1UdDgQWBBQJqBjMu61c1p24txw/y+kv3D+V6DAfBgNV
HSMEGDAWgBQJqBjMu61c1p24txw/y+kv3D+V6DAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQB8m2YfFGLugNt5vAAOvNxVqDA8c72yCVYr3CBCpmTIEY5Z
d3qVGhG9//ux6+J8ntkSwd9nV5GJyYXHukCG1VavnAWolWdNF/WAllf0jhLuz7kD
/cJnuI1By4tBsBmSz851i6HJ4t5k99Be+6GQVzi0e7zzfxTHZE4xP2J6Ox8QbPsP
n0m76nIp/WbWaJqzvIIjJhmUUPPv+4wN+eOArgjiGLzptM2qTtGZtd0c9nS5gvep
+mEbSUN9zkhAroZf80wf+hEvy+fJ94VbZ9QjTzTg7odZLrsXGIe8DaG63EYRQ25b
W5iYBAreln5fGSt7qHsGfqwZibTEk/Lx3dydO1Kg
MIIDGjCCAgKgAwIBAgIUbSbx+1UGptOTGefqEn7Zh3yoChIwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHODfkrFsOkqCnXnTb
QWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+1azC
SlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+I9O6
NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBhwLyC
DW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+Liyt
X/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPERYS2H
Ru/RAgMBAAGjUDBOMB0GA1UdDgQWBBTXNfLAKVayGQda/JZLPszrpz6LVzAfBgNV
HSMEGDAWgBTXNfLAKVayGQda/JZLPszrpz6LVzAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQCvGfyopHHgZB+horGH6i/Xg41V+r4d0o092F1Lfr4vh86e
XMakRw92vsyk/iWOnLPNPcpVWzPcvINaCs/bahgnGSOAnrA4jjcXqymyGIy/6xc5
1EeZAxehiL9E5q4LQ841HDX0gps4ZzUO1BRYQcjG9Rdts83JO2ekkfHkZdNj2eQr
KOrr92Na1/w+EQdo/T9Rs2ES623xKEOxPqb8d/rx5Z4DdeuGx1u+3AfS76Lpo4ni
EJ0g1ImqdSUtiOLzeCQh6pqqb+vuFbxAyeyYSAJ49847EtFBvZCmWmPL2JICg9uq
7rKW/qDfEK9GUs0GWCs3+mJkNvOOxBwtMuQrL7ZF
-----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDA2y0hjmNn1vRs
VSdy8IOfo8N1q9UgkhQWpGKXzPh+D5d1fnuJEmHIVwtDEtS/PwQ43LTmegChPtKH
9jdTtG0IihW9Ja5YNG+9xAwaoA/sB3CGCBYsz+2/XjVUpXoBJXIPoFBWsn+K0oeF
w9fweRO1z9abM4cl+LjKzMNM8CCyu9uI1MaGjYez2YIWvG854VucLxX7HSlMJxZN
WnieUi7fMakuJhB2+aiIQjdKxy4E5RHNhzYG/LXhvP+wBYBDPNRsP3rtzEaE9HAv
eL9KFGqd3R4cBia6r1WIXmpAzyu5RGP5Eou0TZlGkal96/bF0I7q/pKlL23Jt1BL
PiQUKGKrAgMBAAECggEAAPX2kxi5AQ7ul82SzT1KgpSXyDHLdYaUyAoYnaX9RO+B
8ylmpyeqygs4+KQS4EMJm9jpo85Oy37bIKdG3kljU6wQcKlL5Y+ZUOo1nzpV6fid
hGVs6ts8VXw8KshKQ9AyccZ8L/pirUfgOffgTwfjY7/90zceAL/s98GuZWc62nkX
55joQv/OikqYfAGP/U6Bp2Zyf23DwJB09Z3B6NnZj/ZyAbDrDEHuA15LhCOcCczp
IU/mFEywBPHT9Tg4w4Beq78PeAETvku2UalYRLhP3RLlXr2oEbwUtINRVt2QjZ85
Esps4uCqL/mgQluIebtudD9HL/YMlNPXue1mDXFxJQKBgQDgZZY4yJBcf488T1V6
HNm06b/LvVGj253pKgw14hpY1xQu3Ymgzv1GEqzhSYdzxhpmj0tMUNHxAp+YdGQu
SZ0wcPKhw0aYVkIjDRYDC3Wn5GJhyIEYHGYMo/n4l49UzHRBPOTDzp49DkHTKBgh
XgIIazYT3CkjTIMRrkUv+qfIPQKBgQDcBGu/mqbjxs4sN3zqPS4aB21o6t6W0sXs
ZP9w6RlTPQi5U2oRbftjZtYc0bbEgkMUImB1HwYPQT5pJ+MyC414xDvSc2exBr5d
To6yyPIy78Tf5PHM12fpKV92nSvoz/pSjYcGxxDtKfPqu+t8mOJfjCV1lLLA+xuB
DDaE4p8dBwKBgQCdAne6A5v/HMH8UQZeCxHJpESvKiiVnnU/UEx651nID7XvlNNX
0X0mKqsMd4ZvW43ddSYan/JF0LAa3FW8jYWO/3jF9vzOWoysOdvNBZetgf/Uq5ao
aDZ/YbzmVCXWD7jIbPMkjs3pqrAkL0mzDzQc7+dGviWKrV6IYIfIqnn7gQKBgDCz
vdIk/qpO+JZrFfiX4Fucp0hhLTJ/p5ZDaRPqVVPKn+K+Jy2ChfIj8mNgvK9VEloj
nexvGJ1J2PHYBX+vdPp1nbRhHWPfVUY8PHQw7QP/dToGaMvqJrNDGEGeWvjnCMc7
UtdaO1H0Rm0AegkTopB56lTTvJnhO95eALd7nrMDAoGAEPdzJtWoKafp49svhSj0
hiXQv2SPBwVUN4LZ4SOWiXUcmYYm80aNpYKLkBxYjrfqFWhE7NUHLGp8YorQWKY2
acD9AReHk/xku0ABy6jeYmSCmCxASxst5liKD+l12sk0gB0rk5MBxB4Uu1MIbQZ2
aCASX3AVD2/XyC2MKkzc8Eg=
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHODfkrFsOkqCn
XnTbQWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+
1azCSlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+
I9O6NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBh
wLyCDW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+
LiytX/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPER
YS2HRu/RAgMBAAECggEABhB9CxYAE5p9D3s9nWsJcSDUdELRQSYlOoPFLmeMkF9c
dcvq7LmduMh1Q8TnoivOBxRIwbj7pZHEYfYJM0TmH82wrQzXu5KLVltm4gTkVt9b
DR8vjBgYdb8HVpM17Cl2xhW62XpJIiseFRUsHc/9sf2Egc3MIpPuIleGR0budbSW
ybBkqEokTYTSiAztcu3G+VN0U9MsJgLMa8HApya7M48ojdrhzngVHZRUOXul9o7u
zYJWSxPHIIYp5C4pYQBAx8OttThwKK1A9lwbQ2EJx0KnTbBC6O5Gna/jENpGd1h2
rzK/9MONtsjln7IejP+4mDlNupS6SF3zzHPBHjqKAQKBgQDtXUIKPiVTFS45yWtK
XD62s3j8jfIi+22b/C30fCPtppn0cm/0zY+vovgWVUBnQXkExafRthZCuxnE8ry7
E29S40+4z9yivAC9dz7vHZUbyIFP6VG9WyhUYo+/WqOIePyh+iBISQ9TA1DneIYz
+VZ8iU5GvdybUPl2C5WN8seaoQKBgQDW3EwVN2EEkChLRJbQYN2qpjn+0vYESMJ8
K0sgMRtgh4+/T2Xb9b8O/dd87Fi/4oaUqWZ2E2sdsXq8P/IEo0cv6SRfHMy7GyxL
RM7ztwUfMC4LVWi0ZIXMrm4gRDGN2XjGvhkX6fU2lSf6azWL1K3wI3amNV2b7P7d
ItpvdkH3MQKBgQCXf29YJEQkXB9t6J3fDzND3xb4cwy5wSo7ZeBa7CTuWOhoeeX1
JIJyAp0/e9goT0SThChRlFtu6gZPivJkoMnr6IOInLrg7we15fc4HPR/kCDgxTVT
m2wJOAMxigNYZogwRfn2yRLL1BD+PBHD+H936xcX1bSJOUyPSGOC/xLhIQKBgQCb
kCDd85ygyycBaAWxlZCor3WqFF/fNjbp5Aaepi9mMoBXSUs8eK7+UbelURHozEAY
fpYaw3B4rTlp9vppdTZjb+/PlXB9v+zQCl+0gTyKGj4cIpiOk4F0co51eipOw7f4
XUaZ0+CgxlmNq/W26iONjH+pU1YVQQA+Z6+zp/GW4QKBgQCrzYgeugxxqgJzyIRu
0njJkIg+T5gHvsQrtpzq7LVob+HBiBiT7eDOeGDXTK8F//sk969QVrDMQsTMvGW9
sG1oTqxciALTMqkJTf8+hT9Uogir0/iTbJUzTt5vPYpQOEQwQHIXMUTjZ9C6NDKT
QlmeMCxeWyPYqoMfwKmdtDP/Iw==
-----END PRIVATE KEY-----

View File

@@ -1,21 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDZzCCAk+gAwIBAgIUXizLjwkdqepX0bh0K3abeJxj68IwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA1MDUxMzU5
MTFaFw0zNTA1MDMxMzU5MTFaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCrzGSScSgaQuZdELGFYiLiYRwr
LKyUdNr0rsPcOo0bvbeZ3zQMeUMRNlA69zGFdarumiDRXUoAmZI39WmH95aX3d+A
U7EFnWev7xpWSVhSYj8T0d4rke8HjGk3LpaffJ93tbJuagBIH1ouuN6AOdzWs8hp
RYIomWleEeeuVnnfaMwaXOdc+ihJJ6wzm2hwQSfdpjZPWBDd/DFft1ZXxIZOCjDs
rEIiI7uU8iZPLB3QEM/tgxSSAOxrcKvQvxZokk+FD7aMJFP71IfieLCEzMTP1VXa
tP7UTAKAqB2NyDJ8m3IHbOINiqcdFvFR3R1D9bXOYE4oRynNvYZrQUGnL2RtAgMB
AAGjgaIwgZ8wHwYDVR0jBBgwFoAUCagYzLutXNaduLccP8vpL9w/legwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwMAYDVR0RBCkw
J4IJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdDAdBgNVHQ4E
FgQUfdh1p52ZgWyZcBgBXGwKi4EnUE0wDQYJKoZIhvcNAQELBQADggEBAKrHEuB6
33j8+EwSHw3zrvt/DRXK2BDHI1Ir9JcztSunaKAjZXVvf/dvZp0Xs1dEdJIdnv6G
iZYhBbOqDqpQZbf2h/h0kuu5yZSBUdnQXnYNxlhp2UaC/UEgw5iZT/p1rm7RjVie
y4Dp2WytV5iZOLmLj6xDvd3DXazgJPWIRX8p8qJZbKTkwCjTr7nDIj8jjG1sVFf7
1RJBO5/6WSnImrpDmlLUrvjiKvbxcdseDJyBOhTwdRdSk4S2M+s5tR5j2I1gXLOq
J5ioN76+SCrTY0K0WKRy9oOXWO1/X3+VYcekp+0F3SGkd5w17jylCv1XIGHAdEsQ
v2z2/aMI/7sAD2Q=
MIIDgDCCAmigAwIBAgIUT9NYpZbrAKokSPSTE3zzsAMowvEwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDK1DNwTQWmyK71Ar56NvmSMQ8s
qUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgfJlZMd4lEvt6V
EEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+7Z22i6cGMOnQ
0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6WxJmFF+eV9P/ax
fxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiVG1jCrVcaKNGT
PYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrncneIJR24/AgMB
AAGjgbswgbgwHwYDVR0jBBgwFoAU1zXywClWshkHWvyWSz7M66c+i1cwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwSQYDVR0RBEIw
QIIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
cnNpdGUubS5sb2NhbGhvc3QwHQYDVR0OBBYEFIkGX+cEJ1ISKIwuT1zzp7uHJ90e
MA0GCSqGSIb3DQEBCwUAA4IBAQBnnnfB7KmyYo16ZYUCmoqGhbM4p8npeYTh5ySb
K01YwGCnMU1qGfJnKHaRwQ2+KtVGZnpBdjmsHcOUetA3V2BirPaYowMCMtaI36LD
LnxvboSZLX0mgEYuN7HmxW4a7fSelDecTYa7xti1sNhE/w8xW7Lky046/DousyUy
d9x3wJ183GGj1W2p6bR1E4sqTr/VbmoULQxnqA3GUNOxW3lRL5e8lQ6jJVRmMF4k
92BtMPrI/m7jwHj0f/WBLI8mdJ/O/W/NxQOG475FZePDfrg+MkeXPChPggf42/ou
AMm56FNB7e1l0b1Fots730RfpCPuXpiAxL4pisS0X1dMVeeM
-----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrzGSScSgaQuZd
ELGFYiLiYRwrLKyUdNr0rsPcOo0bvbeZ3zQMeUMRNlA69zGFdarumiDRXUoAmZI3
9WmH95aX3d+AU7EFnWev7xpWSVhSYj8T0d4rke8HjGk3LpaffJ93tbJuagBIH1ou
uN6AOdzWs8hpRYIomWleEeeuVnnfaMwaXOdc+ihJJ6wzm2hwQSfdpjZPWBDd/DFf
t1ZXxIZOCjDsrEIiI7uU8iZPLB3QEM/tgxSSAOxrcKvQvxZokk+FD7aMJFP71Ifi
eLCEzMTP1VXatP7UTAKAqB2NyDJ8m3IHbOINiqcdFvFR3R1D9bXOYE4oRynNvYZr
QUGnL2RtAgMBAAECggEAJaFQii8U/KOYt9vXNoMnZvSkaeSQLLhn2V6Kciu1CtWE
aMTWLsFE6nk+G5xXkYcTmM3T0GghtH3u5CjyI6EcsEkeEorCZJt0wbmayDmqiekR
LfMzOdHuTHX5+edPgMGYYG1BFyRKyYFsjH1b5zRFZhXdGQnrl5760GsVlz9D1KZQ
iHcT+q1S2tmZeoUukQnADENKXUMCyTGM5FCddgNtsWnGDsTDayh7hUdvDkB+mW4G
lSp+BZuc3PCwpbD6qkXvfugWs6CUAAtXoV3ceWgxQ+TEnNlwxaG1AyugfgNUBolk
8xgeZt4r5QId03jsHDf7hpBAofcaCd5EMIIQYFvWoQKBgQDlbAvAzEFPTZZn2nRV
Xagw4xjqVc1LLEKLCWq0N5rEkwn0h90Dz5N7/3NuonP/sIDsDHCbyiOYBI1Ck6Xi
0WuB+OyKDh+xeF2mekN9G9ywPahdK5lT/TVsxXFyZlwtVv1x/6KBO4yv5URizxqU
gyAPDDxfD/KcNjkOBaodWEwQGQKBgQC/s2gPDBtQkjLwkHXchBomLww5eLlVrac1
WK4UX6uSdOgrjJ375OOgMTxe8NVZdOuAKytGXRWDwgH3nVWvuZhe7dGlX3JMuSer
e9VwDpBESrvqcR4ruL6wm8wej6BXyjH0wD3FHb0S5HfuBDxTn+4bDwrbRzOUMNgy
lSppuflxdQKBgQDiZcIfazFT8evn5nMAvuC4BZNTxIJHmZC9JfjPiUPIkpWzYtOe
7BvNtKOT3Op9uw8uYYRKqKqBXJSNy6ha8XCXHS9HeXKbLn20SFkLQBCDNwVLlDfF
40zyXtF6JDr4XyzSb4NM5pgKCER5AYloXxGm59s3sEQpFXUuOjbKqJS/GQKBgAoI
c7vF4HAZFr1sch62cz/oWnVvkhOf4Q5zs7ixQSOLJtOQqnwSgK9TpFs7s47ZBbJR
kBRAru2Ua9Hv1Bo8VnMxczV6h1roneDlvEf/GyHX33nnrbKQGrrXjJlU3wl5NaAf
p5v3cHvapUQ5yIZ/6lBUOzc6xMJOxCHxmKSr7Rg5AoGAbEE4lt6Xh2dnBPJ81eNI
IDrw/3ITY53qAY4Bx88CByIFuu8CEUdUZprh98jSl6ic1tMinZfUhRMwABLrUD51
DGst8iGLPD9u83iMcUHI/L+p7AbxrKLvWXZrF5UZm440c9mSWqfhPaTBosPtNDsG
LfETwH1flKXMTXd2xA9RTE4=
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK1DNwTQWmyK71
Ar56NvmSMQ8sqUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgf
JlZMd4lEvt6VEEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+
7Z22i6cGMOnQ0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6Wx
JmFF+eV9P/axfxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiV
G1jCrVcaKNGTPYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrnc
neIJR24/AgMBAAECggEANRp6vzfDN4gKWoaV5TyYegCTNv+4rRl74cd9pjmx6Jam
uWaUXCx1etpNqPPWcG1Z9OKLLRnk+kjgKGOqq4mObGvGreNeBot7bHOJZADtwMMI
YG+Gp7StlclS1YoEHoDmezA/AcqDgTXa9KF0rdMBb1sGFJCLAuBNSJCxtVV6CQIz
X26uT0m+Wx8MQyQWA7Sqy6DQNJo++IZkvr7a3cidqBOUPs+QvnIV5JsUb2gp5tGn
zk+ObeRjoFFWYAN/NK7bneRenkP40m3MSL8ZfaEuuonui7CrxM1SiQyq2N1u/Aoy
OE1JtNaVPbLBo6kG5al7Sj4Z0zhRt+iv93S2lZMkBQKBgQD2+FpLTqyLO1NDOFkE
kxU+LdLOx0OV9wASC0ApPOu1dHMG6ksByr7TWeiu6GJDgajusPB7NVPOt2cm4iWU
xPxXPO5l87uiSvu80h5uG4Qdj8KEijHkdap2wbVkU/mm8lBKC36jyBQIlJKySyXY
zSEMfLK9jQPKz5cKKT3dVj/fAwKBgQDSPq9oks6K96MAB66o6cm214otQlnTQkPM
xgjtjddX+Lp9tgihGvtSfPbyy89oUDHCfKvW/AHG52e5dec5YUi6mVdHEWbk33Kt
BoQuxeK3XseIDlD/JD9Dd7KfUyO5w2jtYLfNdqez41O4qj2N52m1KwJYTwMsc8Kq
izVgkC5hFQKBgBFAc/5CtqbbNAvECePZ6mf3h3xOSxhUsrqP8qFu0gBQ7CAVibvM
T9wvsaNWNFcG3age0A2rQfl0sk3zCjEEOaRWa0jP59GEb2VXQCzs2yO9gRcFGEsf
NRMqoOMrQos47gbeGrCSL2QSDNVLjo9AdQiMRWgcS6GFMsXQ77NgbQHFAoGBAI4a
YGTGFWRITJvQlXUFz5kNxg8hMaVgvILDt3UY0dxb+XDOgLajjgsK+77Pkrhmu7tA
mMUOQAU4kxr/XfGil43H5v3Z/Tnk7ZWVOfKDPeHC5gpH4ucQkNIBLXISt6rvMRSA
srrk4CTuGcBPEJvBNemF0Gfvv61j8MdkoAdMbIyhAoGAfGR6yZLBmRMsW5PKmcpT
nq2oSeUpmtGZra6pWz/3XU7AgrCLcx1DmqEjm4w7y5NQJmxyMZqqdTJILCjr3Srt
+2F0NqQL6Li+xQGibAvDj0Jxyol38RvFC0J/w2vQmuF0hTuH95yknSd7FPXK+DPG
qYgXLjun9dht6kx9vGJ69wI=
-----END PRIVATE KEY-----

View File

@@ -34,5 +34,6 @@ subjectAltName = @alt_names
DNS.1 = localhost
DNS.2 = m.localhost
DNS.3 = *.m.localhost
DNS.4 = *.othersite.m.localhost
EOF
)

View File

@@ -0,0 +1,53 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://synapse.othersite.m.localhost",
"server_name": "synapse.othersite.m.localhost"
}
},
"disable_custom_urls": false,
"disable_guests": false,
"disable_login_language_selector": false,
"disable_3pid_login": false,
"force_verification": false,
"brand": "Element",
"integrations_ui_url": "https://scalar.vector.im/",
"integrations_rest_url": "https://scalar.vector.im/api",
"integrations_widgets_urls": [
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
],
"default_widget_container_height": 280,
"default_country_code": "GB",
"show_labs_settings": false,
"features": {
"feature_element_call_video_rooms": true,
"feature_video_rooms": true,
"feature_group_calls": true,
"feature_release_announcement": false
},
"default_federate": true,
"default_theme": "light",
"room_directory": {
"servers": ["matrix.org"]
},
"enable_presence_by_hs_url": {
"https://matrix.org": false,
"https://matrix-client.matrix.org": false
},
"setting_defaults": {
"breadcrumbs": true,
"feature_group_calls": true
},
"jitsi": {
"preferred_domain": "meet.element.io"
},
"element_call": {
"participant_limit": 8,
"brand": "Element Call"
},
"map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx"
}

View File

@@ -0,0 +1,81 @@
server_name: "synapse.othersite.m.localhost"
public_baseurl: https://synapse.othersite.m.localhost/
pid_file: /data/homeserver.pid
listeners:
- port: 18008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation, openid]
compress: false
database:
name: sqlite3
args:
database: /data/homeserver.db
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation.
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "synapse.m.localhost"
accept_keys_insecurely: true
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
# - per_second: number of requests a client can send per second.
# - burst_count: number of requests a client can send before being throttled.
rc_message:
per_second: 10000
burst_count: 10000
rc_delayed_event_mgmt:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000

View File

@@ -19,8 +19,18 @@ database:
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation.
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "matrix.org"
- server_name: "synapse.othersite.m.localhost"
accept_keys_insecurely: true
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
@@ -34,6 +44,13 @@ experimental_features:
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
@@ -44,6 +61,10 @@ rc_message:
per_second: 10000
burst_count: 10000
rc_delayed_event_mgmt:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
@@ -58,10 +79,3 @@ rc_login:
rc_registration:
per_second: 10000
burst_count: 10000
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true

View File

@@ -1,5 +0,0 @@
bind 0.0.0.0
protected-mode yes
port 6379
timeout 0
tcp-keepalive 300

View File

@@ -24,8 +24,31 @@ services:
networks:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
pull_policy: always
hostname: auth-server-1
environment:
- LIVEKIT_JWT_PORT=16080
- LIVEKIT_URL=wss://matrix-rtc.othersite.m.localhost/livekit/sfu
- LIVEKIT_KEY=devkey
- LIVEKIT_SECRET=secret
# If the configured homeserver runs on localhost, it'll probably be using
# a self-signed certificate
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
deploy:
restart_policy:
condition: on-failure
ports:
# HOST_PORT:CONTAINER_PORT
- 16080:16080
networks:
- ecbackend
livekit:
image: livekit/livekit-server:latest
pull_policy: always
hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml
restart: unless-stopped
@@ -43,20 +66,30 @@ services:
networks:
- ecbackend
redis:
image: redis:6-alpine
command: redis-server /etc/redis.conf
livekit-1:
image: livekit/livekit-server:latest
pull_policy: always
hostname: livekit-sfu-1
command: --dev --config /etc/livekit.yaml
restart: unless-stopped
# The SFU seems to work far more reliably when we let it share the host
# network rather than opening specific ports (but why?? we're not missing
# any…)
ports:
# HOST_PORT:CONTAINER_PORT
- 6379:6379
- 17880:17880/tcp
- 17881:17881/tcp
- 17882:17882/tcp
- 50300-50400:50300-50400/udp
volumes:
- ./backend/redis.conf:/etc/redis.conf:Z
- ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
networks:
- ecbackend
synapse:
hostname: homeserver
image: docker.io/matrixdotorg/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
# Needed for rootless podman-compose such that the uid/gid mapping does
@@ -71,6 +104,24 @@ services:
networks:
- ecbackend
synapse-1:
hostname: homeserver-1
image: docker.io/matrixdotorg/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
# Needed for rootless podman-compose such that the uid/gid mapping does
# fit local user uid. If the container runs as root (uid 0) it is fine as
# it actually maps to your non-root user on the host (e.g. 1000).
# Otherwise uid mapping will not match your non-root user.
- UID=0
- GID=0
volumes:
- ./backend/synapse_tmp_othersite:/data:Z
- ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
networks:
- ecbackend
element-web:
image: ghcr.io/element-hq/element-web:develop
pull_policy: always
@@ -83,10 +134,24 @@ services:
networks:
- ecbackend
element-web-1:
image: ghcr.io/element-hq/element-web:develop
pull_policy: always
volumes:
- ./backend/ew.test.othersite.config.json:/app/config.json:Z
environment:
ELEMENT_WEB_PORT: 18081
ports:
# HOST_PORT:CONTAINER_PORT
- "18081:18081"
networks:
- ecbackend
nginx:
# see backend/dev_tls_setup for how to generate the tls certs
hostname: synapse.m.localhost
image: nginx:latest
pull_policy: always
volumes:
- ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z
- ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z
@@ -104,4 +169,7 @@ services:
networks:
ecbackend:
aliases:
- synapse.m.localhost
- synapse.othersite.m.localhost
- matrix-rtc.m.localhost
- matrix-rtc.othersite.m.localhost

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 929 KiB

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 886 KiB

After

Width:  |  Height:  |  Size: 908 KiB

View File

@@ -64,7 +64,7 @@ rc_delayed_event_mgmt:
```
As a prerequisite for the
[Matrix LiveKit JWT auth service](https://github.com/element-hq/lk-jwt-service)
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
make sure that your Synapse server has either a `federation` or `openid`
[listener configured](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#listeners).
@@ -77,7 +77,7 @@ required for each site deployment.
As depicted above in the `example.com` site deployment, Element Call requires a
[Livekit SFU](https://github.com/livekit/livekit) alongside a
[Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service)
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
to implement
[MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
@@ -89,7 +89,7 @@ the example above, this results in:
| Service | Endpoint | Example |
| -------- | ------- | ------- |
| [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` |
| [Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
Using Nginx, you can achieve this by:
@@ -102,7 +102,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# JWT Service running at port 8080
# MatrixRTC Authorization Service running at port 8080
proxy_pass http://localhost:8080/;
}
@@ -126,6 +126,32 @@ server {
}
```
Or Using Caddy, you can achieve this by:
```caddy configuration file
# Route for lk-jwt-service with livekit/jwt prefix
@jwt_service path /livekit/jwt/sfu/get /livekit/jwt/healthz
handle @jwt_service {
uri strip_prefix /livekit/jwt
reverse_proxy http://[::1]:8080 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
# Default route for livekit
handle {
reverse_proxy http://localhost:7880 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
```
#### MatrixRTC backend announcement
> [!IMPORTANT]
@@ -145,10 +171,6 @@ server {
{
"type": "livekit",
"livekit_service_url": "https://matrix-rtc-2.example.com/livekit/jwt"
},
{
"type": "nextgen_new_foci_type",
"props_for_nextgen_foci": "val"
}
]
```
@@ -218,7 +240,7 @@ server {
There are currently two different config files. `.env` holds variables that are
used at build time, while `public/config.json` holds variables that are used at
runtime. Documentation and default values for `public/config.json` can be found
in [ConfigOptions.ts](src/config/ConfigOptions.ts).
in [ConfigOptions.ts](../src/config/ConfigOptions.ts).
> [!CAUTION]
> Please note configuring MatrixRTC backend via `config.json` of
@@ -261,6 +283,7 @@ self-hosters and developers working with Element Call.
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
- [MatrixRTC Back-End for Synapse with Docker Compose and Traefik](https://forge.avontech.net/kstro1/matrixrtc-docker-traefik/)
## 🛠️ Tools

View File

@@ -48,6 +48,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| Name | Values | Required for widget | Required for SPA | Description |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesnt provide any. |
| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
@@ -59,7 +60,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
| `intent` | `start_call` or `join_existing` | No, defaults to `start_call` | No, defaults to `start_call` | The intent of the user with respect to the call. e.g. if they clicked a Start Call button, this would be `start_call`. If it was a Join Call button, it would be `join_existing`. |
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
@@ -70,6 +70,8 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
### Widget-only parameters

View File

@@ -2,11 +2,11 @@
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions]
android_gradle_plugin = "8.11.0"
android_gradle_plugin = "8.11.1"
[libraries]
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
[plugins]
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.33.0" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.34.0" }

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -5,8 +5,6 @@
* Please see LICENSE files in the repository root for full details.
*/
import com.vanniktech.maven.publish.SonatypeHost
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.maven.publish)
@@ -27,7 +25,7 @@ android {
}
mavenPublishing {
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
publishToMavenCentral(automaticRelease = true)
signAllPublications()

View File

@@ -1,4 +1,6 @@
export default {
import type { UserConfig } from "i18next-parser";
const config: UserConfig = {
keySeparator: ".",
namespaceSeparator: false,
contextSeparator: "|",
@@ -26,3 +28,5 @@ export default {
input: ["src/**/*.{ts,tsx}"],
sort: true,
};
export default config;

11
knip.ts
View File

@@ -1,8 +1,15 @@
import { KnipConfig } from "knip";
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type KnipConfig } from "knip";
export default {
vite: {
config: ["vite.config.js", "vite-embedded.config.js"],
config: ["vite.config.ts", "vite-embedded.config.ts"],
},
entry: ["src/main.tsx", "i18next-parser.config.ts"],
ignoreBinaries: [

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Zobrazit možnost sluchátek pro iPhone na všech platformách",
"crypto_version": "Kryptografická verze: {{version}}",
"debug_tile_layout_label": "Ladění rozložení dlaždic",
"device_id": "ID zařízení: {{id}}",
@@ -81,7 +82,7 @@
"error": {
"call_is_not_supported": "Volání není podporováno",
"call_not_found": "Volání nebylo nalezeno",
"call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu volání. Zkontrolujte, zda máte správný odkaz, nebo <1>vytvořte nový</1>.</0>",
"call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu hovoru. Zkontrolujte, zda máte správný odkaz, nebo<2> vytvořte nový</2>.</0>",
"connection_lost": "Spojení ztraceno",
"connection_lost_description": "Hovor byl přerušen.",
"e2ee_unsupported": "Nekompatibilní prohlížeč",
@@ -104,6 +105,11 @@
"knock_reject_heading": "Přístup odepřen",
"reason": "Důvod"
},
"handset": {
"overlay_back_button": "Zpět do režimu reproduktoru",
"overlay_description": "Funguje pouze při používání aplikace",
"overlay_title": "Režim sluchátka"
},
"hangup_button_label": "Ukončit hovor",
"header_label": "Domov Element Call",
"header_participants_label": "Účastníci",
@@ -173,8 +179,11 @@
"devices": {
"camera": "Fotoaparát",
"camera_numbered": "Fotoaparát {{n}}",
"change_device_button": "Změnit zvukové zařízení",
"default": "Výchozí",
"default_named": "Výchozí <2> ({{name}}) </2>",
"handset": "Sluchátko",
"loudspeaker": "Reproduktor",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Reproduktor",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Vis mulighed for iPhone-høretelefon på alle platforme",
"crypto_version": "Krypto-version: {{version}}",
"debug_tile_layout_label": "Fejlfinding af fliselayout",
"device_id": "Enheds-id: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "LiveKit Serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Slå al lyd fra (deltagere, reaktioner, deltagelseslyde)",
"show_connection_stats": "Vis forbindelsesstatistik",
"show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer",
"url_params": "URL-parametre",
@@ -80,7 +82,7 @@
"error": {
"call_is_not_supported": "Opkald er ikke understøttet",
"call_not_found": "Opkald ikke fundet",
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller<1> opret et nyt</1>.</0>",
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller <2> opret et nyt</2>.</0>",
"connection_lost": "Forbindelsen gik tabt",
"connection_lost_description": "Du blev afbrudt fra opkaldet.",
"e2ee_unsupported": "Inkompatibel browser",
@@ -92,6 +94,8 @@
"matrix_rtc_focus_missing": "Serveren er ikke konfigureret til at arbejde med {{brand}}{{domain}}. Kontakt venligst din serveradministrator (domæne:{{domain}}, fejlkode: {{ errorCode }}).",
"open_elsewhere": "Åbnet i en anden fane",
"open_elsewhere_description": "{{brand}} er blevet åbnet i en anden fane. Hvis det ikke lyder rigtigt, kan du prøve at genindlæse siden.",
"room_creation_restricted": "Kunne ikke oprette opkald",
"room_creation_restricted_description": "Oprettelse af opkald er muligvis begrænset til autoriserede brugere. Prøv igen senere, eller kontakt din serveradministrator, hvis problemet fortsætter.",
"unexpected_ec_error": "Der opstod en uventet fejl (<0>Fejlkode:</0> <1> {{ errorCode }}</1>). Kontakt venligst din serveradministrator."
},
"group_call_loader": {
@@ -103,6 +107,11 @@
"knock_reject_heading": "Adgang nægtet",
"reason": "Årsag: {{reason}}"
},
"handset": {
"overlay_back_button": "Tilbage til højttalertilstand",
"overlay_description": "Virker kun, når du bruger appen",
"overlay_title": "Telefon-højtaler"
},
"hangup_button_label": "Afslut opkald",
"header_label": "Element Ring hjem",
"header_participants_label": "Deltagere",
@@ -164,12 +173,18 @@
"effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.",
"effect_volume_label": "Lydstyrke for lydeffekter"
},
"background_blur_header": "Baggrund",
"background_blur_label": "Gør videoens baggrund sløret",
"blur_not_supported_by_browser": "(Baggrundssløring understøttes ikke af denne enhed.)",
"developer_tab_title": "Udvikler",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Skift lydenhed",
"default": "Standard",
"default_named": "Standard <2>({{name}})</2>",
"handset": "Telefon",
"loudspeaker": "Højttaler",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Højttaler",

View File

@@ -55,6 +55,7 @@
"profile": "Profil",
"reaction": "Reaktion",
"reactions": "Reaktionen",
"reconnecting": "Verbindung wird wiederhergestellt...",
"settings": "Einstellungen",
"unencrypted": "Nicht verschlüsselt",
"username": "Benutzername",
@@ -82,7 +83,7 @@
"error": {
"call_is_not_supported": "Anrufe werden nicht unterstützt",
"call_not_found": "Anruf nicht gefunden",
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Vergewissern Sie sich, dass Sie den richtigen Link haben, oder <1> erstellen Sie einen neuen</1>. </0>",
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Es sollte geprüft werden, ob der Link korrekt ist, oder <2>ein neuer erstellt werden</2>.</0>",
"connection_lost": "Verbindung verloren",
"connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.",
"e2ee_unsupported": "Inkompatibler Browser",
@@ -94,6 +95,8 @@
"matrix_rtc_focus_missing": "Der Server ist nicht für die Verwendung mit {{brand}} konfiguriert. Bitte den Serveradministrator kontaktieren (Domain: {{domain}}, Fehlercode: {{ errorCode }}).",
"open_elsewhere": "In einem anderen Tab geöffnet",
"open_elsewhere_description": "{{brand}} wurde in einem anderen Tab geöffnet. Wenn das nicht richtig klingt, versuchen Sie, die Seite neu zu laden.",
"room_creation_restricted": "Anruf konnte nicht erstellt werden",
"room_creation_restricted_description": "Das Erstellen von Anrufen ist nur für autorisierte Nutzer möglich. Versuche es später erneut oder kontaktiere deinen Serveradministrator, falls das Problem weiterhin besteht.",
"unexpected_ec_error": "Ein unerwarteter Fehler ist aufgetreten (<0>Fehlercode: </0> <1>{{ errorCode }}</1>). Bitte den Serveradministrator kontaktieren."
},
"group_call_loader": {
@@ -105,6 +108,11 @@
"knock_reject_heading": "Zugriff verweigert",
"reason": "Grund: {{reason}}"
},
"handset": {
"overlay_back_button": "Zurück zum Lautsprechermodus",
"overlay_description": "Nur wenn App im Vordergrund nutzbar",
"overlay_title": "Ohrhörer Modus"
},
"hangup_button_label": "Anruf beenden",
"header_label": "Element Call-Startseite",
"header_participants_label": "Teilnehmende",
@@ -173,9 +181,11 @@
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Audiogerät wechseln",
"default": "Standard",
"default_named": "Standard<2> ({{name}} )</2>",
"handset": "Ohrhörer",
"loudspeaker": "Lautsprecher",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon{{n}}",
"speaker": "Lautsprecher",

View File

@@ -55,6 +55,7 @@
"profile": "Profile",
"reaction": "Reaction",
"reactions": "Reactions",
"reconnecting": "Reconnecting…",
"settings": "Settings",
"unencrypted": "Not encrypted",
"username": "Username",
@@ -71,12 +72,24 @@
"livekit_server_info": "LiveKit Server Info",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)",
"label": "Compatibility: state events & multi SFU"
},
"Legacy": {
"description": "Compatible with old versions of EC that do not support multi SFU",
"label": "Legacy: state events & oldest membership SFU"
},
"Matrix_2_0": {
"description": "Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later",
"label": "Matrix 2.0: sticky events & multi SFU"
},
"title": "MatrixRTC mode"
},
"mute_all_audio": "Mute all audio (participants, reactions, join sounds)",
"show_connection_stats": "Show connection statistics",
"show_non_member_tiles": "Show tiles for non-member media",
"url_params": "URL parameters",
"use_new_membership_manager": "Use the new implementation of the call MembershipManager",
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
"url_params": "URL parameters"
},
"disconnected_banner": "Connectivity to the server has been lost.",
"error": {
@@ -91,9 +104,11 @@
"generic_description": "Submitting debug logs will help us track down the problem.",
"insufficient_capacity": "Insufficient capacity",
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"room_creation_restricted": "Failed to create call",
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
},
"group_call_loader": {

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Näita iPhone'i kuulari valikut kõikidel platvormidel",
"crypto_version": "Krüptoteekide versioon: {{version}}",
"debug_tile_layout_label": "Meediapaanide paigutus",
"device_id": "Seadme tunnus: {{id}}",
@@ -81,7 +82,7 @@
"error": {
"call_is_not_supported": "Kõne pole toetatud",
"call_not_found": "Kõnet ei leidu",
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <1>loo uus</1>.</0>",
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <2>loo uus</2>.</0>",
"connection_lost": "Ühendus on katkenud",
"connection_lost_description": "Sinu ühendus selle kõnega on katkenud.",
"e2ee_unsupported": "Mitteühilduv brauser",
@@ -93,6 +94,8 @@
"matrix_rtc_focus_missing": "See server pole seadistatud töötama rakendusega {{brand}}. Palun võta ühendust serveri halduriga (domeen: {{domain}}, veakood: {{ errorCode }}).",
"open_elsewhere": "Avatud teisel vahekaardil",
"open_elsewhere_description": "{{brand}} on avatud teisel vahekaardil. Kui see ei tundu olema õige, proovi selle lehe uuesti laadimist.",
"room_creation_restricted": "Kõne loomine ei õnnestunud",
"room_creation_restricted_description": "Kõne loomine võib olla lubatud ainult volitatud kasutajatele. Proovi hiljem uuesti või probleemi püsimisel võta ühendust oma serveri haldajaga.",
"unexpected_ec_error": "Tekkis ootamatu viga (<0>Veakood:</0> <1>{{ errorCode }}</1>). Palun võta ühendust serveri haldajaga."
},
"group_call_loader": {
@@ -104,6 +107,10 @@
"knock_reject_heading": "Liitumine pole lubatud",
"reason": "Põhjus"
},
"handset": {
"overlay_back_button": "Tagasi esineja vaatesse",
"overlay_description": "See toimib vaid rakenduse kasutamise ajal"
},
"hangup_button_label": "Lõpeta kõne",
"header_label": "Avaleht: Element Call",
"header_participants_label": "Osalejad",
@@ -172,8 +179,10 @@
"devices": {
"camera": "Kaamera",
"camera_numbered": "Kaamera {{n}}",
"change_device_button": "Muuda heliseadet",
"default": "Vaikimisi",
"default_named": "Vaikimisi <2>({{name}})</2>",
"loudspeaker": "Valjuhääldi",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Kõlar",

View File

@@ -61,6 +61,7 @@
"video": "Видео"
},
"developer_mode": {
"always_show_iphone_earpiece": "Показать опцию наушников для iPhone на всех платформах",
"crypto_version": "Версия криптографии: {{version}}",
"debug_tile_layout_label": "Отладка расположения плиток",
"device_id": "Идентификатор устройства: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "Информация о сервере LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Отключить все звуки (участников, реакции, звуки присоединения)",
"show_connection_stats": "Показать статистику подключений",
"show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками",
"url_params": "Параметры URL-адреса",
@@ -172,6 +174,7 @@
"devices": {
"camera": "Камера",
"camera_numbered": "Камера {{n}}",
"change_device_button": "Изменить аудиоустройство",
"default": "По умолчанию",
"default_named": "По умолчанию <2>({{name}})</2>",
"microphone": "Микрофон",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Zobraziť možnosť slúchadla iPhone na všetkých platformách",
"crypto_version": "Krypto verzia: {{version}}",
"debug_tile_layout_label": "Ladenie rozloženia dlaždíc",
"device_id": "ID zariadenia: {{id}}",
@@ -81,7 +82,7 @@
"error": {
"call_is_not_supported": "Hovor nie je podporovaný",
"call_not_found": "Hovor nebol nájdený",
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <1> vytvorte nový</1>. </0>",
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <2>vytvorte nový</2>.</0>",
"connection_lost": "Strata spojenia",
"connection_lost_description": "Boli ste odpojení od hovoru.",
"e2ee_unsupported": "Nekompatibilný prehliadač",
@@ -173,8 +174,10 @@
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Zmeniť zvukové zariadenie",
"default": "Predvolené",
"default_named": "Predvolené <2>({{name}})</2>",
"loudspeaker": "Reproduktor",
"microphone": "Mikrofón",
"microphone_numbered": "Mikrofón {{n}}",
"speaker": "Reproduktor",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Visa iPhone-hörsnäckealternativ på alla plattformar",
"crypto_version": "Kryptoversion: {{version}}",
"debug_tile_layout_label": "Felsök panelarrangemang",
"device_id": "Enhets-ID: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "LiveKit-serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix-ID: {{id}}",
"mute_all_audio": "Tysta allt ljud (deltagare, reaktioner, anslutningsljud)",
"show_connection_stats": "Visa anslutningsstatistik",
"show_non_member_tiles": "Visa paneler för media som inte är medlemmar",
"url_params": "URL-parametrar",
@@ -80,7 +82,7 @@
"error": {
"call_is_not_supported": "Call stöds inte",
"call_not_found": "Samtal hittades inte",
"call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <1>skapa en ny</1>.</0>",
"call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <2>skapa en ny</2>.</0>",
"connection_lost": "Anslutning förlorad",
"connection_lost_description": "Du kopplades bort från samtalet.",
"e2ee_unsupported": "Inkompatibel webbläsare",
@@ -92,6 +94,8 @@
"matrix_rtc_focus_missing": "Servern är inte konfigurerad för att fungera med {{brand}}. Vänligen kontakta serveradministratören (Domän: {{domain}}, Felkod: {{ errorCode }}).",
"open_elsewhere": "Öppnades i en annan flik",
"open_elsewhere_description": "{{brand}} har öppnats i en annan flik. Om det inte låter rätt, pröva att ladda om sidan.",
"room_creation_restricted": "Misslyckades att skapa samtal",
"room_creation_restricted_description": "Samtalsskapande kan vara begränsat till endast behöriga användare. Försök igen senare eller kontakta serveradministratören om problemet kvarstår.",
"unexpected_ec_error": "Ett oväntat fel inträffade (<0>Felkod:</0> <1>{{ errorCode }}</1>). Kontakta din serveradministratör."
},
"group_call_loader": {
@@ -103,6 +107,11 @@
"knock_reject_heading": "Inte tillåten att gå med",
"reason": "Anledning"
},
"handset": {
"overlay_back_button": "Tillbaka till högtalarläge",
"overlay_description": "Fungerar bara när appen används",
"overlay_title": "Telefonläge"
},
"hangup_button_label": "Avsluta samtal",
"header_label": "Element Call Hem",
"header_participants_label": "Deltagare",
@@ -164,12 +173,18 @@
"effect_volume_description": "Justera volymen vid vilken reaktioner och handuppräckningseffekter spelas",
"effect_volume_label": "Ljudeffektsvolym"
},
"background_blur_header": "Bakgrund",
"background_blur_label": "Gör bakgrunden i videon suddig",
"blur_not_supported_by_browser": "(Bakgrundssuddighet stöds inte av den här enheten.)",
"developer_tab_title": "Utvecklare",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Byt ljudenhet",
"default": "Förval",
"default_named": "Förval <2>({{name}})</2>",
"handset": "Telefonlur",
"loudspeaker": "Högtalare",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Högtalare",

View File

@@ -44,17 +44,17 @@
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.12.0",
"@livekit/components-react": "^2.0.0",
"@livekit/protocol": "^1.38.0",
"@livekit/protocol": "^1.42.2",
"@livekit/track-processors": "^0.5.5",
"@mediapipe/tasks-vision": "^0.10.18",
"@opentelemetry/api": "^1.4.0",
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
"@opentelemetry/resources": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@playwright/test": "^1.52.0",
"@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
@@ -81,7 +81,7 @@
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^5.0.0",
"@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-web": "^8.0.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^3.0.0",
@@ -99,6 +99,7 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-unicorn": "^56.0.0",
"fetch-mock": "11.1.5",
"global-jsdom": "^26.0.0",
"i18next": "^24.0.0",
"i18next-browser-languagedetector": "^8.0.0",
@@ -108,7 +109,7 @@
"livekit-client": "^2.13.0",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=toger5/sticky-events&commit=e7f5bec51b6f70501a025b79fe5021c933385b21",
"matrix-widget-api": "^1.13.0",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
@@ -129,7 +130,7 @@
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"vaul": "^1.0.0",
"vite": "^6.0.0",
"vite": "^7.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-svgr": "^4.0.0",

View File

@@ -2,3 +2,6 @@ services:
synapse:
volumes:
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
synapse-1:
volumes:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z

View File

@@ -40,6 +40,11 @@ test("Start a new call then leave and show the feedback screen", async ({
// The tooltip with the name should be visible
await expect(page.getByTestId("name_tag")).toContainText("John Doe");
// Resize the window to resemble a small mobile phone
await page.setViewportSize({ width: 350, height: 660 });
// We should still be able to send reactions at this screen size
await expect(page.getByRole("button", { name: "Reactions" })).toBeVisible();
// leave the call
await page.getByTestId("incall_leave").click();
await expect(page.getByRole("heading")).toContainText(

View File

@@ -72,3 +72,56 @@ test("Should automatically retry non fatal JWT errors", async ({
await hasRetriedPromise;
await expect(page.getByTestId("video").first()).toBeVisible();
});
test("Should show error screen if call creation is restricted", async ({
page,
}) => {
await page.goto("/");
// We need the socket connection to fail, but this cannot be done by using the websocket route.
// Instead, we will trick the app by returning a bad URL for the SFU that will not be reachable an error out.
await page.route(
"**/matrix-rtc.m.localhost/livekit/jwt/sfu/get",
async (route) =>
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
url: "wss://badurltotricktest/livekit/sfu",
jwt: "FAKE",
}),
}),
);
// Then if the socket connection fails, livekit will try to validate the token!
// Livekit will not auto_create anymore and will return a 404 error.
await page.route(
"**/badurltotricktest/livekit/sfu/rtc/validate?**",
async (route) =>
await route.fulfill({
status: 404,
contentType: "text/plain",
body: "requested room does not exist",
}),
);
await page.pause();
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
// Join the call
await page.getByTestId("lobby_joinCall").click();
await page.pause();
// Should fail
await expect(page.getByText("Failed to create call")).toBeVisible();
await expect(
page.getByText(
/Call creation might be restricted to authorized users only/,
),
).toBeVisible();
});

View File

@@ -159,8 +159,8 @@ export const widgetTest = test.extend<MyFixtures>({
} = await registerUser(browser, userB);
// Invite the second user
await ewPage1.getByRole("button", { name: "Add room" }).click();
await ewPage1.getByText("New room").click();
await ewPage1.getByRole("button", { name: "Add", exact: true }).click();
await ewPage1.getByRole("menuitem", { name: "New Room" }).click();
await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room");
await ewPage1.getByRole("button", { name: "Create room" }).click();
await expect(ewPage1.getByText("You created this room.")).toBeVisible();
@@ -184,9 +184,9 @@ export const widgetTest = test.extend<MyFixtures>({
// Accept the invite
await expect(
ewPage2.getByRole("treeitem", { name: "Welcome Room" }),
ewPage2.getByRole("option", { name: "Welcome Room" }),
).toBeVisible();
await ewPage2.getByRole("treeitem", { name: "Welcome Room" }).click();
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }),

View File

@@ -0,0 +1,60 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
test.skip(
({ browserName }) => browserName === "firefox",
'This test is not working on firefox, page.keyboard.press("Tab") not reliable in headless mode',
);
test("can only interact with header and footer while reconnecting", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("Test call");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("Test user");
// If we do not call fastForward here, we end up with Date.now() returning an actual timestamp
// but once we call `await page.clock.fastForward(20000);` later this will reset Date.now() to 0
// and we will never get into probablyDisconnected state?
await page.clock.fastForward(10);
await page.getByTestId("home_go").click();
await expect(page.locator("video")).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
// Join the call
await page.getByTestId("lobby_joinCall").click();
// The media tile for the local user should become visible
await new Promise((resolve) => setTimeout(resolve, 1500));
await expect(page.getByTestId("name_tag")).toContainText("Test user");
// Now disconnect from the internet
await page.route("https://synapse.m.localhost/**/*", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 10000));
await route.continue();
});
await page.clock.fastForward(20000);
await expect(
page.getByRole("dialog", { name: "Reconnecting…" }),
).toBeVisible();
// Tab order should jump directly from header to footer, skipping media tiles
await page.getByRole("button", { name: "Mute microphone" }).focus();
await expect(
page.getByRole("button", { name: "Mute microphone" }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click();
});

View File

@@ -0,0 +1,75 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { sleep } from "matrix-js-sdk/lib/utils.js";
test("Should request JWT token before starting the call", async ({ page }) => {
await page.goto("/");
let sfGetTimestamp = 0;
let sendStateEventTimestamp = 0;
await page.route(
"**/matrix-rtc.m.localhost/livekit/jwt/sfu/get",
async (route) => {
await sleep(2000); // Simulate very slow request
await route.continue();
sfGetTimestamp = Date.now();
},
);
await page.route(
"**/state/org.matrix.msc3401.call.member/**",
async (route) => {
await route.continue();
sendStateEventTimestamp = Date.now();
},
);
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
// Join the call
await page.getByTestId("lobby_joinCall").click();
await page.waitForTimeout(4000);
// Ensure that the call is connected
await page
.locator("div")
.filter({ hasText: /^HelloCall$/ })
.click();
expect(sfGetTimestamp).toBeGreaterThan(0);
expect(sendStateEventTimestamp).toBeGreaterThan(0);
expect(sfGetTimestamp).toBeLessThan(sendStateEventTimestamp);
});
test("Error when pre-warming the focus are caught by the ErrorBoundary", async ({
page,
}) => {
await page.goto("/");
await page.route("**/openid/request_token", async (route) => {
await route.fulfill({
status: 418, // Simulate an error not retryable
});
});
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
// Join the call
await page.getByTestId("lobby_joinCall").click();
// Should fail
await expect(page.getByText("Something went wrong")).toBeVisible();
});

View File

@@ -100,5 +100,5 @@ test("When creator left, avoid reconnect to the same SFU", async ({
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1);
expect(sfuGetCallCount).toBe(1);
expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
});

View File

@@ -49,7 +49,10 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
// Check the join indicator on the room list
await expect(
brooks.page.locator("div").filter({ hasText: /^Joined • 1$/ }),
brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByRole("button", { name: "End call" }),
).toBeVisible();
// Join from the other side
@@ -59,26 +62,28 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
).toBeVisible();
await whistler.page.getByRole("button", { name: "Join" }).click();
await expect(
whistler.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall"),
).toBeVisible();
// Currently disabled due to recent Element Web is bypassing Lobby
// await expect(
// whistler.page
// .locator('iframe[title="Element Call"]')
// .contentFrame()
// .getByTestId("lobby_joinCall"),
// ).toBeVisible();
//
// await whistler.page
// .locator('iframe[title="Element Call"]')
// .contentFrame()
// .getByTestId("lobby_joinCall")
// .click();
await whistler.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall")
.click();
// Currrenty disabled due to recent Element Web not indicating the number of participants
// await expect(
// whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
// ).toBeVisible();
await expect(
whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
).toBeVisible();
await expect(
brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
).toBeVisible();
// await expect(
// brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
// ).toBeVisible();
// Whistler leaves
await whistler.page.waitForTimeout(1000);

View File

@@ -24,7 +24,6 @@ import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { DisconnectedBanner } from "./DisconnectedBanner";
import { Initializer } from "./initializer";
import { widget } from "./widget";
import { useTheme } from "./useTheme";
@@ -86,7 +85,6 @@ export const App: FC<Props> = ({ vm }) => {
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} widget={widget} />}
>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />

View File

@@ -61,7 +61,11 @@ export const AppBar: FC<Props> = ({ children }) => {
style={{ display: hidden ? "none" : "block" }}
className={styles.bar}
>
<Header>
<Header
// App bar is mainly seen in the call view, which has its own
// 'reconnecting' toast
disconnectedBanner={false}
>
<LeftNav>
<Tooltip label={t("common.back")}>
<IconButton onClick={onBackClick}>

View File

@@ -17,27 +17,38 @@ import Logo from "./icons/Logo.svg?react";
import { Avatar, Size } from "./Avatar";
import { EncryptionLock } from "./room/EncryptionLock";
import { useMediaQuery } from "./useMediaQuery";
import { DisconnectedBanner } from "./DisconnectedBanner";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
ref?: Ref<HTMLElement>;
children: ReactNode;
className?: string;
/**
* Whether the header should display an informational banner whenever the
* client is disconnected from the homeserver.
* @default true
*/
disconnectedBanner?: boolean;
}
export const Header: FC<HeaderProps> = ({
ref,
children,
className,
disconnectedBanner = true,
...rest
}) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
<>
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
{disconnectedBanner && <DisconnectedBanner />}
</>
);
};

View File

@@ -23,14 +23,6 @@ export function useMediaDevices(): MediaDevices {
return mediaDevices;
}
export const useIsEarpiece = (): boolean => {
const devices = useMediaDevices();
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
const available = useObservableEagerState(devices.audioOutput.available$);
if (!audioOutput?.id) return false;
return available.get(audioOutput.id)?.type === "earpiece";
};
/**
* A convenience hook to get the audio node configuration for the earpiece.
* It will check the `useAsEarpiece` of the `audioOutput` device and return

View File

@@ -35,6 +35,8 @@ Please see LICENSE in the repository root for full details.
.bg.animate[data-state="closed"] {
animation: fade-out 130ms;
opacity: 0;
pointer-events: none;
}
.overlay {

View File

@@ -19,10 +19,26 @@ import mediaViewStyles from "../src/tile/MediaView.module.css";
interface Props {
audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
focusUrl?: string;
}
const extractDomain = (url: string): string => {
try {
const parsedUrl = new URL(url);
return parsedUrl.hostname; // Returns "kdk.cpm"
} catch (error) {
console.error("Invalid URL:", error);
return url;
}
};
// This is only used in developer mode for debugging purposes, so we don't need full localization
export const RTCConnectionStats: FC<Props> = ({ audio, video, ...rest }) => {
export const RTCConnectionStats: FC<Props> = ({
audio,
video,
focusUrl,
...rest
}) => {
const [showModal, setShowModal] = useState(false);
const [modalContents, setModalContents] = useState<
"video" | "audio" | "none"
@@ -55,6 +71,13 @@ export const RTCConnectionStats: FC<Props> = ({ audio, video, ...rest }) => {
</pre>
</div>
</Modal>
{focusUrl && (
<div>
<Text as="span" size="xs" title="focusURL">
&nbsp;{extractDomain(focusUrl)}
</Text>
</div>
)}
{audio && (
<div>
<Button

View File

@@ -45,6 +45,12 @@ interface Props {
* A supporting icon to display within the toast.
*/
Icon?: ComponentType<SVGAttributes<SVGElement>>;
/**
* Whether the toast should be modal, making it fill the screen (by portalling
* it into the root of the document) and trap focus until dismissed.
* @default true
*/
modal?: boolean;
}
/**
@@ -56,6 +62,7 @@ export const Toast: FC<Props> = ({
autoDismiss,
children,
Icon,
modal = true,
}) => {
const onOpenChange = useCallback(
(open: boolean) => {
@@ -71,29 +78,33 @@ export const Toast: FC<Props> = ({
}
}, [open, autoDismiss, onDismiss]);
const content = (
<>
<DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)}
/>
<DialogContent aria-describedby={undefined} asChild>
<DialogClose
className={classNames(
overlayStyles.overlay,
overlayStyles.animate,
styles.toast,
)}
>
<DialogTitle asChild>
<Text as="h3" size="sm" weight="semibold">
{children}
</Text>
</DialogTitle>
{Icon && <Icon width={20} height={20} aria-hidden />}
</DialogClose>
</DialogContent>
</>
);
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)}
/>
<DialogContent aria-describedby={undefined} asChild>
<DialogClose
className={classNames(
overlayStyles.overlay,
overlayStyles.animate,
styles.toast,
)}
>
<DialogTitle asChild>
<Text as="h3" size="sm" weight="semibold">
{children}
</Text>
</DialogTitle>
{Icon && <Icon width={20} height={20} aria-hidden />}
</DialogClose>
</DialogContent>
</DialogPortal>
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
{modal ? <DialogPortal>{content}</DialogPortal> : content}
</DialogRoot>
);
};

View File

@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import * as PlatformMod from "../src/Platform";
import {
getRoomIdentifierFromUrl,
getUrlParams,
computeUrlParams,
HeaderStyle,
getUrlParams,
} from "../src/UrlParams";
const ROOM_NAME = "roomNameHere";
@@ -103,16 +106,16 @@ describe("UrlParams", () => {
describe("preload", () => {
it("defaults to false", () => {
expect(getUrlParams().preload).toBe(false);
expect(computeUrlParams().preload).toBe(false);
});
it("ignored in SPA mode", () => {
expect(getUrlParams("?preload=true").preload).toBe(false);
expect(computeUrlParams("?preload=true").preload).toBe(false);
});
it("respected in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?preload=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).preload,
).toBe(true);
@@ -121,19 +124,20 @@ describe("UrlParams", () => {
describe("returnToLobby", () => {
it("is false in SPA mode", () => {
expect(getUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
expect(computeUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
});
it("defaults to false in widget mode", () => {
expect(
getUrlParams("?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo")
.returnToLobby,
computeUrlParams(
"?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby,
).toBe(false);
});
it("respected in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?returnToLobby=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby,
).toBe(true);
@@ -142,12 +146,12 @@ describe("UrlParams", () => {
describe("userId", () => {
it("is ignored in SPA mode", () => {
expect(getUrlParams("?userId=asd").userId).toBe(null);
expect(computeUrlParams("?userId=asd").userId).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?userId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).userId,
).toBe("asd");
@@ -156,12 +160,12 @@ describe("UrlParams", () => {
describe("deviceId", () => {
it("is ignored in SPA mode", () => {
expect(getUrlParams("?deviceId=asd").deviceId).toBe(null);
expect(computeUrlParams("?deviceId=asd").deviceId).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?deviceId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).deviceId,
).toBe("asd");
@@ -170,12 +174,12 @@ describe("UrlParams", () => {
describe("baseUrl", () => {
it("is ignored in SPA mode", () => {
expect(getUrlParams("?baseUrl=asd").baseUrl).toBe(null);
expect(computeUrlParams("?baseUrl=asd").baseUrl).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?baseUrl=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).baseUrl,
).toBe("asd");
@@ -185,28 +189,28 @@ describe("UrlParams", () => {
describe("viaServers", () => {
it("is ignored in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?viaServers=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).viaServers,
).toBe(null);
});
it("is parsed in SPA mode", () => {
expect(getUrlParams("?viaServers=asd").viaServers).toBe("asd");
expect(computeUrlParams("?viaServers=asd").viaServers).toBe("asd");
});
});
describe("homeserver", () => {
it("is ignored in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?homeserver=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).homeserver,
).toBe(null);
});
it("is parsed in SPA mode", () => {
expect(getUrlParams("?homeserver=asd").homeserver).toBe("asd");
expect(computeUrlParams("?homeserver=asd").homeserver).toBe("asd");
});
});
@@ -228,7 +232,7 @@ describe("UrlParams", () => {
const startNewCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
@@ -237,12 +241,12 @@ describe("UrlParams", () => {
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
sendNotificationType: platform === "desktop" ? "notification" : "ring",
});
const joinExistingCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
@@ -252,24 +256,55 @@ describe("UrlParams", () => {
skipLobby: false,
returnToLobby: false,
sendNotificationType: "notification",
defaultAudioEnabled: true,
defaultVideoEnabled: true,
});
it("use no-intent-defaults with unknown intent", () => {
expect(getUrlParams()).toMatchObject(noIntentDefaults);
expect(computeUrlParams()).toMatchObject(noIntentDefaults);
});
it("ignores intent if it is not a valid value", () => {
expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
expect(computeUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
});
it("accepts start_call", () => {
expect(
getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"),
).toMatchObject(startNewCallDefaults("desktop"));
computeUrlParams(
"?intent=start_call&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false });
});
it("accepts start_call_dm mobile", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(startNewCallDefaults("android"));
});
it("accepts start_call_dm mobile and prioritizes overwritten params", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org&sendNotificationType=notification",
),
).toMatchObject({
...startNewCallDefaults("android"),
sendNotificationType: "notification",
});
});
it("accepts join_existing", () => {
expect(
getUrlParams(
computeUrlParams(
"?intent=join_existing&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(joinExistingCallDefaults("desktop"));
@@ -278,31 +313,55 @@ describe("UrlParams", () => {
describe("skipLobby", () => {
it("defaults to false", () => {
expect(getUrlParams().skipLobby).toBe(false);
expect(computeUrlParams().skipLobby).toBe(false);
});
it("defaults to false if intent is start_call in SPA mode", () => {
expect(getUrlParams("?intent=start_call").skipLobby).toBe(false);
expect(computeUrlParams("?intent=start_call").skipLobby).toBe(false);
});
it("defaults to true if intent is start_call in widget mode", () => {
it("defaults to false if intent is start_call in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?intent=start_call&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).skipLobby,
).toBe(true);
).toBe(false);
});
it("default to false if intent is join_existing", () => {
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
});
});
describe("header", () => {
it("uses header if provided", () => {
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe(
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
"app_bar",
);
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
expect(computeUrlParams("?header=none&hideHeader=false").header).toBe(
"none",
);
});
});
describe("getUrlParams", () => {
it("uses cached values", () => {
const spy = vi.spyOn(logger, "info");
// call get once
const params = getUrlParams("?header=app_bar&hideHeader=true", "");
// call get twice
expect(getUrlParams("?header=app_bar&hideHeader=true", "")).toBe(params);
// expect compute to only be called once
// it will only log when it is computing the values
expect(spy).toHaveBeenCalledExactlyOnceWith(
"UrlParams: final set of url params\n",
"intent:",
"unknown",
"\nproperties:",
expect.any(Object),
"configuration:",
expect.any(Object),
"intentAndPlatformDerivedConfiguration:",
{},
);
});
});
});

View File

@@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details.
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { pickBy } from "lodash-es";
import { Config } from "./config/Config";
@@ -23,9 +26,12 @@ interface RoomIdentifier {
}
export enum UserIntent {
// TODO: add DM vs room call
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm",
JoinExistingCallDMVoice = "join_existing_dm_voice",
Unknown = "unknown",
}
@@ -209,6 +215,33 @@ export interface UrlConfiguration {
* Whether and what type of notification EC should send, when the user joins the call.
*/
sendNotificationType?: RTCNotificationType;
/**
* Whether the app should automatically leave the call when there
* is no one left in the call.
* This is one part to make the call matrixRTC session behave like a telephone call.
*/
autoLeaveWhenOthersLeft: boolean;
/**
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
* This is a no-op if not combined with sendNotificationType.
*
* This entails:
* - show ui that it is awaiting an answer
* - play a sound that indicates that it is awaiting an answer
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
waitForCallPickup: boolean;
callIntent?: RTCCallIntent;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
// If you need to add a new flag to this interface, prefer a name that describes
@@ -216,7 +249,10 @@ export interface UrlConfiguration {
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams extends UrlProperties, UrlConfiguration {}
export interface UrlParams
extends UrlProperties,
UrlConfiguration,
IntentAndPlatformDerivedConfiguration {}
// This is here as a stopgap, but what would be far nicer is a function that
// takes a UrlParams and returns a query string. That would enable us to
@@ -276,18 +312,30 @@ class ParamParser {
];
}
/**
* Returns true if the flag exists and is not "false".
*/
public getFlagParam(name: string, defaultValue = false): boolean {
const param = this.getParam(name);
return param === null ? defaultValue : param !== "false";
}
/**
* Returns the value of the flag if it exists, or undefined if it does not.
*/
public getFlag(name: string): boolean | undefined {
const param = this.getParam(name);
return param !== null ? param !== "false" : undefined;
}
}
let urlParamCache: {
search?: string;
hash?: string;
params?: UrlParams;
} = {};
/**
* Gets the app parameters for the current URL.
* Gets the url params and loads them from a cache if already computed.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
@@ -296,6 +344,26 @@ export const getUrlParams = (
search = window.location.search,
hash = window.location.hash,
): UrlParams => {
if (
urlParamCache.search === search &&
urlParamCache.hash === hash &&
urlParamCache.params
) {
return urlParamCache.params;
}
const params = computeUrlParams(search, hash);
urlParamCache = { search, hash, params };
return params;
};
/**
* Gets the app parameters for the current URL.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
*/
export const computeUrlParams = (search = "", hash = ""): UrlParams => {
const parser = new ParamParser(search, hash);
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
@@ -319,11 +387,10 @@ export const getUrlParams = (
? UserIntent.Unknown
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
// Here we only use constants and `platform` to determine the intent preset.
let intentPreset: UrlConfiguration;
const inAppDefault = {
let intentPreset: UrlConfiguration = {
confineToRoom: true,
appPrompt: false,
preload: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
@@ -332,20 +399,38 @@ export const getUrlParams = (
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification" as RTCNotificationType,
sendNotificationType: "notification",
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
switch (intent) {
case UserIntent.StartNewCall:
intentPreset = {
...inAppDefault,
skipLobby: true,
};
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.JoinExistingCall:
intentPreset = {
...inAppDefault,
skipLobby: false,
};
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.StartNewCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
intentPreset.skipLobby = true;
intentPreset.sendNotificationType = "ring";
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.waitForCallPickup = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
break;
case UserIntent.JoinExistingCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = true;
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
break;
// Non widget usecase defaults
default:
@@ -362,9 +447,34 @@ export const getUrlParams = (
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
}
const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
{};
// Desktop also includes web. Its anything that is not mobile.
const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
switch (desktopMobile) {
case "desktop":
case "mobile":
switch (intent) {
case UserIntent.StartNewCall:
case UserIntent.JoinExistingCall:
case UserIntent.StartNewCallDM:
case UserIntent.JoinExistingCallDM:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
break;
case UserIntent.StartNewCallDMVoice:
case UserIntent.JoinExistingCallDMVoice:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = false;
break;
}
}
const properties: UrlProperties = {
widgetId,
parentUrl,
@@ -413,12 +523,29 @@ export const getUrlParams = (
"ring",
"notification",
]),
waitForCallPickup: parser.getFlag("waitForCallPickup"),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
};
// Log the final configuration for debugging purposes.
// This will only log when the cache is not yet set.
logger.info(
"UrlParams: final set of url params\n",
"intent:",
intent,
"\nproperties:",
properties,
"configuration:",
configuration,
"intentAndPlatformDerivedConfiguration:",
intentAndPlatformDerivedConfiguration,
);
return {
...properties,
...intentPreset,
...pickBy(configuration, (v) => v !== undefined),
...pickBy(configuration, (v?: unknown) => v !== undefined),
...intentAndPlatformDerivedConfiguration,
};
};

View File

@@ -16,7 +16,6 @@ import {
EndCallIcon,
ShareScreenSolidIcon,
SettingsSolidIcon,
SwitchCameraSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Button.module.css";
@@ -67,23 +66,6 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
);
};
export const SwitchCameraButton: FC<ComponentPropsWithoutRef<"button">> = (
props,
) => {
const { t } = useTranslation();
return (
<Tooltip label={t("switch_camera")}>
<CpdButton
iconOnly
Icon={SwitchCameraSolidIcon}
kind="secondary"
{...props}
/>
</Tooltip>
);
};
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
}

View File

@@ -6,21 +6,22 @@ Please see LICENSE in the repository root for full details.
*/
import { act, render } from "@testing-library/react";
import { expect, test } from "vitest";
import { expect, test, vi } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event";
import { type ReactNode } from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { ReactionToggleButton } from "./ReactionToggleButton";
import { ElementCallReactionEventType } from "../reactions";
import { type CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { type MockRTCSession } from "../utils/test";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
const localIdent = `${localRtcMember.sender}:${localRtcMember.deviceId}`;
vi.mock("livekit-client/e2ee-worker?worker");
const localIdent = `${localRtcMember.userId}:${localRtcMember.deviceId}`;
function TestComponent({
rtcSession,
@@ -33,7 +34,7 @@ function TestComponent({
<TooltipProvider>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession as unknown as MatrixRTCSession}
rtcSession={rtcSession.asMockedSession()}
>
<ReactionToggleButton vm={vm} identifier={localIdent} />
</ReactionsSenderProvider>

View File

@@ -33,7 +33,7 @@ import {
ReactionsRowSize,
} from "../reactions";
import { Modal } from "../Modal";
import { type CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {

View File

@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="«rb5»"
aria-labelledby="«rbb»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"

View File

@@ -114,16 +114,12 @@ export interface ConfigOptions {
* when someone leaves a call.
*/
wait_for_key_rotation_ms?: number;
/** @deprecated use wait_for_key_rotation_ms instead */
key_rotation_on_leave_delay?: number;
/**
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
* that the server waits before sending the leave MatrixRTC membership event.
*/
delayed_leave_event_delay_ms?: number;
/** @deprecated use delayed_leave_event_delay_ms instead */
membership_server_side_expiry_timeout?: number;
/**
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
@@ -141,8 +137,6 @@ export interface ConfigOptions {
* messages to the server by restarting the timer for the delayed leave event.
*/
delayed_leave_event_restart_ms?: number;
/** @deprecated use delayed_leave_event_restart_ms instead */
membership_keep_alive_period?: number;
/**
* How long we wait before retrying after a network error on any of the requests.

View File

@@ -6,9 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { Subject } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
const logger = rootLogger.getChild("[controlled-output]");
import { logger } from "matrix-js-sdk/lib/logger";
export interface Controls {
canEnterPip(): boolean;

View File

@@ -14,7 +14,7 @@ import {
import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { type GridLayout as GridLayoutModel } from "../state/CallViewModel";
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
import styles from "./GridLayout.module.css";
import { useInitial } from "../useInitial";
import { type CallLayout, arrangeTiles } from "./CallLayout";

View File

@@ -9,7 +9,7 @@ import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewModel";
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type ReactNode, useCallback } from "react";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";

View File

@@ -10,7 +10,7 @@ import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { type CallLayout } from "./CallLayout";
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/CallViewModel";
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
import styles from "./SpotlightLandscapeLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid";

View File

@@ -10,7 +10,7 @@ import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/layout-types.ts";
import styles from "./SpotlightPortraitLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";

View File

@@ -61,7 +61,12 @@ const TileWrapper_ = memo(
useDrag((state) => onDrag?.current!(id, state), {
target: ref,
filterTaps: true,
preventScroll: true,
// Previous designs, which allowed tiles to be dragged and dropped around
// the scrolling grid, required us to set preventScroll to true here. But
// our designs no longer call for this, and meanwhile there's a bug in
// use-gesture that causes filterTaps + preventScroll to break buttons
// within tiles (like the 'switch camera' button) on mobile.
// https://github.com/pmndrs/use-gesture/issues/593
});
return (

View File

@@ -113,19 +113,49 @@ const roomIsJoinable = (room: Room): boolean => {
}
};
/**
* Determines if a given room has call events in it, and therefore
* is likely to be a call room.
* @param room The Matrix room instance.
* @returns `true` if the room has call events.
*/
const roomHasCallMembershipEvents = (room: Room): boolean => {
switch (room.getMyMembership()) {
case KnownMembership.Join:
return !!room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.events?.get(EventType.GroupCallMemberPrefix);
case KnownMembership.Knock:
// Assume that a room you've knocked on is able to hold calls
return true;
default:
return false;
// Check our room membership first, to rule out any rooms
// we can't have a call in.
const myMembership = room.getMyMembership();
if (myMembership === KnownMembership.Knock) {
// Assume that a room you've knocked on is able to hold calls
return true;
} else if (myMembership !== KnownMembership.Join) {
// Otherwise, non-joined rooms should never show up.
return false;
}
// Legacy member state checks (cheaper to check.)
const timeline = room.getLiveTimeline();
if (
timeline
.getState(EventTimeline.FORWARDS)
?.events?.has(EventType.GroupCallMemberPrefix)
) {
return true;
}
// Check for *active* calls using sticky events.
for (const sticky of room._unstable_getStickyEvents()) {
if (sticky.getType() === EventType.RTCMembership) {
return true;
}
}
// Otherwise, check recent event history to see if anyone had
// sent a call membership in here.
return timeline.getEvents().some(
(e) =>
// Membership events only count if both of these are true
e.unstableStickyInfo && e.getType() === EventType.GroupCallMemberPrefix,
);
// Otherwise, it's *unlikely* this room was ever a call.
};
export function useGroupCallRooms(client: MatrixClient): GroupCallRoom[] {

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M4 14C4.55228 14 5 14.4477 5 15V19H9C9.55228 19 10 19.4477 10 20C10 20.5523 9.55228 21 9 21H3V15C3 14.4477 3.44772 14 4 14Z"/>
<path d="M20 14C20.5523 14 21 14.4477 21 15V21H15C14.4477 21 14 20.5523 14 20C14 19.4477 14.4477 19 15 19H19V15C19 14.4477 19.4477 14 20 14Z" />
<path d="M9 3C9.55228 3 10 3.44772 10 4C10 4.55228 9.55228 5 9 5H5V9C5 9.55228 4.55228 10 4 10C3.44772 10 3 9.55228 3 9V3H9Z" />
<path d="M21 9C21 9.55228 20.5523 10 20 10C19.4477 10 19 9.55228 19 9V5H15C14.4477 5 14 4.55228 14 4C14 3.44772 14.4477 3 15 3H21V9Z" />
</svg>

After

Width:  |  Height:  |  Size: 658 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M10 20C10 20.5523 9.55228 21 9 21C8.44772 21 8 20.5523 8 20V16H4C3.44772 16 3 15.5523 3 15C3 14.4477 3.44772 14 4 14H10V20Z" />
<path d="M20 14C20.5523 14 21 14.4477 21 15C21 15.5523 20.5523 16 20 16H16V20C16 20.5523 15.5523 21 15 21C14.4477 21 14 20.5523 14 20V14H20Z" />
<path d="M9 3C9.55228 3 10 3.44772 10 4V10H4C3.44772 10 3 9.55228 3 9C3 8.44772 3.44772 8 4 8H8V4C8 3.44772 8.44772 3 9 3Z" />
<path d="M15 3C15.5523 3 16 3.44772 16 4V8H20C20.5523 8 21 8.44772 21 9C21 9.55228 20.5523 10 20 10H14V4C14 3.44772 14.4477 3 15 3Z" />
</svg>

After

Width:  |  Height:  |  Size: 656 B

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

View File

@@ -6,20 +6,28 @@ Please see LICENSE in the repository root for full details.
*/
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { render, type RenderResult } from "@testing-library/react";
import {
getTrackReferenceId,
type TrackReference,
} from "@livekit/components-core";
import { type RemoteAudioTrack } from "livekit-client";
import {
type Participant,
type RemoteAudioTrack,
type Room,
Track,
} from "livekit-client";
import { type ReactNode } from "react";
import { useTracks } from "@livekit/components-react";
import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "../MediaDevicesContext";
import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
import { mockMediaDevices, mockTrack } from "../utils/test";
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
import {
mockMediaDevices,
mockRemoteParticipant,
mockTrack,
} from "../utils/test";
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
@@ -48,42 +56,203 @@ vi.mock("@livekit/components-react", async (importOriginal) => {
};
});
const tracks = [mockTrack("test:123")];
vi.mocked(useTracks).mockReturnValue(tracks);
let tracks: TrackReference[] = [];
it("should render for member", () => {
const { container, queryAllByTestId } = render(
/**
* Render the test component with given rtc members and livekit participant identities.
*
* It is possible to have rtc members that are not in livekit (e.g. not yet joined) and vice versa.
*
* @param rtcMembers - Array of active rtc members with userId and deviceId.
* @param livekitParticipantIdentities - Array of livekit participant (that are publishing).
* @param explicitTracks - Array of tracks available in livekit, if not provided, one audio track per livekitParticipantIdentities will be created.
* */
function renderTestComponent(
rtcMembers: { userId: string; deviceId: string }[],
livekitParticipantIdentities: string[],
explicitTracks?: {
participantId: string;
kind: Track.Kind;
source: Track.Source;
}[],
): RenderResult {
const liveKitParticipants = livekitParticipantIdentities.map((identity) =>
mockRemoteParticipant({ identity }),
);
const participants = rtcMembers.flatMap(({ userId, deviceId }) => {
const p = liveKitParticipants.find(
(p) => p.identity === `${userId}:${deviceId}`,
);
return p === undefined ? [] : [p];
});
const livekitRoom = {
remoteParticipants: new Map<string, Participant>(
liveKitParticipants.map((p) => [p.identity, p]),
),
} as unknown as Room;
if (explicitTracks?.length ?? 0 > 0) {
tracks = explicitTracks!.map(({ participantId, source, kind }) => {
const participant =
liveKitParticipants.find((p) => p.identity === participantId) ??
mockRemoteParticipant({ identity: participantId });
return mockTrack(participant, kind, source);
});
} else {
tracks = participants.map((p) => mockTrack(p));
}
vi.mocked(useTracks).mockReturnValue(tracks);
return render(
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
<LivekitRoomAudioRenderer
validIdentities={participants.map((p) => p.identity)}
livekitRoom={livekitRoom}
url={""}
/>
</MediaDevicesProvider>,
);
}
it("should render for member", () => {
const { container, queryAllByTestId } = renderTestComponent(
[{ userId: "@alice", deviceId: "DEV0" }],
["@alice:DEV0"],
);
expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(1);
});
it("should not render without member", () => {
const memberships = [
{ sender: "othermember", deviceId: "123" },
] as CallMembership[];
const { container, queryAllByTestId } = render(
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer members={memberships} />
</MediaDevicesProvider>,
const { container, queryAllByTestId } = renderTestComponent(
[{ userId: "@bob", deviceId: "DEV0" }],
["@alice:DEV0"],
);
expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(0);
});
const TEST_CASES: {
name: string;
rtcUsers: { userId: string; deviceId: string }[];
livekitParticipantIdentities: string[];
explicitTracks?: {
participantId: string;
kind: Track.Kind;
source: Track.Source;
}[];
expectedAudioTracks: number;
}[] = [
{
name: "single user single device",
rtcUsers: [
{ userId: "@alice", deviceId: "DEV0" },
{ userId: "@alice", deviceId: "DEV1" },
{ userId: "@bob", deviceId: "DEV0" },
],
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@alice:DEV1"],
expectedAudioTracks: 3,
},
// Charlie is a rtc member but not in livekit
{
name: "Charlie is rtc member but not in livekit",
rtcUsers: [
{ userId: "@alice", deviceId: "DEV0" },
{ userId: "@bob", deviceId: "DEV0" },
{ userId: "@charlie", deviceId: "DEV0" },
],
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0"],
expectedAudioTracks: 2,
},
// Charlie is in livekit but not rtc member
{
name: "Charlie is in livekit but not rtc member",
rtcUsers: [
{ userId: "@alice", deviceId: "DEV0" },
{ userId: "@bob", deviceId: "DEV0" },
],
livekitParticipantIdentities: ["@alice:DEV0", "@bob:DEV0", "@charlie:DEV0"],
expectedAudioTracks: 2,
},
{
name: "no audio track, only video track",
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
livekitParticipantIdentities: ["@alice:DEV0"],
explicitTracks: [
{
participantId: "@alice:DEV0",
kind: Track.Kind.Video,
source: Track.Source.Camera,
},
],
expectedAudioTracks: 0,
},
{
name: "Audio track from unknown source",
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
livekitParticipantIdentities: ["@alice:DEV0"],
explicitTracks: [
{
participantId: "@alice:DEV0",
kind: Track.Kind.Audio,
source: Track.Source.Unknown,
},
],
expectedAudioTracks: 1,
},
{
name: "Audio track from other device",
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
livekitParticipantIdentities: ["@alice:DEV0"],
explicitTracks: [
{
participantId: "@alice:DEV1",
kind: Track.Kind.Audio,
source: Track.Source.Microphone,
},
],
expectedAudioTracks: 0,
},
{
name: "two audio tracks, microphone and screenshare",
rtcUsers: [{ userId: "@alice", deviceId: "DEV0" }],
livekitParticipantIdentities: ["@alice:DEV0"],
explicitTracks: [
{
participantId: "@alice:DEV0",
kind: Track.Kind.Audio,
source: Track.Source.Microphone,
},
{
participantId: "@alice:DEV0",
kind: Track.Kind.Audio,
source: Track.Source.ScreenShareAudio,
},
],
expectedAudioTracks: 2,
},
];
it.each(TEST_CASES)(
`should render sound test cases $name`,
({
rtcUsers,
livekitParticipantIdentities,
explicitTracks,
expectedAudioTracks,
}) => {
const { queryAllByTestId } = renderTestComponent(
rtcUsers,
livekitParticipantIdentities,
explicitTracks,
);
expect(queryAllByTestId("audio")).toHaveLength(expectedAudioTracks);
},
);
it("should not setup audioContext gain and pan if there is no need to.", () => {
render(
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
expect(audioTrack.setAudioContext).toHaveBeenCalledTimes(1);
@@ -100,13 +269,8 @@ it("should setup audioContext gain and pan", () => {
pan: 1,
volume: 0.1,
});
render(
<MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
);
renderTestComponent([{ userId: "@bob", deviceId: "DEV0" }], ["@bob:DEV0"]);
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
expect(audioTrack.setAudioContext).toHaveBeenCalled();

View File

@@ -6,28 +6,33 @@ Please see LICENSE in the repository root for full details.
*/
import { getTrackReferenceId } from "@livekit/components-core";
import { type Room as LivekitRoom } from "livekit-client";
import { type RemoteAudioTrack, Track } from "livekit-client";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
useTracks,
AudioTrack,
type AudioTrackProps,
} from "@livekit/components-react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { type ParticipantId } from "matrix-js-sdk/lib/matrixrtc";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls";
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
export interface MatrixAudioRendererProps {
/**
* The list of participants to render audio for.
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users
* that are not expected to be in the rtc session.
* The service URL of the LiveKit room.
*/
members: CallMembership[];
url: string;
livekitRoom: LivekitRoom;
/**
* The list of participant identities to render audio for.
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users
* that are not expected to be in the rtc session (local user is excluded).
*/
validIdentities: ParticipantId[];
/**
* If set to `true`, mutes all audio tracks rendered by the component.
* @remarks
@@ -36,9 +41,9 @@ export interface MatrixAudioRendererProps {
muted?: boolean;
}
const prefixedLogger = logger.getChild("[MatrixAudioRenderer]");
/**
* The `MatrixAudioRenderer` component is a drop-in solution for adding audio to your LiveKit app.
* It takes care of handling remote participants audio tracks and makes sure that microphones and screen share are audible.
* Takes care of handling remote participants audio tracks and makes sure that microphones and screen share are audible.
*
* It also takes care of the earpiece audio configuration for iOS devices.
* This is done by using the WebAudio API to create a stereo pan effect that mimics the earpiece audio.
@@ -50,35 +55,12 @@ export interface MatrixAudioRendererProps {
* ```
* @public
*/
export function MatrixAudioRenderer({
members,
export function LivekitRoomAudioRenderer({
url,
livekitRoom,
validIdentities,
muted,
}: MatrixAudioRendererProps): ReactNode {
const validIdentities = useMemo(
() =>
new Set(members?.map((member) => `${member.sender}:${member.deviceId}`)),
[members],
);
const loggedInvalidIdentities = useRef(new Set<string>());
/**
* Log an invalid livekit track identity.
* A invalid identity is one that does not match any of the matrix rtc members.
*
* @param identity The identity of the track that is invalid
* @param validIdentities The list of valid identities
*/
const logInvalid = (identity: string, validIdentities: Set<string>): void => {
if (loggedInvalidIdentities.current.has(identity)) return;
logger.warn(
`Audio track ${identity} has no matching matrix call member`,
`current members: ${Array.from(validIdentities.values())}`,
`track will not get rendered`,
);
loggedInvalidIdentities.current.add(identity);
};
const tracks = useTracks(
[
Track.Source.Microphone,
@@ -88,25 +70,25 @@ export function MatrixAudioRenderer({
{
updateOnlyOn: [],
onlySubscribed: true,
room: livekitRoom,
},
).filter((ref) => {
const isValid = validIdentities?.has(ref.participant.identity);
if (!isValid && !ref.participant.isLocal)
logInvalid(ref.participant.identity, validIdentities);
return (
!ref.participant.isLocal &&
ref.publication.kind === Track.Kind.Audio &&
isValid
);
});
useEffect(() => {
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
logger.debug(
`All audio tracks have a matching matrix call member identity.`,
);
loggedInvalidIdentities.current.clear();
}
}, [tracks, validIdentities]);
)
// Only keep audio tracks
.filter((ref) => ref.publication.kind === Track.Kind.Audio)
// Only keep tracks from participants that are in the validIdentities list
.filter((ref) => {
const isValid = validIdentities.includes(ref.participant.identity);
if (!isValid) {
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
prefixedLogger.warn(
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
`current members: ${validIdentities.join()}`,
`track will not get rendered`,
);
return false;
}
return true;
});
// This component is also (in addition to the "only play audio for connected members" logic above)
// responsible for mimicking earpiece audio on iPhones.
@@ -182,7 +164,7 @@ interface StereoPanAudioTrackProps {
/**
* This wraps `livekit.AudioTrack` to allow adding audio nodes to a track.
* It main purpose is to remount the AudioTrack component when switching from
* audiooContext to normal audio playback.
* audioContext to normal audio playback.
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
* @param param0
* @returns
@@ -202,7 +184,7 @@ function AudioTrackWithAudioNodes({
const [trackReady, setTrackReady] = useReactiveState(
() => false,
// We only want the track to reset once both (audioNodes and audioContext) are set.
// for unsetting the audioContext its enough if one of the the is undefined.
// for unsetting the audioContext its enough if one of the two is undefined.
[audioContext && audioNodes],
);

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
@@ -19,14 +19,22 @@ import {
useMemo,
} from "react";
import { type LocalVideoTrack } from "livekit-client";
import { combineLatest, map, type Observable } from "rxjs";
import { useObservable } from "observable-hooks";
import {
backgroundBlur as backgroundBlurSettings,
useSetting,
} from "../settings/settings";
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
type ProcessorState = {
//TODO-MULTI-SFU: This is not yet fully there.
// it is a combination of exposing observable and react hooks.
// preferably we should not make this a context anymore and instead just a vm?
export type ProcessorState = {
supported: boolean | undefined;
processor: undefined | ProcessorWrapper<BackgroundOptions>;
};
@@ -42,6 +50,43 @@ export function useTrackProcessor(): ProcessorState {
return state;
}
export function useTrackProcessorObservable$(): Observable<ProcessorState> {
const state = use(ProcessorContext);
if (state === undefined)
throw new Error(
"useTrackProcessor must be used within a ProcessorProvider",
);
const state$ = useObservable(
(init$) => init$.pipe(map(([init]) => init)),
[state],
);
return state$;
}
/**
* Updates your video tracks to always use the given processor.
*/
export const trackProcessorSync = (
scope: ObservableScope,
videoTrack$: Behavior<LocalVideoTrack | null>,
processor$: Behavior<ProcessorState>,
): void => {
combineLatest([videoTrack$, processor$])
.pipe(scope.bind())
.subscribe(([videoTrack, processorState]) => {
if (!processorState) return;
if (!videoTrack) return;
const { processor } = processorState;
if (processor && !videoTrack.getProcessor()) {
void videoTrack.setProcessor(processor);
}
if (!processor && videoTrack.getProcessor()) {
void videoTrack.stopProcessor();
}
});
};
export const useTrackProcessorSync = (
videoTrack: LocalVideoTrack | null,
): void => {

View File

@@ -7,12 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useEffect, useState } from "react";
import { type LivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
import { useActiveLivekitFocus } from "../room/useActiveFocus";
import { useErrorBoundary } from "../useErrorBoundary";
import { FailToGetOpenIdToken } from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix";
@@ -21,51 +16,17 @@ export interface SFUConfig {
jwt: string;
}
export function sfuConfigEquals(a?: SFUConfig, b?: SFUConfig): boolean {
if (a === undefined && b === undefined) return true;
if (a === undefined || b === undefined) return false;
return a.jwt === b.jwt && a.url === b.url;
}
// The bits we need from MatrixClient
export type OpenIDClientParts = Pick<
MatrixClient,
"getOpenIdToken" | "getDeviceId"
>;
export function useOpenIDSFU(
client: OpenIDClientParts,
rtcSession: MatrixRTCSession,
): SFUConfig | undefined {
const [sfuConfig, setSFUConfig] = useState<SFUConfig | undefined>(undefined);
const activeFocus = useActiveLivekitFocus(rtcSession);
const { showErrorBoundary } = useErrorBoundary();
useEffect(() => {
if (activeFocus) {
getSFUConfigWithOpenID(client, activeFocus).then(
(sfuConfig) => {
setSFUConfig(sfuConfig);
},
(e) => {
showErrorBoundary(new FailToGetOpenIdToken(e));
logger.error("Failed to get SFU config", e);
},
);
} else {
setSFUConfig(undefined);
}
}, [client, activeFocus, showErrorBoundary]);
return sfuConfig;
}
export async function getSFUConfigWithOpenID(
client: OpenIDClientParts,
activeFocus: LivekitFocus,
): Promise<SFUConfig | undefined> {
serviceUrl: string,
matrixRoomId: string,
): Promise<SFUConfig> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
@@ -78,26 +39,16 @@ export async function getSFUConfigWithOpenID(
}
logger.debug("Got openID token", openIdToken);
try {
logger.info(
`Trying to get JWT from call's active focus URL of ${activeFocus.livekit_service_url}...`,
);
const sfuConfig = await getLiveKitJWT(
client,
activeFocus.livekit_service_url,
activeFocus.livekit_alias,
openIdToken,
);
logger.info(`Got JWT from call's active focus URL.`);
logger.info(`Trying to get JWT for focus ${serviceUrl}...`);
const sfuConfig = await getLiveKitJWT(
client,
serviceUrl,
matrixRoomId,
openIdToken,
);
logger.info(`Got JWT from call's active focus URL.`);
return sfuConfig;
} catch (e) {
logger.warn(
`Failed to get JWT from RTC session's active focus URL of ${activeFocus.livekit_service_url}.`,
e,
);
return undefined;
}
return sfuConfig;
}
async function getLiveKitJWT(

View File

@@ -1,184 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useState } from "react";
import { describe, expect, test, vi, vitest } from "vitest";
import {
ConnectionError,
ConnectionErrorReason,
type Room,
} from "livekit-client";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { defer, sleep } from "matrix-js-sdk/lib/utils";
import { useECConnectionState } from "./useECConnectionState";
import { type SFUConfig } from "./openIDSFU";
import { GroupCallErrorBoundary } from "../room/GroupCallErrorBoundary.tsx";
test.each<[string, ConnectionError]>([
[
"LiveKit hits track limit",
new ConnectionError("", ConnectionErrorReason.InternalError, 503),
],
[
"LiveKit hits room participant limit",
new ConnectionError("", ConnectionErrorReason.ServerUnreachable, 200),
],
[
"LiveKit Cloud hits connection limit",
new ConnectionError("", ConnectionErrorReason.NotAllowed, 429),
],
])(
"useECConnectionState throws error when %s hits track limit",
async (_server, error) => {
const mockRoom = {
on: () => {},
off: () => {},
once: () => {},
connect: () => {
throw error;
},
localParticipant: {
getTrackPublication: () => {},
createTracks: () => [],
},
} as unknown as Room;
const TestComponent: FC = () => {
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
undefined,
);
const connect = useCallback(
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
[],
);
useECConnectionState("default", false, mockRoom, sfuConfig);
return <button onClick={connect}>Connect</button>;
};
const user = userEvent.setup();
render(
<MemoryRouter>
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()} widget={null}>
<TestComponent />
</GroupCallErrorBoundary>
</MemoryRouter>,
);
await user.click(screen.getByRole("button", { name: "Connect" }));
screen.getByText("Insufficient capacity");
},
);
describe("Leaking connection prevention", () => {
function createTestComponent(mockRoom: Room): FC {
const TestComponent: FC = () => {
const [sfuConfig, setSfuConfig] = useState<SFUConfig | undefined>(
undefined,
);
const connect = useCallback(
() => setSfuConfig({ url: "URL", jwt: "JWT token" }),
[],
);
useECConnectionState("default", false, mockRoom, sfuConfig);
return <button onClick={connect}>Connect</button>;
};
return TestComponent;
}
test("Should cancel pending connections when the component is unmounted", async () => {
const connectCall = vi.fn();
const pendingConnection = defer<void>();
// let pendingDisconnection = defer<void>()
const disconnectMock = vi.fn();
const mockRoom = {
on: () => {},
off: () => {},
once: () => {},
connect: async () => {
connectCall.call(undefined);
return await pendingConnection.promise;
},
disconnect: disconnectMock,
localParticipant: {
getTrackPublication: () => {},
createTracks: () => [],
},
} as unknown as Room;
const TestComponent = createTestComponent(mockRoom);
const { unmount } = render(<TestComponent />);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Connect" }));
expect(connectCall).toHaveBeenCalled();
// unmount while the connection is pending
unmount();
// resolve the pending connection
pendingConnection.resolve();
await vitest.waitUntil(
() => {
return disconnectMock.mock.calls.length > 0;
},
{
timeout: 1000,
interval: 100,
},
);
// There should be some cleaning up to avoid leaking an open connection
expect(disconnectMock).toHaveBeenCalledTimes(1);
});
test("Should cancel about to open but not yet opened connection", async () => {
const createTracksCall = vi.fn();
const pendingCreateTrack = defer<void>();
// let pendingDisconnection = defer<void>()
const disconnectMock = vi.fn();
const connectMock = vi.fn();
const mockRoom = {
on: () => {},
off: () => {},
once: () => {},
connect: connectMock,
disconnect: disconnectMock,
localParticipant: {
getTrackPublication: () => {},
createTracks: async () => {
createTracksCall.call(undefined);
await pendingCreateTrack.promise;
return [];
},
},
} as unknown as Room;
const TestComponent = createTestComponent(mockRoom);
const { unmount } = render(<TestComponent />);
const user = userEvent.setup();
await user.click(screen.getByRole("button", { name: "Connect" }));
expect(createTracksCall).toHaveBeenCalled();
// unmount while createTracks is pending
unmount();
// resolve createTracks
pendingCreateTrack.resolve();
// Yield to the event loop to let the connection attempt finish
await sleep(100);
// The operation should have been aborted before even calling connect.
expect(connectMock).not.toHaveBeenCalled();
});
});

View File

@@ -1,365 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ConnectionError,
ConnectionState,
type LocalTrack,
type Room,
RoomEvent,
Track,
} from "livekit-client";
import { useCallback, useEffect, useRef, useState } from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import * as Sentry from "@sentry/react";
import { type SFUConfig, sfuConfigEquals } from "./openIDSFU";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import {
ElementCallError,
InsufficientCapacityError,
UnknownCallError,
} from "../utils/errors.ts";
import { AbortHandle } from "../utils/abortHandle.ts";
declare global {
interface Window {
peerConnectionTimeout?: number;
websocketTimeout?: number;
}
}
/*
* Additional values for states that a call can be in, beyond what livekit
* provides in ConnectionState. Also reconnects the call if the SFU Config
* changes.
*/
export enum ECAddonConnectionState {
// We are switching from one focus to another (or between livekit room aliases on the same focus)
ECSwitchingFocus = "ec_switching_focus",
// The call has just been initialised and is waiting for credentials to arrive before attempting
// to connect. This distinguishes from the 'Disconnected' state which is now just for when livekit
// gives up on connectivity and we consider the call to have failed.
ECWaiting = "ec_waiting",
}
export type ECConnectionState = ConnectionState | ECAddonConnectionState;
// This is mostly necessary because an empty useRef is an empty object
// which is truthy, so we can't just use Boolean(currentSFUConfig.current)
function sfuConfigValid(sfuConfig?: SFUConfig): boolean {
return Boolean(sfuConfig?.url) && Boolean(sfuConfig?.jwt);
}
async function doConnect(
livekitRoom: Room,
sfuConfig: SFUConfig,
audioEnabled: boolean,
initialDeviceId: string | undefined,
abortHandle: AbortHandle,
): Promise<void> {
// Always create an audio track manually.
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
// doesn't publish it until you unmute. We want to publish it from the start so we're
// always capturing audio: it helps keep bluetooth headsets in the right mode and
// mobile browsers to know we're doing a call.
if (
livekitRoom!.localParticipant.getTrackPublication(Track.Source.Microphone)
) {
logger.warn(
"Pre-creating audio track but participant already appears to have an microphone track: this shouldn't happen!",
);
Sentry.captureMessage(
"Pre-creating audio track but participant already appears to have an microphone track!",
);
return;
}
logger.info("Pre-creating microphone track");
let preCreatedAudioTrack: LocalTrack | undefined;
try {
const audioTracks = await livekitRoom!.localParticipant.createTracks({
audio: { deviceId: initialDeviceId },
});
if (audioTracks.length < 1) {
logger.info("Tried to pre-create local audio track but got no tracks");
} else {
preCreatedAudioTrack = audioTracks[0];
}
// There was a yield point previously (awaiting for the track to be created) so we need to check
// if the operation was cancelled and stop connecting if needed.
if (abortHandle.isAborted()) {
logger.info(
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
);
preCreatedAudioTrack?.stop();
return;
}
logger.info("Pre-created microphone track");
} catch (e) {
logger.error("Failed to pre-create microphone track", e);
}
if (!audioEnabled) {
await preCreatedAudioTrack?.mute();
// There was a yield point. Check if the operation was cancelled and stop connecting.
if (abortHandle.isAborted()) {
logger.info(
"[Lifecycle] Signal Aborted: Pre-created audio track but connection aborted",
);
preCreatedAudioTrack?.stop();
return;
}
}
// check again having awaited for the track to create
if (
livekitRoom!.localParticipant.getTrackPublication(Track.Source.Microphone)
) {
logger.warn(
"Pre-created audio track but participant already appears to have an microphone track: this shouldn't happen!",
);
preCreatedAudioTrack?.stop();
return;
}
logger.info("[Lifecycle] Connecting & publishing");
try {
await connectAndPublish(livekitRoom, sfuConfig, preCreatedAudioTrack, []);
if (abortHandle.isAborted()) {
logger.info(
"[Lifecycle] Signal Aborted: Connected but operation was cancelled. Force disconnect",
);
livekitRoom?.disconnect().catch((err) => {
logger.error("Failed to disconnect from SFU", err);
});
return;
}
} catch (e) {
preCreatedAudioTrack?.stop();
logger.debug("Stopped precreated audio tracks.");
throw e;
}
}
/**
* Connect to the SFU and publish specific tracks, if provided.
* This is very specific to what we need to do: for instance, we don't
* currently have a need to prepublish video tracks. We just prepublish
* a mic track at the start of a call and copy any srceenshare tracks over
* when switching focus (because we can't re-acquire them without the user
* going through the dialog to choose them again).
*/
async function connectAndPublish(
livekitRoom: Room,
sfuConfig: SFUConfig,
micTrack: LocalTrack | undefined,
screenshareTracks: MediaStreamTrack[],
): Promise<void> {
const tracker = PosthogAnalytics.instance.eventCallConnectDuration;
// Track call connect duration
tracker.cacheConnectStart();
livekitRoom.once(RoomEvent.SignalConnected, tracker.cacheWsConnect);
try {
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
// Due to stability issues on Firefox we are testing the effect of different
// timeouts, and allow these values to be set through the console
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
websocketTimeout: window.websocketTimeout ?? 45000,
});
logger.info(`[Lifecycle] ... connected to livekit room`);
} catch (e) {
logger.error("[Lifecycle] Failed to connect", e);
// LiveKit uses 503 to indicate that the server has hit its track limits.
// https://github.com/livekit/livekit/blob/fcb05e97c5a31812ecf0ca6f7efa57c485cea9fb/pkg/service/rtcservice.go#L171
// It also errors with a status code of 200 (yes, really) for room
// participant limits.
// LiveKit Cloud uses 429 for connection limits.
// Either way, all these errors can be explained as "insufficient capacity".
if (
e instanceof ConnectionError &&
(e.status === 503 || e.status === 200 || e.status === 429)
)
throw new InsufficientCapacityError();
throw e;
}
// remove listener in case the connect promise rejects before `SignalConnected` is emitted.
livekitRoom.off(RoomEvent.SignalConnected, tracker.cacheWsConnect);
tracker.track({ log: true });
if (micTrack) {
logger.info(`Publishing precreated mic track`);
await livekitRoom.localParticipant.publishTrack(micTrack, {
source: Track.Source.Microphone,
});
}
logger.info(
`Publishing ${screenshareTracks.length} precreated screenshare tracks`,
);
for (const st of screenshareTracks) {
livekitRoom.localParticipant
.publishTrack(st, {
source: Track.Source.ScreenShare,
})
.catch((e) => {
logger.error("Failed to publish screenshare track", e);
});
}
}
export function useECConnectionState(
initialDeviceId: string | undefined,
initialAudioEnabled: boolean,
livekitRoom?: Room,
sfuConfig?: SFUConfig,
): ECConnectionState {
const [connState, setConnState] = useState(
sfuConfig && livekitRoom
? livekitRoom.state
: ECAddonConnectionState.ECWaiting,
);
const [isSwitchingFocus, setSwitchingFocus] = useState(false);
const [isInDoConnect, setIsInDoConnect] = useState(false);
const [error, setError] = useState<ElementCallError | null>(null);
if (error !== null) throw error;
const onConnStateChanged = useCallback((state: ConnectionState) => {
if (state == ConnectionState.Connected) setSwitchingFocus(false);
setConnState(state);
}, []);
useEffect(() => {
const oldRoom = livekitRoom;
if (livekitRoom) {
livekitRoom.on(RoomEvent.ConnectionStateChanged, onConnStateChanged);
}
return (): void => {
if (oldRoom)
oldRoom.off(RoomEvent.ConnectionStateChanged, onConnStateChanged);
};
}, [livekitRoom, onConnStateChanged]);
const doFocusSwitch = useCallback(async (): Promise<void> => {
const screenshareTracks: MediaStreamTrack[] = [];
for (const t of livekitRoom!.localParticipant.videoTrackPublications.values()) {
if (t.track && t.source == Track.Source.ScreenShare) {
const newTrack = t.track.mediaStreamTrack.clone();
newTrack.enabled = true;
screenshareTracks.push(newTrack);
}
}
// Flag that we're currently switching focus. This will get reset when the
// connection state changes back to connected in onConnStateChanged above.
setSwitchingFocus(true);
await livekitRoom?.disconnect();
setIsInDoConnect(true);
try {
await connectAndPublish(
livekitRoom!,
sfuConfig!,
undefined,
screenshareTracks,
);
} finally {
setIsInDoConnect(false);
}
}, [livekitRoom, sfuConfig]);
const currentSFUConfig = useRef(Object.assign({}, sfuConfig));
// Protection against potential leaks, where the component to be unmounted and there is
// still a pending doConnect promise. This would lead the user to still be in the call even
// if the component is unmounted.
const abortHandlesBag = useRef(new Set<AbortHandle>());
// This is a cleanup function that will be called when the component is about to be unmounted.
// It will cancel all abortHandles in the bag
useEffect(() => {
const bag = abortHandlesBag.current;
return (): void => {
bag.forEach((handle) => {
handle.abort();
});
};
}, []);
// Id we are transitioning from a valid config to another valid one, we need
// to explicitly switch focus
useEffect(() => {
if (
sfuConfigValid(sfuConfig) &&
sfuConfigValid(currentSFUConfig.current) &&
!sfuConfigEquals(currentSFUConfig.current, sfuConfig)
) {
logger.info(
`SFU config changed! URL was ${currentSFUConfig.current?.url} now ${sfuConfig?.url}`,
);
doFocusSwitch().catch((e) => {
logger.error("Failed to switch focus", e);
});
} else if (
!sfuConfigValid(currentSFUConfig.current) &&
sfuConfigValid(sfuConfig)
) {
// if we're transitioning from an invalid config to a valid one (ie. connecting)
// then do an initial connection, including publishing the microphone track:
// livekit (by default) keeps the mic track open when you mute, but if you start muted,
// doesn't publish it until you unmute. We want to publish it from the start so we're
// always capturing audio: it helps keep bluetooth headsets in the right mode and
// mobile browsers to know we're doing a call.
setIsInDoConnect(true);
const abortHandle = new AbortHandle();
abortHandlesBag.current.add(abortHandle);
doConnect(
livekitRoom!,
sfuConfig!,
initialAudioEnabled,
initialDeviceId,
abortHandle,
)
.catch((e) => {
if (e instanceof ElementCallError) {
setError(e); // Bubble up any error screens to React
} else if (e instanceof Error) {
setError(new UnknownCallError(e));
} else logger.error("Failed to connect to SFU", e);
})
.finally(() => {
abortHandlesBag.current.delete(abortHandle);
setIsInDoConnect(false);
});
}
currentSFUConfig.current = Object.assign({}, sfuConfig);
}, [
sfuConfig,
livekitRoom,
initialDeviceId,
initialAudioEnabled,
doFocusSwitch,
]);
// Because we create audio tracks by hand, there's more to connecting than
// just what LiveKit does in room.connect, and we should continue to return
// ConnectionState.Connecting for the entire duration of the doConnect promise
return isSwitchingFocus
? ECAddonConnectionState.ECSwitchingFocus
: isInDoConnect
? ConnectionState.Connecting
: connState;
}

View File

@@ -1,403 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ConnectionState,
type E2EEManagerOptions,
ExternalE2EEKeyProvider,
LocalVideoTrack,
Room,
type RoomOptions,
Track,
} from "livekit-client";
import { useEffect, useRef } from "react";
import E2EEWorker from "livekit-client/e2ee-worker?worker";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useObservable, useObservableEagerState } from "observable-hooks";
import {
map,
NEVER,
type Observable,
type Subscription,
switchMap,
} from "rxjs";
import { defaultLiveKitOptions } from "./options";
import { type SFUConfig } from "./openIDSFU";
import { type MuteStates } from "../room/MuteStates";
import { useMediaDevices } from "../MediaDevicesContext";
import {
type ECConnectionState,
useECConnectionState,
} from "./useECConnectionState";
import { MatrixKeyProvider } from "../e2ee/matrixKeyProvider";
import { E2eeType } from "../e2ee/e2eeType";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import {
useTrackProcessor,
useTrackProcessorSync,
} from "./TrackProcessorContext";
import { observeTrackReference$ } from "../state/MediaViewModel";
import { useUrlParams } from "../UrlParams";
import { useInitial } from "../useInitial";
import { getValue } from "../utils/observable";
import { type SelectedDevice } from "../state/MediaDevices";
interface UseLivekitResult {
livekitRoom?: Room;
connState: ECConnectionState;
}
export function useLivekit(
rtcSession: MatrixRTCSession,
muteStates: MuteStates,
sfuConfig: SFUConfig | undefined,
e2eeSystem: EncryptionSystem,
): UseLivekitResult {
const { controlledAudioDevices } = useUrlParams();
const initialMuteStates = useInitial(() => muteStates);
const devices = useMediaDevices();
const initialAudioInputId = useInitial(
() => getValue(devices.audioInput.selected$)?.id,
);
// Store if audio/video are currently updating. If to prohibit unnecessary calls
// to setMicrophoneEnabled/setCameraEnabled
const audioMuteUpdating = useRef(false);
const videoMuteUpdating = useRef(false);
// Store the current button mute state that gets passed to this hook via props.
// We need to store it for awaited code that relies on the current value.
const buttonEnabled = useRef({
audio: initialMuteStates.audio.enabled,
video: initialMuteStates.video.enabled,
});
const { processor } = useTrackProcessor();
// Only ever create the room once via useInitial.
const room = useInitial(() => {
logger.info("[LivekitRoom] Create LiveKit room");
let e2ee: E2EEManagerOptions | undefined;
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
logger.info("Created MatrixKeyProvider (per participant)");
e2ee = {
keyProvider: new MatrixKeyProvider(),
worker: new E2EEWorker(),
};
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
logger.info("Created ExternalE2EEKeyProvider (shared key)");
e2ee = {
keyProvider: new ExternalE2EEKeyProvider(),
worker: new E2EEWorker(),
};
}
const roomOptions: RoomOptions = {
...defaultLiveKitOptions,
videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: getValue(devices.videoInput.selected$)?.id,
processor,
},
audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: initialAudioInputId,
},
audioOutput: {
// When using controlled audio devices, we don't want to set the
// deviceId here, because it will be set by the native app.
// (also the id does not need to match a browser device id)
deviceId: controlledAudioDevices
? undefined
: getValue(devices.audioOutput.selected$)?.id,
},
e2ee,
};
// We have to create the room manually here due to a bug inside
// @livekit/components-react. JSON.stringify() is used in deps of a
// useEffect() with an argument that references itself, if E2EE is enabled
const room = new Room(roomOptions);
room.setE2EEEnabled(e2eeSystem.kind !== E2eeType.NONE).catch((e) => {
logger.error("Failed to set E2EE enabled on room", e);
});
return room;
});
// Setup and update the keyProvider which was create by `createRoom`
useEffect(() => {
const e2eeOptions = room.options.e2ee;
if (
e2eeSystem.kind === E2eeType.NONE ||
!(e2eeOptions && "keyProvider" in e2eeOptions)
)
return;
if (e2eeSystem.kind === E2eeType.PER_PARTICIPANT) {
(e2eeOptions.keyProvider as MatrixKeyProvider).setRTCSession(rtcSession);
} else if (e2eeSystem.kind === E2eeType.SHARED_KEY && e2eeSystem.secret) {
(e2eeOptions.keyProvider as ExternalE2EEKeyProvider)
.setKey(e2eeSystem.secret)
.catch((e) => {
logger.error("Failed to set shared key for E2EE", e);
});
}
}, [room.options.e2ee, e2eeSystem, rtcSession]);
// Sync the requested track processors with LiveKit
useTrackProcessorSync(
useObservableEagerState(
useObservable(
(room$) =>
room$.pipe(
switchMap(([room]) =>
observeTrackReference$(
room.localParticipant,
Track.Source.Camera,
),
),
map((trackRef) => {
const track = trackRef?.publication?.track;
return track instanceof LocalVideoTrack ? track : null;
}),
),
[room],
),
),
);
const connectionState = useECConnectionState(
initialAudioInputId,
initialMuteStates.audio.enabled,
room,
sfuConfig,
);
useEffect(() => {
// Sync the requested mute states with LiveKit's mute states. We do it this
// way around rather than using LiveKit as the source of truth, so that the
// states can be consistent throughout the lobby and loading screens.
// It's important that we only do this in the connected state, because
// LiveKit's internal mute states aren't consistent during connection setup,
// and setting tracks to be enabled during this time causes errors.
if (room !== undefined && connectionState === ConnectionState.Connected) {
const participant = room.localParticipant;
// Always update the muteButtonState Ref so that we can read the current
// state in awaited blocks.
buttonEnabled.current = {
audio: muteStates.audio.enabled,
video: muteStates.video.enabled,
};
enum MuteDevice {
Microphone,
Camera,
}
const syncMuteState = async (
iterCount: number,
type: MuteDevice,
): Promise<void> => {
// The approach for muting is to always bring the actual livekit state in sync with the button
// This allows for a very predictable and reactive behavior for the user.
// (the new state is the old state when pressing the button n times (where n is even))
// (the new state is different to the old state when pressing the button n times (where n is uneven))
// In case there are issues with the device there might be situations where setMicrophoneEnabled/setCameraEnabled
// return immediately. This should be caught with the Error("track with new mute state could not be published").
// For now we are still using an iterCount to limit the recursion loop to 10.
// This could happen if the device just really does not want to turn on (hardware based issue)
// but the mute button is in unmute state.
// For now our fail mode is to just stay in this state.
// TODO: decide for a UX on how that fail mode should be treated (disable button, hide button, sync button back to muted without user input)
if (iterCount > 10) {
logger.error(
"Stop trying to sync the input device with current mute state after 10 failed tries",
);
return;
}
let devEnabled;
let btnEnabled;
let updating;
switch (type) {
case MuteDevice.Microphone:
devEnabled = participant.isMicrophoneEnabled;
btnEnabled = buttonEnabled.current.audio;
updating = audioMuteUpdating.current;
break;
case MuteDevice.Camera:
devEnabled = participant.isCameraEnabled;
btnEnabled = buttonEnabled.current.video;
updating = videoMuteUpdating.current;
break;
}
if (devEnabled !== btnEnabled && !updating) {
try {
let trackPublication;
switch (type) {
case MuteDevice.Microphone:
audioMuteUpdating.current = true;
trackPublication = await participant.setMicrophoneEnabled(
buttonEnabled.current.audio,
room.options.audioCaptureDefaults,
);
audioMuteUpdating.current = false;
break;
case MuteDevice.Camera:
videoMuteUpdating.current = true;
trackPublication = await participant.setCameraEnabled(
buttonEnabled.current.video,
room.options.videoCaptureDefaults,
);
videoMuteUpdating.current = false;
break;
}
if (trackPublication) {
// await participant.setMicrophoneEnabled can return immediately in some instances,
// so that participant.isMicrophoneEnabled !== buttonEnabled.current.audio still holds true.
// This happens if the device is still in a pending state
// "sleeping" here makes sure we let react do its thing so that participant.isMicrophoneEnabled is updated,
// so we do not end up in a recursion loop.
await new Promise((r) => setTimeout(r, 100));
// track got successfully changed to mute/unmute
// Run the check again after the change is done. Because the user
// can update the state (presses mute button) while the device is enabling
// itself we need might need to update the mute state right away.
// This async recursion makes sure that setCamera/MicrophoneEnabled is
// called as little times as possible.
await syncMuteState(iterCount + 1, type);
} else {
throw new Error(
"track with new mute state could not be published",
);
}
} catch (e) {
if ((e as DOMException).name === "NotAllowedError") {
logger.error(
"Fatal error while syncing mute state: resetting",
e,
);
if (type === MuteDevice.Microphone) {
audioMuteUpdating.current = false;
muteStates.audio.setEnabled?.(false);
} else {
videoMuteUpdating.current = false;
muteStates.video.setEnabled?.(false);
}
} else {
logger.error(
"Failed to sync audio mute state with LiveKit (will retry to sync in 1s):",
e,
);
setTimeout(() => {
syncMuteState(iterCount + 1, type).catch((e) => {
logger.error(
`Failed to sync ${MuteDevice[type]} mute state with LiveKit iterCount=${iterCount + 1}`,
e,
);
});
}, 1000);
}
}
}
};
syncMuteState(0, MuteDevice.Microphone).catch((e) => {
logger.error("Failed to sync audio mute state with LiveKit", e);
});
syncMuteState(0, MuteDevice.Camera).catch((e) => {
logger.error("Failed to sync video mute state with LiveKit", e);
});
}
}, [room, muteStates, connectionState]);
useEffect(() => {
// Sync the requested devices with LiveKit's devices
if (room !== undefined && connectionState === ConnectionState.Connected) {
const syncDevice = (
kind: MediaDeviceKind,
selected$: Observable<SelectedDevice | undefined>,
): Subscription =>
selected$.subscribe((device) => {
logger.info(
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
room.getActiveDevice(kind),
" !== ",
device?.id,
);
if (
device !== undefined &&
room.getActiveDevice(kind) !== device.id
) {
room
.switchActiveDevice(kind, device.id)
.catch((e) =>
logger.error(`Failed to sync ${kind} device with LiveKit`, e),
);
}
});
const subscriptions = [
syncDevice("audioinput", devices.audioInput.selected$),
!controlledAudioDevices
? syncDevice("audiooutput", devices.audioOutput.selected$)
: undefined,
syncDevice("videoinput", devices.videoInput.selected$),
// Restart the audio input track whenever we detect that the active media
// device has changed to refer to a different hardware device. We do this
// for the sake of Chrome, which provides a "default" device that is meant
// to match the system's default audio input, whatever that may be.
// This is special-cased for only audio inputs because we need to dig around
// in the LocalParticipant object for the track object and there's not a nice
// way to do that generically. There is usually no OS-level default video capture
// device anyway, and audio outputs work differently.
devices.audioInput.selected$
.pipe(switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER))
.subscribe(() => {
const activeMicTrack = Array.from(
room.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track;
if (
activeMicTrack &&
// only restart if the stream is still running: LiveKit will detect
// when a track stops & restart appropriately, so this is not our job.
// Plus, we need to avoid restarting again if the track is already in
// the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended"
) {
// Restart the track, which will cause Livekit to do another
// getUserMedia() call with deviceId: default to get the *new* default device.
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
// the deviceId hasn't changed (was & still is default).
room.localParticipant
.getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack()
.catch((e) => {
logger.error(`Failed to restart audio device track`, e);
});
}
}),
];
return (): void => {
for (const s of subscriptions) s?.unsubscribe();
};
}
}, [room, devices, connectionState, controlledAudioDevices]);
return {
connState: connectionState,
livekitRoom: room,
};
}

View File

@@ -24,6 +24,7 @@ import { App } from "./App";
import { init as initRageshake } from "./settings/rageshake";
import { Initializer } from "./initializer";
import { AppViewModel } from "./state/AppViewModel";
import { globalScope } from "./state/ObservableScope";
window.setLKLogLevel = setLKLogLevel;
@@ -61,7 +62,7 @@ Initializer.initBeforeReact()
.then(() => {
root.render(
<StrictMode>
<App vm={new AppViewModel()} />
<App vm={new AppViewModel(globalScope)} />
</StrictMode>,
);
})

View File

@@ -22,11 +22,13 @@ export function RaisedHandIndicator({
miniature,
showTimer,
onClick,
tabIndex,
}: {
raisedHandTime?: Date;
miniature?: boolean;
showTimer?: boolean;
onClick?: () => void;
tabIndex?: number;
}): ReactNode {
const { t } = useTranslation();
const [raisedHandDuration, setRaisedHandDuration] = useState("");
@@ -94,6 +96,7 @@ export function RaisedHandIndicator({
background: "none",
}}
onClick={clickCallback}
tabIndex={tabIndex}
>
{content}
</button>

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import { renderHook } from "@testing-library/react";
import { afterEach, test, vitest } from "vitest";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import {
RoomEvent as MatrixRoomEvent,
MatrixEvent,
@@ -24,7 +23,7 @@ import {
localRtcMember,
} from "../utils/test-fixtures";
import { getBasicRTCSession } from "../utils/test-viewmodel";
import { withTestScheduler } from "../utils/test";
import { testScope, withTestScheduler } from "../utils/test";
import { ElementCallReactionEventType, ReactionSet } from ".";
afterEach(() => {
@@ -38,7 +37,8 @@ test("handles a hand raised reaction", () => {
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
testScope(),
rtcSession.asMockedSession(),
);
schedule("ab", {
a: () => {},
@@ -48,7 +48,7 @@ test("handles a hand raised reaction", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
@@ -68,7 +68,7 @@ test("handles a hand raised reaction", () => {
expectObservable(raisedHands$).toBe("ab", {
a: {},
b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionEventId,
membershipEventId: localRtcMember.eventId,
time: localTimestamp,
@@ -86,7 +86,8 @@ test("handles a redaction", () => {
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
testScope(),
rtcSession.asMockedSession(),
);
schedule("abc", {
a: () => {},
@@ -96,7 +97,7 @@ test("handles a redaction", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
@@ -118,7 +119,7 @@ test("handles a redaction", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: EventType.RoomRedaction,
redacts: reactionEventId,
}),
@@ -130,7 +131,7 @@ test("handles a redaction", () => {
expectObservable(raisedHands$).toBe("abc", {
a: {},
b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionEventId,
membershipEventId: localRtcMember.eventId,
time: localTimestamp,
@@ -149,7 +150,8 @@ test("handles waiting for event decryption", () => {
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
testScope(),
rtcSession.asMockedSession(),
);
schedule("abc", {
a: () => {},
@@ -157,7 +159,7 @@ test("handles waiting for event decryption", () => {
const encryptedEvent = new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
@@ -184,7 +186,7 @@ test("handles waiting for event decryption", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
@@ -200,7 +202,7 @@ test("handles waiting for event decryption", () => {
expectObservable(raisedHands$).toBe("a-c", {
a: {},
c: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionEventId,
membershipEventId: localRtcMember.eventId,
time: localTimestamp,
@@ -218,7 +220,8 @@ test("hands rejecting events without a proper membership", () => {
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { raisedHands$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
testScope(),
rtcSession.asMockedSession(),
);
schedule("ab", {
a: () => {},
@@ -228,7 +231,7 @@ test("hands rejecting events without a proper membership", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: EventType.Reaction,
origin_server_ts: localTimestamp.getTime(),
content: {
@@ -263,7 +266,8 @@ test("handles a reaction", () => {
withTestScheduler(({ schedule, time, expectObservable }) => {
renderHook(() => {
const { reactions$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
testScope(),
rtcSession.asMockedSession(),
);
schedule(`abc`, {
a: () => {},
@@ -273,7 +277,7 @@ test("handles a reaction", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: ElementCallReactionEventType,
content: {
emoji: reaction.emoji,
@@ -298,7 +302,7 @@ test("handles a reaction", () => {
{
a: {},
b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionOption: reaction,
expireAfter: new Date(REACTION_ACTIVE_TIME_MS),
},
@@ -321,7 +325,8 @@ test("ignores bad reaction events", () => {
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { reactions$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
testScope(),
rtcSession.asMockedSession(),
);
schedule("ab", {
a: () => {},
@@ -332,7 +337,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: ElementCallReactionEventType,
content: {},
}),
@@ -347,7 +352,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: ElementCallReactionEventType,
content: {
emoji: reaction.emoji,
@@ -368,7 +373,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: aliceRtcMember.sender,
sender: aliceRtcMember.userId,
type: ElementCallReactionEventType,
content: {
emoji: reaction.emoji,
@@ -389,7 +394,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: ElementCallReactionEventType,
content: {
name: reaction.name,
@@ -409,7 +414,7 @@ test("ignores bad reaction events", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: ElementCallReactionEventType,
content: {
emoji: " ",
@@ -445,7 +450,8 @@ test("that reactions cannot be spammed", () => {
withTestScheduler(({ schedule, expectObservable }) => {
renderHook(() => {
const { reactions$ } = new ReactionsReader(
rtcSession as unknown as MatrixRTCSession,
testScope(),
rtcSession.asMockedSession(),
);
schedule("abcd", {
a: () => {},
@@ -455,7 +461,7 @@ test("that reactions cannot be spammed", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: ElementCallReactionEventType,
content: {
emoji: reactionA.emoji,
@@ -477,7 +483,7 @@ test("that reactions cannot be spammed", () => {
new MatrixEvent({
room_id: rtcSession.room.roomId,
event_id: reactionEventId,
sender: localRtcMember.sender,
sender: localRtcMember.userId,
type: ElementCallReactionEventType,
content: {
emoji: reactionB.emoji,
@@ -502,7 +508,7 @@ test("that reactions cannot be spammed", () => {
{
a: {},
b: {
[`${localRtcMember.sender}:${localRtcMember.deviceId}`]: {
[`${localRtcMember.userId}:${localRtcMember.deviceId}`]: {
reactionOption: reactionA,
expireAfter: new Date(REACTION_ACTIVE_TIME_MS),
},

View File

@@ -18,7 +18,7 @@ import {
EventType,
RoomEvent as MatrixRoomEvent,
} from "matrix-js-sdk";
import { BehaviorSubject, delay, type Subscription } from "rxjs";
import { BehaviorSubject, delay } from "rxjs";
import {
ElementCallReactionEventType,
@@ -28,6 +28,7 @@ import {
type RaisedHandInfo,
type ReactionInfo,
} from ".";
import { type ObservableScope } from "../state/ObservableScope";
export const REACTION_ACTIVE_TIME_MS = 3000;
@@ -54,12 +55,13 @@ export class ReactionsReader {
*/
public readonly reactions$ = this.reactionsSubject$.asObservable();
private readonly reactionsSub: Subscription;
public constructor(private readonly rtcSession: MatrixRTCSession) {
public constructor(
private readonly scope: ObservableScope,
private readonly rtcSession: MatrixRTCSession,
) {
// Hide reactions after a given time.
this.reactionsSub = this.reactionsSubject$
.pipe(delay(REACTION_ACTIVE_TIME_MS))
this.reactionsSubject$
.pipe(delay(REACTION_ACTIVE_TIME_MS), this.scope.bind())
.subscribe((reactions) => {
const date = new Date();
const nextEntries = Object.fromEntries(
@@ -71,15 +73,38 @@ export class ReactionsReader {
this.reactionsSubject$.next(nextEntries);
});
// TODO: Convert this class to the functional reactive style and get rid of
// all this manual setup and teardown for event listeners
this.rtcSession.room.on(MatrixRoomEvent.Timeline, this.handleReactionEvent);
this.scope.onEnd(() =>
this.rtcSession.room.off(
MatrixRoomEvent.Timeline,
this.handleReactionEvent,
),
);
this.rtcSession.room.on(
MatrixRoomEvent.Redaction,
this.handleReactionEvent,
);
this.scope.onEnd(() =>
this.rtcSession.room.off(
MatrixRoomEvent.Redaction,
this.handleReactionEvent,
),
);
this.rtcSession.room.client.on(
MatrixEventEvent.Decrypted,
this.handleReactionEvent,
);
this.scope.onEnd(() =>
this.rtcSession.room.client.off(
MatrixEventEvent.Decrypted,
this.handleReactionEvent,
),
);
// We listen for a local echo to get the real event ID, as timeline events
// may still be sending.
@@ -87,11 +112,23 @@ export class ReactionsReader {
MatrixRoomEvent.LocalEchoUpdated,
this.handleReactionEvent,
);
this.scope.onEnd(() =>
this.rtcSession.room.off(
MatrixRoomEvent.LocalEchoUpdated,
this.handleReactionEvent,
),
);
rtcSession.on(
this.rtcSession.on(
MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged,
);
this.scope.onEnd(() =>
this.rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged,
),
);
// Run this once to ensure we have fetched the state from the call.
this.onMembershipsChanged([]);
@@ -130,7 +167,7 @@ export class ReactionsReader {
private onMembershipsChanged = (oldMemberships: CallMembership[]): void => {
// Remove any raised hands for users no longer joined to the call.
for (const identifier of Object.keys(this.raisedHandsSubject$.value).filter(
(rhId) => oldMemberships.find((u) => u.sender == rhId),
(rhId) => oldMemberships.find((u) => u.userId == rhId),
)) {
this.removeRaisedHand(identifier);
}
@@ -138,10 +175,10 @@ export class ReactionsReader {
// For each member in the call, check to see if a reaction has
// been raised and adjust.
for (const m of this.rtcSession.memberships) {
if (!m.sender || !m.eventId) {
if (!m.userId || !m.eventId) {
continue;
}
const identifier = `${m.sender}:${m.deviceId}`;
const identifier = `${m.userId}:${m.deviceId}`;
if (
this.raisedHandsSubject$.value[identifier] &&
this.raisedHandsSubject$.value[identifier].membershipEventId !==
@@ -151,13 +188,13 @@ export class ReactionsReader {
// was raised, reset.
this.removeRaisedHand(identifier);
}
const reaction = this.getLastReactionEvent(m.eventId, m.sender);
const reaction = this.getLastReactionEvent(m.eventId, m.userId);
if (reaction) {
const eventId = reaction?.getId();
if (!eventId) {
continue;
}
this.addRaisedHand(`${m.sender}:${m.deviceId}`, {
this.addRaisedHand(`${m.userId}:${m.deviceId}`, {
membershipEventId: m.eventId,
reactionEventId: eventId,
time: new Date(reaction.localTimestamp),
@@ -219,7 +256,7 @@ export class ReactionsReader {
const membershipEventId = content?.["m.relates_to"]?.event_id;
const membershipEvent = this.rtcSession.memberships.find(
(e) => e.eventId === membershipEventId && e.sender === sender,
(e) => e.eventId === membershipEventId && e.userId === sender,
);
// Check to see if this reaction was made to a membership event (and the
// sender of the reaction matches the membership)
@@ -229,7 +266,8 @@ export class ReactionsReader {
);
return;
}
const identifier = `${membershipEvent.sender}:${membershipEvent.deviceId}`;
// TODO refactor to use memer id `membershipEvent.membershipID` (needs to happen in combination with other memberId refactors)
const identifier = `${membershipEvent.userId}:${membershipEvent.deviceId}`;
if (!content.emoji) {
logger.warn(`Reaction had no emoji from ${reactionEventId}`);
@@ -278,7 +316,7 @@ export class ReactionsReader {
// Check to see if this reaction was made to a membership event (and the
// sender of the reaction matches the membership)
const membershipEvent = this.rtcSession.memberships.find(
(e) => e.eventId === membershipEventId && e.sender === sender,
(e) => e.eventId === membershipEventId && e.userId === sender,
);
if (!membershipEvent) {
logger.warn(
@@ -289,7 +327,7 @@ export class ReactionsReader {
if (content?.["m.relates_to"].key === "🖐️") {
this.addRaisedHand(
`${membershipEvent.sender}:${membershipEvent.deviceId}`,
`${membershipEvent.userId}:${membershipEvent.deviceId}`,
{
reactionEventId,
membershipEventId,
@@ -309,31 +347,4 @@ export class ReactionsReader {
this.removeRaisedHand(targetUser);
}
};
/**
* Stop listening for events.
*/
public destroy(): void {
this.rtcSession.off(
MatrixRTCSessionEvent.MembershipsChanged,
this.onMembershipsChanged,
);
this.rtcSession.room.off(
MatrixRoomEvent.Timeline,
this.handleReactionEvent,
);
this.rtcSession.room.off(
MatrixRoomEvent.Redaction,
this.handleReactionEvent,
);
this.rtcSession.room.client.off(
MatrixEventEvent.Decrypted,
this.handleReactionEvent,
);
this.rtcSession.room.off(
MatrixRoomEvent.LocalEchoUpdated,
this.handleReactionEvent,
);
this.reactionsSub.unsubscribe();
}
}

View File

@@ -27,6 +27,8 @@ import rockSoundOgg from "../sound/reactions/rock.ogg?url";
import rockSoundMp3 from "../sound/reactions/rock.mp3?url";
import waveSoundOgg from "../sound/reactions/wave.ogg?url";
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
export const ElementCallReactionEventType = "io.element.call.reaction";
@@ -191,6 +193,15 @@ export const ReactionSet: ReactionOption[] = [
mp3: waveSoundMp3,
},
},
{
emoji: "🥁",
name: "drum",
alias: ["joke"],
sound: {
ogg: baduntssSoundOgg,
mp3: baduntssSoundMp3,
},
},
];
export interface RaisedHandInfo {

View File

@@ -20,7 +20,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
interface ReactionsSenderContextType {
@@ -65,7 +65,7 @@ export const ReactionsSenderProvider = ({
const myMembershipEvent = useMemo(
() =>
memberships.find(
(m) => m.sender === myUserId && m.deviceId === myDeviceId,
(m) => m.userId === myUserId && m.deviceId === myDeviceId,
)?.eventId,
[memberships, myUserId, myDeviceId],
);

View File

@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import { render } from "@testing-library/react";
import {
afterAll,
beforeEach,
expect,
type MockedFunction,
@@ -16,32 +15,41 @@ import {
afterEach,
} from "vitest";
import { act } from "react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { mockRtcMembership } from "../utils/test";
import { type RoomMember } from "matrix-js-sdk";
import {
CallEventAudioRenderer,
MAX_PARTICIPANT_COUNT_FOR_SOUND,
} from "./CallEventAudioRenderer";
type LivekitTransport,
type CallMembership,
} from "matrix-js-sdk/lib/matrixrtc";
import {
exampleTransport,
mockMatrixRoomMember,
mockRtcMembership,
} from "../utils/test";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import {
alice,
aliceRtcMember,
bob,
bobRtcMember,
local,
localRtcMember,
} from "../utils/test-fixtures";
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel/CallViewModel";
vitest.mock("livekit-client/e2ee-worker?worker");
vitest.mock("../useAudioContext");
vitest.mock("../soundUtils");
vitest.mock("../rtcSessionHelpers", async (importOriginal) => ({
...(await importOriginal()),
makeTransport: (): [LivekitTransport] => [exampleTransport],
}));
afterEach(() => {
vitest.resetAllMocks();
});
afterAll(() => {
vitest.restoreAllMocks();
vitest.clearAllMocks();
});
let playSound: MockedFunction<
@@ -55,6 +63,8 @@ beforeEach(() => {
playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
});
});
@@ -66,64 +76,83 @@ beforeEach(() => {
* a noise every time.
*/
test("plays one sound when entering a call", () => {
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
local,
alice,
bob,
]);
render(<CallEventAudioRenderer vm={vm} />);
// Joining a call usually means remote participants are added later.
act(() => {
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
});
expect(playSound).toHaveBeenCalledOnce();
});
test("plays a sound when a user joins", () => {
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
local,
alice,
bob,
]);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
});
// Play a sound when joining a call.
expect(playSound).toBeCalledWith("join");
});
test("plays a sound when a user leaves", () => {
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
remoteRtcMemberships$.next([]);
rtcMemberships$.next([localRtcMember]);
});
expect(playSound).toBeCalledWith("left");
});
test("does not play a sound before the call is successful", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
[local, alice],
[localRtcMember],
{ waitForCallPickup: true },
);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
rtcMemberships$.next([localRtcMember]);
});
expect(playSound).not.toBeCalledWith("left");
});
test("plays no sound when the participant list is more than the maximum size", () => {
const mockRtcMemberships: CallMembership[] = [];
const mockMembers: RoomMember[] = [local];
const mockRtcMemberships: CallMembership[] = [localRtcMember];
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
mockRtcMemberships.push(
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
);
const membership = mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`);
mockMembers.push(mockMatrixRoomMember(membership));
mockRtcMemberships.push(membership);
}
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment(
[local, alice],
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
mockMembers,
mockRtcMemberships,
);
render(<CallEventAudioRenderer vm={vm} />);
expect(playSound).not.toBeCalled();
// Remove the last membership in the array to test the leaving sound
// (The array has length MAX_PARTICIPANT_COUNT_FOR_SOUND + 1)
act(() => {
remoteRtcMemberships$.next(
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
rtcMemberships$.next(
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND),
);
});
expect(playSound).toBeCalledWith("left");
@@ -133,12 +162,14 @@ test("plays one sound when a hand is raised", () => {
const { vm, handRaisedSubject$ } = getBasicCallViewModelEnvironment([
local,
alice,
bob,
]);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
handRaisedSubject$.next({
[bobRtcMember.callId]: {
// TODO: What is this string supposed to be?
[`${bobRtcMember.userId}:${bobRtcMember.deviceId}`]: {
time: new Date(),
membershipEventId: "",
reactionEventId: "",
@@ -155,6 +186,7 @@ test("should not play a sound when a hand raise is retracted", () => {
]);
render(<CallEventAudioRenderer vm={vm} />);
playSound.mockClear();
act(() => {
handRaisedSubject$.next({
["foo"]: {
@@ -169,7 +201,7 @@ test("should not play a sound when a hand raise is retracted", () => {
},
});
});
expect(playSound).toHaveBeenCalledTimes(2);
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
act(() => {
handRaisedSubject$.next({
["foo"]: {
@@ -179,5 +211,5 @@ test("should not play a sound when a hand raise is retracted", () => {
},
});
});
expect(playSound).toHaveBeenCalledTimes(2);
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
});

View File

@@ -6,9 +6,8 @@ Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useEffect } from "react";
import { filter, interval, throttle } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import joinCallSoundMp3 from "../sound/join_call.mp3";
import joinCallSoundOgg from "../sound/join_call.ogg";
import leftCallSoundMp3 from "../sound/left_call.mp3";
@@ -17,15 +16,14 @@ import handSoundOgg from "../sound/raise_hand.ogg";
import handSoundMp3 from "../sound/raise_hand.mp3";
import screenShareStartedOgg from "../sound/screen_share_started.ogg";
import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
import declineMp3 from "../sound/call_declined.mp3?url";
import declineOgg from "../sound/call_declined.ogg?url";
import timeoutMp3 from "../sound/call_timeout.mp3?url";
import timeoutOgg from "../sound/call_timeout.ogg?url";
import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest";
// Do not play any sounds if the participant count has exceeded this
// number.
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500;
export const callEventAudioSounds = prefetchSounds({
join: {
mp3: joinCallSoundMp3,
@@ -43,8 +41,18 @@ export const callEventAudioSounds = prefetchSounds({
mp3: screenShareStartedMp3,
ogg: screenShareStartedOgg,
},
decline: {
mp3: declineMp3,
ogg: declineOgg,
},
timeout: {
mp3: timeoutMp3,
ogg: timeoutOgg,
},
});
export type CallEventSounds = keyof Awaited<typeof callEventAudioSounds>;
export function CallEventAudioRenderer({
vm,
muted,
@@ -60,37 +68,18 @@ export function CallEventAudioRenderer({
const audioEngineRef = useLatest(audioEngineCtx);
useEffect(() => {
const joinSub = vm.memberChanges$
.pipe(
filter(
({ joined, ids }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
),
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
void audioEngineRef.current?.playSound("join");
});
const leftSub = vm.memberChanges$
.pipe(
filter(
({ ids, left }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
),
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
void audioEngineRef.current?.playSound("left");
});
const handRaisedSub = vm.newHandRaised$.subscribe(() => {
void audioEngineRef.current?.playSound("raiseHand");
});
const screenshareSub = vm.newScreenShare$.subscribe(() => {
void audioEngineRef.current?.playSound("screenshareStarted");
});
const joinSub = vm.joinSoundEffect$.subscribe(
() => void audioEngineRef.current?.playSound("join"),
);
const leftSub = vm.leaveSoundEffect$.subscribe(
() => void audioEngineRef.current?.playSound("left"),
);
const handRaisedSub = vm.newHandRaised$.subscribe(
() => void audioEngineRef.current?.playSound("raiseHand"),
);
const screenshareSub = vm.newScreenShare$.subscribe(
() => void audioEngineRef.current?.playSound("screenshareStarted"),
);
return (): void => {
joinSub.unsubscribe();

View File

@@ -34,7 +34,6 @@
.overlay[data-show="false"] {
animation: fade-out 130ms forwards;
content-visibility: hidden;
pointer-events: none;
}

View File

@@ -20,7 +20,7 @@ interface Props {
export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
const { t } = useTranslation();
return (
<div className={styles.overlay} data-show={show}>
<div className={styles.overlay} data-show={show} aria-hidden={!show}>
<BigIcon className={styles.icon}>
<VoiceCallIcon aria-hidden />
</BigIcon>

View File

@@ -26,7 +26,7 @@ import {
E2EENotSupportedError,
type ElementCallError,
InsufficientCapacityError,
MatrixRTCFocusMissingError,
MatrixRTCTransportMissingError,
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
@@ -34,7 +34,7 @@ import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
test.each([
{
error: new MatrixRTCFocusMissingError("example.com"),
error: new MatrixRTCTransportMissingError("example.com"),
expectedTitle: "Call is not supported",
},
{
@@ -85,7 +85,7 @@ test.each([
);
test("should render the error page with link back to home", async () => {
const error = new MatrixRTCFocusMissingError("example.com");
const error = new MatrixRTCTransportMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};
@@ -106,7 +106,7 @@ test("should render the error page with link back to home", async () => {
await screen.findByText("Call is not supported");
expect(screen.getByText(/Domain: example\.com/i)).toBeInTheDocument();
expect(
screen.getByText(/Error Code: MISSING_MATRIX_RTC_FOCUS/i),
screen.getByText(/Error Code: MISSING_MATRIX_RTC_TRANSPORT/i),
).toBeInTheDocument();
await screen.findByRole("button", { name: "Return to home screen" });
@@ -132,9 +132,10 @@ test("ConnectionLostError: Action handling should reset error state", async () =
const WrapComponent = (): ReactNode => {
const [failState, setFailState] = useState(true);
const reconnectCallback = useCallback(
(action: CallErrorRecoveryAction) => {
async (action: CallErrorRecoveryAction) => {
reconnectCallbackSpy(action);
setFailState(false);
return Promise.resolve();
},
[setFailState],
);
@@ -212,7 +213,7 @@ describe("Rageshake button", () => {
});
test("should have a close button in widget mode", async () => {
const error = new MatrixRTCFocusMissingError("example.com");
const error = new MatrixRTCTransportMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};

View File

@@ -36,7 +36,9 @@ import { type WidgetHelpers } from "../widget.ts";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
export type RecoveryActionHandler = (
action: CallErrorRecoveryAction,
) => Promise<void>;
interface ErrorPageProps {
error: ElementCallError;
@@ -71,7 +73,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
if (error instanceof ConnectionLostError) {
actions.push({
label: t("call_ended_view.reconnect_button"),
onClick: () => recoveryActionHandler("reconnect"),
onClick: () => void recoveryActionHandler("reconnect"),
});
}
@@ -131,9 +133,9 @@ export const GroupCallErrorBoundary = ({
widget={widget ?? null}
error={callError}
resetError={resetError}
recoveryActionHandler={(action: CallErrorRecoveryAction) => {
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
await recoveryActionHandler(action);
resetError();
recoveryActionHandler(action);
}}
/>
);

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