Compare commits

..

335 Commits

Author SHA1 Message Date
Andrew Ferrazzutti
9e89c4ced3 Add example docker compose; fix dynamic reg 2025-09-03 15:36:42 -04:00
Andrew Ferrazzutti
e52083e29a Start supporting OAuth 2.0 authentication API 2025-08-29 12:44:49 -04: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
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
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
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
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
Valere
b0503f41ef use latest lk-jwt-service for CI 2025-07-31 17:10:40 +02:00
fkwp
d59e794bdf add alias to nginx 2025-07-31 17:05:57 +02:00
Valere
b35fcf9076 DevX: update dev backend to support jwt-token v3 2025-07-31 17:05:57 +02:00
Valere Fedronic
029827488c Merge pull request #3430 from element-hq/valere/fix_snapshot
fix snapshot test following commit 007a50d
2025-07-31 17:04:51 +02:00
Valere
4ee67fa4e5 keep lk-jwt-service on 0.2.3 for now 2025-07-31 16:58:14 +02:00
Valere
446a2d4080 fix snapshot test following commit 007a50d 2025-07-31 16:37:09 +02:00
Valere
007a50d0f1 Fix missing button styling on error dialog 2025-07-31 15:00:21 +02:00
Timo
c7b0f9783d expose delayed_leave_event_restart_local_timeout_ms to config.json (#3415) 2025-07-30 14:58:45 +02:00
Valere Fedronic
391ca63b7e Merge pull request #3422 from element-hq/valere/bump-js-sdk-28-07-2025
bump js-sdk version 28/07/2025
2025-07-28 17:59:56 +02:00
Valere
5dfa420135 bump js-sdk version 2025-07-28 17:45:38 +02:00
Robin
ff3f727c80 Merge pull request #3418 from element-hq/robin/notification-capability
Request capability to send call notifications
2025-07-28 16:04:21 +02:00
Robin
f7565ef104 Request capability to send call notifications
0e0fba6575 added the ability to send call notification events when starting a call, but I forgot to give the widget the right capabilities to do this. The effect was that notifications just wouldn't send in widget mode.
2025-07-24 18:50:03 +02:00
Timo
2f55d8e30c UrlParams: Intent system update, split into configuration and propreties (#3376)
* refactor UrlParams to use a preset intent system

* change defaults for intend headers

* add: getEnumParam to ParamParser

* remove deprecated url params

* only allow skip lobby in widget (more strict needs test adjustment)

* fix tests that now require the url to be a widget url

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

Co-authored-by: Robin <robin@robin.town>
2025-07-24 17:46:26 +02:00
Will Hunt
3145bafd5e Trim extra characters from roomId parameter in URL (#3412)
* Trim roomId when parsing from URL

* fix char

* fixup

* limit to roomId

* Add a comment
2025-07-22 20:11:53 +01:00
Robin
7dd45acd29 Update matrix-js-sdk
To include https://github.com/matrix-org/matrix-js-sdk/pull/4927.
2025-07-21 21:01:00 +02:00
Valere Fedronic
8f561c0319 bump js-sdk to matrix-js-sdk 0ce944f3daa2c8926c673af33ae24e89b1e6796a (#3414) 2025-07-21 17:46:45 +02:00
Robin
4104db3e7a Merge pull request #3411 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-design-tokens to v5
2025-07-21 14:16:24 +02:00
Robin
d530997725 Merge pull request #3410 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v8.2.0
2025-07-21 14:15:58 +02:00
renovate[bot]
fb0fefae10 Update dependency @vector-im/compound-design-tokens to v5 2025-07-21 00:47:44 +00:00
renovate[bot]
8bb79e6f33 Update dependency @vector-im/compound-web to v8.2.0 2025-07-21 00:47:32 +00:00
Robin
0e0fba6575 Send notification events when starting a call (#3248)
* Send notification events when starting a call

Previously this has been the responsibility of the hosting application (Element Web / Element X), but I would like to move this responsibility to Element Call itself to make it even more lightweight to integrate Element Call into a widget-capable client.

* use RTCNotification event

* add url param

* bump to latest js-sdk

* remove everything decline related

* use notification type in url params

* fix url .md docs

* back to `head=develop` and using js-sdk with send notification feature

* format

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-07-18 16:58:50 +02:00
Timo
5ea0759427 Earpiece follow up: Change labels and icons (#3401)
* fix collapse icon on android

* update wording `earpiece` -> `headset`

* update icon `earpiece` -> `phone`

* i18n

* update icons to solid (top right) and non solid (overlay)

* update snapshots

* add config.json to gitignore

* add offset for earpice overlay

* update snapshots to include offset spacer
2025-07-18 15:19:53 +02:00
Valere Fedronic
7ac378037f Merge pull request #3408 from element-hq/valere/bump-js-sdk
Bump js-sdk resolved commit to the latest develop
2025-07-18 10:59:11 +02:00
Valere
ff7a00ed3d Bump js-sdk resolved commit 2025-07-18 10:43:06 +02:00
Robin
e4915b5bd3 Merge pull request #3346 from element-hq/robin/behaviors
Create a type-level distinction between raw Observables and Behaviors
2025-07-17 19:14:35 -04:00
renovate[bot]
38fc34667c Update LiveKit components (#3407)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 13:41:10 +02:00
renovate[bot]
6873ef8dd6 Update dependency livekit-client to v2.15.3 (#3406)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-17 13:40:36 +02:00
Timo
6f0ffa84dc Merge branch 'livekit' into robin/behaviors 2025-07-14 19:03:18 +02:00
Timo
8458d198c9 Fix bluetooth iOS issue (device auto switches from Bluetooth to speaker) (#3388)
* fix ios bluetooth

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

* fix lints

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-07-14 12:53:09 +02:00
Robin
c34516e871 Use the initialValue parameter of 'behavior' instead of startWith 2025-07-12 00:34:52 -04:00
Robin
2b76d3dd70 Move 'behavior' to be a method on ObservableScope 2025-07-12 00:34:52 -04:00
Robin
32bf1c30d2 Use Behaviors even more consistently 2025-07-11 23:53:59 -04:00
Robin
586a923be3 Fix remaining tests 2025-07-11 23:53:31 -04:00
Timo
434712ba17 fixing some tests 2025-07-11 00:27:46 -04:00
Robin
b3863748dc Replace many usages of useObservableEagerState with useBehavior
This hook is simpler in its implementation (therefore hopefully more correct & performant) and enforces a type-level distinction between raw Observables and Behaviors.
2025-07-11 00:24:46 -04:00
Robin
35ed313577 Replace ObservableScope.state with Observable.behavior 2025-07-10 23:57:33 -04:00
Robin
7e81eca068 Create a Behavior type 2025-07-10 17:39:18 -04:00
Robin
dbcc0e2c18 Merge pull request #3395 from element-hq/renovate/livekit-components
Update dependency @livekit/components-react to v2.9.13
2025-07-10 16:26:13 -04:00
Robin
3bdb71ee40 Merge pull request #3392 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v4.0.5
2025-07-10 16:23:19 -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
Valere Fedronic
2e23bbbb94 Merge pull request #3398 from element-hq/valere/enable_new_encryption_mgr
Bump js-sdk dependency to have new encryption mgr
2025-07-09 19:01:50 +02:00
Valere
f0d753e58d Bump js-sdk dependency to have new encryption mgr 2025-07-09 18:47:33 +02:00
renovate[bot]
aad59ea624 Update dependency livekit-client to v2.15.2 (#3396)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 12:46:20 +02:00
renovate[bot]
23ff26a988 Update dependency @livekit/components-react to v2.9.13 2025-07-09 10:39:41 +00:00
renovate[bot]
8c1e69f15a Update dependency @vector-im/compound-design-tokens to v4.0.5 2025-07-07 01:31:26 +00:00
renovate[bot]
01ede7629e Update all non-major dependencies (#3377)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 10:28:43 +02:00
renovate[bot]
ff382ec283 Update embedded package dependencies (#3378)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 10:28:19 +02:00
Timo
31355ba7af Update earpiece overlay button text for clarity (#3370)
* Update earpiece overlay button text for clarity

* update test snapshots
2025-06-27 15:24:46 +02:00
renovate[bot]
1ac9630964 Update dependency livekit-client to v2.14.0 (#3371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-27 14:14:51 +02:00
Timo
db3477ea17 Increase opacity of the EarpieceOverlay background for better visibility (#3369) 2025-06-27 11:55:15 +02:00
Robin
30eea63aae Merge pull request #3337 from element-hq/robin/error-fixes
Touch up some error screens
2025-06-26 09:52:35 -04:00
fkwp
825b83dc03 reduce wait after network error timeout down to 100 ms (#3365) 2025-06-26 11:54:41 +02:00
Robin
f509c06cc6 Earpiece switcher and overlay (#3347)
* Add a global control for toggling earpiece mode

This will be used by Element X to show an earpiece toggle button in the header.

* Add an earpiece overlay


* Fix header
The header needs to be passed forward as a string to some components and as a bool (hideHeader) to others.
Also use a enum instead of string options.

* fix top clipping with header


* hide app bar in pip

* revert android overlay app_bar

* Modernize AppBarContext

* Style header icon color as desired and switch earpice/speaker icon

* fix initial selection when using controlled media

* Add "Back to video" button

* fix tests

* remove dead code

* add snapshot test

* fix back to video button

* Request capability to learn the room name

We now need the room name in order to implement the mobile (widget-based) designs with the app bar.

* Test the CallViewModel output switcher directly

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-06-26 11:08:57 +02:00
Robin
c012aec909 Merge pull request #3353 from element-hq/toger5/device-permissions-request-possible-fix
Skip unnecassary media devices permissions requests (video feed flicker when opening settings)
2025-06-25 15:56:10 -04:00
Timo
d08a180c54 Simplify and improve locality of the device name request logic 2025-06-25 15:50:53 -04:00
Robin
db948cb718 Merge pull request #3364 from element-hq/renovate/livekit-components
Update LiveKit components
2025-06-25 15:49:25 -04:00
Timo
c03e2c4bde simple subject instead of derived observable 2025-06-25 15:47:56 -04:00
Timo
e47a743f1e Also fix camera reloading in widget mode 2025-06-25 15:47:56 -04:00
Timo
73ccb38c11 comment 2025-06-25 15:47:56 -04:00
Timo
ab4eadf58f Only rerequest permissions if we do not yet get labels when enumerating 2025-06-25 15:47:56 -04:00
renovate[bot]
93cb7955c9 Update LiveKit components 2025-06-25 18:41:41 +00:00
Robin
8f841dfb59 Merge pull request #3363 from element-hq/toger5/fix-initial-device-selection-in-controlled-devices-mode
Fix initial selection when using controlled media
2025-06-25 13:47:42 -04:00
Robin
f4d590c703 Use consistent name for preferred device IDs 2025-06-25 13:42:31 -04:00
Robin
f3419f94c3 Fix empty string IDs not counting as devices 2025-06-25 13:40:39 -04:00
Timo
a53585c96d Fix white borders in PiP mode android (Fix scroll issues in small documents) (#3362)
* Fix scroll issues in small documents (PiP mode android)
This scroll issue resulted in white borders on android PiP mode.
2025-06-25 19:38:51 +02:00
Robin
3b1ce22b71 Fold map operation into combineLatest 2025-06-25 13:38:47 -04:00
Valere Fedronic
9787ac3abc bugfix: #3344 Reconnecting to the same SFU on membership change (#3361)
* bugfix: #3344 Reconnecting to the same SFU on membership change

* fixup! commit error

* Keep useActiveLivekitFocus from changing focus spuriously

* Remove redundant fix for spurious focus changes

We've now fixed it at the source by prohibiting state changes in useActiveLivekitFocus itself.

---------

Co-authored-by: Robin <robin@robin.town>
2025-06-25 19:35:50 +02:00
Timo
131bdc3522 fix initial selection when using controlled media 2025-06-25 12:14:05 +02:00
Robin
daa931c3c4 Merge pull request #3358 from element-hq/robin/remove-forward-ref
Remove usages of forwardRef
2025-06-24 08:32:03 -04:00
Robin
0f8c4675cf Make condition simpler 2025-06-24 08:28:15 -04:00
Timo
337d09cd4d Use the platform property for ios detection (#3360) 2025-06-24 14:25:05 +02:00
Timo
6b6b679774 Make camera unavailable if using earpice mode (#3351) 2025-06-24 08:59:16 +00:00
Robin
3ffb118dc7 Modernize how we use React contexts (#3359)
* Replace useContext with use

The docs recommend the use hook because it is simpler and allows itself to be called conditionally.

* Simplify our context providers

React 19 lets you omit the '.Provider' bit.
2025-06-24 10:48:35 +02:00
Timo
a507bcde90 Add onBackButtonPressed controls api (#3357)
* Add `onBackButtonPressed` controls api

* Update docs/controls.md

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

---------

Co-authored-by: Robin <robin@robin.town>
2025-06-24 08:46:04 +00:00
Robin
0c27610119 Remove usages of forwardRef
It has been deprecated in React 19, which allows functional components to receive refs just like any other prop.
2025-06-23 23:06:33 -04:00
Robin
f86c9fe0a0 Merge pull request #3355 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-web to v8
2025-06-23 22:10:07 -04:00
renovate[bot]
3ae535bf31 Update dependency @vector-im/compound-web to v8 2025-06-23 00:39:22 +00:00
Robin
5e2e94d794 Refactor media devices to live outside React as Observables (#3334)
* Refactor media devices to live outside React as Observables

This moves the media devices state out of React to further our transition to a MVVM architecture in which we can more easily model and store complex application state. I have created an AppViewModel to act as the overarching state holder for any future non-React state we end up creating, and the MediaDevices reside within this. We should move more application logic (including the CallViewModel itself) there in the future.

* Address review feedback

* Fixes from ios debugging session: (#3342)

- dont use preferred vs selected concept in controlled media. Its not needed since we dont use the id for actual browser media devices (the id's are not even actual browser media devices)
  - add more logging
  - add more conditions to not accidently set a deviceId that is not a browser deviceId but one provided via controlled.

---------

Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2025-06-20 18:37:25 +02:00
renovate[bot]
5bf7361d01 Update dependency livekit-client to v2.13.8 (#3352)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-20 10:01:31 +02:00
renovate[bot]
0fe8d16740 Update dependency @livekit/components-react to v2.9.10 (#3349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 16:42:15 +02:00
renovate[bot]
f1a2002072 Update dependency livekit-client to v2.13.7 (#3350)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 16:40:28 +02:00
renovate[bot]
1af1a8ed0b Update dependency livekit-client to v2.13.6 (#3348)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-19 13:19:31 +02:00
renovate[bot]
0970db8297 Update dependency livekit-client to v2.13.5 (#3343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-18 13:59:35 +02:00
Robin
a36e72147c Hide error screen icon from accessibility technologies
It does not have any meaningful accessible label to contribute beyond what is already stated in the heading.
2025-06-16 16:48:13 -04:00
Robin
7f4a5a8bab Merge pull request #3338 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v4.0.4
2025-06-16 08:50:53 -04:00
renovate[bot]
442fc1fc16 Update dependency @vector-im/compound-design-tokens to v4.0.4 2025-06-16 01:46:00 +00:00
Robin
905177f0b6 Don't present internal error messages as if they are localized 2025-06-13 00:15:25 -04:00
Robin
31c35583fb Replace technical & unlocalized error message with "Connection lost" 2025-06-13 00:15:25 -04:00
Robin
764c6fce24 Fix missing text on "Call not found" screen 2025-06-12 23:54:40 -04:00
Robin
86aa459a84 Center the heading on the error screen 2025-06-12 23:47:33 -04:00
fkwp
32b6250cc3 fix: Force the docker image for lk-jwt-service to be pulled everytime (#3325)
lk-jwt-service latest-ci to be force pulled every time
2025-06-12 16:42:06 +02:00
Timo
4a6dbf350f bump js-sdk to latest develop (containing a fix for membership manager resend join event issue) (#3333)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-06-12 16:28:00 +02:00
fkwp
29968a3eba Apply new timing constants to MatrixRTC session (#3332)
Apply new timing constants
2025-06-12 14:10:54 +02:00
fkwp
aa092c2195 Merge pull request #3323 from element-hq/fkwp/dev_build
Add developer build mode to GitHub action build-element-call.yaml
2025-06-11 22:56:38 +02:00
fkwp
0bf5c4a835 simplify package.json scripts and remove NODE_ENV variables since we dont use them 2025-06-11 16:54:10 +02:00
fkwp
3d47609cff Since we are now using templating anyhow we can also combine full and embedded build into one step 2025-06-10 18:17:16 +02:00
Timo
1425de8726 Expose keep alive event configurarion in ec config and update defaults. (#3303) 2025-06-10 18:09:52 +02:00
fkwp
c1aa1888f0 Merge pull request #3327 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.13.4
2025-06-10 16:12:11 +02:00
fkwp
58ebbfeed7 Merge pull request #3326 from element-hq/fkwp/doc_self-hosting_update
Add tools section to self-hosting guide and adding Matrixtester to it
2025-06-10 15:59:12 +02:00
Timo
02f3f1b71a Improvements to audio renderer codebase. (#3308)
* Improvements to audio renderer codebase.

* review
2025-06-10 13:00:21 +00:00
renovate[bot]
f7a6e20ae4 Update dependency livekit-client to v2.13.4 2025-06-10 12:31:41 +00:00
fkwp
378d59e948 prettier 2025-06-10 14:27:15 +02:00
fkwp
a07dbe645e tools section to self-hosting guide and adding Matrixtester to it 2025-06-10 14:24:59 +02:00
Timo
a596eb71a9 add onAudioPlaybackStarted callback for controls (#3309)
* add onAudioTrackReady callback for controls

* more details in controls.md for `onAudioPlaybackStarted`
2025-06-10 12:35:04 +02:00
Robin
c8a1cae18b Merge pull request #2874 from element-hq/renovate/major-react-monorepo
Update react monorepo to v19 (major)
2025-06-09 11:59:26 -04:00
Robin
4f1450aaf0 Replace react-use-clipboard with copy-to-clipboard
We didn't need the complexity of the (admittedly very small) React hook, and the package hasn't declared compatibility with React 19, so let's just switch to copying things manually via copy-to-clipboard.
2025-06-09 11:53:58 -04:00
Robin
8704b44b78 Update test snapshots for React 19 changes 2025-06-09 11:48:01 -04:00
Robin
0ee11af3c5 Upgrade react-spring to v10
For React 19 compatibility.
2025-06-09 11:47:47 -04:00
Andrew Ferrazzutti
13fac57b01 Build Docker image on slim base (#3204)
* Build Docker image on slim base

* Run Playwright tests against Docker container

For Playwright end-to-end tests in CI, instead of running a development
webserver with `yarn dev`, build and deploy a Docker container for
Element Call and use that as the webserver to test against.

* Shut down playwright webserver gracefully

When using a containerized webserver, this stops the container once
tests finish.

* Increase Playwright timeout in CI

---------

Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-06-06 12:04:57 -04:00
Robin
e0ce58b2a2 Merge pull request #3320 from element-hq/revert-3319-revert-3313-misc/migrate-to-maven-central-portal
Upgrade Android publishing to use Maven Central Portal (again)
2025-06-06 10:18:20 -04:00
fkwp
6f9adfe3e8 fix: add misssing yarn 2025-06-06 00:21:23 +02:00
fkwp
d47e579994 add build_mode also to build_embedded_element_call 2025-06-06 00:20:11 +02:00
fkwp
61d8f9a412 Merge branch 'livekit' into fkwp/dev_build 2025-06-06 00:14:45 +02:00
fkwp
31185ae365 make build_mode optional 2025-06-06 00:13:49 +02:00
fkwp
e07c7c864b prettier 2025-06-06 00:02:19 +02:00
fkwp
b88bce5220 add production and developt build scripts to package.json 2025-06-05 23:58:51 +02:00
fkwp
775e110ca3 Add build_mode to inputs of build_full_element_call. derive build_mode from PR label 'development build' 2025-06-05 23:58:24 +02:00
fkwp
97d548ee3a add build_mode input and use it in run commands 2025-06-05 23:51:02 +02:00
Timo
34724b7a8c revert "custom compare logic" 2025-06-05 14:39:06 +02:00
Timo
b005f36ac7 custom compare logic 2025-06-05 14:36:43 +02:00
Robin
b0587fcfb3 Avoid reactivity bugs in how we track external state (#3316)
* Avoid reactivity bugs in how we track external state

Many of our hooks which attempt to bridge external state from an EventEmitter or EventTarget into React had subtle bugs which could cause them to fail to react to certain updates. The conditions necessary for triggering these bugs are explained by the tests that I've included.

In the majority of cases, I don't think we were triggering these bugs in practice. They could've become problems if we refactored our components in certain ways. The one concrete case I'm aware of in which we actually triggered such a bug was the race condition with the useRoomEncryptionSystem shared secret logic (addressed by a1110af6d5).

But, particularly with all the weird reactivity issues we're debugging this week, I think we need to eliminate the possibility that any of the bugs in these hooks are the cause of our current headaches.

* Reuse useTypedEventEmitterState in useLocalStorage

* Fix type error
2025-06-05 13:54:57 +02:00
fkwp
b8eccc6d7a use vite minify depending on vite mode 2025-06-05 12:16:22 +02:00
Jorge Martin Espinosa
34df584366 Revert "Revert "Upgrade Android publishing to use Maven Central Portal"" 2025-06-05 11:20:47 +02:00
Jorge Martin Espinosa
a62b0be831 Merge pull request #3319 from element-hq/revert-3313-misc/migrate-to-maven-central-portal 2025-06-05 11:20:36 +02:00
Jorge Martin Espinosa
01240fb59d Revert "Upgrade Android publishing to use Maven Central Portal" 2025-06-05 11:10:03 +02:00
Robin
7851b9c289 Merge pull request #3318 from element-hq/robin/deep-compare
Use the same function for deep equality everywhere
2025-06-04 18:12:08 -04:00
Robin
15bcaef3a5 Use the same function for deep equality everywhere 2025-06-04 17:41:42 -04:00
Timo
3c831fc5e6 Fix react errors in settings tab (#3314)
* Fix react errors in settings tab

* review
2025-06-04 23:29:49 +02:00
Robin
26efbc1389 Merge pull request #3317 from element-hq/robin/reactive-members
Always render audio from the current set of participants
2025-06-04 17:25:29 -04:00
Robin
929367c9ce Always render audio from the current set of participants
We forgot to tell React that we need the audio renderer to react to changes in the set of MatrixRTC participants; instead we had it referencing rtcSession.memberships non-reactively.

Now, I'm not 100% confident that this is going to fix the "speaking from the void" issues observed in the wild, because I can't reproduce them and, in my testing, the InCallView component always seemed to be rendered redundantly when the MatrixRTC participants change, even though we hadn't explicitly stated that it needs to react. (This makes sense as we haven't memoized the component.) But it's worth a shot.
2025-06-04 17:21:48 -04:00
Timo
2f3e0b419d Leave issue refactor (#3302)
* Simplify key local storage management.

* Refactor useLivekit to only ever connect to one room.
This change also tries to make the code more explicit so that we only do the things we really need to do and rely less on react updating everything correctly.

It also surfaces, that we are currently implementing useLivekit in a way, so that we can change the encryption system on the fly and recreate the room. I am not sure this is a case we need to support?

* simplify the useLivekit hook even more
This is possible because we concluded that we do not need to be able to hot reload the e2ee system.

* review

* linter

* Update src/room/InCallView.tsx

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

---------

Co-authored-by: Robin <robin@robin.town>
2025-06-04 20:51:13 +00:00
Robin
ef41ef85eb Merge pull request #3311 from element-hq/robin/break-safari-loop
Break loop in acquiring media on Safari
2025-06-04 11:58:56 -04:00
Robin
d9b91778b1 Merge pull request #3304 from element-hq/renovate/embedded-package-dependencies
Update embedded package dependencies
2025-06-04 11:56:52 -04:00
Robin
2f6bae5e5e Merge pull request #3313 from element-hq/misc/migrate-to-maven-central-portal
Upgrade Android publishing to use Maven Central Portal
2025-06-04 11:56:11 -04:00
Robin
40b351d76d Ignore spurious 'devicechange' events
This gives us the additional insurance of breaking the Safari media acquisition loop at the source by admitting that they can be spurious in practice. Safari, why!?
2025-06-04 11:23:20 -04:00
Jorge Martín
8bc914a70f Upgrade Android publishing to use Maven Central Portal
- Upgrade maven publishing plugin to `v0.32.0`.
- Change the `SonatypeHost` to point to `CENTRAL_PORTAL`.
2025-06-04 12:21:14 +02:00
Robin
83bfb721e7 Break loop in acquiring media on Safari 2025-06-03 17:22:20 -04:00
Robin
64e27152c6 Merge pull request #3307 from element-hq/renovate/livekit-components
Update dependency @livekit/components-react to v2.9.9
2025-06-03 09:40:48 -04:00
Robin
f4a5ca9e84 Merge pull request #3306 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v4.0.3
2025-06-03 09:39:26 -04:00
renovate[bot]
789f37611b Update react monorepo to v19 2025-06-03 13:28:48 +00:00
Robin
931cc9ddd7 Merge pull request #3305 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2025-06-03 09:27:44 -04:00
Robin
65846dcf7d Revert eslint-plugin-matrix-org to 2.1.0
See 7eae5b0ffb.
2025-06-03 09:22:44 -04:00
renovate[bot]
aa1b690b11 Update dependency @livekit/components-react to v2.9.9 2025-06-03 12:53:27 +00:00
renovate[bot]
e5561dbd20 Update all non-major dependencies 2025-06-03 12:53:08 +00:00
renovate[bot]
e68b403808 Update embedded package dependencies 2025-06-02 19:19:33 +00:00
renovate[bot]
85d91de2e7 Update dependency @vector-im/compound-design-tokens to v4.0.3 2025-06-02 01:32:10 +00:00
Robin
5f8a8d4c5b Merge pull request #3294 from element-hq/fkwp/docker_compose/resolve_conflicting_ports
Fix Playwright tests
2025-05-30 10:42:05 -04:00
Robin
d2534748eb Merge pull request #3300 from element-hq/robin/synchronize-blocked
Run the 'Prevent blocked' check whenever a PR branch is updated
2025-05-30 10:26:10 -04:00
fkwp
a548c5d592 test.skip() does only skip the test call back but not the fiturex. Hence, disabling the whole test 2025-05-30 15:03:43 +02:00
fkwp
08ab38280a prettier 2025-05-30 14:49:29 +02:00
fkwp
a732fcc604 linting 2025-05-30 14:46:35 +02:00
fkwp
7e19b58976 define the widget test as a slow test 2025-05-30 14:29:17 +02:00
fkwp
e56fb7cfc8 use .toBeVisible instead of continueButton.isVisible 2025-05-30 14:10:57 +02:00
fkwp
aabfe16021 prettier 2025-05-30 13:51:55 +02:00
fkwp
d572977922 refactor: splitout registerUser into its own function. Make the'Confirm your identity by entering your account password below.' flow optional 2025-05-30 13:38:45 +02:00
fkwp
6e0eee3509 Merge pull request #3283 from element-hq/fkwp/docker_compose_allow_local_lk_service
Add nginx upstream group of servers for JWT Service
2025-05-30 12:43:05 +02:00
Timo
87186058ab Only use ios platform logic when in controlled media device condition (#3295) 2025-05-30 12:03:22 +02:00
Robin
fbafc9f3f6 Merge pull request #3301 from element-hq/renovate/npm-vite-vulnerability
Update dependency vite to v6.2.7 [SECURITY]
2025-05-28 18:20:30 -04:00
renovate[bot]
4f944ea72e Update dependency vite to v6.2.7 [SECURITY] 2025-05-28 22:14:42 +00:00
Robin
61d2da2259 Merge pull request #3036 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-design-tokens to v4
2025-05-28 18:13:37 -04:00
Robin
0661486676 Merge branch 'livekit' into renovate/major-compound 2025-05-28 18:07:30 -04:00
Robin
07a4244c05 Upgrade Compound Web to v7.12.0 2025-05-28 18:04:29 -04:00
Robin
03f92ed6e4 Merge pull request #3172 from element-hq/renovate/livekit-components
Update LiveKit components
2025-05-28 17:43:07 -04:00
Robin
469ec63fec Fix LiveKit types import 2025-05-28 17:34:33 -04:00
Robin
50caca7bd3 Merge branch 'livekit' into renovate/livekit-components 2025-05-28 17:26:03 -04:00
Robin
81caa3321f Merge pull request #3186 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2025-05-28 17:22:38 -04:00
Robin
3c3fce96e7 Upgrade to OpenTelemetry v2 SDKs
Necessitated by our minor version upgrade of @opentelemetry/exporter-trace-otlp-http.
2025-05-28 17:17:31 -04:00
Robin
e757f7af6b Run the 'Prevent blocked' check whenever a PR branch is updated
Because we're now requiring the 'Prevent blocked' check to pass before merging a PR, GitHub Actions now expects it to be associated with the latest Git ref of the PR's branch whenever the branch is updated. Therefore we need to re-run the workflow on the 'synchronize' event.
2025-05-28 17:00:57 -04:00
Robin
7eae5b0ffb Revert eslint-plugin-matrix-org to 2.1.0
Because the latest version requires eslint-plugin-unicorn v57, which requires eslint v9, and eslint-plugin-matrix-org is not yet compatible with eslint v9.
2025-05-28 16:25:21 -04:00
renovate[bot]
d82b91ad4a Update all non-major dependencies 2025-05-28 07:09:25 +00:00
renovate[bot]
82b41ae86c Update LiveKit components 2025-05-28 07:08:55 +00:00
Timo
68396f8a83 Merge pull request #3293 from element-hq/toger5/more-disconnect-logging
Fix creating two lk rooms if there is no local store setup (fixes a resulting disconnect bug)
2025-05-28 09:01:00 +02:00
fkwp
ebc714b73f force to pull the latest docker images 2025-05-27 20:06:28 +02:00
fkwp
e4fd630f37 fix element web fixture 2025-05-27 19:03:53 +02:00
Timo
a52b723414 code styling 2025-05-27 18:17:41 +02:00
fkwp
99fc7162e2 fix widget fixture 2025-05-27 18:04:34 +02:00
fkwp
8953936d3d fix docker compose playwright override 2025-05-27 17:53:06 +02:00
Timo
a1110af6d5 Fix the actual leaving issue 2025-05-27 17:38:54 +02:00
Timo
b8951944ab start with a couple of more logs 2025-05-27 17:38:45 +02:00
fkwp
16aa4ae3a9 Merge branch 'fkwp/docker_compose/resolve_conflicting_ports' into fkwp/docker_compose_allow_local_lk_service 2025-05-27 16:40:28 +02:00
fkwp
d585f6232e change JWT port ot 6080 to not conflict with the playwright tests. 2025-05-27 16:30:06 +02:00
Timo
b1ca464c58 Merge pull request #3292 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.13.1
2025-05-26 12:42:32 +02:00
renovate[bot]
0b8e7e9cae Update dependency livekit-client to v2.13.1 2025-05-26 03:06:25 +00:00
Timo
5374342d98 Disable device switching when in controlled audio devices mode (#3290)
* Disable device switching when in controlled audio devices mode

* Temporarily switch matrix-js-sdk to robin/embedded-no-update-state

To allow us to test this change on Element X, which does not yet support the update_state action.

* Also add a check for controlled audio devices in useAudioContext

* use develop branch

* fix tests

---------

Co-authored-by: Robin <robin@robin.town>
2025-05-23 15:54:47 +00:00
Robin
0971a15c40 Merge pull request #3270 from element-hq/robin/audio-output-controls
Audio device controls for mobile native audio device selection
2025-05-22 14:09:04 -04:00
Robin
78c59bb41c Fix formatting 2025-05-22 14:03:33 -04:00
Robin
045d861ad1 Merge branch 'livekit' into robin/audio-output-controls 2025-05-22 14:00:19 -04:00
Timo
269565d317 Update docs/url-params.md
Co-authored-by: Robin <robin@robin.town>
2025-05-22 19:28:20 +02:00
Timo
f0403c80bc also add non deprecated audio url parameter 2025-05-22 19:18:04 +02:00
Robin
a21da9417c Merge pull request #3285 from element-hq/robin/reset-develop
Reset to develop branch of matrix-js-sdk
2025-05-22 13:10:56 -04:00
Timo
fa0b5210fe Update docs/controls.md
Co-authored-by: Robin <robin@robin.town>
2025-05-22 19:07:47 +02:00
Timo
9f84a5cae0 Deprecate old naming and introduce new words 2025-05-22 18:58:18 +02:00
Robin
821c6e3763 Reset to develop branch of matrix-js-sdk
Now that the toger5/add-room-key-fallback-on-encryption-manager-not-supported branch has been merged, we can reset to develop. (And need to, actually, because that branch is deleted.)
2025-05-22 11:59:36 -04:00
Timo
4eb867436a Merge branch 'livekit' into robin/audio-output-controls 2025-05-22 14:10:03 +02:00
Timo
323e088378 use js-sdk branch as in livekit branch 2025-05-22 14:08:35 +02:00
Timo
ab9dfc75f0 Comment to explain the usage/impact of: controlledMediaDevices 2025-05-22 13:04:42 +02:00
fkwp
b969857583 Allow the jwt service to be running locallay rather than part of the docker-compose. This helps with developing the lk-jwt-service 2025-05-22 12:49:37 +02:00
Robin
aeae964e97 Merge pull request #3177 from element-hq/robin/reintroduce-update-state
Improve the reliability of state changes in widget mode
2025-05-21 13:09:13 -04:00
Robin
e4924df8fb Reference matrix-js-sdk by branch name
Rather than by commit, which makes it hard to tell whether we're using mainline matrix-js-sdk or not.
2025-05-21 12:59:31 -04:00
fkwp
cd48139fc3 Merge pull request #3279 from element-hq/af/backend-compose-fix-lk-port-cfg
Fix lk-jwt-service config in example compose file
2025-05-21 14:22:34 +02:00
Andrew Ferrazzutti
2ec4b4ee87 Fix lk-jwt-service config in example compose file
The port config name was changed in v0.2.0
2025-05-21 08:06:04 -04:00
Timo
a056a28423 review 2025-05-21 12:53:17 +02:00
Robin
c473d1fa8f Update matrix-widget-api to support update_state action 2025-05-20 17:54:57 -04:00
Robin
57f312494f Update matrix-js-sdk to support update_state action 2025-05-20 17:54:30 -04:00
Timo
435a7d0adb earpice -> earpiece 2025-05-20 16:37:14 +02:00
Timo
a1759a46de rename everything to controlledMediaDevices to make it consistent with the actual name in the url parameters (controlledOutput is now only used for the hook that provides the output handle) 2025-05-20 15:30:02 +02:00
Timo
1cf11b91c1 Back to translated button 2025-05-20 10:17:25 +02:00
Timo
0412629871 fix start with for output devices. 2025-05-20 10:14:08 +02:00
Timo
c11a37ca61 back to non translated label 2025-05-19 20:01:59 +02:00
Timo
fb95ba278e make the button prettty again 2025-05-19 19:59:35 +02:00
Timo
aa00a951b6 ITS A FORM NOOOOO 2025-05-19 19:56:12 +02:00
Timo
7fd7dc3794 use normal button 2025-05-19 19:52:08 +02:00
Timo
ed234a1ad1 change label on button 2025-05-19 19:44:19 +02:00
Timo
8497f008c8 Bump js-sdk to support to-device fallback (#3277) 2025-05-19 19:27:08 +02:00
Timo
d7e0abc55c remove the whole button on click logic 2025-05-19 19:26:50 +02:00
Timo
e8c6d79a89 logger instead of native window picker 2025-05-19 19:20:26 +02:00
Timo
d94feaa8db smaller diff 2025-05-19 19:08:39 +02:00
Timo
6d0697cf2f inform ios about earpice mode 2025-05-19 18:44:48 +02:00
Timo
28246ef86b Update RTCSession configuration to non deprecated names. (#3267)
* Update RTCSession configuration to non deprecated names.

* bump js-sdk

* es2024 and array buffer adaptions

* Alternative without `createKeyMaterialFromBuffer`
2025-05-19 18:04:07 +02:00
Timo
0110465b38 Docker-compose: do not access ports <443 (#3261)
* Do not try accessing ports <1024

* adapt nginx reverse proxy for element web

---------

Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-05-19 14:18:03 +02:00
Timo
5d6ec19ca0 Allow some controls to be set before the call view is loaded. 2025-05-19 14:14:08 +02:00
Timo
956b7fc480 actually test the impl 2025-05-19 13:42:13 +02:00
Timo
2946b307c2 fix no audio thinko. 2025-05-19 13:35:21 +02:00
Timo
c22e0cb7e2 better logging 2025-05-19 13:03:16 +02:00
Timo
01a2cd1410 bump livekit client (the current version has an issue on safari) 2025-05-16 19:12:18 +02:00
Timo
7f4b0a33f6 isBluetooth -> isExternalHeadset 2025-05-16 17:06:54 +02:00
Timo
35963bbc9e Add flags to optimize EC device handling 2025-05-16 15:50:19 +02:00
Timo
abf683f275 Hide the input list on both, android+ios.
This will now be decided based on controlled media devices.
2025-05-16 15:23:44 +02:00
Timo
acaf69ca1b add change audio button with callback on ios 2025-05-16 12:28:49 +02:00
Timo
7a4c189249 test for mute all audio 2025-05-16 11:56:07 +02:00
Timo
7227c7b368 Merge branch 'livekit' into robin/audio-output-controls 2025-05-16 11:36:33 +02:00
Timo
2012b09845 review cleanup 2025-05-16 11:32:32 +02:00
fkwp
cc8ab0ac93 Merge pull request #3241 from element-hq/renovate/github-actions
Update GitHub Actions
2025-05-16 09:57:30 +02:00
renovate[bot]
da2ce68348 Update GitHub Actions 2025-05-15 23:01:56 +00:00
ElementRobot
471917b16a Merge pull request #3273 from element-hq/actions/localazy-download
Localazy Download
2025-05-15 15:15:05 -05:00
fkwp
f6870ae5b4 Translations updates 2025-05-15 20:10:41 +00:00
Timo
b5fe55aef2 Add custom audio renderer for iPhone earpiece and only render joined participants (#3249)
* Add custom audio renderer to only render joined participants & add ios earpice workaround

fix left right to match chromium + safari
(firefox is swapped)

earpice as setting

Simpler code and documentation
The doc explains, what this class actually does and why it is so complicated.

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

use only one audioContext, remove (non working) standby fallback

* Add tests

* use optional audio context and effect to initiate it + review
2025-05-15 20:46:39 +02:00
Timo
abd66f50db fix mute all audio via controls 2025-05-15 20:21:43 +02:00
Timo
610e792394 rename setOutputDevices-> setAvailableOutputDevices 2025-05-15 17:20:12 +02:00
Timo
7fa534d70d refactor
- `MediaDevice`->`MediaDeviceHandle`
 - use just one provider and switch inside the
 MediaDevicesProvider between: controlledAudioOutput, webViewAudioOutput
 - fix muteAllAudio
2025-05-15 15:34:35 +02:00
Timo
c8091ac111 Quickfix for testing 2025-05-15 11:37:01 +02:00
Timo
86beaeb20f apply mute from mobile controls 2025-05-14 20:09:39 +02:00
Timo
6b39d0a5fd turn on url flag by default 2025-05-14 19:58:34 +02:00
Timo
f69c75322f add earpice mode 2025-05-14 19:55:08 +02:00
Timo
f9b04ae38e temp 2025-05-14 19:07:36 +02:00
Robin
53adfa4497 WIP 2025-05-14 19:07:36 +02:00
Valere Fedronic
86d80630c1 Fix connection leaks: Ensure that any pending connection open are cancelled/undo when ActiveCall is unmounted (#3255)
* Better logs for connection/component lifecycle

* fix: `AudioCaptureOptions` was causing un-necessary effect render

AudioCaptureOptions was a different object but with same internal values, use directly deviceId so that Object.is works properly

* fix: Livekit openned connection leaks

* review: rename to AbortHandles

* review: rename variable

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-05-14 18:41:22 +02:00
Timo
18a59dd7db use optional audio context and effect to initiate it + review 2025-05-14 17:23:42 +02:00
renovate[bot]
4569d01353 Update dependency vite to v6.2.7 [SECURITY] (#3240)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-14 12:09:53 +02:00
Timo
6b8c620bbb Add tests 2025-05-14 10:59:23 +02:00
Timo
56328108ca Add custom audio renderer to only render joined participants & add ios earpice workaround
fix left right to match chromium + safari
(firefox is swapped)

earpice as setting

Simpler code and documentation
The doc explains, what this class actually does and why it is so complicated.

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

use only one audioContext, remove (non working) standby fallback
2025-05-14 10:41:03 +02:00
Valere Fedronic
1ff2fb3fff only show to-device transport label in dev mode (#3221)
* Only show to device encryption label if developer mode on

* Add tests for developer mode to device label

---------

Co-authored-by: Timo <toger5@hotmail.de>
2025-05-13 22:22:56 +02:00
Timo
e6710f72e3 Fix to-device encryption info label (#3208)
* Fix to-device encryption info label
The label was shown also without checking that we use PerParticipantE2EE. Which is a prerequisite for toDevice transport. As a result the label was shown when not desired.

* rename: useLiveKit -> useLivekit

* make the settings naming consistent
2025-05-13 22:05:55 +02:00
Timo
04bc6c77a3 developer option to mute all audio (#3239) 2025-05-13 21:11:12 +02:00
fkwp
34b3b9d733 Merge pull request #3265 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.12.0
2025-05-13 09:44:26 +02:00
fkwp
44ed04475d bump livekit protocol version 2025-05-13 09:38:43 +02:00
renovate[bot]
de8a2cb533 Update dependency livekit-client to v2.12.0 2025-05-13 07:25:55 +00:00
fkwp
4344f1dd1e Merge pull request #3260 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.11.4
2025-05-13 09:23:49 +02:00
renovate[bot]
9f8dd000e0 Update embedded package dependencies (#3242)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 14:20:03 +02:00
renovate[bot]
b681e53c35 Update dependency livekit-client to v2.11.4 2025-05-12 01:54:04 +00:00
Robin
6281628e4e Merge pull request #3258 from element-hq/robin/webcodecs-update
Remove patched version of WebCodecs types
2025-05-09 10:54:23 -04:00
renovate[bot]
f78b8dd153 Update dependency @vector-im/compound-design-tokens to v4 2025-03-27 18:22:52 +00:00
204 changed files with 12219 additions and 5582 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",
@@ -44,8 +42,17 @@ module.exports = {
],
// To encourage good usage of RxJS:
"rxjs/no-exposed-subjects": "error",
"rxjs/finnish": "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

@@ -1,7 +1,7 @@
name: Prevent blocked
on:
pull_request_target:
types: [opened, labeled, unlabeled]
types: [opened, labeled, unlabeled, synchronize]
jobs:
prevent-blocked:
name: Prevent blocked

View File

@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: 📥 Download artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ inputs.artifact_run_id }}
@@ -53,7 +53,7 @@ jobs:
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
- name: Build and push Docker image
uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -9,6 +9,11 @@ on:
type: string # This would ideally be a `choice` type, but that isn't supported yet
description: The package type to be built. Must be one of 'full' or 'embedded'
required: true
build_mode:
type: string # This would ideally be a `choice` type, but that isn't supported yet
description: The build mode for vite. Must be either 'development' or 'production'
required: false
default: production
secrets:
SENTRY_ORG:
required: true
@@ -31,26 +36,14 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"
- name: Install dependencies
run: "yarn install --immutable"
- name: Build full version
if: ${{ inputs.package == 'full' }}
run: "yarn run build:full"
env:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
NODE_OPTIONS: "--max-old-space-size=4096"
- name: Build embedded
if: ${{ inputs.package == 'embedded' }}
run: "yarn run build:embedded"
- name: Build Element Call
run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }}
env:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}

View File

@@ -14,6 +14,7 @@ jobs:
with:
package: full
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
@@ -61,6 +62,7 @@ jobs:
with:
package: embedded
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}

View File

@@ -7,8 +7,8 @@ jobs:
pr-changelog-label:
runs-on: ubuntu-latest
steps:
- uses: yogevbd/enforce-label-action@2.1.0
- 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

@@ -46,7 +46,7 @@ jobs:
Exercise caution. Use test accounts.
- name: 📥 Download artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ inputs.artifact_run_id }}

View File

@@ -11,7 +11,7 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"

View File

@@ -71,7 +71,7 @@ jobs:
- name: Determine filename
run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -83,7 +83,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@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -104,7 +104,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -113,7 +113,7 @@ jobs:
# n.b. We don't enable corepack here because we are using plain npm
- name: Setup node
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version-file: .node-version
registry-url: "https://registry.npmjs.org"
@@ -145,7 +145,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -153,7 +153,7 @@ jobs:
path: embedded/android/lib/src/main/assets/element-call
- name: ☕️ Setup Java
uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 # v4
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
with:
distribution: "temurin"
java-version: "17"
@@ -200,7 +200,7 @@ jobs:
path: element-call
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -260,7 +260,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@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
append_body: true
body: |

View File

@@ -31,7 +31,7 @@ jobs:
- name: Determine filename
run: echo "FILENAME_PREFIX=element-call-${VERSION:1}" >> "$GITHUB_ENV"
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id || github.run_id }}
@@ -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@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # 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@c95fe1489396fe8a9eb87c0abf8aa5b2ef267fda # v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2
with:
append_body: true
body: |

View File

@@ -13,7 +13,7 @@ jobs:
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"
@@ -22,7 +22,7 @@ jobs:
- name: Vitest
run: "yarn run test:coverage"
- name: Upload to codecov
uses: codecov/codecov-action@0565863a31f2c772f9f0395002a31e3f06189574 # v5
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
@@ -30,13 +30,13 @@ jobs:
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
timeout-minutes: 10
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"
@@ -46,11 +46,12 @@ jobs:
run: yarn playwright install --with-deps
- name: Run backend components
run: |
docker compose -f playwright-backend-docker-compose.yml up -d
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
docker ps
- name: Copy config file
run: cp config/config.devenv.json public/config.json
- name: Run Playwright tests
env:
USE_DOCKER: 1
run: yarn playwright test
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
if: ${{ !cancelled() }}

View File

@@ -18,7 +18,7 @@ jobs:
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version-file: ".node-version"

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ dist-ssr
public/config.json
backend/synapse_tmp/*
/coverage
config.json
# Yarn
yarn-error.log

View File

@@ -6,7 +6,7 @@ COPY ./dist /dist
WORKDIR /dist/assets
RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json
FROM nginxinc/nginx-unprivileged:alpine
FROM nginxinc/nginx-unprivileged:alpine-slim
COPY --from=builder ./dist /app

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
@@ -208,7 +209,8 @@ 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:
- Minimum Synapse Setup (servername: `synapse.m.localhost`)
- LiveKit Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Matrix Authentication Service Setup (issuer: `mas.m.localhost`)
- MatrixRTC 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 `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
@@ -217,6 +219,7 @@ whole stack of components which is required for a local development environment:
certificates
- Minimum TLS reverse proxy for
- Synapse homeserver: `synapse.m.localhost`
- Matrix Authentication Service: `mas.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost`
- Local Element Call development `call.m.localhost` via `yarn dev --host `
- Element Web `app.m.localhost`

View File

@@ -12,6 +12,11 @@ listeners:
- names: [client, federation, openid]
compress: false
matrix_authentication_service:
enabled: true
secret: "mas-matrix-secret"
endpoint: http://mas:8080
database:
name: sqlite3
args:
@@ -46,9 +51,5 @@ rc_message:
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

@@ -21,3 +21,5 @@ turn:
external_tls: true
keys:
devkey: secret
room:
auto_create: false

96
backend/dev_mas.yaml Normal file
View File

@@ -0,0 +1,96 @@
http:
listeners:
- name: web
resources:
- name: discovery
- name: human
- name: oauth
- name: assets
- name: adminapi
binds:
- address: '[::]:8080'
proxy_protocol: false
trusted_proxies:
- 192.168.0.0/16
- 172.16.0.0/12
- 10.0.0.0/10
- 127.0.0.1/8
- fd00::/8
- ::1/128
public_base: https://mas.m.localhost/
database:
uri: postgres://postgres@mas-db/mas
max_connections: 10
min_connections: 0
connect_timeout: 30
idle_timeout: 600
max_lifetime: 1800
email:
from: '"Authentication Service" <root@localhost>'
reply_to: '"Authentication Service" <root@localhost>'
transport: blackhole
secrets:
encryption: 91c9eda308d874d1b8ba51c0fe3b7cbb868638c8fbb82d7eec0e6912586bdabd
keys:
- kid: H30QE7M5eX
key: |
-----BEGIN RSA PRIVATE KEY-----
MIIEogIBAAKCAQEA3+t9XjM8LhYMpagIKXnpTXVWxIo5zwM/R8wYRPg0MPGCrOUB
i/L/Vof4yK7lIMWtCT724e989PLR4YmG2pXBpB8P7SZ3feggLoUMi8+QNzyKsGD+
lYmZd0D/2aVmxBxK83JP3LLodmoHduva2qSBF9YdZ6Greg/IcEdG2UY3iGYeojsQ
1Wx7V2+WPeUCtaIONBPk/rwOgWmzAhqGyCXONbvGazElNEuM0fwI278qveP5kNoh
aL6HvlaYQbMSGAg1tf06AKOjsJG0CRsvsMdFxuG9GEwd4pJr9+v+OqwnaFJHJ70z
kQFC3s/w+xCk4NnO+jSopBptu8ycwjZYMuq2kQIDAQABAoIBAHbDqFL2Sc0H1N1o
KiwVhTCYM9U6mz65Mi8aiSTLoKL09aJONGvODrAOnl2SpeSj9AsbYkajh1tEDx3Y
m7YECBjMgN3/sREOtUL3PphJFuy1J7o1N9KIkOU3jHwbxk3t07MbxlAAdFuaESt6
HTIqXm4OGrqEfTbYeC9VHrbPD1VAFj/OGHsYDurJzhfIlFSlZWZqHNjdNh2HAOJM
FElqJWqqR9fj2pYYdpo+oaheI/iIAuWpAgcZOJzaZ4iui4R9i4od+qqQ3EVECPvS
/QnezvDpiobShG6WOmrRj3WBtheiPLdNlNB0sVW9h3dHcrkE/l2n5pfArVbHB+wg
4e5FEAECgYEA4XjB6hpN1iX66ADf27L5mHymu1hTojZLoQdy76OcjgG+4ZoQl71U
OAww4ek9I6Alz+aQqTAnRtLHBKH+xSuO2VoxQfimk71mUmByCr25vIU8mGtXRIJO
rtWEVE4HQPhK2LODLm5zbp3I1GsrfNgMCsuA/yse5MIczC8bRFyf+DECgYEA/jzx
ddl1asjArcFcQyKFJxdobNqYJ4P+rbDLIOmC9IQ8n/v+ETERCzj/y93yaXXqCBlR
uHDzo72F/+SYDDWGYanpRmN2cUv1A0XTUs+dWYjfrscFJUEx8CZh0GeoLE4H3uru
GlwqPnc9sMPee98mj4yDMyrNqLx/VaXV+wnpbGECgYAh4JoKSa9+SLCdYVxBT2/v
OHN43LmcOto8NLlRRl0EfUCn9xUdJ4Za8YH6v6e/DZYA2dzMfv63xn2+tXRpPbU1
9TZHekvVEPUp1XHtKTqaF87V+/LdyVJ3NH+whxTR7zyXuMkyFchkS3Lcb8nV9URB
7vfP3zPCHWRkTYOkTuJ+UQKBgFCrj8ZgMOSoPJMlppvayTtFLypTFjJ7rIT6cwnH
bnkduIrfD5fu5MSV2nyauT+DXbYiKo8GsBhFm849f41oMnKs0ks2Zi++9UiLkGlX
XUs6phc0KUrP7AOSejkBmxgrzk2KZ/DPS8w0U8vR6reNcBPedwb2Tvl6jkDj9QjJ
9VohAoGAISjlufqw3y8F/0on1AhqyROlJghTBsQ+xDEBZ9txx/HcKVghqMhBCbFj
LRf8B4vH9QXtlZVtPFj0wE3INxsYtilsbD8wbwkxsLeGUFdPDucPacfBSyX+wLRh
S1/twrPS9KVhkU5d0TbyfOlEB1OSXZTWZ1n9NaqOZUPf6FAwm78=
-----END RSA PRIVATE KEY-----
- kid: VrRd3Y2OeF
key: |
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIB3zBxhuh275A3piMMDZ2BEM6vsoxswNLrTJRiaY+m80oAoGCCqGSM49
AwEHoUQDQgAECqFotDpyEYNrWf2UZaUB0CZz6KiptQL2wi8oRNkKlarjgDDNCBzR
dgCokx9C8bLpfqhTJE/6aSe6T19qkaPHIg==
-----END EC PRIVATE KEY-----
- kid: SHaCwxflXU
key: |
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDCdchyP7aFxQce7vA+QMPkMkOaYKbmNoN1fnlKviKsJK1riq+1eKSEe
UeUF5BOczfugBwYFK4EEACKhZANiAAR1pIE4xN9xkULiCgMd/uztt4Lnu8FhvEZD
3BhUfy5kdBVbYyk1khgKy3k+dQvXaTVkzsHkQN8K78WxlUDlF5zKXLjgkeEiqgz7
HU0rr2e8geUiaEE2AkzWhvmIikvhuMo=
-----END EC PRIVATE KEY-----
- kid: ngjUaMfCuT
key: |
-----BEGIN EC PRIVATE KEY-----
MHQCAQEEIE11jnxjUvPk93ylMuIcwcayJsFUhsSH2EqAn97CiHf8oAcGBSuBBAAK
oUQDQgAE1XySwFNBUkzZ946MBf2/3ecXVptrauZEQ8d8zqUdBS7wOe5pZwZ15Jx4
aZhlusZ3BPl0KiTlWwOlaRDMrw9EGA==
-----END EC PRIVATE KEY-----
passwords:
enabled: true
schemes:
- version: 1
algorithm: argon2id
minimum_complexity: 0
matrix:
kind: synapse
homeserver: synapse.m.localhost
secret: "mas-matrix-secret"
endpoint: http://homeserver:8008/

View File

@@ -40,9 +40,31 @@ server {
}
# Matrix Authentication Server reverse proxy
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name mas.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
location / {
proxy_pass "http://mas:8080";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
error_page 500 502 503 504 /50x.html;
}
# MatrixRTC reverse proxy
# - MatrixRTC Authorization Service
# - LiveKit SFU websocket signaling connection
upstream jwt-auth-services {
server auth-server:6080;
server host.docker.internal:6080;
}
server {
listen 80;
listen [::]:80;
@@ -62,8 +84,9 @@ 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
proxy_pass http://auth-server:8080/;
# JWT Service running at port 6080
proxy_pass http://jwt-auth-services/;
}
location ^~ /livekit/sfu/ {
@@ -145,11 +168,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://element-web:81;
proxy_pass http://element-web:8081;
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}
}

View File

@@ -8,5 +8,15 @@
"features": {
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 100
},
"oidc_metadata": {
"client_name": "Element Call (dev)"
}
}

View File

@@ -11,5 +11,12 @@
"features": {
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 100
}
}

View File

@@ -5,6 +5,14 @@
"server_name": "call-unstable.ems.host"
}
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 100
},
"posthog": {
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
"api_host": "https://posthog-element-call.element.io"

View File

@@ -4,21 +4,23 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
pull_policy: always
hostname: auth-server
environment:
- LK_JWT_PORT=8080
- LIVEKIT_JWT_PORT=6080
- LIVEKIT_URL=wss://matrix-rtc.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
- 8080:8080
- 6080:6080
networks:
- ecbackend
@@ -66,6 +68,31 @@ services:
volumes:
- ./backend/synapse_tmp:/data:Z
- ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
depends_on:
- mas
networks:
- ecbackend
mas:
# To add users, see `docker exec element-call-mas-1 mas-cli manage register-user -h`
image: ghcr.io/element-hq/matrix-authentication-service:latest
pull_policy: always
hostname: mas
volumes:
- ./backend/dev_mas.yaml:/config.yaml:ro,Z
depends_on:
- mas-db
networks:
- ecbackend
mas-db:
image: docker.io/postgres:16-alpine
hostname: mas-db
restart: always
shm_size: 128mb
environment:
- POSTGRES_HOST_AUTH_METHOD=trust
- POSTGRES_DB=mas
networks:
- ecbackend
@@ -73,11 +100,11 @@ services:
image: ghcr.io/element-hq/element-web:develop
pull_policy: always
volumes:
- ./backend/ew.test.config.json:/app/config.json
- ./backend/ew.test.config.json:/app/config.json:Z
environment:
ELEMENT_WEB_PORT: 81
ELEMENT_WEB_PORT: 8081
ports:
- "8081:81"
- "8081:8081"
networks:
- ecbackend
@@ -91,7 +118,6 @@ services:
- ./backend/dev_tls_m.localhost.crt:/root/ssl/cert.pem:Z
ports:
# HOST_PORT:CONTAINER_PORT
- "80:80"
- "443:443"
- "8008:80"
- "4443:443"
@@ -100,5 +126,9 @@ services:
- "host.docker.internal:host-gateway"
depends_on:
- synapse
- mas
networks:
- ecbackend
ecbackend:
aliases:
- mas.m.localhost
- matrix-rtc.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

View File

@@ -1,7 +1,31 @@
# Global JS controls
A few aspects of Element Call's interface can be controlled through a global API on the `window`:
A few aspects of Element Call's interface can be controlled through a global API on the `window`.
## Picture-in-picture
- `controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode.
- `controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call.
- `controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call.
## Audio devices
On mobile platforms (iOS, Android), web views do not reliably support selecting audio output devices such as the main speaker, earpiece, or headset. To address this limitation, the following functions allow the hosting application (e.g., Element Web, Element X) to manage audio devices via exposed JavaScript interfaces. These functions must be enabled using the URL parameter `controlledAudioDevices` to take effect.
- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?, boolean; }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
It flags the device that should be used if the user selects earpiece mode. This should be the main stereo loudspeaker of the device.
- `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
- `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example.
- `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default.
- `controls.onAudioPlaybackStarted: ((id: string) => void) | undefined`: This will be called the first time we start
playing audio in the webview. It can be helpful to do device setup on the native app when the webviews audio is ready.
In particular android is using it to setup the output channel so that the call volume can
be controlled by the hardware volume rocker.
## Element Call button delegation
Callbacks for buttons in EC that are handled by the native application
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
This button is only shown on iOS. (`/iPad|iPhone|iPod|Mac/.test(navigator.userAgent)`)
- `onBackButtonPressed: (() => void) | undefined`. Callback when the webview detects a tab on the header's back button.

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/;
}
@@ -145,10 +145,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"
}
]
```
@@ -255,13 +251,17 @@ self-hosters and developers working with Element Call.
- [How to resolve stuck MatrixRTC calls](https://sspaeth.de/2025/02/how-to-resolve-stuck-matrixrtc-calls/)
## 🛠️ How-Tos & Tutorials
## 📝 How-Tos & Tutorials
- [MatrixRTC aka Element-call setup (Geek warning)](https://sspaeth.de/2024/11/sfu/)
- [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/)
## 🛠️ Tools
- [A Matrix server sanity tester including tests for proper MatrixRTC setup](https://codeberg.org/spaetz/testmatrix)
## 🤝 Want to Contribute?
Have a guide or blog post you'd like to share? Open a

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. |
@@ -56,18 +57,19 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. |
| `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. |
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
| `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. |
### 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.8.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.30.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.10.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

@@ -86,8 +86,7 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -115,7 +114,7 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
@@ -206,7 +205,7 @@ fi
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
@@ -214,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.

View File

@@ -70,11 +70,11 @@ goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell

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.S01, 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}}",
@@ -70,16 +71,18 @@
"livekit_server_info": "Informace o serveru LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Ztlumit všechny zvuky (účastníci, reakce, zvuky připojení)",
"show_connection_stats": "Zobrazit statistiky připojení",
"show_non_member_tiles": "Zobrazit dlaždice pro nečlenská média",
"url_params": "Parametry URL",
"use_new_membership_manager": "Použijte novou implementaci volání MembershipManager"
"use_new_membership_manager": "Použijte novou implementaci volání MembershipManager",
"use_to_device_key_transport": "Použít přenos klíčů do zařízení. Tím se vrátíte k přenosu klíčů do místnosti, když jiný účastník hovoru pošle klíč místnosti"
},
"disconnected_banner": "Připojení k serveru bylo ztraceno.",
"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č",
@@ -102,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",
@@ -164,12 +172,18 @@
"effect_volume_description": "Upravit hlasitost přehrávání reakcí a efektů zvednutých rukou.",
"effect_volume_label": "Hlasitost zvukového efektu"
},
"background_blur_header": "Pozadí",
"background_blur_label": "Rozostřit pozadí videa",
"blur_not_supported_by_browser": "(Toto zařízení nepodporuje rozostření pozadí.)",
"developer_tab_title": "Vývojář",
"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",

236
locales/da/app.json Normal file
View File

@@ -0,0 +1,236 @@
{
"a11y": {
"user_menu": "Brugermenu"
},
"action": {
"close": "Luk",
"copy_link": "Kopiér link",
"edit": "Rediger",
"go": "Gå",
"invite": "Invitér",
"lower_hand": "Sænk hånd",
"no": "Nej",
"pick_reaction": "Vælg reaktion",
"raise_hand": "Ræk hånden op",
"register": "Registrér",
"remove": "Fjern",
"show_less": "Vis mindre",
"show_more": "Vis mere",
"sign_in": "Log ind",
"sign_out": "Log ud",
"submit": "Indsend",
"upload_file": "Upload fil"
},
"analytics_notice": "Ved at deltage i denne beta giver du samtykke til indsamling af anonyme data, som vi bruger til at forbedre produktet. Du kan finde flere oplysninger om, hvilke data vi sporer, i vores <2>fortrolighedspolitik</2> og vores <6>cookiepolitik</6>.",
"app_selection_modal": {
"continue_in_browser": "Fortsæt i browseren",
"open_in_app": "Åbn i appen",
"text": "Klar til at deltage?",
"title": "Vælg app"
},
"call_ended_view": {
"create_account_button": "Opret konto",
"create_account_prompt": "<0>Hvorfor ikke afslutte med at oprette en adgangskode for at beholde din konto? </0><1>Du kan beholde dit navn og indstille en avatar til brug ved fremtidige opkald </1>",
"feedback_done": "<0>Tak for din feedback! </0>",
"feedback_prompt": "<0>Vi vil meget gerne høre din feedback, så vi kan forbedre din oplevelse.</0>",
"headline": "{{displayName}}, dit opkald er afsluttet.",
"not_now_button": "Ikke nu, vend tilbage til startskærmen",
"reconnect_button": "Tilslut igen",
"survey_prompt": "Hvordan gik det?"
},
"call_name": "Navn på opkald",
"common": {
"analytics": "Analyse-værktøj",
"audio": "Lyd",
"avatar": "Avatar",
"back": "Tilbage",
"display_name": "Vist navn",
"encrypted": "Krypteret",
"home": "Hjem",
"loading": "Indlæser...",
"next": "Næste",
"options": "Valgmuligheder",
"password": "Adgangskode",
"preferences": "Foretrukne",
"profile": "Profil",
"reaction": "Reaktion",
"reactions": "Reaktioner",
"settings": "Indstillinger",
"unencrypted": "Ikke krypteret",
"username": "Brugernavn",
"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}}",
"duplicate_tiles_label": "Antal ekstra flisekopier pr. deltager",
"environment_variables": "Miljøvariabler",
"hostname": "Værtsnavn: {{hostname}}",
"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",
"use_new_membership_manager": "Brug den nye implementering af opkaldet MembershipManager",
"use_to_device_key_transport": "Bruges til at transportere enhedsnøgler. Dette vil falde tilbage til transport af værelsesnøgler, når et andet opkaldsmedlem sender en rumnøgle"
},
"disconnected_banner": "Forbindelsen til serveren er gået tabt.",
"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 <2> opret et nyt</2>.</0>",
"connection_lost": "Forbindelsen gik tabt",
"connection_lost_description": "Du blev afbrudt fra opkaldet.",
"e2ee_unsupported": "Inkompatibel browser",
"e2ee_unsupported_description": "Din webbrowser understøtter ikke krypterede opkald. Understøttede browsere inkluderer Chrome, Safari og Firefox 117+.",
"generic": "Noget gik galt",
"generic_description": "Indsendelse af fejlfindingslogfiler hjælper os med at spore problemet.",
"insufficient_capacity": "Utilstrækkelig kapacitet",
"insufficient_capacity_description": "Serveren har nået sin maksimale kapacitet, og du kan ikke deltage i opkaldet på dette tidspunkt. Prøv igen senere, eller kontakt din serveradministrator, hvis problemet fortsætter.",
"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": {
"banned_body": "Du er blevet spærret fra rummet.",
"banned_heading": "Spærret",
"call_ended_body": "Du er blevet fjernet fra opkaldet.",
"call_ended_heading": "Opkaldet afsluttet",
"knock_reject_body": "Din anmodning om at deltage blev afvist.",
"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",
"invite_modal": {
"link_copied_toast": "Link kopieret til udklipsholder",
"title": "Inviter til dette opkald"
},
"join_existing_call_modal": {
"join_button": "Ja, deltag i opkald",
"text": "Dette opkald findes allerede, vil du være med?",
"title": "Deltag i eksisterende opkald?"
},
"layout_grid_label": "Gitter",
"layout_spotlight_label": "Spotlys",
"lobby": {
"ask_to_join": "Anmod om at deltage i opkaldet",
"join_as_guest": "Deltag som gæst",
"join_button": "Deltag i opkald",
"leave_button": "Tilbage til seneste",
"waiting_for_invite": "Anmodning sendt! Venter på tilladelse til at deltage..."
},
"log_in": "Log ind",
"logging_in": "Logger ind...",
"login_auth_links": "<0>Opret en konto </0> eller <2> få adgang som gæst </2>",
"login_auth_links_prompt": "Ikke registreret endnu?",
"login_subheading": "For at fortsætte til Element",
"login_title": "Login",
"microphone_off": "Mikrofon slukket",
"microphone_on": "Mikrofon tændt",
"mute_microphone_button_label": "Slå mikrofonen fra",
"participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"qr_code": "QR-kode",
"rageshake_button_error_caption": "Prøv at sende logfiler igen",
"rageshake_request_modal": {
"body": "En anden bruger på dette opkald har et problem. For bedre at kunne diagnosticere sådanne problemer, vil vi gerne indsamle en fejlfindingslog.",
"title": "Anmodning om fejlfindingslogfil"
},
"rageshake_send_logs": "Send fejlfindingslogfiler",
"rageshake_sending": "Sender...",
"rageshake_sending_logs": "Afsendelse af fejlfindingslogfiler...",
"rageshake_sent": "Tak!",
"recaptcha_dismissed": "Recaptcha afvist",
"recaptcha_not_loaded": "Recaptcha ikke indlæst",
"recaptcha_ssla_caption": "Dette websted er beskyttet af ReCAPTCHA og Googles <2>Privatlivspolitik</2> og <6>Servicevilkår</6> gælder.<9></9>Ved at klikke på \"Registrer\" accepterer du vores <12>Software og Services Licensaftale (SSLA)</12>",
"register": {
"passwords_must_match": "Adgangskoderne skal være identiske",
"registering": "Registrering..."
},
"register_auth_links": "<0>Har du allerede en konto? </0><1><0>Log ind </0> eller <2> få adgang som gæst </2> </1>",
"register_confirm_password_label": "Bekræft adgangskode",
"register_heading": "Opret din konto",
"return_home_button": "Vend tilbage til startskærmen",
"room_auth_view_continue_button": "Fortsæt",
"room_auth_view_ssla_caption": "Ved at klikke på „Deltag i opkald nu“ accepterer du vores <2> Software og Services Licensaftale (SSLA) </2>",
"screenshare_button_label": "Del din skærm",
"settings": {
"audio_tab": {
"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",
"speaker_numbered": "Højttaler {{n}}"
},
"feedback_tab_body": "Hvis du oplever problemer eller bare gerne vil give feedback, kan du sende os en kort beskrivelse herunder.",
"feedback_tab_description_label": "Din tilbagemelding",
"feedback_tab_h4": "Indsend feedback",
"feedback_tab_send_logs_label": "Medtag fejlfindingslogfiler",
"feedback_tab_thank_you": "Tak, vi har modtaget din feedback!",
"feedback_tab_title": "Feedback",
"opt_in_description": "<0></0><1></1>Du kan trække dit samtykke tilbage ved at fjerne markeringen i dette felt. Hvis du i øjeblikket er i gang med et opkald, træder denne indstilling i kraft ved afslutningen af opkaldet.",
"preferences_tab": {
"developer_mode_label": "Udviklertilstand",
"developer_mode_label_description": "Aktivér udviklertilstand og vis fanen udviklerindstillinger.",
"introduction": "Her kan du konfigurere ekstra muligheder for en forbedret oplevelse.",
"reactions_play_sound_description": "Afspil en lydeffekt, når nogen sender en reaktion i et opkald.",
"reactions_play_sound_label": "Afspil reaktionslyde",
"reactions_show_description": "Vis en animation, når nogen sender en reaktion.",
"reactions_show_label": "Vis reaktioner",
"show_hand_raised_timer_description": "Vis en timer, når en deltager rækker hånden",
"show_hand_raised_timer_label": "Vis varighed af håndsoprækning"
}
},
"star_rating_input_label_one": "{{count}} stjerne",
"star_rating_input_label_other": "{{count}} stjerner",
"start_new_call": "Start nyt opkald",
"start_video_button_label": "Start video",
"stop_screenshare_button_label": "Skærmen bliver delt",
"stop_video_button_label": "Stop video",
"submitting": "Indsender...",
"switch_camera": "Skift kamera",
"unauthenticated_view_body": "Ikke registreret endnu? <2>Opret en konto </2>",
"unauthenticated_view_login_button": "Log ind på din konto",
"unauthenticated_view_ssla_caption": "Ved at klikke på \"Start\" accepterer du vores <2>Software og Services Licensaftale (SSLA)</2>",
"unmute_microphone_button_label": "Slå mikrofonen til",
"version": "{{productName}} version: {{version}}",
"video_tile": {
"always_show": "Vis altid",
"camera_starting": "Indlæser video",
"change_fit_contain": "Tilpas til rammen",
"collapse": "Fold sammen",
"expand": "Udvid",
"mute_for_me": "Slå lyden fra for mig",
"muted_for_me": "Dæmpet for mig",
"volume": "Lydstyrke",
"waiting_for_media": "Venter på medier..."
}
}

View File

@@ -55,12 +55,14 @@
"profile": "Profil",
"reaction": "Reaktion",
"reactions": "Reaktionen",
"reconnecting": "Verbindung wird wiederhergestellt...",
"settings": "Einstellungen",
"unencrypted": "Nicht verschlüsselt",
"username": "Benutzername",
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "iPhone-Ohrhörer-Option auf allen Plattformen anzeigen",
"crypto_version": "Krypto-Version: {{version}}",
"debug_tile_layout_label": "Kachel-Layout debuggen",
"device_id": "Geräte-ID: {{id}}",
@@ -70,16 +72,18 @@
"livekit_server_info": "LiveKit-Server Informationen",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix-ID: {{id}}",
"mute_all_audio": "Stummschalten aller Audiosignale (Teilnehmer, Reaktionen, Beitrittsgeräusche)",
"show_connection_stats": "Verbindungsstatistiken anzeigen",
"show_non_member_tiles": "Kacheln für Nicht-Mitgliedermedien anzeigen",
"url_params": "URL-Parameter",
"use_new_membership_manager": "Neuen MembershipManager verwenden"
"use_new_membership_manager": "Neuen MembershipManager verwenden",
"use_to_device_key_transport": "To-Device media E2EE Schlüssel-Transport verwenden. Falls ein anderer Teilnehmer bereits den Raumschlüssel-Transport verwendet, wird automatisch auf Raumschlüssel-Transport zurückgegriffen."
},
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
"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",
@@ -91,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": {
@@ -102,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",
@@ -163,12 +174,18 @@
"effect_volume_description": "Lautstärke anpassen, mit der Reaktionen und Handmeldungen abgespielt werden.",
"effect_volume_label": "Lautstärke der Soundeffekte"
},
"background_blur_header": "Hintergrund",
"background_blur_label": "Unschärfeeffekt für den Hintergrund aktivieren",
"blur_not_supported_by_browser": "(Hintergrundunschärfe wird von diesem Gerät nicht unterstützt.)",
"developer_tab_title": "Entwickler",
"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

@@ -4,15 +4,30 @@
},
"action": {
"close": "Κλείσιμο",
"copy_link": "Αντιγραφή συνδέσμου",
"edit": "Επεξεργασία",
"go": "Μετάβαση",
"invite": "Πρόσκληση",
"lower_hand": "Κατεβάστε το χέρι",
"no": "Όχι",
"pick_reaction": "Επιλέξτε αντίδραση",
"raise_hand": "Σηκώστε το χέρι",
"register": "Εγγραφή",
"remove": "Αφαίρεση",
"show_less": "Εμφάνιση λιγότερων",
"show_more": "Εμφάνιση περισσότερων",
"sign_in": "Σύνδεση",
"sign_out": "Αποσύνδεση",
"submit": "Υποβολή"
"submit": "Υποβολή",
"upload_file": "Μεταφόρτωση αρχείου"
},
"analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου</2> και στην <6>Πολιτική cookies</6>.",
"app_selection_modal": {
"continue_in_browser": "Συνέχεια στο πρόγραμμα περιήγησης",
"open_in_app": "Ανοίξτε στην εφαρμογή",
"text": "Έτοιμοι να συμμετάσχετε?",
"title": "Επιλέξτε εφαρμογή"
},
"analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου</2> και στην <5>Πολιτική cookies</5>.",
"call_ended_view": {
"create_account_button": "Δημιουργία λογαριασμού",
"create_account_prompt": "<0>Γιατί να μην ολοκληρώσετε με τη δημιουργία ενός κωδικού πρόσβασης για τη διατήρηση του λογαριασμού σας;</0><1>Θα μπορείτε να διατηρήσετε το όνομά σας και να ορίσετε ένα avatar για χρήση σε μελλοντικές κλήσεις.</1>",
@@ -20,19 +35,45 @@
"feedback_prompt": "<0>Θα θέλαμε να ακούσουμε τα σχόλιά σας ώστε να βελτιώσουμε την εμπειρία σας.</0>",
"headline": "{{displayName}}, η κλήση σας τερματίστηκε.",
"not_now_button": "Όχι τώρα, επιστροφή στην αρχική οθόνη",
"reconnect_button": "Επανασύνδεση",
"survey_prompt": "Πώς σας φάνηκε;"
},
"call_name": "Όνομα κλήσης",
"common": {
"analytics": "Δεδομένα ανάλυσης",
"audio": "Ήχος",
"avatar": "Εικόνα Προφίλ",
"back": "Πίσω",
"display_name": "Εμφανιζόμενο όνομα",
"encrypted": "Κρυπτογραφημένο",
"home": "Αρχική",
"loading": "Φόρτωση…",
"next": "Επόμενο",
"options": "Επιλογές",
"password": "Κωδικός",
"preferences": "Προτιμήσεις",
"profile": "Προφίλ",
"reaction": "Αντίδραση",
"reactions": "Αντιδράσεις",
"settings": "Ρυθμίσεις",
"unencrypted": "Μη κρυπτογραφημένο",
"username": "Όνομα χρήστη",
"video": "Βίντεο"
},
"developer_mode": {
"crypto_version": "Έκδοση κρυπτογράφησης: {{version}}",
"debug_tile_layout_label": "Διάταξη πλακιδίων εντοπισμού σφαλμάτων",
"device_id": "Αναγνωριστικό συσκευής: {{id}}",
"duplicate_tiles_label": "Αριθμός επιπλέον αντιγράφων πλακιδίων ανά συμμετέχοντα",
"environment_variables": "Μεταβλητές περιβάλλοντος",
"hostname": "Όνομα κεντρικού υπολογιστή: {{hostname}}",
"livekit_server_info": "Πληροφορίες διακομιστή LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Αναγνωριστικό Matrix: {{id}}",
"show_connection_stats": "Εμφάνιση στατιστικών σύνδεσης",
"show_non_member_tiles": "Εμφάνιση πλακιδίων για μέσα μη-μελών",
"url_params": "Παράμετροι URL"
},
"header_label": "Element Κεντρική Οθόνη Κλήσεων",
"join_existing_call_modal": {
"join_button": "Ναι, συμμετοχή στην κλήση",

View File

@@ -55,12 +55,14 @@
"profile": "Profile",
"reaction": "Reaction",
"reactions": "Reactions",
"reconnecting": "Reconnecting…",
"settings": "Settings",
"unencrypted": "Not encrypted",
"username": "Username",
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}",
"debug_tile_layout_label": "Debug tile layout",
"device_id": "Device ID: {{id}}",
@@ -70,6 +72,7 @@
"livekit_server_info": "LiveKit Server Info",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"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",
@@ -80,18 +83,21 @@
"error": {
"call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one</1>.</0>",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
"connection_lost": "Connection lost",
"connection_lost_description": "You were disconnected from the call.",
"e2ee_unsupported": "Incompatible browser",
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
"generic": "Something went wrong",
"generic_description": "Submitting debug logs will help us track down the problem.",
"homeserver_misconfig": "Misconfigured homeserver",
"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 }}).",
"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": {
@@ -103,6 +109,11 @@
"knock_reject_heading": "Access denied",
"reason": "Reason: {{reason}}"
},
"handset": {
"overlay_back_button": "Back to Speaker Mode",
"overlay_description": "Only works while using app",
"overlay_title": "Handset Mode"
},
"hangup_button_label": "End call",
"header_label": "Element Call Home",
"header_participants_label": "Participants",
@@ -171,8 +182,11 @@
"devices": {
"camera": "Camera",
"camera_numbered": "Camera {{n}}",
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset",
"loudspeaker": "Loudspeaker",
"microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker",

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}}",
@@ -70,16 +71,18 @@
"livekit_server_info": "LiveKiti serveri teave",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrixi kasutajatunnus: {{id}}",
"mute_all_audio": "Summuta kõik helid (osalejad, regeerimised, liitumise helid)",
"show_connection_stats": "Näita ühenduse statistikat",
"show_non_member_tiles": "Näita ka mitteseotud meedia paane",
"url_params": "Võrguaadressi parameetrid",
"use_new_membership_manager": "Kasuta kõne liikmelisuse halduri (MembershipManager) uut implementatsiooni"
"use_new_membership_manager": "Kasuta kõne liikmelisuse halduri (MembershipManager) uut implementatsiooni",
"use_to_device_key_transport": "Kasuta seadmepõhist krüptovõtmete vahetust. Kui jututoa liige peaks saatma jututoakohase krüptovõtme, siis kasuta jututoakohast võtmevahetust"
},
"disconnected_banner": "Võrguühendus serveriga on katkenud.",
"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",
@@ -91,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": {
@@ -102,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",
@@ -163,12 +172,17 @@
"effect_volume_description": "Häälesta helivaljust, mida kasutatakse käe tõstmisel ja regeerimisel",
"effect_volume_label": "Efektide helivajlus"
},
"background_blur_header": "Taust",
"background_blur_label": "Hägusta video taust",
"blur_not_supported_by_browser": "(Tausta hägustamine pole selles seadmes toetatud.)",
"developer_tab_title": "Arendaja",
"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",

224
locales/fi/app.json Normal file
View File

@@ -0,0 +1,224 @@
{
"a11y": {
"user_menu": "Käyttäjävalikko"
},
"action": {
"close": "Sulje",
"copy_link": "Kopioi linkki",
"edit": "Muokkaa",
"go": "Siirry",
"invite": "Kutsu",
"lower_hand": "Laske käsi",
"no": "Ei",
"pick_reaction": "Valitse reaktio",
"raise_hand": "Nosta käsi",
"register": "Rekisteröidy",
"remove": "Poista",
"show_less": "Näytä vähemmän",
"show_more": "Näytä lisää",
"sign_in": "Kirjaudu sisään",
"sign_out": "Kirjaudu ulos",
"submit": "Lähetä",
"upload_file": "Lähetä tiedosto"
},
"analytics_notice": "Osallistumalla tähän betaan hyväksyt nimettömien tietojen keräämisen, joita käytämme tuotteen parantamiseen. Löydät lisätietoa siitä, mitä tietoja seuraamme meidän <2> Tietosuojakäytännöstä</2> ja <6>Evästekäytännöstä</6> .",
"app_selection_modal": {
"continue_in_browser": "Jatka selaimessa",
"open_in_app": "Avaa sovelluksessa",
"text": "Oletko valmis liittymään?",
"title": "Valitse sovellus"
},
"call_ended_view": {
"create_account_button": "Luo tili",
"create_account_prompt": "<0>Miksi et viimeistelisi määrittämällä salasanaa tilisi säilyttämiseksi?</0><1>Voit säilyttää nimesi ja asettaa avatarin käytettäväksi tulevissa puheluissa</1>",
"feedback_done": "<0>Kiitos palautteestasi!</0>",
"feedback_prompt": "<0>Haluaisimme kuulla palautteesi, jotta voimme parantaa käyttökokemustasi.</0>",
"headline": "{{displayName}}, puhelusi on päättynyt.",
"not_now_button": "Ei nyt, palaa aloitusnäyttöön",
"reconnect_button": "Yhdistä uudelleen",
"survey_prompt": "Miten se meni?"
},
"call_name": "Puhelun nimi",
"common": {
"analytics": "Analytiikka",
"audio": "Ääni",
"avatar": "Avatar",
"back": "Takaisin",
"display_name": "Näyttönimi",
"encrypted": "Salattu",
"home": "Etusivu",
"loading": "Ladataan...",
"next": "Seuraava",
"options": "Vaihtoehdot",
"password": "Salasana",
"preferences": "Asetukset",
"profile": "Profiili",
"reaction": "Reaktio",
"reactions": "Reaktiot",
"settings": "Asetukset",
"unencrypted": "Ei salattu",
"username": "Käyttäjänimi",
"video": "Video"
},
"developer_mode": {
"crypto_version": "Kryptoversio: {{version}}",
"debug_tile_layout_label": "Laattojen asettelun vianmääritys",
"device_id": "Laitteen tunnus: {{id}}",
"duplicate_tiles_label": "Lisälaattakopioiden määrä osallistujaa kohti",
"environment_variables": "Ympäristömuuttujat",
"hostname": "Isäntänimi: {{hostname}}",
"livekit_server_info": "LiveKit-palvelimen tiedot",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix tunnus: {{id}}",
"show_connection_stats": "Näytä yhteystilastot",
"show_non_member_tiles": "Näytä laatat ei-jäsenien medialle",
"url_params": "URL-parametrit",
"use_new_membership_manager": "Käytä uutta puhelun MembershipManagerin toteutusta",
"use_to_device_key_transport": "Käytä laitteen avainten kuljetusta. Tämä palaa huoneen avainten siirtoon, kun toinen puhelun jäsen lähettää huoneavaimen"
},
"disconnected_banner": "Yhteys palvelimeen on katkennut.",
"error": {
"call_is_not_supported": "Puhelua ei tueta",
"call_not_found": "Puhelua ei löydy",
"call_not_found_description": "<0>Kyseinen linkki ei näytä kuuluvan mihinkään olemassa olevaan puheluun. Tarkista, että sinulla on oikea linkki, tai <1>luo uusi linkki</1>.</0>",
"connection_lost": "Yhteys katkesi",
"connection_lost_description": "Sinut katkaistiin puhelusta.",
"e2ee_unsupported": "Yhteensopimaton selain",
"e2ee_unsupported_description": "Verkkoselaimesi ei tue salattuja puheluita. Tuettuja selaimia ovat Chrome, Safari ja Firefox 117+.",
"generic": "Jokin meni pieleen",
"generic_description": "Vianmäärityslokien lähettäminen auttaa meitä jäljittämään ongelman.",
"insufficient_capacity": "Riittämätön kapasiteetti",
"insufficient_capacity_description": "Palvelin on saavuttanut maksimikapasiteettinsa, etkä voi liittyä puheluun tällä hetkellä. Yritä myöhemmin uudelleen tai ota yhteyttä palvelimen ylläpitäjään, jos ongelma jatkuu.",
"matrix_rtc_focus_missing": "Palvelinta ei ole määritetty toimimaan {{brand}} -sovelluksen kanssa. Ota yhteyttä palvelimen ylläpitäjään (Verkkotunnus: {{domain}}, Virhekoodi: {{ errorCode }}).",
"open_elsewhere": "Avattu toisessa välilehdessä",
"open_elsewhere_description": "{{brand}} on avattu toisessa välilehdessä. Jos tämä ei kuulosta oikealta, yritä ladata sivu uudelleen.",
"unexpected_ec_error": "Tapahtui odottamaton virhe (<0>Virhekoodi:</0> <1>{{ errorCode }}</1>). Ota yhteyttä palvelimen ylläpitäjään."
},
"group_call_loader": {
"banned_body": "Sinulle on annettu porttikielto huoneesta.",
"banned_heading": "Kielletty",
"call_ended_body": "Sinut on poistettu puhelusta.",
"call_ended_heading": "Puhelu päättyi",
"knock_reject_body": "Liittymispyyntösi hylättiin.",
"knock_reject_heading": "Pääsy kielletty",
"reason": "Syy: {{reason}}"
},
"hangup_button_label": "Lopeta puhelu",
"header_label": "Element Call Etusivu",
"header_participants_label": "Osallistujat",
"invite_modal": {
"link_copied_toast": "Linkki kopioitu leikepöydälle",
"title": "Kutsu tähän puheluun"
},
"join_existing_call_modal": {
"join_button": "Kyllä, liity puheluun",
"text": "Tämä puhelu on jo olemassa, haluatko liittyä siihen?",
"title": "Liity olemassa olevaan puheluun?"
},
"layout_grid_label": "Ruudukko",
"layout_spotlight_label": "Valokeila",
"lobby": {
"ask_to_join": "Pyydä liittymistä puheluun",
"join_as_guest": "Liity vieraana",
"join_button": "Liity puheluun",
"leave_button": "Takaisin viimeisimpiin puheluihin",
"waiting_for_invite": "Pyyntö lähetetty! Odotetaan lupaa liittyä…"
},
"log_in": "Kirjaudu sisään",
"logging_in": "Kirjaudutaan sisään…",
"login_auth_links": "<0>Luo tili</0> tai <2>Käytä vieraana</2>",
"login_auth_links_prompt": "Etkö ole vielä rekisteröitynyt?",
"login_subheading": "Jatkaaksesi Elementiin",
"login_title": "Kirjaudu sisään",
"microphone_off": "Mikrofoni pois päältä",
"microphone_on": "Mikrofoni päällä",
"mute_microphone_button_label": "Mykistä mikrofoni",
"participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"qr_code": "QR-koodi",
"rageshake_button_error_caption": "Yritä uudelleen lokien lähettämistä",
"rageshake_request_modal": {
"body": "Toisella käyttäjällä tässä puhelussa on ongelma. Jotta voimme diagnosoida nämä ongelmat paremmin, haluaisimme kerätä virheenkorjauslokin.",
"title": "Virheenkorjauslokipyyntö"
},
"rageshake_send_logs": "Lähetä virheenkorjauslokit",
"rageshake_sending": "Lähetetään…",
"rageshake_sending_logs": "Lähetetään virheenkorjauslokeja…",
"rageshake_sent": "Kiitos!",
"recaptcha_dismissed": "Recaptcha hylätty",
"recaptcha_not_loaded": "Recaptcha ei ole ladattu",
"recaptcha_ssla_caption": "Tämä sivusto on suojattu ReCAPTCHA:lla, ja siihen sovelletaan Googlen <2>Tietosuojakäytäntöä</2> ja <6>Palveluehtoja</6>.<9></9>Klikkaamalla \"Rekisteröidy\" hyväksyt <12>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</12>",
"register": {
"passwords_must_match": "Salasanojen on täsmättävä",
"registering": "Rekisteröidään…"
},
"register_auth_links": "<0>Onko sinulla jo tili?</0><1><0>Kirjaudu sisään</0> tai <2>Käytä vieraana</2></1>",
"register_confirm_password_label": "Vahvista salasana",
"register_heading": "Luo tilisi",
"return_home_button": "Palaa aloitusnäyttöön",
"room_auth_view_continue_button": "Jatka",
"room_auth_view_ssla_caption": "Klikkaamalla \"Liity puheluun nyt\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</2>",
"screenshare_button_label": "Jaa näyttö",
"settings": {
"audio_tab": {
"effect_volume_description": "Säädä äänenvoimakkuutta, jolla reaktioiden ja käden nostojen ääniefektit toistetaan.",
"effect_volume_label": "Ääniefektien äänenvoimakkuus"
},
"background_blur_header": "Tausta",
"background_blur_label": "Sumenna videon tausta",
"blur_not_supported_by_browser": "(Tämä laite ei tue taustan sumennusta.)",
"developer_tab_title": "Kehittäjä",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"default": "Oletus",
"default_named": "Oletus <2>({{name}})</2>",
"microphone": "Mikrofoni",
"microphone_numbered": "Mikrofoni {{n}}",
"speaker": "Kaiutin",
"speaker_numbered": "Kaiutin {{n}}"
},
"feedback_tab_body": "Jos sinulla on ongelmia tai haluat vain antaa palautetta, lähetä meille lyhyt kuvaus alla.",
"feedback_tab_description_label": "Palautteesi",
"feedback_tab_h4": "Lähetä palautetta",
"feedback_tab_send_logs_label": "Sisällytä virheenkorjauslokit",
"feedback_tab_thank_you": "Kiitos, saimme palautteesi!",
"feedback_tab_title": "Palaute",
"opt_in_description": "<0></0><1></1>Voit peruuttaa suostumuksesi poistamalla tämän ruudun valinnan. Jos puhelu on käynnissä, tämä asetus tulee voimaan puhelun lopussa.",
"preferences_tab": {
"developer_mode_label": "Kehittäjätila",
"developer_mode_label_description": "Ota kehittäjätila käyttöön ja näytä kehittäjäasetukset-välilehti.",
"introduction": "Täällä voit määrittää lisävaihtoehtoja parempaa käyttökokemusta varten.",
"reactions_play_sound_description": "Toista äänitefekti, kun joku lähettää reaktion puheluun.",
"reactions_play_sound_label": "Toista reaktioäänet",
"reactions_show_description": "Näytä animaatio, kun joku lähettää reaktion.",
"reactions_show_label": "Näytä reaktiot",
"show_hand_raised_timer_description": "Näytä ajastin, kun osallistuja nostaa kätensä",
"show_hand_raised_timer_label": "Näytä kädennoston kesto"
}
},
"star_rating_input_label_one": "{{count}} tähti",
"star_rating_input_label_other": "{{count}} tähteä",
"start_new_call": "Aloita uusi puhelu",
"start_video_button_label": "Aloita video",
"stop_screenshare_button_label": "Jaetaan näyttöä",
"stop_video_button_label": "Lopeta video",
"submitting": "Lähetetään…",
"switch_camera": "Vaihda kameraa",
"unauthenticated_view_body": "Etkö ole vielä rekisteröitynyt? <2>Luo tili</2>",
"unauthenticated_view_login_button": "Kirjaudu tilillesi",
"unauthenticated_view_ssla_caption": "Klikkaamalla \"Siirry\" hyväksyt <2>ohjelmisto- ja palvelulisenssisopimuksen (SSLA)</2>",
"unmute_microphone_button_label": "Poista mikrofonin mykistys",
"version": "{{productName}} versio: {{version}}",
"video_tile": {
"always_show": "Näytä aina",
"camera_starting": "Videota ladataan...",
"change_fit_contain": "Sovita kehykseen",
"collapse": "Supista",
"expand": "Laajenna",
"mute_for_me": "Mykistä minulle",
"muted_for_me": "Mykistetty minulle",
"volume": "Äänenvoimakkuus",
"waiting_for_media": "Odotetaan mediaa..."
}
}

View File

@@ -5,14 +5,21 @@
"action": {
"close": "Tutup",
"copy_link": "Salin tautan",
"edit": "Sunting",
"go": "Bergabung",
"invite": "Undang",
"lower_hand": "Turunkan tangan",
"no": "Tidak",
"pick_reaction": "Pilih reaksi",
"raise_hand": "Angkat tangan",
"register": "Daftar",
"remove": "Hapus",
"show_less": "Tampilkan lebih sedikit",
"show_more": "Tampilkan lebih banyak",
"sign_in": "Masuk",
"sign_out": "Keluar",
"submit": "Kirim"
"submit": "Kirim",
"upload_file": "Unggah berkas"
},
"analytics_notice": "Dengan bergabung dalam beta ini, Anda mengizinkan kami untuk mengumpulkan data anonim, yang kami gunakan untuk meningkatkan produk ini. Anda dapat mempelajari lebih lanjut tentang data apa yang kami lacak dalam <2>Kebijakan Privasi</2> dan <5>Kebijakan Kuki</5> kami.",
"app_selection_modal": {
@@ -33,17 +40,69 @@
},
"call_name": "Nama panggilan",
"common": {
"analytics": "Analitik",
"audio": "Audio",
"avatar": "Avatar",
"back": "Kembali",
"display_name": "Nama tampilan",
"encrypted": "Terenkripsi",
"home": "Beranda",
"loading": "Memuat…",
"next": "Berikutnya",
"options": "Opsi",
"password": "Kata sandi",
"preferences": "Preferensi",
"profile": "Profil",
"reaction": "Reaksi",
"reactions": "Reaksi",
"settings": "Pengaturan",
"unencrypted": "Tidak terenkripsi",
"username": "Nama pengguna"
"username": "Nama pengguna",
"video": "Video"
},
"developer_mode": {
"crypto_version": "Versi kripto: {{version}}",
"debug_tile_layout_label": "Awakutu tata letak ubin",
"device_id": "ID perangkat: {{id}}",
"duplicate_tiles_label": "Jumlah salinan ubin tambahan per peserta",
"environment_variables": "Variabel lingkungan",
"hostname": "Nama hos: {{hostname}}",
"livekit_server_info": "Info Server LiveKit",
"livekit_sfu": "SFU LiveKit: {{url}}",
"matrix_id": "ID Matrix: {{id}}",
"show_connection_stats": "Tampilkan statistik koneksi",
"show_non_member_tiles": "Tampilkan ubin untuk media non-anggota",
"url_params": "Parameter URL",
"use_new_membership_manager": "Gunakan implementasi baru dari panggilan MembershipManager",
"use_to_device_key_transport": "Gunakan untuk transportasi kunci perangkat. Ini akan kembali ke transportasi kunci ruangan ketika anggota panggilan lain mengirim kunci ruangan"
},
"disconnected_banner": "Koneksi ke server telah hilang.",
"error": {
"call_is_not_supported": "Panggilan tidak didukung",
"call_not_found": "Panggilan tidak ditemukan",
"call_not_found_description": "<0>Tautan itu tampaknya bukan milik panggilan yang ada. Periksa apakah Anda memiliki tautan yang tepat, atau <1> buat yang baru</1>.</0>",
"connection_lost": "Koneksi terputus",
"connection_lost_description": "Anda terputus dari panggilan.",
"e2ee_unsupported": "Peramban tidak kompatibel",
"e2ee_unsupported_description": "Peramban web Anda tidak mendukung panggilan terenkripsi. Peramban yang didukung meliputi Chrome, Safari, dan Firefox 117+.",
"generic": "Ada yang salah",
"generic_description": "Mengirimkan log awakutu akan membantu kami melacak masalah.",
"insufficient_capacity": "Kapasitas tidak mencukupi",
"insufficient_capacity_description": "Server telah mencapai kapasitas maksimum dan Anda tidak dapat bergabung dalam panggilan saat ini. Coba lagi nanti, atau hubungi admin server Anda jika masalah masih berlanjut.",
"matrix_rtc_focus_missing": "Server tidak dikonfigurasi untuk bekerja dengan {{brand}}. Silakan hubungi admin server Anda (Domain: {{domain}}, Kode Kesalahan: {{ errorCode }}).",
"open_elsewhere": "Dibuka di tab lain",
"open_elsewhere_description": "{{brand}} telah dibuka di tab lain. Jika sepertinya tidak benar, coba muat ulang halaman.",
"unexpected_ec_error": "Terjadi kesalahan tak terduga (<0> Kode Kesalahan:</0><1>{{ errorCode }}</1>). Silakan hubungi admin server Anda."
},
"group_call_loader": {
"banned_body": "Anda telah dilarang dari ruangan.",
"banned_heading": "Dilarang",
"call_ended_body": "Anda telah dikeluarkan dari panggilan.",
"call_ended_heading": "Panggilan berakhir",
"knock_reject_body": "Permintaan Anda untuk bergabung ditolak.",
"knock_reject_heading": "Akses ditolak",
"reason": "Alasan: {{reason}}"
},
"hangup_button_label": "Akhiri panggilan",
"header_label": "Beranda Element Call",
"header_participants_label": "Peserta",
@@ -59,15 +118,23 @@
"layout_grid_label": "Kisi",
"layout_spotlight_label": "Sorotan",
"lobby": {
"ask_to_join": "Permintaan untuk bergabung dalam panggilan",
"join_as_guest": "Bergabung sebagai tamu",
"join_button": "Bergabung ke panggilan",
"leave_button": "Kembali ke terkini"
"leave_button": "Kembali ke terkini",
"waiting_for_invite": "Permintaan terkirim! Menunggu izin untuk bergabung…"
},
"log_in": "Masuk",
"logging_in": "Memasuki…",
"login_auth_links": "<0>Buat akun</0> Atau <2>Akses sebagai tamu</2>",
"login_auth_links_prompt": "Belum terdaftar?",
"login_subheading": "Untuk melanjutkan ke Element",
"login_title": "Masuk",
"microphone_off": "Mikrofon dimatikan",
"microphone_on": "Mikrofon dinyalakan",
"mute_microphone_button_label": "Matikan mikrofon",
"participant_count_other": "{{count, number}}",
"qr_code": "Kode QR",
"rageshake_button_error_caption": "Kirim ulang catatan",
"rageshake_request_modal": {
"body": "Pengguna yang lain di panggilan ini sedang mengalami masalah. Supaya dapat mendiagnosa masalah ini, kami ingin mengumpulkan sebuah catatan pengawakutuan.",
@@ -79,23 +146,55 @@
"rageshake_sent": "Terima kasih!",
"recaptcha_dismissed": "Recaptcha ditutup",
"recaptcha_not_loaded": "Recaptcha tidak dimuat",
"recaptcha_ssla_caption": "Situs ini dilindungi oleh ReCAPTCHA dan <2>Kebijakan Privasi</2> dan <6>Ketentuan Layanan</6> Google berlaku.<9></9>Dengan mengeklik \"Daftar\", Anda menyetujui <12>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami</12>",
"register": {
"passwords_must_match": "Kata sandi harus cocok",
"registering": "Mendaftarkan…"
},
"register_auth_links": "<0>Sudah punya akun?</0><1><0>Masuk</0> Atau <2>Akses sebagai tamu</2></1>",
"register_confirm_password_label": "Konfirmasi kata sandi",
"register_heading": "Buat akun Anda",
"return_home_button": "Kembali ke layar beranda",
"room_auth_view_continue_button": "Lanjutkan",
"room_auth_view_ssla_caption": "Dengan mengeklik “Gabung panggilan sekarang”, Anda menyetujui <2>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami</2>",
"screenshare_button_label": "Bagikan layar",
"settings": {
"audio_tab": {
"effect_volume_description": "Sesuaikan volume saat reaksi dan efek tangan terangkat diputar.",
"effect_volume_label": "Volume efek suara"
},
"background_blur_header": "Latar belakang",
"background_blur_label": "Buramkan latar belakang video",
"blur_not_supported_by_browser": "(Pemburaman latar belakang tidak didukung oleh perangkat ini.)",
"developer_tab_title": "Pengembang",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"default": "Bawaan",
"default_named": "Bawaan <2>({{name}})</2>",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Speaker",
"speaker_numbered": "Speaker {{n}}"
},
"feedback_tab_body": "Jika Anda mengalami masalah atau hanya ingin memberikan masukan, silakan kirimkan kami deskripsi pendek di bawah.",
"feedback_tab_description_label": "Masukan Anda",
"feedback_tab_h4": "Kirim masukan",
"feedback_tab_send_logs_label": "Termasuk catatan pengawakutuan",
"feedback_tab_thank_you": "Terima kasih, kami telah menerima masukan Anda!",
"feedback_tab_title": "Masukan",
"opt_in_description": "<0></0><1></1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan."
"opt_in_description": "<0></0><1></1>Anda dapat mengurungkan kembali izin dengan mencentang kotak ini. Jika Anda saat ini dalam panggilan, pengaturan ini akan diterapkan di akhir panggilan.",
"preferences_tab": {
"developer_mode_label": "Mode pengembang",
"developer_mode_label_description": "Aktifkan mode pengembang dan tampilkan tab pengaturan pengembang.",
"introduction": "Di sini Anda dapat mengonfigurasi opsi tambahan untuk pengalaman yang lebih baik.",
"reactions_play_sound_description": "Mainkan efek suara ketika seseorang mengirim reaksi ke panggilan.",
"reactions_play_sound_label": "Putar suara reaksi",
"reactions_show_description": "Tampilkan animasi ketika ada yang mengirimkan reaksi.",
"reactions_show_label": "Tampilkan reaksi",
"show_hand_raised_timer_description": "Tampilkan pengatur waktu saat peserta mengangkat tangannya",
"show_hand_raised_timer_label": "Tampilkan durasi angkat tangan"
}
},
"star_rating_input_label_one": "{{count}} bintang",
"star_rating_input_label_other": "{{count}} bintang",
@@ -104,8 +203,21 @@
"stop_screenshare_button_label": "Berbagi layar",
"stop_video_button_label": "Matikan video",
"submitting": "Mengirim…",
"switch_camera": "Ganti kamera",
"unauthenticated_view_body": "Belum terdaftar? <2>Buat sebuah akun</2>",
"unauthenticated_view_login_button": "Masuk ke akun Anda",
"unauthenticated_view_ssla_caption": "Dengan mengeklik \"Go\", Anda menyetujui <2>Perjanjian Lisensi Perangkat Lunak dan Layanan (SSLA) kami</2>",
"unmute_microphone_button_label": "Nyalakan mikrofon",
"version": "Versi: {{version}}"
"version": "Versi: {{version}}",
"video_tile": {
"always_show": "Selalu tampilkan",
"camera_starting": "Memuat video...",
"change_fit_contain": "Sesuai dengan bingkai",
"collapse": "Tutup",
"expand": "Buka",
"mute_for_me": "Bisukan untuk saya",
"muted_for_me": "Dibisukan untuk saya",
"volume": "Volume",
"waiting_for_media": "Menunggu media..."
}
}

View File

@@ -4,15 +4,30 @@
},
"action": {
"close": "Aizvērt",
"copy_link": "Kopēt saiti",
"edit": "Labot",
"go": "Aiziet",
"invite": "Uzaicināt",
"lower_hand": "Nolaist roku",
"no": "Nē",
"pick_reaction": "Reaģēt",
"raise_hand": "Pacelt roku",
"register": "Reģistrēties",
"remove": "Noņemt",
"show_less": "Rādīt mazāk",
"show_more": "Rādīt vairāk",
"sign_in": "Pieteikties",
"sign_out": "Atteikties",
"submit": "Iesniegt"
"submit": "Iesniegt",
"upload_file": "Augšupielādēt failu"
},
"analytics_notice": "Piedaloties šajā beta versijā, jūs piekrītat anonīmu datu vākšanai, ko mēs izmantojam produkta uzlabošanai. Plašāku informāciju par to, kādus datus mēs izsekojam, varat atrast mūsu <2>konfidencialitātes politikā</2> un mūsu <6>sīkfailu politikā</6>.",
"app_selection_modal": {
"continue_in_browser": "Turpināt pārlūkprogrammā",
"open_in_app": "Atvērt lietotnē",
"text": "Gatavs pievienoties?",
"title": "Izvēlies lietotni"
},
"analytics_notice": "Piedalīšanās šajā beta apliecina piekrišanu anonīmu datu ievākšanai, ko mēs izmantojam, lai uzlabotu izstrādājumu. Vairāk informācijas par datiem, ko mēs ievācam, var atrast mūsu <2>privātuma nosacījumos</2> un <5>sīkdatņu nosacījumos</5>.",
"call_ended_view": {
"create_account_button": "Izveidot kontu",
"create_account_prompt": "<0>Kādēļ nepabeigt ar paroles iestatīšanu, lai paturētu savu kontu?</0><1>Būs iespējams paturēt savu vārdu un iestatīt attēlu izmantošanai turpmākajos zvanos</1>",
@@ -23,31 +38,104 @@
"reconnect_button": "Atkārtoti savienoties",
"survey_prompt": "Kā Tev veicās?"
},
"call_name": "Zvana nosaukums",
"common": {
"analytics": "Analītika",
"audio": "Skaņa",
"avatar": "Attēls",
"back": "Atpakaļ",
"display_name": "Attēlojamais vārds",
"encrypted": "Šifrēts",
"home": "Sākums",
"loading": "Lādējas…",
"next": "Nākamais",
"options": "Opcijas",
"password": "Parole",
"preferences": "Iestatījumi",
"profile": "Profils",
"reaction": "Reakcija",
"reactions": "Reakcijas",
"settings": "Iestatījumi",
"username": "Lietotājvārds"
"unencrypted": "Nav šifrēts",
"username": "Lietotājvārds",
"video": "Video"
},
"developer_mode": {
"crypto_version": "Crypto versija: {{version}}",
"debug_tile_layout_label": "Vietu izkārtojuma atkļūdošana",
"device_id": "Ierīces ID: {{id}}",
"duplicate_tiles_label": "Papildu vietu kopiju skaits vienam dalībniekam",
"environment_variables": "Vides mainīgie",
"hostname": "Saimniekdatora nosaukums: {{hostname}}",
"livekit_server_info": "LiveKit Server informācija",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"show_connection_stats": "Rādīt savienojuma statistiku",
"show_non_member_tiles": "Rādīt vietu medijiem no ne-dalībniekiem",
"url_params": "URL parametri",
"use_new_membership_manager": "Izmantojiet jauno zvana MembershipManager versiju"
},
"disconnected_banner": "Ir zaudēts savienojums ar serveri.",
"error": {
"call_is_not_supported": "Zvans netiek atbalstīts",
"call_not_found": "Zvans nav atrasts",
"call_not_found_description": "<0>Šķiet, ka šī saite nepieder nevienam esošam zvaniem. Pārbaudiet, vai jums ir pareizā saite, vai <1> izveidojiet jaunu</1>. </0>",
"connection_lost": "Savienojums zaudēts",
"connection_lost_description": "Jūs tikāt atvienots no zvana.",
"e2ee_unsupported": "Nesaderīgs pārlūks",
"e2ee_unsupported_description": "Jūsu tīmekļa pārlūkprogramma neatbalsta encrypted zvanus. Atbalstītās pārlūkprogrammas ir Chrome, Safari un Firefox 117+.",
"generic": "Kaut kas nogāja greizi",
"generic_description": "Atkļūdošanas žurnālu iesniegšana palīdzēs mums izsekot problēmu.",
"insufficient_capacity": "Nepietiekama jauda",
"insufficient_capacity_description": "Serveris ir sasniedzis maksimālo ietilpību, un jūs šobrīd nevarat pievienoties zvanam. Mēģiniet vēlreiz vēlāk vai sazinieties ar servera administratoru, ja problēma joprojām pastāv.",
"matrix_rtc_focus_missing": "Serveris nav konfigurēts darbam ar{{brand}}. Lūdzu, sazinieties ar sava servera administratoru (Domēns: {{domain}}, Kļūdas kods: {{ errorCode }}).",
"open_elsewhere": "Atvērts citā cilnē",
"open_elsewhere_description": "{{brand}} ir atvērts citā cilnē. Ja tas neizklausās pareizi, mēģiniet atkārtoti ielādēt lapu.",
"unexpected_ec_error": "Negaidīta kļūda (<0>kļūdas kods: </0> <1> {{ errorCode }}</1>). Lūdzu, sazinieties ar servera administratoru."
},
"group_call_loader": {
"banned_body": "Jums ir liegta ieeja šajā istabā.",
"banned_heading": "Aizliegts",
"call_ended_body": "Jūs esat noņemts no zvana.",
"call_ended_heading": "Zvans beidzies",
"knock_reject_body": "Jūsu pieteikums pievienoties tika noraidīts.",
"knock_reject_heading": "Piekļuve liegta",
"reason": "Iemesls: {{reason}}"
},
"hangup_button_label": "Beigt zvanu",
"header_label": "Element Call sākums",
"header_participants_label": "Dalībnieki",
"invite_modal": {
"link_copied_toast": "Saite nokopēta",
"title": "Uzaicināt uz šo zvanu"
},
"join_existing_call_modal": {
"join_button": "Jā, pievienoties zvanam",
"text": "Šis zvans jau pastāv. Vai vēlies pievienoties?",
"title": "Pievienoties esošam zvanam?"
},
"layout_grid_label": "Režģis",
"layout_spotlight_label": "Starmešu gaisma",
"lobby": {
"join_button": "Pievienoties zvanam"
"ask_to_join": "Pieprasīt pievienoties zvanam",
"join_as_guest": "Pievienojies kā viesis",
"join_button": "Pievienoties zvanam",
"leave_button": "Atpakaļ uz jaunākajiem",
"waiting_for_invite": "Pieprasījums nosūtīts! Gaida atļauju pievienoties..."
},
"log_in": "Pieslēgties",
"logging_in": "Piesakās…",
"login_auth_links": "<0>Izveidot kontu</0> vai <2>Piekļūt kā viesim</2>",
"login_auth_links_prompt": "Vēl neesi reģistrējies?",
"login_subheading": "Lai turpinātu uz Element",
"login_title": "Pieteikties",
"microphone_off": "Mikrofons izslēgts",
"microphone_on": "Mikrofons ieslēgts",
"mute_microphone_button_label": "Izslēgt mikrofonu",
"participant_count_zero": "{{count, number}}",
"participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"qr_code": "QR kods",
"rageshake_button_error_caption": "Atkārtoti mēģināt žurnāla ierakstu nosūtīšanu",
"rageshake_request_modal": {
"body": "Citam lietotājam šajā zvanā ir sarežģījumi. Lai labāk atklātu šīs nepilnības, mēs gribētu iegūt atkļūdošanas žurnālu.",
@@ -59,28 +147,76 @@
"rageshake_sent": "Paldies!",
"recaptcha_dismissed": "ReCaptcha atmesta",
"recaptcha_not_loaded": "ReCaptcha nav ielādēta",
"recaptcha_ssla_caption": "Šo vietni aizsargā ReCAPTCHA un tiek piemēroti Google <2>konfidencialitātes politika</2> un <6>Pakalpojuma noteikumi</6>.<9></9>Noklikšķinot uz \"Reģistrēties\", jūs piekrītat mūsu <12>Programmatūras un pakalpojumu licences līgumam (SSLA)</12>",
"register": {
"passwords_must_match": "Parolēm ir jāsakrīt",
"registering": "Reģistrē…"
},
"register_auth_links": "<0>Jau ir konts?</0><1><0>Pieteikties</0> vai <2>Piekļūt kā viesim</2></1>",
"register_confirm_password_label": "Apstiprināt paroli",
"register_heading": "Izveido kontu",
"return_home_button": "Atgriezties sākuma ekrānā",
"room_auth_view_continue_button": "Turpināt",
"room_auth_view_ssla_caption": "Noklikšķinot uz “Pievienoties zvanam tūlīt”, jūs piekrītat mūsu <2> programmatūras un pakalpojumu licences līgumam (SSLA) </2>",
"screenshare_button_label": "Kopīgot ekrānu",
"settings": {
"audio_tab": {
"effect_volume_description": "Pielāgojiet skaļumu, kurā tiek atskaņotas reakcijas un paceltas rokas skaņas.",
"effect_volume_label": "Skaņas efektu skaļums"
},
"developer_tab_title": "Izstrādātājs",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"default": "Noklusējums",
"default_named": "Noklusējums <2> ({{name}} )</2>",
"microphone": "Mikrofons",
"microphone_numbered": "Mikrofons {{n}}",
"speaker": "Skaļrunis",
"speaker_numbered": "Skaļrunis {{n}}"
},
"feedback_tab_body": "Ja tiek piedzīvoti sarežģījumi vai vienkārši ir vēlme sniegt kādu atsauksmi, lūgums zemāk nosūtīt mums īsu aprakstu.",
"feedback_tab_description_label": "Tava atsauksme",
"feedback_tab_h4": "Iesniegt atsauksmi",
"feedback_tab_send_logs_label": "Iekļaut atkļūdošanas žurnāla ierakstus",
"feedback_tab_thank_you": "Paldies, mēs saņēmām atsauksmi!",
"feedback_tab_title": "Atsauksmes",
"opt_in_description": "<0></0><1></1>Savu piekrišanu var atsaukt ar atzīmes noņemšanu no šīs rūtiņas. Ja pašreiz atrodies zvanā, šis iestatījums stāsies spēkā zvana beigās."
"opt_in_description": "<0></0><1></1>Savu piekrišanu var atsaukt ar atzīmes noņemšanu no šīs rūtiņas. Ja pašreiz atrodies zvanā, šis iestatījums stāsies spēkā zvana beigās.",
"preferences_tab": {
"developer_mode_label": "Izstrādātāja režīms",
"developer_mode_label_description": "Iespējot izstrādātāja režīmu un rādīt cilni izstrādātāja iestatījumi.",
"introduction": "Šeit varat konfigurēt papildu opcijas, lai uzlabotu pieredzi.",
"reactions_play_sound_description": "Atskaņojiet skaņas efektu, kad kāds sūta reakciju uz zvanu.",
"reactions_play_sound_label": "Atskaņojiet reakcijas skaņas",
"reactions_show_description": "Rādīt animāciju, kad kāds nosūta reakciju.",
"reactions_show_label": "Rādīt reakcijas",
"show_hand_raised_timer_description": "Rādīt taimeri, kad dalībnieks paceļ roku",
"show_hand_raised_timer_label": "Rādīt rokas pacelšanas ilgumu"
}
},
"star_rating_input_label_zero": "{{count}} zvaigznes",
"star_rating_input_label_one": "{{count}} zvaigzne",
"star_rating_input_label_other": "{{count}} zvaigznes",
"start_new_call": "Sākt jaunu zvanu",
"start_video_button_label": "Sākt video",
"stop_screenshare_button_label": "Kopīgo ekrānu",
"stop_video_button_label": "Apturēt video",
"submitting": "Iesniedz…",
"switch_camera": "Pārslēgt kameru",
"unauthenticated_view_body": "Vēl neesi reģistrējies? <2>Izveidot kontu</2>",
"unauthenticated_view_login_button": "Pieteikties kontā",
"version": "Versija: {{version}}"
"unauthenticated_view_ssla_caption": "Noklikšķinot uz \"Aiziet\", jūs piekrītat mūsu <2>Programmatūras un pakalpojumu licences līgumam (SSLA)</2>",
"unmute_microphone_button_label": "Ieslēgt mikrofonu",
"version": "{{productName}} versija: {{version}}",
"video_tile": {
"always_show": "Vienmēr rādīt",
"camera_starting": "Video ielāde...",
"change_fit_contain": "Pielāgot rāmim",
"collapse": "Sakļaut",
"expand": "Izvērst",
"mute_for_me": "Klusums man",
"muted_for_me": "Man izslēgts",
"volume": "Skaļums",
"waiting_for_media": "Gaida medijus..."
}
}

View File

@@ -4,20 +4,20 @@
},
"action": {
"close": "Închide",
"copy_link": "Copia linkul",
"copy_link": "Copiaţi linkul",
"edit": "Editare",
"go": "Du-te",
"invite": "Invită",
"lower_hand": "Mâna inferioară",
"no": "No",
"no": "Nu",
"pick_reaction": "Alegeți reacția",
"raise_hand": "Ridicați mâna",
"register": "Inregistrare",
"register": "Creaţi un cont",
"remove": "elimina",
"show_less": "Arată mai puțin",
"show_more": "Arată mai mult",
"sign_in": "Autentificare",
"sign_out": "Sign out",
"sign_out": "Deconecta-ţi-vă",
"submit": "Trimiteți",
"upload_file": "Încărcați fișierul"
},
@@ -26,27 +26,27 @@
"continue_in_browser": "Continuați în browser",
"open_in_app": "Deschideți în aplicație",
"text": "Sunteți gata să vă alăturați?",
"title": "Selectați aplicația"
"title": "Selectați o aplicație"
},
"call_ended_view": {
"create_account_button": "Creează cont",
"create_account_button": "Creaţi un cont",
"create_account_prompt": "<0>De ce să nu terminați prin configurarea unei parole pentru a vă păstra contul? </0><1>Veți putea să vă păstrați numele și să setați un avatar pentru a fi utilizat la apelurile viitoare </1>",
"feedback_done": "<0>Vă mulțumim pentru feedback! </0>",
"feedback_prompt": "<0>Ne-ar plăcea să auzim feedback-ul dvs., astfel încât să vă putem îmbunătăți experiența. </0>",
"headline": "{{displayName}}, apelul tău s-a încheiat.",
"not_now_button": "Nu acum, reveniți la ecranul de pornire",
"reconnect_button": "Reconecta",
"survey_prompt": "Cum a mers?"
"not_now_button": "Nu acum, reveniți la ecranul principal",
"reconnect_button": "Reconectaţi-vă",
"survey_prompt": "Cum a fost?"
},
"call_name": "Numele apelului",
"common": {
"analytics": "Analiză",
"audio": "Audio",
"avatar": "avatar",
"avatar": "Imaginea de profil",
"back": "Înapoi",
"display_name": "Nume afișat",
"encrypted": "Criptat",
"home": "Acasa",
"home": "Acasă",
"loading": "Se încarcă...",
"next": "Urmator\n",
"options": "Opțiuni",
@@ -55,19 +55,43 @@
"profile": "Profil",
"reaction": "Reacție",
"reactions": "Reacții",
"settings": "Settings",
"settings": "Setări",
"unencrypted": "Nu este criptat",
"username": "Nume utilizator",
"video": "Videoclip"
"username": "Numele utilizatorului",
"video": "Video"
},
"developer_mode": {
"crypto_version": "Versiunea Crypto: {{version}}",
"debug_tile_layout_label": "Depanaţi aranjamentul cartonaşelor",
"device_id": "ID-ul dispozitivului: {{id}}",
"duplicate_tiles_label": "Numărul de exemplare suplimentare de cartonașe per participant",
"environment_variables": "Variabile de mediu",
"hostname": "Numele gazdei: {{hostname}}",
"matrix_id": "ID-ul matricei: {{id}}"
"matrix_id": "ID-ul matricei: {{id}}",
"show_connection_stats": "Afişaţi informaţii cu privire la starea conexiunii",
"show_non_member_tiles": "Afişaţi pictograme pentru fluxul media care nu aparţine participanţilor apelului",
"url_params": "Parametrii linkului",
"use_new_membership_manager": "Folosiţi noua versiune de administrator pentru participanţi ai apelului",
"use_to_device_key_transport": "Folosiţi metoda de transport direct către dispozitiv. Aceasta va reveni la transportul prin intermediul evenimentelor din cameră doar dacă un alt participant la apel recurge la acel mod de transport mai întâi."
},
"disconnected_banner": "Conexiunea către server s-a încheiat abrupt",
"error": {
"call_is_not_supported": "Acest tip de apel nu este suportat",
"call_not_found": "Apelul nu a fost găsit",
"call_not_found_description": "<0>Acel link nu pare să aparţină unui apel existent. Verificaţi dacă aţi introdus linkul corect, sau <1>creaţi unul nou</1>.</0>",
"connection_lost": "Conexiunea s-a pierdut",
"connection_lost_description": "Aţi fost deconectat/ă de la apel",
"e2ee_unsupported": "Navigatorul dumneavoastră este incompatibil cu criptarea integrală",
"e2ee_unsupported_description": "Navigatorul/browserul dumneavoastră web nu suportă apeluri în conversaţii cu criptare integrală. Printre navigatoarele suportate, se numără Chrome, Safari şi Firefox 117+.",
"generic": "A apărut o eroare neaşteptată",
"generic_description": "Dacă ne trimiteţi jurnalele de depanare generate de aplicaţie, ne puteţi ajuta să rezolvăm problema.",
"insufficient_capacity": "Capacitate insuficientă",
"insufficient_capacity_description": "Serverul a ajuns la capacitatea maximă și nu vă puteți alătura apelului în acest moment. Încercați din nou in câteva minute, sau contactați administratorul serverului dumneavoastră dacă problema persistă.",
"matrix_rtc_focus_missing": "Serverul nu este configurat să funcționeze cu{{brand}}. Vă rugăm să contactați administratorul serverului dumneavoastră pentru a raporta o eroare în configurare. Detalii: Domeniu: {{domain}}. Cod de eroare: {{ errorCode }}.",
"open_elsewhere": "Aplicaţia este deschisă intr-o altă pagină",
"open_elsewhere_description": "{{brand}} a fost deschis într-o altă pagină. Dacă credeți că acest mesaj a fost emis in eroare, încercați să reîncărcați pagina.",
"unexpected_ec_error": "A apărut o eroare neașteptată (Cod de <0> eroare: </0> <1> {{ errorCode }}</1>). Vă rugăm să contactați administratorul serverului dumneavoastră."
},
"disconnected_banner": "Conectivitatea la server a fost pierdută.",
"group_call_loader": {
"banned_body": "Ai fost interzis să ieși din cameră.",
"banned_heading": "Interzis",
@@ -135,6 +159,10 @@
"effect_volume_label": "Volumul efectului sonor"
},
"developer_tab_title": "dezvoltator",
"devices": {
"microphone": "Microfon",
"speaker": "Difuzor"
},
"feedback_tab_body": "Dacă întâmpinați probleme sau pur și simplu doriți să oferiți feedback, vă rugăm să ne trimiteți o scurtă descriere mai jos.",
"feedback_tab_description_label": "Feedback-ul tău",
"feedback_tab_h4": "Trimiteți Feedback",
@@ -143,6 +171,8 @@
"feedback_tab_title": "Feedback",
"opt_in_description": "<0></0><1></1>Puteți retrage consimțământul debifând această casetă. Dacă sunteți în prezent la un apel, această setare va intra în vigoare la sfârșitul apelului.",
"preferences_tab": {
"developer_mode_label": "Modul dezvoltator",
"developer_mode_label_description": "Activați modul dezvoltator și afișați pagina specifică setărilor pentru dezvoltatori",
"introduction": "Aici puteți configura opțiuni suplimentare pentru o experiență îmbunătățită.",
"reactions_play_sound_description": "Redați un efect sonor atunci când cineva trimite o reacție la un apel.",
"reactions_play_sound_label": "Redați sunete de reacție",
@@ -164,11 +194,13 @@
"version": "{{productName}}Versiune: {{version}}",
"video_tile": {
"always_show": "Arată întotdeauna",
"camera_starting": "Se încarcă fluxul video...",
"change_fit_contain": "Se potrivește cadrului",
"collapse": "colaps",
"expand": "Extindeți",
"mute_for_me": "Mute pentru mine",
"muted_for_me": "Dezactivat pentru mine",
"volume": "VOLUM"
"volume": "VOLUM",
"waiting_for_media": "Flux multimedia în aşteptare"
}
}

View File

@@ -21,7 +21,7 @@
"submit": "Отправить",
"upload_file": "Загрузить файл"
},
"analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Более подробную информацию о том, какие данные мы отслеживаем, вы можете найти в нашей <2> Политике конфиденциальности</2> и нашей <5> Политике использования файлов cookie</5>.",
"analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Дополнительную информацию о том, какие данные мы отслеживаем, можно найти в нашей <2> Политике конфиденциальности </2> и Политике <6> использования файлов cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Продолжить в браузере",
"open_in_app": "Открыть в приложении",
@@ -61,17 +61,22 @@
"video": "Видео"
},
"developer_mode": {
"always_show_iphone_earpiece": "Показать опцию наушников для iPhone на всех платформах",
"crypto_version": "Версия криптографии: {{version}}",
"debug_tile_layout_label": "Отладка расположения плиток",
"device_id": "Идентификатор устройства: {{id}}",
"duplicate_tiles_label": "Количество дополнительных копий плиток на участника",
"environment_variables": "Переменные окружения",
"hostname": "Имя хоста: {{hostname}}",
"livekit_server_info": "Информация о сервере LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Отключить все звуки (участников, реакции, звуки присоединения)",
"show_connection_stats": "Показать статистику подключений",
"show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками",
"use_new_membership_manager": "Используйте новую реализацию вызова MembershipManager"
"url_params": "Параметры URL-адреса",
"use_new_membership_manager": "Используйте новую реализацию вызова MembershipManager",
"use_to_device_key_transport": "Используйте для передачи ключей устройства. Это позволит вернуться к передаче ключей комнаты, когда другой участник вызова отправит ключ комнаты"
},
"disconnected_banner": "Связь с сервером была потеряна.",
"error": {
@@ -98,7 +103,7 @@
"call_ended_heading": "Вызов завершен",
"knock_reject_body": "Участники комнаты отклонили ваш запрос на присоединение.",
"knock_reject_heading": "Не разрешено присоединиться",
"reason": "Причина"
"reason": "Причина: {{reason}}"
},
"hangup_button_label": "Завершить звонок",
"header_label": "Главная Element Call",
@@ -145,6 +150,7 @@
"rageshake_sent": "Спасибо!",
"recaptcha_dismissed": "Проверка не пройдена",
"recaptcha_not_loaded": "Невозможно начать проверку",
"recaptcha_ssla_caption": "Этот сайт защищен reCAPTCHA, и к нему применяются <2> Политика конфиденциальности </2> и <6> Условия обслуживания Google. </6> <9></9>Нажимая «Зарегистрироваться», вы соглашаетесь с нашим лицензионным соглашением на <12> программное обеспечение и услуги (SSLA) </12>",
"register": {
"passwords_must_match": "Пароли должны совпадать",
"registering": "Регистрация…"
@@ -154,16 +160,21 @@
"register_heading": "Создать учетную запись",
"return_home_button": "Вернуться в Начало",
"room_auth_view_continue_button": "Продолжить",
"room_auth_view_ssla_caption": "Нажимая кнопку \"Присоединиться к звонку\", вы соглашаетесь с нашим <2>Лицензионное соглашение на программное обеспечение и услуги (SSLA)</2>",
"screenshare_button_label": "Поделиться экраном",
"settings": {
"audio_tab": {
"effect_volume_description": "Отрегулируйте громкость воспроизведения реакций и эффектов поднятия руки.",
"effect_volume_label": "Громкость звукового эффекта"
},
"background_blur_header": "Фон",
"background_blur_label": "Размытие фона видео",
"blur_not_supported_by_browser": "(Размытие фона не поддерживается этим устройством.)",
"developer_tab_title": "Разработчику",
"devices": {
"camera": "Камера",
"camera_numbered": "Камера {{n}}",
"change_device_button": "Изменить аудиоустройство",
"default": "По умолчанию",
"default_named": "По умолчанию <2>({{name}})</2>",
"microphone": "Микрофон",
@@ -190,8 +201,9 @@
"show_hand_raised_timer_label": "Показать продолжительность поднятия руки"
}
},
"star_rating_input_label_one": "{{count}} отмечен",
"star_rating_input_label_other": "{{count}} отмеченных",
"star_rating_input_label_one": "{{count}} звезда",
"star_rating_input_label_few": "{{count}} звезды",
"star_rating_input_label_many": "{{count}} звезд",
"start_new_call": "Начать новый звонок",
"start_video_button_label": "Начать видео",
"stop_screenshare_button_label": "Демонстрация экрана",
@@ -200,8 +212,9 @@
"switch_camera": "Переключить камеру",
"unauthenticated_view_body": "Ещё не зарегистрированы? <2>Создайте аккаунт</2>",
"unauthenticated_view_login_button": "Войдите в свой аккаунт",
"unauthenticated_view_ssla_caption": "Нажимая «Перейти», вы соглашаетесь с нашим лицензионным соглашением на <2> программное обеспечение и услуги (SSLA) </2>",
"unmute_microphone_button_label": "Включить микрофон",
"version": "Версия: {{version}}",
"version": "{{productName}} версия: {{version}}",
"video_tile": {
"always_show": "Показывать всегда",
"camera_starting": "Загрузка видео...",

View File

@@ -21,7 +21,7 @@
"submit": "Odoslať",
"upload_file": "Nahrať súbor"
},
"analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <5>Zásadách používania súborov cookie</5>.",
"analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <6>Zásadách používania súborov cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Pokračovať v prehliadači",
"open_in_app": "Otvoriť v aplikácii",
@@ -61,23 +61,28 @@
"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}}",
"duplicate_tiles_label": "Počet ďalších kópií dlaždíc na účastníka",
"environment_variables": "Premenné prostredia",
"hostname": "Názov hostiteľa: {{hostname}}",
"livekit_server_info": "Informácie o serveri LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Stlmiť všetky zvuky (účastníkov, reakcií, zvuky pripojenia)",
"show_connection_stats": "Zobraziť štatistiky pripojenia",
"show_non_member_tiles": "Zobraziť dlaždice pre nečlenské médiá",
"use_new_membership_manager": "Použiť novú implementáciu hovoru MembershipManager"
"url_params": "Parametre URL adresy",
"use_new_membership_manager": "Použiť novú implementáciu hovoru MembershipManager",
"use_to_device_key_transport": "Používa sa na prenos kľúča zariadenia. Toto sa vráti k prenosu kľúča miestnosti, keď iný účastník hovoru poslal kľúč od miestnosti"
},
"disconnected_banner": "Spojenie so serverom sa stratilo.",
"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č",
@@ -145,6 +150,7 @@
"rageshake_sent": "Ďakujeme!",
"recaptcha_dismissed": "Recaptcha zamietnutá",
"recaptcha_not_loaded": "Recaptcha sa nenačítala",
"recaptcha_ssla_caption": "Táto stránka je chránená reCAPTCHA a platia <2>Zásady ochrany osobných údajov</2> a <6>Podmienky služby</6> spoločnosti Google. <9></9>Kliknutím na tlačidlo „Registrovať“ súhlasíte s našou <12>Licenčnou zmluvou o softvéri a službách (SSLA)</12>",
"register": {
"passwords_must_match": "Heslá sa musia zhodovať",
"registering": "Registrácia…"
@@ -154,18 +160,24 @@
"register_heading": "Vytvorte si účet",
"return_home_button": "Návrat na domovskú obrazovku",
"room_auth_view_continue_button": "Pokračovať",
"room_auth_view_ssla_caption": "Kliknutím na „Pripojiť sa k hovoru teraz“ súhlasíte s našou <2>Licenčnou zmluvou o softvéri a službách (SSLA)</2>",
"screenshare_button_label": "Zdieľať obrazovku",
"settings": {
"audio_tab": {
"effect_volume_description": "Upraviť hlasitosť, pri ktorej sa prehrávajú reakcie a efekty zdvihnutia ruky.",
"effect_volume_label": "Hlasitosť zvukového efektu"
},
"background_blur_header": "Pozadie",
"background_blur_label": "Rozmazať pozadie videa",
"blur_not_supported_by_browser": "(Rozmazanie pozadia nie je podporované týmto zariadením.)",
"developer_tab_title": "Vývojár",
"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",
@@ -191,6 +203,7 @@
}
},
"star_rating_input_label_one": "{{count}} hviezdička",
"star_rating_input_label_few": "{{count}} hviezdičky",
"star_rating_input_label_other": "{{count}} hviezdičiek",
"start_new_call": "Spustiť nový hovor",
"start_video_button_label": "Spustiť video",
@@ -200,8 +213,9 @@
"switch_camera": "Prepnúť fotoaparát",
"unauthenticated_view_body": "Ešte nie ste zaregistrovaný? <2>Vytvorte si účet</2>",
"unauthenticated_view_login_button": "Prihláste sa do svojho konta",
"unauthenticated_view_ssla_caption": "Kliknutím na tlačidlo „Prejsť“ súhlasítes našou <2>Licenčnou zmluvou o softvéri a službách (SSLA)</2>",
"unmute_microphone_button_label": "Zrušiť stlmenie mikrofónu",
"version": "Verzia: {{version}}",
"version": "Verzia {{productName}}: {{version}}",
"video_tile": {
"always_show": "Vždy zobraziť",
"camera_starting": "Načítavanie videa...",

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,16 +71,18 @@
"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",
"use_new_membership_manager": "Använd den nya implementeringen av samtals-MembershipManager"
"use_new_membership_manager": "Använd den nya implementeringen av samtals-MembershipManager",
"use_to_device_key_transport": "Använd \"till enhet\"-nyckeltransport. Detta kommer att falla tillbaka till rumsnyckeltransport om en annan samtalsmedlem skickar en rumsnyckel."
},
"disconnected_banner": "Anslutningen till servern har brutits.",
"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",
@@ -91,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": {
@@ -102,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",
@@ -163,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

@@ -21,7 +21,7 @@
"submit": "Надіслати",
"upload_file": "Завантажити файл"
},
"analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <5>Політиці про куки</5>.",
"analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <6>Політиці про файли cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Продовжити у браузері",
"open_in_app": "Відкрити у застосунку",
@@ -194,8 +194,9 @@
"show_hand_raised_timer_label": "Показувати тривалість підняття руки"
}
},
"star_rating_input_label_one": "{{count}} зірок",
"star_rating_input_label_other": "{{count}} зірок",
"star_rating_input_label_one": "{{count}} зірка",
"star_rating_input_label_few": "{{count}} зірки",
"star_rating_input_label_many": "{{count}} зірок",
"start_new_call": "Розпочати новий виклик",
"start_video_button_label": "Розпочати відео",
"stop_screenshare_button_label": "Презентація екрана",
@@ -206,7 +207,7 @@
"unauthenticated_view_login_button": "Увійдіть до свого облікового запису",
"unauthenticated_view_ssla_caption": "Натискаючи \"Перейти\", ви погоджуєтеся з нашою <2>Ліцензійною угодою на програмне забезпечення та послуги (SSLA) </2>",
"unmute_microphone_button_label": "Увімкнути мікрофон",
"version": "Версія: {{version}}",
"version": "{{productName}} версія: {{version}}",
"video_tile": {
"always_show": "Показувати завжди",
"camera_starting": "Завантаження відео...",

View File

@@ -4,7 +4,9 @@
},
"action": {
"close": "关闭",
"copy_link": "复制链接",
"go": "开始",
"invite": "邀请",
"no": "否",
"register": "注册",
"remove": "移除",
@@ -31,12 +33,14 @@
},
"call_name": "通话名称",
"common": {
"analytics": "分析",
"audio": "音频",
"avatar": "头像",
"display_name": "显示名称",
"encrypted": "已加密",
"home": "主页",
"loading": "加载中……",
"options": "选项",
"password": "密码",
"profile": "个人信息",
"settings": "设置",
@@ -45,8 +49,20 @@
"video": "视频"
},
"disconnected_banner": "与服务器的连接中断。",
"group_call_loader": {
"banned_body": "你已被房间封禁",
"banned_heading": "已被封禁",
"call_ended_body": "你已被移出通话",
"call_ended_heading": "通话结束",
"knock_reject_body": "房间成员拒绝了你的加入请求"
},
"hangup_button_label": "通话结束",
"header_label": "Element Call主页",
"header_participants_label": "参与者",
"invite_modal": {
"link_copied_toast": "链接已复制到剪贴板",
"title": "邀请参加此次通话"
},
"join_existing_call_modal": {
"join_button": "是,加入通话",
"text": "该通话已存在,你想加入吗?",
@@ -58,12 +74,16 @@
"join_button": "加入通话",
"leave_button": "返回最近通话"
},
"log_in": "登录",
"logging_in": "登录中……",
"login_auth_links": "<0>创建账户</0> Or <2>以访客身份继续</2>",
"login_auth_links_prompt": "还未注册?",
"login_subheading": "继续使用 Element",
"login_title": "登录",
"microphone_off": "麦克风关闭",
"microphone_on": "麦克风开启",
"mute_microphone_button_label": "静音麦克风",
"participant_count_other": "{{count, number}}",
"rageshake_button_error_caption": "重传日志",
"rageshake_request_modal": {
"body": "这个通话中的另一个用户出现了问题。为了更好地诊断这些问题,我们想收集调试日志。",
@@ -81,6 +101,7 @@
},
"register_auth_links": "<0>已有账户?</0><1><0>登录</0> Or <2>以访客身份继续</2></1>",
"register_confirm_password_label": "确认密码",
"register_heading": "创建您的账户",
"return_home_button": "返回主页",
"screenshare_button_label": "屏幕共享",
"settings": {
@@ -103,5 +124,10 @@
"unauthenticated_view_body": "还没有注册? <2>创建账户<2>",
"unauthenticated_view_login_button": "登录你的账户",
"unmute_microphone_button_label": "取消麦克风静音",
"version": "版本:{{version}}"
"version": "版本:{{version}}",
"video_tile": {
"change_fit_contain": "贴合框架",
"mute_for_me": "为我静音",
"volume": "音量"
}
}

View File

@@ -8,7 +8,11 @@
"dev:embedded": "vite --config vite-embedded.config.js",
"build": "yarn build:full",
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
"build:full:production": "yarn build:full",
"build:full:development": "yarn build:full --mode development",
"build:embedded": "yarn build:full --config vite-embedded.config.js",
"build:embedded:production": "yarn build:embedded",
"build:embedded:development": "yarn build:embedded --mode development",
"serve": "vite preview",
"prettier:check": "prettier -c .",
"prettier:format": "prettier -w .",
@@ -22,6 +26,7 @@
"test": "vitest",
"test:coverage": "vitest --coverage",
"backend": "docker-compose -f dev-backend-docker-compose.yml up",
"backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up",
"test:playwright": "playwright test",
"test:playwright:open": "yarn test:playwright --ui",
"links:enable": "mv .links.disabled.yaml .links.yaml & touch .links.yaml",
@@ -39,21 +44,21 @@
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.12.0",
"@livekit/components-react": "^2.0.0",
"@livekit/protocol": "^1.33.0",
"@livekit/protocol": "^1.38.0",
"@livekit/track-processors": "^0.5.5",
"@mediapipe/tasks-vision": "^0.10.18",
"@opentelemetry/api": "^1.4.0",
"@opentelemetry/core": "^1.25.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.57.0",
"@opentelemetry/resources": "^1.25.1",
"@opentelemetry/sdk-trace-base": "^1.25.1",
"@opentelemetry/sdk-trace-web": "^1.9.1",
"@opentelemetry/core": "^2.0.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",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-spring/web": "^9.4.4",
"@react-spring/web": "^10.0.0",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^3.0.0",
"@stylistic/eslint-plugin": "^3.0.0",
@@ -69,26 +74,27 @@
"@types/node": "^22.0.0",
"@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/sdp-transform": "^2.4.5",
"@types/uuid": "10",
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^3.0.0",
"@vector-im/compound-web": "^7.2.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",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "^0.8.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "^2.0.0",
"eslint-plugin-matrix-org": "2.1.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-rxjs": "^5.0.3",
@@ -99,11 +105,11 @@
"i18next-parser": "^9.1.0",
"jsdom": "^26.0.0",
"knip": "^5.27.2",
"livekit-client": "^2.11.3",
"livekit-client": "^2.13.0",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#19b1b901f575755d29d1fe03ca48cbf7c1cae05c",
"matrix-widget-api": "1.11.0",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",
"matrix-widget-api": "^1.13.0",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
@@ -112,11 +118,10 @@
"posthog-js": "1.160.3",
"prettier": "^3.0.0",
"qrcode": "^1.5.4",
"react": "18",
"react-dom": "18",
"react": "19",
"react-dom": "19",
"react-i18next": "^15.0.0",
"react-router-dom": "^7.0.0",
"react-use-clipboard": "^1.0.7",
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sass": "^1.42.1",
@@ -125,7 +130,7 @@
"unique-names-generator": "^4.6.0",
"vaul": "^1.0.0",
"vite": "^6.0.0",
"vite-plugin-generate-file": "^0.2.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-svgr": "^4.0.0",
"vitest": "^3.0.0",
@@ -133,8 +138,7 @@
},
"resolutions": {
"@livekit/components-core/rxjs": "^7.8.1",
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18",
"matrix-widget-api": "1.11.0"
"@livekit/track-processors/@mediapipe/tasks-vision": "^0.10.18"
},
"packageManager": "yarn@4.7.0"
}

View File

@@ -7,6 +7,10 @@ Please see LICENSE in the repository root for full details.
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.USE_DOCKER
? "http://localhost:8080"
: "https://localhost:3000";
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -25,7 +29,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "https://localhost:3000",
baseURL,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@@ -73,9 +77,13 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: "yarn dev",
url: "https://localhost:3000",
command: "./scripts/playwright-webserver-command.sh",
url: baseURL,
reuseExistingServer: !process.env.CI,
ignoreHTTPSErrors: true,
gracefulShutdown: {
signal: "SIGTERM",
timeout: 500,
},
},
});

View File

@@ -49,7 +49,7 @@ test("Sign up a new account, then login, then logout", async ({ browser }) => {
// logout
await returningUserPage.getByTestId("usermenu_open").click();
await returningUserPage.locator('[data-test-id="usermenu_logout"]').click();
await returningUserPage.locator('[data-testid="usermenu_logout"]').click();
await expect(
returningUserPage.getByRole("link", { name: "Log In" }),

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

@@ -5,7 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Page, test, expect, type JSHandle } from "@playwright/test";
import {
type Browser,
type Page,
test,
expect,
type JSHandle,
} from "@playwright/test";
import type { MatrixClient } from "matrix-js-sdk";
@@ -30,8 +36,8 @@ const PASSWORD = "foobarbaz1!";
const CONFIG_JSON = {
default_server_config: {
"m.homeserver": {
base_url: "http://synapse.localhost:8008",
server_name: "synapse.localhost",
base_url: "https://synapse.m.localhost",
server_name: "synapse.m.localhost",
},
},
@@ -63,15 +69,72 @@ const CONFIG_JSON = {
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
* @param page
*/
async function setDevToolElementCallDevUrl(page: Page): Promise<void> {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
? async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"http://localhost:8080/room",
);
});
}
: async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
});
};
/**
* Registers a new user and returns page, clientHandle and mxId.
*/
async function registerUser(
browser: Browser,
username: string,
): Promise<{ page: Page; clientHandle: JSHandle<MatrixClient>; mxId: string }> {
const userContext = await browser.newContext({
reducedMotion: "reduce",
});
const page = await userContext.newPage();
await page.goto("http://localhost:8081/#/welcome");
await page.getByRole("link", { name: "Create Account" }).click();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await page.getByRole("textbox", { name: "Confirm password" }).click();
await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD);
await page.getByRole("button", { name: "Register" }).click();
const continueButton = page.getByRole("button", { name: "Continue" });
try {
await expect(continueButton).toBeVisible({ timeout: 5000 });
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await continueButton.click();
} catch {
// continueButton not visible, continue as normal
}
await expect(
page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible();
await setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const mxId = (await clientHandle.evaluate(
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
return { page, clientHandle, mxId };
}
export const widgetTest = test.extend<MyFixtures>({
@@ -83,61 +146,17 @@ export const widgetTest = test.extend<MyFixtures>({
const userA = `brooks_${Date.now()}`;
const userB = `whistler_${Date.now()}`;
const user1Context = await browser.newContext({
reducedMotion: "reduce",
});
const ewPage1 = await user1Context.newPage();
// Register the first user
await ewPage1.goto("http://localhost:8081/#/welcome");
await ewPage1.getByRole("link", { name: "Create Account" }).click();
await ewPage1.getByRole("textbox", { name: "Username" }).fill(userA);
await ewPage1
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await ewPage1.getByRole("textbox", { name: "Confirm password" }).click();
await ewPage1
.getByRole("textbox", { name: "Confirm password" })
.fill(PASSWORD);
await ewPage1.getByRole("button", { name: "Register" }).click();
await expect(
ewPage1.getByRole("heading", { name: `Welcome ${userA}` }),
).toBeVisible();
await setDevToolElementCallDevUrl(ewPage1);
const brooksClientHandle = await ewPage1.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const brooksMxId = (await brooksClientHandle.evaluate((cli) => {
return cli.getUserId();
}, brooksClientHandle))!;
const user2Context = await browser.newContext({
reducedMotion: "reduce",
});
const ewPage2 = await user2Context.newPage();
// Register the second user
await ewPage2.goto("http://localhost:8081/#/welcome");
await ewPage2.getByRole("link", { name: "Create Account" }).click();
await ewPage2.getByRole("textbox", { name: "Username" }).fill(userB);
await ewPage2
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await ewPage2.getByRole("textbox", { name: "Confirm password" }).click();
await ewPage2
.getByRole("textbox", { name: "Confirm password" })
.fill(PASSWORD);
await ewPage2.getByRole("button", { name: "Register" }).click();
await expect(
ewPage2.getByRole("heading", { name: `Welcome ${userB}` }),
).toBeVisible();
await setDevToolElementCallDevUrl(ewPage2);
const whistlerClientHandle = await ewPage2.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const whistlerMxId = (await whistlerClientHandle.evaluate((cli) => {
return cli.getUserId();
}, whistlerClientHandle))!;
// Register users
const {
page: ewPage1,
clientHandle: brooksClientHandle,
mxId: brooksMxId,
} = await registerUser(browser, userA);
const {
page: ewPage2,
clientHandle: whistlerClientHandle,
mxId: whistlerMxId,
} = await registerUser(browser, userB);
// Invite the second user
await ewPage1.getByRole("button", { name: "Add room" }).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

@@ -0,0 +1,104 @@
/*
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";
test("When creator left, avoid reconnect to the same SFU", async ({
browser,
}) => {
// Use reduce motion to disable animations that are making the tests a bit flaky
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
const creatorPage = await creatorContext.newPage();
await creatorPage.goto("/");
// ========
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
// ========
await creatorPage.getByTestId("home_callName").click();
await creatorPage.getByTestId("home_callName").fill("Welcome");
await creatorPage.getByTestId("home_displayName").click();
await creatorPage.getByTestId("home_displayName").fill("Inviter");
await creatorPage.getByTestId("home_go").click();
await expect(creatorPage.locator("video")).toBeVisible();
// join
await creatorPage.getByTestId("lobby_joinCall").click();
// Spotlight mode to make checking the test visually clearer
await creatorPage.getByRole("radio", { name: "Spotlight" }).check();
// Get the invite link
await creatorPage.getByRole("button", { name: "Invite" }).click();
await expect(
creatorPage.getByRole("heading", { name: "Invite to this call" }),
).toBeVisible();
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await creatorPage.getByTestId("modal_inviteLink").click();
const inviteLink = (await creatorPage.evaluate(
"navigator.clipboard.readText()",
)) as string;
expect(inviteLink).toContain("room/#/");
// ========
// ACT: The other user use the invite link to join the call as a guest
// ========
const guestB = await browser.newContext({
reducedMotion: "reduce",
});
const guestBPage = await guestB.newPage();
await guestBPage.goto(inviteLink);
await guestBPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestBPage.getByTestId("joincall_joincall")).toBeVisible();
await guestBPage.getByTestId("joincall_joincall").click();
await guestBPage.getByTestId("lobby_joinCall").click();
await guestBPage.getByRole("radio", { name: "Spotlight" }).check();
// ========
// ACT: add a third user to the call to reproduce the bug
// ========
const guestC = await browser.newContext({
reducedMotion: "reduce",
});
const guestCPage = await guestC.newPage();
let sfuGetCallCount = 0;
await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => {
sfuGetCallCount++;
await route.continue();
});
// Track WebSocket connections
let wsConnectionCount = 0;
await guestCPage.routeWebSocket("**", (ws) => {
// For some reason the interception is not working with the **
if (ws.url().includes("livekit/sfu/rtc")) {
wsConnectionCount++;
}
ws.connectToServer();
});
await guestCPage.goto(inviteLink);
await guestCPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestCPage.getByTestId("joincall_joincall")).toBeVisible();
await guestCPage.getByTestId("joincall_joincall").click();
await guestCPage.getByTestId("lobby_joinCall").click();
await guestCPage.getByRole("radio", { name: "Spotlight" }).check();
await guestCPage.waitForTimeout(1000);
// ========
// the creator leaves the call
await creatorPage.getByTestId("incall_leave").click();
await guestCPage.waitForTimeout(2000);
// 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(2 /* the first one is for the warmup */);
});

View File

@@ -9,11 +9,14 @@ import { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
// Skip test, including Fixtures
widgetTest.skip(
({ browserName }) => browserName === "firefox",
"This test is not working on firefox, after hangup brooks is locked in a strange state with a blank widget",
);
widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
test.skip(
browserName === "firefox",
"This test is not working on firefox, after hangup brooks is locked in a strange state with a blank widget",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;

View File

@@ -59,7 +59,7 @@
}
],
"semanticCommits": "disabled",
"ignoreDeps": ["posthog-js"],
"ignoreDeps": ["posthog-js", "eslint-plugin-matrix-org"],
"vulnerabilityAlerts": {
"schedule": ["at any time"],
"prHourlyLimit": 0,

View File

@@ -0,0 +1,10 @@
#!/bin/sh
if [ -n "$USE_DOCKER" ]; then
set -ex
yarn build
docker build -t element-call:testing .
exec docker run --rm --name element-call-testing -p 8080:8080 -v ./config/config.devenv.json:/app/config.json:ro,Z element-call:testing
else
cp config/config.devenv.json public/config.json
exec yarn dev
fi

View File

@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
import {
type FC,
type JSX,
Suspense,
useEffect,
useMemo,
useState,
} from "react";
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -13,16 +20,19 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { OidcRedirectPage } from "./auth/OidcRedirectPage";
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 { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
import { widget } from "./widget";
import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
import { getUrlParams, HeaderStyle } from "./UrlParams";
import { AppBar } from "./AppBar";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -50,7 +60,11 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
return children;
};
export const App: FC = () => {
interface Props {
vm: AppViewModel;
}
export const App: FC<Props> = ({ vm }) => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
Initializer.init()
@@ -62,41 +76,43 @@ export const App: FC = () => {
.catch(logger.error);
});
// Since we are outside the router component, we cannot use useUrlParams here
const { header } = useMemo(getUrlParams, []);
const content = loaded ? (
<ClientProvider>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} widget={widget} />}
>
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute path="/after-login" element={<OidcRedirectPage />} />
<SentryRoute path="/register" element={<RegisterPage />} />
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
) : (
<LoadingPage />
);
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<BrowserRouter>
<BackgroundProvider>
<ThemeProvider>
<TooltipProvider>
{loaded ? (
<Suspense fallback={null}>
<ClientProvider>
<MediaDevicesProvider>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => (
<ErrorPage error={error} widget={widget} />
)}
>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute
path="/register"
element={<RegisterPage />}
/>
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesProvider>
</ClientProvider>
</Suspense>
) : (
<LoadingPage />
)}
<Suspense fallback={null}>
{header === HeaderStyle.AppBar ? (
<AppBar>{content}</AppBar>
) : (
content
)}
</Suspense>
</TooltipProvider>
</ThemeProvider>
</BackgroundProvider>

23
src/AppBar.module.css Normal file
View File

@@ -0,0 +1,23 @@
.bar {
block-size: 64px;
flex-shrink: 0;
}
.bar > header {
position: absolute;
inset-inline: 0;
inset-block-start: 0;
block-size: 64px;
z-index: var(--call-view-header-footer-layer);
}
.bar svg path {
fill: var(--cpd-color-icon-primary);
}
.bar > header > h1 {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

25
src/AppBar.test.tsx Normal file
View File

@@ -0,0 +1,25 @@
/*
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 { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { AppBar } from "./AppBar";
describe("AppBar", () => {
it("renders", () => {
const { container } = render(
<TooltipProvider>
<AppBar>
<p>This is the content.</p>
</AppBar>
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
});
});

134
src/AppBar.tsx Normal file
View File

@@ -0,0 +1,134 @@
/*
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 {
createContext,
type FC,
type MouseEvent,
type ReactNode,
use,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { Header, LeftNav, RightNav } from "./Header";
import { platform } from "./Platform";
import styles from "./AppBar.module.css";
interface AppBarContext {
setTitle: (value: string) => void;
setSecondaryButton: (value: ReactNode) => void;
setHidden: (value: boolean) => void;
}
const AppBarContext = createContext<AppBarContext | null>(null);
interface Props {
children: ReactNode;
}
/**
* A "top app bar" featuring a back button, title and possibly a secondary
* button, similar to what you might see in mobile apps.
*/
export const AppBar: FC<Props> = ({ children }) => {
const { t } = useTranslation();
const onBackClick = useCallback((e: MouseEvent) => {
e.preventDefault();
window.controls.onBackButtonPressed?.();
}, []);
const [title, setTitle] = useState<string>("");
const [hidden, setHidden] = useState<boolean>(false);
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
const context = useMemo(
() => ({ setTitle, setSecondaryButton, setHidden }),
[setTitle, setHidden, setSecondaryButton],
);
return (
<>
<div
style={{ display: hidden ? "none" : "block" }}
className={styles.bar}
>
<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}>
<CollapseIcon />
</IconButton>
</Tooltip>
</LeftNav>
{title && (
<Heading
type="body"
size="lg"
weight={platform === "android" ? "medium" : "semibold"}
>
{title}
</Heading>
)}
<RightNav>{secondaryButton}</RightNav>
</Header>
</div>
<AppBarContext value={context}>{children}</AppBarContext>
</>
);
};
/**
* React hook which sets the title to be shown in the app bar, if present. It is
* an error to call this hook from multiple sites in the same component tree.
*/
export function useAppBarTitle(title: string): void {
const setTitle = use(AppBarContext)?.setTitle;
useEffect(() => {
if (setTitle !== undefined) {
setTitle(title);
return (): void => setTitle("");
}
}, [title, setTitle]);
}
/**
* React hook which sets the title to be shown in the app bar, if present. It is
* an error to call this hook from multiple sites in the same component tree.
*/
export function useAppBarHidden(hidden: boolean): void {
const setHidden = use(AppBarContext)?.setHidden;
useEffect(() => {
if (setHidden !== undefined) {
setHidden(hidden);
return (): void => setHidden(false);
}
}, [setHidden, hidden]);
}
/**
* React hook which sets the secondary button to be shown in the app bar, if
* present. It is an error to call this hook from multiple sites in the same
* component tree.
*/
export function useAppBarSecondaryButton(button: ReactNode): void {
const setSecondaryButton = use(AppBarContext)?.setSecondaryButton;
useEffect(() => {
if (setSecondaryButton !== undefined) {
setSecondaryButton(button);
return (): void => setSecondaryButton("");
}
}, [button, setSecondaryButton]);
}

View File

@@ -21,6 +21,7 @@ const TestComponent: FC<
<ClientContextProvider
value={{
state: "valid",
oidcClientConfig: null,
disconnected: false,
supportedFeatures: {
reactions: true,

View File

@@ -1,5 +1,5 @@
/*
Copyright 2021-2024 New Vector Ltd.
Copyright 2021-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.
@@ -11,7 +11,7 @@ import {
useEffect,
useState,
createContext,
useContext,
use,
useRef,
useMemo,
type JSX,
@@ -19,7 +19,7 @@ import {
import { useNavigate } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
import { ClientEvent, type OidcClientConfig, type MatrixClient } from "matrix-js-sdk";
import type { WidgetApi } from "matrix-widget-api";
import { ErrorPage } from "./FullScreenView";
@@ -29,6 +29,7 @@ import {
RegistrationType,
} from "./analytics/PosthogAnalytics";
import { useEventTarget } from "./useEvents";
import { getAuthMetadata } from "./utils/oidc/discovery";
import { OpenElsewhereError } from "./RichError";
declare global {
@@ -42,6 +43,7 @@ export type ClientState = ValidClientState | ErrorState;
export type ValidClientState = {
state: "valid";
oidcClientConfig: OidcClientConfig | null,
authenticated?: AuthenticatedClient;
// 'Disconnected' rather than 'connected' because it tracks specifically
// whether the client is supposed to be connected but is not
@@ -69,23 +71,25 @@ const ClientContext = createContext<ClientState | undefined>(undefined);
export const ClientContextProvider = ClientContext.Provider;
export const useClientState = (): ClientState | undefined =>
useContext(ClientContext);
export const useClientState = (): ClientState | undefined => use(ClientContext);
export function useClient(): {
client?: MatrixClient;
setClient?: (client: MatrixClient, session: Session) => void;
oidcClientConfig: OidcClientConfig | null | undefined;
} {
let client;
let setClient;
let oidcClientConfig;
const clientState = useClientState();
if (clientState?.state === "valid") {
client = clientState.authenticated?.client;
setClient = clientState.setClient;
oidcClientConfig = clientState.oidcClientConfig;
}
return { client, setClient };
return { client, setClient, oidcClientConfig };
}
// Plain representation of the `ClientContext` as a helper for old components that expected an object with multiple fields.
@@ -141,6 +145,12 @@ interface Props {
export const ClientProvider: FC<Props> = ({ children }) => {
const navigate = useNavigate();
// null = no OIDC config, undefined = loading
const [oidcClientConfig, setOidcClientConfig] = useState<
OidcClientConfig | null | undefined
>(undefined);
const [oidcErr, setOidcErr] = useState<Error | undefined>(undefined);
// null = signed out, undefined = loading
const [initClientState, setInitClientState] = useState<
InitResult | null | undefined
@@ -154,6 +164,11 @@ export const ClientProvider: FC<Props> = ({ children }) => {
if (initializing.current) return;
initializing.current = true;
if (!widget) {
// TODO: spec says this may change over time & should be refreshed upon cache expiry
getAuthMetadata().then(setOidcClientConfig, setOidcErr);
}
loadClient()
.then((initResult) => {
setInitClientState(initResult);
@@ -252,12 +267,18 @@ export const ClientProvider: FC<Props> = ({ children }) => {
const [supportsReactions, setSupportsReactions] = useState(false);
const [supportsThumbnails, setSupportsThumbnails] = useState(false);
const state: ClientState | undefined = useMemo(() => {
if (alreadyOpenedErr) {
return { state: "error", error: alreadyOpenedErr };
const state = useMemo((): ClientState | undefined => {
const error = alreadyOpenedErr || oidcErr;
if (error) {
return { state: "error", error };
}
if (initClientState === undefined) return undefined;
if (
initClientState === undefined ||
oidcClientConfig === undefined
) {
return undefined;
}
const authenticated =
initClientState === null
@@ -271,6 +292,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
return {
state: "valid",
oidcClientConfig,
authenticated,
setClient,
disconnected: isDisconnected,
@@ -281,6 +303,8 @@ export const ClientProvider: FC<Props> = ({ children }) => {
};
}, [
alreadyOpenedErr,
oidcErr,
oidcClientConfig,
changePassword,
initClientState,
logout,
@@ -346,13 +370,12 @@ export const ClientProvider: FC<Props> = ({ children }) => {
};
}, [initClientState, onSync]);
if (alreadyOpenedErr) {
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
const error = alreadyOpenedErr || oidcErr;
if (error) {
return <ErrorPage widget={widget} error={error} />;
}
return (
<ClientContext.Provider value={state}>{children}</ClientContext.Provider>
);
return <ClientContext value={state}>{children}</ClientContext>;
};
export type InitResult = {

View File

@@ -12,6 +12,7 @@
.error > h1 {
margin: 0;
text-align: center;
}
.error > p {

View File

@@ -99,7 +99,7 @@ export const ErrorView: FC<Props> = ({
return (
<div className={styles.error}>
<BigIcon className={styles.icon}>
<Icon />
<Icon aria-hidden />
</BigIcon>
<Heading as="h1" weight="semibold" size="md">
{title}

View File

@@ -10,7 +10,7 @@ import classNames from "classnames";
import { useTranslation } from "react-i18next";
import * as Sentry from "@sentry/react";
import { logger } from "matrix-js-sdk/lib/logger";
import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Header, HeaderLogo, LeftNav, RightNav } from "./Header";
import styles from "./FullScreenView.module.css";
@@ -28,10 +28,10 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
className,
children,
}) => {
const { hideHeader } = useUrlParams();
const { header } = useUrlParams();
return (
<div className={classNames(styles.page, className)}>
{!hideHeader && (
{header === "standard" && (
<Header>
<LeftNav>
<HeaderLogo />
@@ -67,7 +67,7 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
) : (
<ErrorView
widget={widget}
Icon={ErrorIcon}
Icon={ErrorSolidIcon}
title={t("error.generic")}
rageshake
fatal

View File

@@ -6,12 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import {
type FC,
type HTMLAttributes,
type ReactNode,
forwardRef,
} from "react";
import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web";
@@ -22,15 +17,29 @@ 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 = forwardRef<HTMLElement, HeaderProps>(
({ children, className, ...rest }, ref) => {
return (
export const Header: FC<HeaderProps> = ({
ref,
children,
className,
disconnectedBanner = true,
...rest
}) => {
return (
<>
<header
ref={ref}
className={classNames(styles.header, className)}
@@ -38,9 +47,10 @@ export const Header = forwardRef<HTMLElement, HeaderProps>(
>
{children}
</header>
);
},
);
{disconnectedBanner && <DisconnectedBanner />}
</>
);
};
Header.displayName = "Header";

View File

@@ -0,0 +1,56 @@
/*
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 { createContext, use, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type MediaDevices } from "./state/MediaDevices";
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
undefined,
);
export function useMediaDevices(): MediaDevices {
const mediaDevices = use(MediaDevicesContext);
if (mediaDevices === undefined)
throw new Error(
"useMediaDevices must be used within a MediaDevices context provider",
);
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
* the appropriate pan and volume values.
*
* @returns pan and volume values for the earpiece audio node configuration.
*/
export const useEarpieceAudioConfig = (): {
pan: number;
volume: number;
} => {
const devices = useMediaDevices();
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
return {
// We use only the right speaker (pan = 1) for the earpiece.
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
pan: useMemo(() => (isVirtualEarpiece ? 1 : 0), [isVirtualEarpiece]),
// We also do lower the volume by a factor of 10 to optimize for the usecase where
// a user is holding the phone to their ear.
volume: useMemo(() => (isVirtualEarpiece ? 0.1 : 1), [isVirtualEarpiece]),
};
};

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { useTranslation } from "react-i18next";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { ErrorIcon, PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import type { FC, ReactNode } from "react";
import { ErrorView } from "./ErrorView";
@@ -51,3 +51,29 @@ export class OpenElsewhereError extends RichError {
super("App opened in another tab", <OpenElsewhere />);
}
}
const HomeserverMisconfig: FC<{message?: string}> = (props) => {
const { t } = useTranslation();
// TODO: don't want to show "Return to home screen" button for an error as fatal as this
return (
<ErrorView
widget={widget}
Icon={ErrorIcon}
title={t("error.homeserver_misconfig")}
fatal={true}
>
{props.message && (
<p>
{props.message}
</p>
)}
</ErrorView>
);
};
export class HomeserverMisconfigError extends RichError {
public constructor(message?: string) {
super(message || "Unknown error", <HomeserverMisconfig message={message} />);
}
}

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 portaled into the root of the document (rather
* than rendered in-place within the component tree).
* @default true
*/
portal?: boolean;
}
/**
@@ -56,6 +62,7 @@ export const Toast: FC<Props> = ({
autoDismiss,
children,
Icon,
portal = 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>
{portal ? <DialogPortal>{content}</DialogPortal> : content}
</DialogRoot>
);
};

View File

@@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest";
import {
getRoomIdentifierFromUrl,
getUrlParams,
UserIntent,
HeaderStyle,
} from "../src/UrlParams";
const ROOM_NAME = "roomNameHere";
@@ -82,6 +82,16 @@ describe("UrlParams", () => {
getRoomIdentifierFromUrl("", `?roomId=${ROOM_ID}`, "").roomId,
).toBe(ROOM_ID);
});
it("(roomId with unprintable characters)", () => {
const invisibleChar = "\u2066";
expect(
getRoomIdentifierFromUrl(
"",
`?roomId=${invisibleChar}${ROOM_ID}${invisibleChar}`,
"",
).roomId,
).toBe(ROOM_ID);
});
});
it("ignores room alias", () => {
@@ -201,24 +211,68 @@ describe("UrlParams", () => {
});
describe("intent", () => {
it("defaults to unknown", () => {
expect(getUrlParams().intent).toBe(UserIntent.Unknown);
const noIntentDefaults = {
confineToRoom: false,
appPrompt: true,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
};
const startNewCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
});
const joinExistingCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: false,
returnToLobby: false,
sendNotificationType: "notification",
});
it("use no-intent-defaults with unknown intent", () => {
expect(getUrlParams()).toMatchObject(noIntentDefaults);
});
it("ignores intent if it is not a valid value", () => {
expect(getUrlParams("?intent=foo").intent).toBe(UserIntent.Unknown);
expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
});
it("accepts start_call", () => {
expect(getUrlParams("?intent=start_call").intent).toBe(
UserIntent.StartNewCall,
);
expect(
getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"),
).toMatchObject(startNewCallDefaults("desktop"));
});
it("accepts join_existing", () => {
expect(getUrlParams("?intent=join_existing").intent).toBe(
UserIntent.JoinExistingCall,
);
expect(
getUrlParams(
"?intent=join_existing&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(joinExistingCallDefaults("desktop"));
});
});
@@ -243,4 +297,12 @@ describe("UrlParams", () => {
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
});
});
describe("header", () => {
it("uses header if provided", () => {
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe(
"app_bar",
);
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
});
});
});

View File

@@ -8,10 +8,13 @@ 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 { pickBy } from "lodash-es";
import { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";
import { platform } from "./Platform";
interface RoomIdentifier {
roomAlias: string | null;
@@ -22,15 +25,23 @@ interface RoomIdentifier {
export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallDM = "start_call_dm",
JoinExistingCallDM = "join_existing_dm",
Unknown = "unknown",
}
// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// 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 {
export enum HeaderStyle {
None = "none",
Standard = "standard",
AppBar = "app_bar",
}
/**
* The UrlProperties are used to pass required data to the widget.
* Those are different in different rooms, users, devices. They do not configure the behavior of the
* widget but provide the required data to the widget.
*/
export interface UrlProperties {
// Widget api related params
widgetId: string | null;
parentUrl: string | null;
@@ -42,42 +53,11 @@ export interface UrlParams {
* is also not validated, where it is in useRoomIdentifier().
*/
roomId: string | null;
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* The app prompt must also be enabled in the config for this to take effect.
*/
appPrompt: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* Whether to hide the room header when in a call.
*/
hideHeader: boolean;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether to use end-to-end encryption.
*/
e2eEnabled: boolean;
/**
* The user's ID (only used in matryoshka mode).
*/
userId: string | null;
/**
* The display name to use for auto-registration.
*/
@@ -115,18 +95,106 @@ export interface UrlParams {
*/
posthogApiKey: string | null;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
* Whether to use end-to-end encryption.
*/
allowIceFallback: boolean;
e2eEnabled: boolean;
/**
* E2EE password
*/
password: string | null;
/** This defines the homeserver that is going to be used when joining a room.
* It has to be set to a non default value for links to rooms
* that are not on the default homeserver,
* that is in use for the current user.
*/
viaServers: string | null;
/**
* Whether we the app should use per participant keys for E2EE.
* This defines the homeserver that is going to be used when registering
* a new (guest) user.
* This can be user to configure a non default guest user server when
* creating a spa link.
*/
homeserver: string | null;
/**
* The rageshake submit URL. This is only used in the embedded package of Element Call.
*/
rageshakeSubmitUrl: string | null;
/**
* The Sentry DSN. This is only used in the embedded package of Element Call.
*/
sentryDsn: string | null;
/**
* The Sentry environment. This is only used in the embedded package of Element Call.
*/
sentryEnvironment: string | null;
/**
* The theme to use for element call.
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
*/
theme: string | null;
}
/**
* The configuration for the app, which can be set via URL parameters.
* Those property are different to the UrlProperties, since they are all optional
* and configure the behavior of the app. Their value is the same if EC is used in
* the same context but with different accounts/users.
*
* Their defaults can be controlled by the `intent` property.
*/
export interface UrlConfiguration {
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* The app prompt must also be enabled in the config for this to take effect.
*/
appPrompt: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* 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.
*/
header: HeaderStyle;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
*/
allowIceFallback: boolean;
/**
* Whether the app should use per participant keys for E2EE.
*/
perParticipantE2EE: boolean;
/**
* Whether the global JS controls for audio output devices should be enabled,
* allowing the list of output devices to be controlled by the app hosting
* Element Call.
*/
controlledAudioDevices: boolean;
/**
* Setting this flag skips the lobby and brings you in the call directly.
* In the widget this can be combined with preload to pass the device settings
@@ -139,46 +207,24 @@ export interface UrlParams {
*/
returnToLobby: boolean;
/**
* The theme to use for element call.
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
* Whether and what type of notification EC should send, when the user joins the call.
*/
theme: string | null;
/** This defines the homeserver that is going to be used when joining a room.
* It has to be set to a non default value for links to rooms
* that are not on the default homeserver,
* that is in use for the current user.
*/
viaServers: string | null;
sendNotificationType?: RTCNotificationType;
/**
* This defines the homeserver that is going to be used when registering
* a new (guest) user.
* This can be user to configure a non default guest user server when
* creating a spa link.
* 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.
*/
homeserver: string | null;
/**
* The user's intent 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`.
*/
intent: string | null;
/**
* The rageshake submit URL. This is only used in the embedded package of Element Call.
*/
rageshakeSubmitUrl: string | null;
/**
* The Sentry DSN. This is only used in the embedded package of Element Call.
*/
sentryDsn: string | null;
/**
* The Sentry environment. This is only used in the embedded package of Element Call.
*/
sentryEnvironment: string | null;
autoLeaveWhenOthersLeft: boolean;
}
// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// 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 {}
// 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
// consolidate all the data about URL parameters and their meanings to this one
@@ -219,6 +265,17 @@ class ParamParser {
return this.fragmentParams.get(name) ?? this.queryParams.get(name);
}
public getEnumParam<T extends string>(
name: string,
type: { [s: string]: T } | ArrayLike<T>,
): T | undefined {
const value = this.getParam(name);
if (value !== null && Object.values(type).includes(value as T)) {
return value as T;
}
return undefined;
}
public getAllParams(name: string): string[] {
return [
...this.fragmentParams.getAll(name),
@@ -226,10 +283,20 @@ 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;
}
}
/**
@@ -246,32 +313,95 @@ export const getUrlParams = (
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
let intent = parser.getParam("intent");
if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) {
intent = UserIntent.Unknown;
}
const widgetId = parser.getParam("widgetId");
const parentUrl = parser.getParam("parentUrl");
const isWidget = !!widgetId && !!parentUrl;
return {
/**
* The user's intent 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`.
* This is a platform specific default set of parameters, that allows to minize the configuration
* needed to start a call. And empowers the EC codebase to control the platform/intent behavior in
* a central place.
*
* In short: either provide url query parameters of UrlConfiguration or set the intent
* (or the global defaults will be used).
*/
const intent = !isWidget
? 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 = {
confineToRoom: true,
appPrompt: false,
preload: true,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification" as RTCNotificationType,
autoLeaveWhenOthersLeft: false,
};
switch (intent) {
case UserIntent.StartNewCall:
intentPreset = {
...inAppDefault,
skipLobby: true,
};
break;
case UserIntent.JoinExistingCall:
intentPreset = {
...inAppDefault,
skipLobby: false,
};
break;
case UserIntent.StartNewCallDM:
intentPreset = {
...inAppDefault,
skipLobby: true,
autoLeaveWhenOthersLeft: true,
};
break;
case UserIntent.JoinExistingCallDM:
intentPreset = {
...inAppDefault,
skipLobby: true,
autoLeaveWhenOthersLeft: true,
};
break;
// Non widget usecase defaults
default:
intentPreset = {
confineToRoom: false,
appPrompt: true,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false,
};
}
const properties: UrlProperties = {
widgetId,
parentUrl,
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
// what would we do if it were invalid? If the widget API says that's what
// the room ID is, then that's what it is.
roomId: parser.getParam("roomId"),
password: parser.getParam("password"),
// This flag has 'embed' as an alias for historical reasons
confineToRoom:
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
appPrompt: parser.getFlagParam("appPrompt", true),
preload: isWidget ? parser.getFlagParam("preload") : false,
hideHeader: parser.getFlagParam("hideHeader"),
showControls: parser.getFlagParam("showControls", true),
hideScreensharing: parser.getFlagParam("hideScreensharing"),
e2eEnabled: parser.getFlagParam("enableE2EE", true),
userId: isWidget ? parser.getParam("userId") : null,
displayName: parser.getParam("displayName"),
deviceId: isWidget ? parser.getParam("deviceId") : null,
@@ -279,19 +409,9 @@ export const getUrlParams = (
lang: parser.getParam("lang"),
fonts: parser.getAllParams("font"),
fontScale: Number.isNaN(fontScale) ? null : fontScale,
allowIceFallback: parser.getFlagParam("allowIceFallback"),
perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
skipLobby: parser.getFlagParam(
"skipLobby",
isWidget && intent === UserIntent.StartNewCall,
),
// In SPA mode the user should always exit to the home screen when hanging
// up, rather than being sent back to the lobby
returnToLobby: isWidget ? parser.getFlagParam("returnToLobby") : false,
theme: parser.getParam("theme"),
viaServers: !isWidget ? parser.getParam("viaServers") : null,
homeserver: !isWidget ? parser.getParam("homeserver") : null,
intent,
posthogApiHost: parser.getParam("posthogApiHost"),
posthogApiKey: parser.getParam("posthogApiKey"),
posthogUserId:
@@ -299,6 +419,36 @@ export const getUrlParams = (
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
sentryDsn: parser.getParam("sentryDsn"),
sentryEnvironment: parser.getParam("sentryEnvironment"),
e2eEnabled: parser.getFlagParam("enableE2EE", true),
};
const configuration: Partial<UrlConfiguration> = {
confineToRoom: parser.getFlag("confineToRoom"),
appPrompt: parser.getFlag("appPrompt"),
preload: isWidget ? parser.getFlag("preload") : undefined,
// Check hideHeader for backwards compatibility. If header is set, hideHeader
// is ignored.
header: parser.getEnumParam("header", HeaderStyle),
showControls: parser.getFlag("showControls"),
hideScreensharing: parser.getFlag("hideScreensharing"),
allowIceFallback: parser.getFlag("allowIceFallback"),
perParticipantE2EE: parser.getFlag("perParticipantE2EE"),
controlledAudioDevices: parser.getFlag("controlledAudioDevices"),
skipLobby: isWidget ? parser.getFlag("skipLobby") : false,
// In SPA mode the user should always exit to the home screen when hanging
// up, rather than being sent back to the lobby
returnToLobby: isWidget ? parser.getFlag("returnToLobby") : false,
sendNotificationType: parser.getEnumParam("sendNotificationType", [
"ring",
"notification",
]),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
};
return {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
};
};
@@ -357,10 +507,16 @@ export function getRoomIdentifierFromUrl(
// Make sure roomId is valid
let roomId: string | null = parser.getParam("roomId");
if (!roomId?.startsWith("!")) {
roomId = null;
} else if (!roomId.includes("")) {
roomId = null;
if (roomId !== null) {
// Replace any non-printable characters that another client may have inserted.
// For instance on iOS, some copied links end up with zero width characters on the end which get encoded into the URL.
// This isn't valid for a roomId, so we can freely strip the content.
roomId = roomId.replaceAll(/^[^ -~]+|[^ -~]+$/g, "");
if (!roomId.startsWith("!")) {
roomId = null;
} else if (!roomId.includes("")) {
roomId = null;
}
}
return {

View File

@@ -119,7 +119,7 @@ export const UserMenu: FC<Props> = ({
key={key}
Icon={Icon}
label={label}
data-test-id={dataTestid}
data-testid={dataTestid}
onSelect={() => onAction(key)}
/>
))}

View File

@@ -0,0 +1,50 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AppBar > renders 1`] = `
<div>
<div
class="bar"
style="display: block;"
>
<header
class="header"
>
<div
class="nav leftNav"
>
<button
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</div>
</button>
</div>
<div
class="nav rightNav"
/>
</header>
</div>
<p>
This is the content.
</p>
</div>
`;

View File

@@ -2,10 +2,10 @@
exports[`the content is rendered when the modal is open 1`] = `
<div
aria-labelledby="radix-:r4:"
class="overlay animate modal dialog _glass_1x9g9_17"
aria-labelledby="radix-«r4»"
class="overlay animate modal dialog _glass_sepwu_8"
data-state="open"
id="radix-:r3:"
id="radix-«r3»"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
@@ -17,8 +17,8 @@ exports[`the content is rendered when the modal is open 1`] = `
class="header"
>
<h2
class="_typography_yh5dq_162 _font-heading-md-semibold_yh5dq_121"
id="radix-:r4:"
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
id="radix-«r4»"
>
My modal
</h2>
@@ -36,7 +36,7 @@ exports[`the content is rendered when the modal is open 1`] = `
exports[`the modal renders as a drawer in mobile viewports 1`] = `
<div
aria-labelledby="radix-:ra:"
aria-labelledby="radix-«ra»"
class="overlay modal drawer"
data-state="open"
data-vaul-animate="true"
@@ -45,7 +45,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
data-vaul-drawer=""
data-vaul-drawer-direction="bottom"
data-vaul-snap-points="false"
id="radix-:r9:"
id="radix-«r9»"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
@@ -60,7 +60,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
class="handle"
/>
<h2
id="radix-:ra:"
id="radix-«ra»"
style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
>
My modal

View File

@@ -2,18 +2,18 @@
exports[`Toast > renders 1`] = `
<button
aria-labelledby="radix-:r4:"
aria-labelledby="radix-«r4»"
class="overlay animate toast"
data-state="open"
id="radix-:r3:"
id="radix-«r3»"
role="dialog"
style="pointer-events: auto;"
tabindex="-1"
type="button"
>
<h3
class="_typography_yh5dq_162 _font-body-sm-semibold_yh5dq_45"
id="radix-:r4:"
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36"
id="radix-«r4»"
>
Hello world!
</h3>

View File

@@ -107,13 +107,13 @@ export class RageshakeSpanProcessor implements SpanProcessor {
startTime,
duration,
references:
span.parentSpanId === undefined
span.parentSpanContext?.spanId === undefined
? []
: [
{
refType: "CHILD_OF",
traceID: traceId,
spanID: span.parentSpanId,
spanID: span.parentSpanContext?.spanId,
},
],
tags: dumpAttributes(span.attributes),

View File

@@ -1,11 +1,11 @@
/*
Copyright 2021-2024 New Vector Ltd.
Copyright 2021-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, type FormEvent, useCallback, useRef, useState } from "react";
import { type FC, type FormEvent, type ReactElement, useCallback, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@vector-im/compound-web";
@@ -19,13 +19,14 @@ import { usePageTitle } from "../usePageTitle";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { Config } from "../config/Config";
import { Link } from "../button/Link";
import { LoadingPage } from "../FullScreenView";
import { getOidcClientId } from "../utils/oidc/registerClient";
import { startOidcLogin } from "../utils/oidc/authorize";
export const LoginPage: FC = () => {
const { t } = useTranslation();
usePageTitle(t("login_title"));
const { client, setClient } = useClient();
const login = useInteractiveLogin(client);
const homeserver = Config.defaultHomeserverUrl(); // TODO: Make this configurable
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
@@ -36,19 +37,23 @@ export const LoginPage: FC = () => {
// TODO: Handle hitting login page with authenticated client
const onSubmitLoginForm = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
let node: ReactElement;
const { client, setClient, oidcClientConfig } = useClient();
if (oidcClientConfig === null) {
const login = useInteractiveLogin(client);
const onSubmitLoginForm = useCallback(
async (e: FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
setLoading(true);
if (!homeserver || !usernameRef.current || !passwordRef.current) {
setError(Error("Login parameters are undefined"));
setLoading(false);
return;
}
if (!homeserver) {
setError(Error("Login parameters are undefined"));
setLoading(false);
return;
}
login(homeserver, usernameRef.current.value, passwordRef.current.value)
.then(async ([client, session]) => {
try {
const [client, session] = await login(homeserver, usernameRef.current?.value ?? "", passwordRef.current?.value ?? "");
if (!setClient) {
return;
}
@@ -66,20 +71,84 @@ export const LoginPage: FC = () => {
await navigate("/");
}
PosthogAnalytics.instance.eventLogin.track();
})
.catch((error) => {
} catch (error: any) {
setError(error);
setLoading(false);
});
},
[login, location, navigate, homeserver, setClient],
);
// we need to limit the length of the homserver name to not cover the whole loginview input with the string.
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
shortendHomeserverName =
shortendHomeserverName?.length !== Config.defaultServerName()?.length
? shortendHomeserverName + "..."
: shortendHomeserverName;
}
},
[login, location, navigate, homeserver, setClient],
);
// we need to limit the length of the homserver name to not cover the whole loginview input with the string.
let shortendHomeserverName = Config.defaultServerName()?.slice(0, 25);
shortendHomeserverName =
shortendHomeserverName?.length !== Config.defaultServerName()?.length
? shortendHomeserverName + "..."
: shortendHomeserverName;
node = (
<form onSubmit={onSubmitLoginForm}>
<FieldRow>
<InputField
type="text"
ref={usernameRef}
placeholder={t("common.username")}
label={t("common.username")}
autoCorrect="off"
autoCapitalize="none"
prefix="@"
suffix={`:${shortendHomeserverName}`}
data-testid="login_username"
/>
</FieldRow>
<FieldRow>
<InputField
type="password"
ref={passwordRef}
placeholder={t("common.password")}
label={t("common.password")}
data-testid="login_password"
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
<Button
type="submit"
disabled={loading}
data-testid="login_login"
>
{loading ? t("logging_in") : t("login_title")}
</Button>
</FieldRow>
</form>
);
} else if (oidcClientConfig !== undefined) {
if (!homeserver) {
setError(Error("No homeserver is configured"));
setLoading(false);
return;
}
node = <Button
kind="primary"
onClick={async () => {
// TODO: get the clientId at the same time as doing OIDC discovery
const clientId = await getOidcClientId(oidcClientConfig);
await startOidcLogin(
oidcClientConfig,
clientId,
homeserver,
);
}}
>
{t("room_auth_view_continue_button") /* TODO: make a dedicated word for this */}
</Button>;
} else {
return <LoadingPage />;
}
return (
<>
<div className={styles.container}>
@@ -89,44 +158,7 @@ export const LoginPage: FC = () => {
<h2>{t("log_in")}</h2>
<h4>{t("login_subheading")}</h4>
<form onSubmit={onSubmitLoginForm}>
<FieldRow>
<InputField
type="text"
ref={usernameRef}
placeholder={t("common.username")}
label={t("common.username")}
autoCorrect="off"
autoCapitalize="none"
prefix="@"
suffix={`:${shortendHomeserverName}`}
data-testid="login_username"
/>
</FieldRow>
<FieldRow>
<InputField
type="password"
ref={passwordRef}
placeholder={t("common.password")}
label={t("common.password")}
data-testid="login_password"
/>
</FieldRow>
{error && (
<FieldRow>
<ErrorMessage error={error} />
</FieldRow>
)}
<FieldRow>
<Button
type="submit"
disabled={loading}
data-testid="login_login"
>
{loading ? t("logging_in") : t("login_title")}
</Button>
</FieldRow>
</form>
{node}
</div>
<div className={styles.authLinks}>
<p>{t("login_auth_links_prompt")}</p>

View File

@@ -0,0 +1,104 @@
/*
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 { useState, type FC } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { createClient, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { useClient } from "../ClientContext";
import { usePageTitle } from "../usePageTitle";
import { completeOidcLogin } from "../utils/oidc/authorize";
import { initClient } from "../utils/matrix";
/**
* TODO: Yoinked from element-web, so move to SDK if possible
* Gets information about the owner of a given access token.
* @returns Promise that resolves with whoami response
* @throws when whoami request fails
*/
async function getUserIdFromAccessToken(
accessToken: string,
homeserverUrl: string,
): Promise<ReturnType<MatrixClient["whoami"]>> {
try {
const client = createClient({
baseUrl: homeserverUrl,
accessToken: accessToken,
});
return await client.whoami();
} catch (error) {
throw new Error("Failed to retrieve userId using accessToken");
}
}
export const OidcRedirectPage: FC = async () => {
const { t } = useTranslation();
// TODO: probably want a new page title
usePageTitle(t("login_title"));
const navigate = useNavigate();
const location = useLocation();
const [_, setError] = useState<Error>();
const { setClient } = useClient();
if (!setClient) {
return;
}
// TODO: make reactive
try {
const queryParams = new URLSearchParams(location.search);
const { accessToken, refreshToken, homeserverUrl, idToken, clientId, issuer } =
await completeOidcLogin(queryParams);
const {
user_id: userId,
device_id: deviceId,
} = await getUserIdFromAccessToken(accessToken, homeserverUrl);
const session = {
user_id: userId,
access_token: accessToken,
device_id: deviceId!, // TODO: make sure this really is always defined
passwordlessUser: false,
};
console.debug(`TODO: use ${refreshToken}`);
const client = await initClient(
{
baseUrl: homeserverUrl,
accessToken,
userId,
deviceId,
},
false,
);
setClient(client, session);
console.debug(`TODO: use ${clientId}, ${issuer}, ${idToken}`);
// persistOidcAuthenticatedSettings(clientId, issuer, idToken);
const locationState = location.state;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (locationState && locationState.from) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await navigate(locationState.from);
} else {
await navigate("/");
}
} catch (error: any) {
setError(error);
return;
}
return (
<></>
);
}

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

@@ -5,11 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type ComponentPropsWithoutRef,
forwardRef,
type MouseEvent,
} from "react";
import { type ComponentProps, type FC, type MouseEvent } from "react";
import { Link as CpdLink } from "@vector-im/compound-web";
import { type LinkProps, useHref, useLinkClickHandler } from "react-router-dom";
import classNames from "classnames";
@@ -26,31 +22,30 @@ export function useLink(
return [href, onClick];
}
type Props = Omit<
ComponentPropsWithoutRef<typeof CpdLink>,
"href" | "onClick"
> & { to: LinkProps["to"]; state?: unknown };
type Props = Omit<ComponentProps<typeof CpdLink>, "href" | "onClick"> & {
to: LinkProps["to"];
state?: unknown;
};
/**
* A version of Compound's link component that integrates with our router setup.
* This is only for app-internal links.
*/
export const Link = forwardRef<HTMLAnchorElement, Props>(function Link(
{ to, state, ...props },
ref,
) {
export const Link: FC<Props> = ({ ref, to, state, ...props }) => {
const [path, onClick] = useLink(to, state);
return <CpdLink ref={ref} {...props} href={path} onClick={onClick} />;
});
};
/**
* A link to an external web page, made to fit into blocks of text more subtly
* than the normal Compound link component.
*/
export const ExternalLink = forwardRef<
HTMLAnchorElement,
ComponentPropsWithoutRef<"a">
>(function ExternalLink({ className, children, ...props }, ref) {
export const ExternalLink: FC<ComponentProps<"a">> = ({
ref,
className,
children,
...props
}) => {
return (
<a
ref={ref}
@@ -62,4 +57,4 @@ export const ExternalLink = forwardRef<
{children}
</a>
);
});
};

View File

@@ -5,24 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ComponentPropsWithoutRef, forwardRef } from "react";
import { type ComponentProps, type FC } from "react";
import { Button } from "@vector-im/compound-web";
import type { LinkProps } from "react-router-dom";
import { useLink } from "./Link";
type Props = Omit<
ComponentPropsWithoutRef<typeof Button<"a">>,
"as" | "href"
> & { to: LinkProps["to"]; state?: unknown };
type Props = Omit<ComponentProps<typeof Button<"a">>, "as" | "href"> & {
to: LinkProps["to"];
state?: unknown;
};
/**
* A version of Compound's button component that acts as a link and integrates
* with our router setup.
*/
export const LinkButton = forwardRef<HTMLAnchorElement, Props>(
function LinkButton({ to, state, ...props }, ref) {
const [path, onClick] = useLink(to, state);
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
},
);
export const LinkButton: FC<Props> = ({ ref, to, state, ...props }) => {
const [path, onClick] = useLink(to, state);
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
};

View File

@@ -24,8 +24,6 @@ import {
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import classNames from "classnames";
import { useObservableState } from "observable-hooks";
import { map } from "rxjs";
import { useReactionsSender } from "../reactions/useReactionsSender";
import styles from "./ReactionToggleButton.module.css";
@@ -36,6 +34,7 @@ import {
} from "../reactions";
import { Modal } from "../Modal";
import { type CallViewModel } from "../state/CallViewModel";
import { useBehavior } from "../useBehavior";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
@@ -180,12 +179,8 @@ export function ReactionToggleButton({
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>();
const isHandRaised = useObservableState(
vm.handsRaised$.pipe(map((v) => !!v[identifier])),
);
const canReact = useObservableState(
vm.reactions$.pipe(map((v) => !v[identifier])),
);
const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
const canReact = !useBehavior(vm.reactions$)[identifier];
useEffect(() => {
// Clear whenever the reactions menu state changes.

View File

@@ -9,8 +9,8 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":rb5:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
aria-labelledby="«rb5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
role="button"
@@ -26,7 +26,7 @@ exports[`Can close reaction dialog 1`] = `
>
<path
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
fill-rule="evenodd"
/>
</svg>
@@ -43,8 +43,8 @@ exports[`Can fully expand emoji picker 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":r7m:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
aria-labelledby="«r7m»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
role="button"
@@ -60,7 +60,7 @@ exports[`Can fully expand emoji picker 1`] = `
>
<path
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
fill-rule="evenodd"
/>
</svg>
@@ -74,8 +74,8 @@ exports[`Can lower hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby=":r36:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
aria-labelledby="«r36»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="secondary"
data-size="lg"
role="button"
@@ -91,7 +91,7 @@ exports[`Can lower hand 1`] = `
>
<path
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
fill-rule="evenodd"
/>
</svg>
@@ -108,8 +108,8 @@ exports[`Can open menu 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby=":r0:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
aria-labelledby="«r0»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
role="button"
@@ -125,7 +125,7 @@ exports[`Can open menu 1`] = `
>
<path
clip-rule="evenodd"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Zm3.536-6.464a1 1 0 0 0-1.415-1.415A2.988 2.988 0 0 1 12 15a2.988 2.988 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10m3.536-6.464a1 1 0 0 0-1.415-1.415A3 3 0 0 1 12 15a3 3 0 0 1-2.121-.879 1 1 0 1 0-1.414 1.415A5 5 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464M10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3"
fill-rule="evenodd"
/>
</svg>
@@ -139,8 +139,8 @@ exports[`Can raise hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby=":r1j:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
aria-labelledby="«r1j»"
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
role="button"
@@ -155,7 +155,7 @@ exports[`Can raise hand 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.944 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 2 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0V3Z"
d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.943 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 1.999 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0z"
/>
</svg>
</button>

View File

@@ -1,5 +1,5 @@
/*
Copyright 2022-2024 New Vector Ltd.
Copyright 2022-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.
@@ -14,6 +14,7 @@ export interface ConfigOptions {
api_key: string;
api_host: string;
};
/**
* The Sentry endpoint to which crash data will be sent.
* This is only used in the full package of Element Call.
@@ -22,6 +23,7 @@ export interface ConfigOptions {
DSN: string;
environment: string;
};
/**
* The rageshake server to which feedback and debug logs will be sent.
* This is only used in the full package of Element Call.
@@ -66,6 +68,7 @@ export interface ConfigOptions {
* Allow to join group calls without audio and video.
*/
feature_group_calls_without_video_and_audio?: boolean;
/**
* Send device-specific call session membership state events instead of
* legacy user-specific call membership state events.
@@ -86,6 +89,7 @@ export interface ConfigOptions {
* Defines whether participants should start with audio enabled by default.
*/
enable_audio?: boolean;
/**
* Defines whether participants should start with video enabled by default.
*/
@@ -109,20 +113,61 @@ export interface ConfigOptions {
* How long (in milliseconds) to wait before rotating end-to-end media encryption keys
* when someone leaves a call.
*/
key_rotation_on_leave_delay?: number;
wait_for_key_rotation_ms?: number;
/**
* How often (in milliseconds) keep-alive messages should be sent to the server for
* the MatrixRTC membership event.
* 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.
*/
membership_keep_alive_period?: number;
delayed_leave_event_delay_ms?: number;
/**
* How long (in milliseconds) after the last keep-alive the server should expire the
* MatrixRTC membership event.
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
*
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
* helps by keeping more delayed event reset candidates in flight,
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/
membership_server_side_expiry_timeout?: number;
delayed_leave_event_restart_local_timeout_ms?: number;
/**
* The time interval (in milliseconds) at which the client sends membership keep-alive
* messages to the server by restarting the timer for the delayed leave event.
*/
delayed_leave_event_restart_ms?: number;
/**
* How long we wait before retrying after a network error on any of the requests.
*/
network_error_retry_ms?: number;
/**
* The timeout (in milliseconds) after we joined the call, that our membership should expire
* unless we have explicitly updated it.
*
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
*/
membership_event_expiry_ms?: number;
};
/**
* Configuration for OIDC issuers where a static client_id has been issued for the app.
* Otherwise dynamic client registration is attempted.
*/
oidc_static_clients?: {
[issuer: string]: { client_id: string };
};
oidc_metadata?: {
client_name?: string;
client_uri?: string;
logo_uri?: string;
tos_uri?: string;
policy_uri?: string;
contacts?: string[];
}
}
// Overrides members from ConfigOptions that are always provided by the

View File

@@ -1,20 +1,72 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2024-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 { Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
export interface Controls {
canEnterPip: () => boolean;
enablePip: () => void;
disablePip: () => void;
canEnterPip(): boolean;
enablePip(): void;
disablePip(): void;
setAvailableAudioDevices(devices: OutputDevice[]): void;
setAudioDevice(id: string): void;
onAudioDeviceSelect?: (id: string) => void;
onAudioPlaybackStarted?: () => void;
setAudioEnabled(enabled: boolean): void;
showNativeAudioDevicePicker?: () => void;
onBackButtonPressed?: () => void;
/** @deprecated use setAvailableAudioDevices instead*/
setAvailableOutputDevices(devices: OutputDevice[]): void;
/** @deprecated use setAudioDevice instead*/
setOutputDevice(id: string): void;
/** @deprecated use onAudioDeviceSelect instead*/
onOutputDeviceSelect?: (id: string) => void;
/** @deprecated use setAudioEnabled instead*/
setOutputEnabled(enabled: boolean): void;
/** @deprecated use showNativeAudioDevicePicker instead*/
showNativeOutputDevicePicker?: () => void;
}
export interface OutputDevice {
id: string;
name: string;
forEarpiece?: boolean;
isEarpiece?: boolean;
isSpeaker?: boolean;
isExternalHeadset?: boolean;
}
/**
* If pipMode is enabled, EC will render a adapted call view layout.
*/
export const setPipEnabled$ = new Subject<boolean>();
export const availableOutputDevices$ = new Subject<OutputDevice[]>();
export const outputDevice$ = new Subject<string>();
/**
* This allows the os to mute the call if the user
* presses the volume down button when it is at the minimum volume.
*
* This should also be used to display a darkened overlay screen letting the user know that audio is muted.
*/
export const setAudioEnabled$ = new Subject<boolean>();
let playbackStartedEmitted = false;
export const setPlaybackStarted = (): void => {
if (!playbackStartedEmitted) {
playbackStartedEmitted = true;
window.controls.onAudioPlaybackStarted?.();
}
};
window.controls = {
canEnterPip(): boolean {
return setPipEnabled$.observed;
@@ -27,4 +79,32 @@ window.controls = {
if (!setPipEnabled$.observed) throw new Error("No call is running");
setPipEnabled$.next(false);
},
setAvailableAudioDevices(devices: OutputDevice[]): void {
logger.info("setAvailableAudioDevices called from native:", devices);
availableOutputDevices$.next(devices);
},
setAudioDevice(id: string): void {
logger.info("setAudioDevice called from native", id);
outputDevice$.next(id);
},
setAudioEnabled(enabled: boolean): void {
logger.info("setAudioEnabled called from native:", enabled);
if (!setAudioEnabled$.observed)
throw new Error(
"Output controls are disabled. No setAudioEnabled$ observer",
);
setAudioEnabled$.next(enabled);
},
// wrappers for the deprecated controls fields
setOutputEnabled(enabled: boolean): void {
this.setAudioEnabled(enabled);
},
setAvailableOutputDevices(devices: OutputDevice[]): void {
this.setAvailableAudioDevices(devices);
},
setOutputDevice(id: string): void {
this.setAudioDevice(id);
},
};

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BaseKeyProvider, createKeyMaterialFromBuffer } from "livekit-client";
import { BaseKeyProvider } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type MatrixRTCSession,
@@ -44,20 +44,29 @@ export class MatrixKeyProvider extends BaseKeyProvider {
encryptionKeyIndex: number,
participantId: string,
): void => {
createKeyMaterialFromBuffer(encryptionKey).then(
(keyMaterial) => {
this.onSetEncryptionKey(keyMaterial, participantId, encryptionKeyIndex);
crypto.subtle
.importKey("raw", encryptionKey, "HKDF", false, [
"deriveBits",
"deriveKey",
])
.then(
(keyMaterial) => {
this.onSetEncryptionKey(
keyMaterial,
participantId,
encryptionKeyIndex,
);
logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
);
},
(e) => {
logger.error(
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
e,
);
},
);
logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
);
},
(e) => {
logger.error(
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
e,
);
},
);
};
}

View File

@@ -6,58 +6,70 @@ Please see LICENSE in the repository root for full details.
*/
import { useEffect, useMemo } from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { setLocalStorageItem, useLocalStorage } from "../useLocalStorage";
import { type UrlParams, getUrlParams, useUrlParams } from "../UrlParams";
import {
setLocalStorageItemReactive,
useLocalStorage,
} from "../useLocalStorage";
import { getUrlParams } from "../UrlParams";
import { E2eeType } from "./e2eeType";
import { useClient } from "../ClientContext";
/**
* This setter will update the state for all `useRoomSharedKey` hooks
* if the password is different from the one in local storage or if its not yet in the local storage.
*/
export function saveKeyForRoom(roomId: string, password: string): void {
setLocalStorageItem(getRoomSharedKeyLocalStorageKey(roomId), password);
if (
localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId)) !== password
)
setLocalStorageItemReactive(
getRoomSharedKeyLocalStorageKey(roomId),
password,
);
}
const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
`room-shared-key-${roomId}`;
const useInternalRoomSharedKey = (roomId: string): string | null => {
const key = getRoomSharedKeyLocalStorageKey(roomId);
const roomSharedKey = useLocalStorage(key)[0];
/**
* An upto-date shared key for the room. Either from local storage or the value from `setInitialValue`.
* @param roomId
* @param setInitialValue The value we get from the URL. The hook will overwrite the local storage value with this.
* @returns [roomSharedKey, setRoomSharedKey] like a react useState hook.
*/
const useRoomSharedKey = (
roomId: string,
setInitialValue?: string,
): [string | null, setKey: (key: string) => void] => {
const [roomSharedKey, setRoomSharedKey] = useLocalStorage(
getRoomSharedKeyLocalStorageKey(roomId),
);
useEffect(() => {
// If setInitialValue is available, update the local storage (usually the password from the url).
// This will update roomSharedKey but wont update the returned value since
// that already defaults to setInitialValue.
if (setInitialValue) setRoomSharedKey(setInitialValue);
}, [setInitialValue, setRoomSharedKey]);
return roomSharedKey;
// make sure we never return the initial null value from `useLocalStorage`
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
};
export function getKeyForRoom(roomId: string): string | null {
saveKeyFromUrlParams(getUrlParams());
const key = getRoomSharedKeyLocalStorageKey(roomId);
return localStorage.getItem(key);
const { roomId: urlRoomId, password } = getUrlParams();
if (roomId !== urlRoomId)
logger.warn(
"requested key for a roomId which is not the current call room id (from the URL)",
roomId,
urlRoomId,
);
return (
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
);
}
function saveKeyFromUrlParams(urlParams: UrlParams): void {
if (!urlParams.password || !urlParams.roomId) return;
// Take the key from the URL and save it.
// It's important to always use the room ID specified in the URL
// when saving keys rather than whatever the current room ID might be,
// in case we've moved to a different room but the URL hasn't changed.
saveKeyForRoom(urlParams.roomId, urlParams.password);
}
/**
* Extracts the room password from the URL if one is present, saving it in localstorage
* and returning it in a tuple with the corresponding room ID from the URL.
* @returns A tuple of the roomId and password from the URL if the URL has both,
* otherwise [undefined, undefined]
*/
const useKeyFromUrl = (): [string, string] | [undefined, undefined] => {
const urlParams = useUrlParams();
useEffect(() => saveKeyFromUrlParams(urlParams), [urlParams]);
return urlParams.roomId && urlParams.password
? [urlParams.roomId, urlParams.password]
: [undefined, undefined];
};
export type Unencrypted = { kind: E2eeType.NONE };
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
@@ -66,12 +78,11 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { client } = useClient();
// make sure we've extracted the key from the URL first
// (and we still need to take the value it returns because
// the effect won't run in time for it to save to localstorage in
// time for us to read it out again).
const [urlRoomId, passwordFromUrl] = useKeyFromUrl();
const storedPassword = useInternalRoomSharedKey(roomId);
const [storedPassword] = useRoomSharedKey(
getRoomSharedKeyLocalStorageKey(roomId),
getKeyForRoom(roomId) ?? undefined,
);
const room = client?.getRoom(roomId);
const e2eeSystem = <EncryptionSystem>useMemo(() => {
if (!room) return { kind: E2eeType.NONE };
@@ -80,15 +91,10 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
kind: E2eeType.SHARED_KEY,
secret: storedPassword,
};
if (urlRoomId === roomId)
return {
kind: E2eeType.SHARED_KEY,
secret: passwordFromUrl,
};
if (room.hasEncryptionStateEvent()) {
return { kind: E2eeType.PER_PARTICIPANT };
}
return { kind: E2eeType.NONE };
}, [passwordFromUrl, room, roomId, storedPassword, urlRoomId]);
}, [room, storedPassword]);
return e2eeSystem;
}

View File

@@ -6,28 +6,32 @@ Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import { type FormEventHandler, forwardRef, type ReactNode } from "react";
import {
type FC,
type Ref,
type FormEventHandler,
type ReactNode,
} from "react";
import styles from "./Form.module.css";
interface FormProps {
ref?: Ref<HTMLFormElement>;
className: string;
onSubmit: FormEventHandler<HTMLFormElement>;
children: ReactNode[];
}
export const Form = forwardRef<HTMLFormElement, FormProps>(
({ children, className, onSubmit }, ref) => {
return (
<form
onSubmit={onSubmit}
className={classNames(styles.form, className)}
ref={ref}
>
{children}
</form>
);
},
);
export const Form: FC<FormProps> = ({ ref, children, className, onSubmit }) => {
return (
<form
onSubmit={onSubmit}
className={classNames(styles.form, className)}
ref={ref}
>
{children}
</form>
);
};
Form.displayName = "Form";

View File

@@ -18,23 +18,22 @@ import {
type ComponentType,
type Dispatch,
type FC,
type LegacyRef,
type ReactNode,
type Ref,
type SetStateAction,
createContext,
forwardRef,
memo,
useContext,
use,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import useMeasure from "react-use-measure";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import { fromEvent, map, startWith } from "rxjs";
import styles from "./Grid.module.css";
import { useMergedRefs } from "../useMergedRefs";
@@ -124,7 +123,7 @@ interface LayoutContext {
const LayoutContext = createContext<LayoutContext | null>(null);
function useLayoutContext(): LayoutContext {
const context = useContext(LayoutContext);
const context = use(LayoutContext);
if (context === null)
throw new Error("useUpdateLayout called outside a Grid layout context");
return context;
@@ -156,13 +155,8 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void {
);
}
const windowHeightObservable$ = fromEvent(window, "resize").pipe(
startWith(null),
map(() => window.innerHeight),
);
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref: LegacyRef<R>;
ref?: Ref<R>;
model: LayoutModel;
/**
* Component creating an invisible "slot" for a tile to go in.
@@ -171,7 +165,7 @@ export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
}
export interface TileProps<Model, R extends HTMLElement> {
ref: LegacyRef<R>;
ref?: Ref<R>;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
/**
@@ -262,7 +256,13 @@ export function Grid<
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
const windowHeight = useObservableEagerState(windowHeightObservable$);
const windowHeight = useSyncExternalStore(
useCallback((onChange) => {
window.addEventListener("resize", onChange);
return (): void => window.removeEventListener("resize", onChange);
}, []),
useCallback(() => window.innerHeight, []),
);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] =
@@ -297,14 +297,13 @@ export function Grid<
// render of Grid causes a re-render of Layout, which in turn re-renders Grid
const LayoutMemo = useMemo(
() =>
memo(
forwardRef<
LayoutRef,
LayoutMemoProps<LayoutModel, TileModel, LayoutRef>
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode {
return <Layout {...props} ref={ref} />;
}),
),
memo(function LayoutMemo({
ref,
Layout,
...props
}: LayoutMemoProps<LayoutModel, TileModel, LayoutRef>): ReactNode {
return <Layout {...props} ref={ref} />;
}),
[],
);
@@ -532,14 +531,14 @@ export function Grid<
className={classNames(className, styles.grid)}
style={style}
>
<LayoutContext.Provider value={context}>
<LayoutContext value={context}>
<LayoutMemo
ref={setLayoutRoot}
Layout={Layout}
model={model}
Slot={Slot}
/>
</LayoutContext.Provider>
</LayoutContext>
{tileTransitions((spring, { id, model, onDrag, width, height }) => (
<TileWrapper
key={id}

View File

@@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type CSSProperties, forwardRef, useCallback, useMemo } from "react";
import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
} from "react";
import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
@@ -33,7 +38,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const alignment = useObservableEagerState(
useInitial(() =>
@@ -68,10 +73,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
)}
</div>
);
}),
},
// The scrolling part of the layout is where all the grid tiles live
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useObservableEagerState(minBounds$);
@@ -98,5 +103,5 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
))}
</div>
);
}),
},
});

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { forwardRef, useCallback, useMemo } from "react";
import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -13,6 +13,7 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod
import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
* An implementation of the "one-on-one" layout, in which the remote participant
@@ -24,15 +25,15 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
}) => ({
scrollingOnTop: false,
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
useUpdateLayout();
return <div ref={ref} />;
}),
},
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
const pipAlignmentValue = useBehavior(pipAlignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
@@ -66,5 +67,5 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
</Slot>
</div>
);
}),
},
});

View File

@@ -5,13 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { forwardRef, useCallback } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type ReactNode, useCallback } from "react";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior";
/**
* An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -22,10 +22,11 @@ export const makeSpotlightExpandedLayout: CallLayout<
> = ({ pipAlignment$ }) => ({
scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
{ model, Slot },
fixed: function SpotlightExpandedLayoutFixed({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
return (
@@ -37,14 +38,15 @@ export const makeSpotlightExpandedLayout: CallLayout<
/>
</div>
);
}),
},
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
{ model, Slot },
scrolling: function SpotlightExpandedLayoutScrolling({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
const pipAlignmentValue = useBehavior(pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
@@ -69,5 +71,5 @@ export const makeSpotlightExpandedLayout: CallLayout<
)}
</div>
);
}),
},
});

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { forwardRef } from "react";
import { type ReactNode } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -24,10 +24,11 @@ export const makeSpotlightLandscapeLayout: CallLayout<
> = ({ minBounds$ }) => ({
scrollingOnTop: false,
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
{ model, Slot },
fixed: function SpotlightLandscapeLayoutFixed({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
useObservableEagerState(minBounds$);
@@ -43,12 +44,13 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<div className={styles.grid} />
</div>
);
}),
},
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
{ model, Slot },
scrolling: function SpotlightLandscapeLayoutScrolling({
ref,
) {
model,
Slot,
}): ReactNode {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$);
@@ -69,5 +71,5 @@ export const makeSpotlightLandscapeLayout: CallLayout<
</div>
</div>
);
}),
},
});

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