Compare commits

..

193 Commits

Author SHA1 Message Date
Robin
6af72f4c90 Format 2026-06-18 11:42:02 +02:00
Robin
3a598ffe43 Restore ability to play sound with custom volume
This was missing from the revert in 8b0f5054dd.
2026-06-18 11:35:46 +02:00
Robin
05797097c6 Only consider a call accepted if the *intended* recipient picks up 2026-06-18 11:05:55 +02:00
Robin
8b0f5054dd Revert to original fix for duplicate leave sounds
My attempted simpler fix of applying takeUntil(autoLeave$) to leaveSoundEffect$ didn't work.
2026-06-18 11:05:55 +02:00
Robin
8cda9777a5 Merge branch 'livekit' into ringing-intent 2026-06-18 06:41:17 +02:00
Johannes Marbach
3ea89e8275 Merge pull request #4043 from element-hq/johannes/update-plugin-react
Update @vitejs/plugin-react to 0.6.2
2026-06-17 16:39:19 +02:00
Johannes Marbach
0410be33cd Merge branch 'livekit' into johannes/update-plugin-react 2026-06-17 16:06:54 +02:00
Johannes Marbach
002f9b0cea Merge pull request #4042 from element-hq/johannes/pnpm-11
Switch to pnpm 11.6.0
2026-06-17 15:52:18 +02:00
Johannes Marbach
669f4264c7 Reformat
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-17 15:35:47 +02:00
Johannes Marbach
dc6511b99e Document why we override resolved versions 2026-06-17 15:33:36 +02:00
Johannes Marbach
032968ea2d Merge branch 'livekit' into johannes/pnpm-11 2026-06-17 15:07:28 +02:00
Johannes Marbach
6ce79d8a52 Merge pull request #4045 from element-hq/renovate/npm-vite-vulnerability
Update dependency vite to v8.0.16 [SECURITY]
2026-06-16 15:22:21 +02:00
renovate[bot]
b68abb7df4 Update dependency vite to v8.0.16 [SECURITY] 2026-06-16 12:45:26 +00:00
Timo
1281ed31df Merge pull request #3816 from element-hq/toger5/fix-depnedabot-security-alerts
Dependabot security alerts: Bump flatted and unidici
2026-06-16 20:34:17 +08:00
Timo K
e7377fb104 fix prettier 2026-06-16 14:17:25 +02:00
Timo K
edb4dfa9ed Merge branch 'livekit' into toger5/fix-depnedabot-security-alerts 2026-06-16 14:16:02 +02:00
Timo K
7db13e1fc0 Merge branch 'livekit' into toger5/fix-depnedabot-security-alerts 2026-06-16 14:13:16 +02:00
Johannes Marbach
df7451c2cf Update @vitejs/plugin-react to 0.6.2 2026-06-15 13:59:54 +02:00
Johannes Marbach
9537c7657c Switch to pnpm 11.6.0 2026-06-15 12:34:32 +02:00
Johannes Marbach
6c105e971e Merge pull request #4041 from element-hq/renovate/compound
Update Compound to v10.2.2
2026-06-15 08:06:20 +02:00
renovate[bot]
cbbe6a812e Update Compound to v10.2.2 2026-06-15 01:03:21 +00:00
Robin
3ef3ebe897 Merge pull request #4036 from element-hq/metadata-leak
Fix a minor resource leak with display names and avatars
2026-06-12 10:05:50 +02:00
Timo
0c284ed75b Merge pull request #4004 from element-hq/toger5/controls-api-onPipMediaOrientation
Controls api `onPipMediaOrientation`
2026-06-12 16:03:35 +08:00
Timo
838d2f3c5d Update src/state/media/observeRtpStreamStats.ts
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-12 09:47:13 +02:00
Timo
2a24fbcee1 Merge pull request #4038 from element-hq/toger5/fix-logging-indexDB
Fix Logging indexDB EC/EW collision
2026-06-12 15:29:33 +08:00
Timo K
be8a76868b rename db 2026-06-11 18:38:54 +02:00
Johannes Marbach
d1ea63587b Merge pull request #4035 from element-hq/johannes/knife-content-type
Remove deprecated @types/content-type dependency
2026-06-11 12:22:49 +02:00
Robin
96a9115ee7 Fix a minor resource leak with display names and avatars
I noticed that calls to createDisplayNameBehavior$ and createAvatarUrlBehavior$ were technically leaking resources since they reused the ObservableScope from their outer scope, which in practice lasts for the entire lifetime of the CallViewModel. This would not have had any noticeable effect unless you had other participants leave and rejoin the same call many thousands of times.
2026-06-11 12:21:07 +02:00
Robin
2ac6cdeb46 Improve internal model of ringing, expose ringing intent to call UI
I found our code's internal model of ringing a little overgrown (it had superfluous states like 'unknown') and difficult to extend with metadata or callbacks relating to ring attempts. By modeling ringing instead as a stream of ring attempts, where each attempt has an intent, a recipient, and an eventual outcome (accept/decline/timeout), I find it more natural to work with.

This makes room for a future 'try again' callback to allow ringing someone again after a timeout, and also forced me to look for a simpler solution to the duplicate leave sound effects. I exposed the intent of the ringing attempt to the call UI so I can later use it in the header.
2026-06-11 12:12:27 +02:00
Johannes Marbach
3af160bb02 Remove deprecated @types/content-type dependency 2026-06-11 08:36:25 +02:00
fkwp
73ccc5f483 Show typed error when matrix_2_0 mode is forced without homeserver MSC4354 support (#4017)
Show typed error when matrix_2_0 mode is forced without homeserver MSC4354 support
2026-06-10 18:48:16 +02:00
Johannes Marbach
3a824dfff0 Merge pull request #4029 from element-hq/johannes/i18next-cli
Replace deprecated i18next-parser with i18next-cli
2026-06-10 15:00:45 +02:00
Timo
7ffa585f0d Merge pull request #4027 from element-hq/johannes/knife-uuid-types
Remove deprecated @types/uuid
2026-06-10 20:51:11 +08:00
Johannes Marbach
0690387b70 Merge pull request #4009 from element-hq/johannes/raised-color
Fix missing text color for remote raised hand duration
2026-06-10 10:17:13 +02:00
renovate[bot]
7fa3febf55 Update LiveKit client to v2.19.2 (#4031)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 21:13:59 +02:00
Johannes Marbach
11b08c9a9f Replace deprecated i18next-parser with i18next-cli 2026-06-08 15:32:34 +02:00
Johannes Marbach
c1f528e120 Remove deprecated @types/uuid 2026-06-08 13:57:01 +02:00
Johannes Marbach
65bd51fe92 Merge pull request #4022 from element-hq/renovate/compound
Update Compound
2026-06-08 10:25:11 +02:00
Johannes Marbach
2dc664b323 Merge branch 'livekit' into renovate/compound 2026-06-08 10:05:00 +02:00
Johannes Marbach
65737ef4a5 Merge pull request #4023 from element-hq/renovate/livekit-client
Update LiveKit client to v2.19.1
2026-06-08 10:02:00 +02:00
renovate[bot]
753613dcdc Update LiveKit client to v2.19.1 2026-06-08 07:35:21 +00:00
Johannes Marbach
a412d0c230 Merge branch 'livekit' into renovate/compound 2026-06-08 09:26:29 +02:00
Johannes Marbach
33e847c1a3 Merge pull request #4024 from element-hq/johannes/codecov
Update codecov-action to v7
2026-06-08 09:25:38 +02:00
Johannes Marbach
ac2f5fe7d6 Update codecov-action to v7 2026-06-08 09:00:12 +02:00
Johannes Marbach
4c2c068c3a Update dev settings snapshot 2026-06-08 08:48:46 +02:00
renovate[bot]
102a7e2650 Update Compound 2026-06-08 01:07:22 +00:00
Valere Fedronic
ba40490ca4 Merge pull request #4013 from element-hq/valere/fix_repeated_click_to_unmute
feat(mute): add syncing state and disable toggle during async mute
2026-06-05 12:58:23 +02:00
Valere
62a3ec8319 review: cleanup removed redundant default properties 2026-06-05 12:50:25 +02:00
Valere
a430e87104 review: add test for busy behavior for Mic and Video buttons 2026-06-05 12:48:04 +02:00
Valere
acc5a440c7 review: remove unneeded early return 2026-06-05 12:35:21 +02:00
fkwp
b99c8821d3 Add matrix_rtc_mode config option (#4014)
* Move MatrixRTCMode enum from settings.ts to ConfigOptions.ts

* Add matrix_rtc_mode config option

* add matrix_rtc_mode to config.sample.json

* Update src/settings/DeveloperSettingsTab.tsx

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* Update src/settings/DeveloperSettingsTab.test.tsx

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* reviewer comments

---------

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-05 09:49:43 +00:00
Valere
dc03d9b358 fix: Initial unmute is reverted 2026-06-05 10:29:36 +02:00
Valere Fedronic
3a6579f78d Merge pull request #4012 from element-hq/valere/livekit_error_boundary
feat(errors): Report livekit error as such instead of generic unknown errors
2026-06-05 09:31:46 +02:00
Valere
55b2537129 fixup: Update snapshot with button busy state 2026-06-04 19:14:12 +02:00
Valere
b5b238ffd3 storybook: Add storybook for audio/video busy 2026-06-04 19:13:27 +02:00
Valere
4606373e5b feat(mute): add syncing state and disable toggle during async mute 2026-06-04 18:55:31 +02:00
Valere
4353f09e54 fixup snapshots 2026-06-04 15:48:30 +02:00
ElementRobot
fc3c4bf566 Merge pull request #3911 from element-hq/actions/localazy-download
Localazy Download
2026-06-04 15:34:46 +02:00
Valere
c05c523182 fixup: ensure dynamic keys are extracted by i18n parser 2026-06-04 15:30:59 +02:00
Johannes Marbach
629c012f48 Reorder entries 2026-06-04 15:17:34 +02:00
Valere
e7d37f87bb feat(errors): Report livekit as such rather than like unknown errors 2026-06-04 11:54:42 +02:00
Johannes Marbach
664a10c724 Fix illegible text color for raised hand duration
Fixes: https://github.com/element-hq/voip-internal/issues/579
2026-06-04 11:33:55 +02:00
Timo K
308a042076 fix error with no media 2026-06-03 10:43:47 +02:00
Timo K
b554ce4a87 Update CallViewModel.test.ts 2026-06-03 10:18:45 +02:00
Johannes Marbach
5f257da4f6 Merge pull request #4005 from element-hq/johannes/dry
Avoid usage of SWIFT_RELEASE_TOKEN in dry runs
2026-06-03 08:14:28 +02:00
Johannes Marbach
1196ba5959 Merge pull request #4000 from element-hq/johannes/pip-corners
Fix corner radius on screenshare overlay in widget mode
2026-06-03 08:12:48 +02:00
Johannes Marbach
067a70d19f Fix corner radius on screenshare overlay in widget mode
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-02 15:18:19 +02:00
Robin
c021fc1548 Merge pull request #3995 from element-hq/toger5/fix-double-leave-sound
Fix play of second leave sound
2026-06-02 14:46:43 +02:00
Johannes Marbach
2e95c2cc15 Avoid usage of SWIFT_RELEASE_TOKEN in dry runs 2026-06-02 14:41:59 +02:00
Timo K
692a55c84c fix race 2026-06-02 14:06:28 +02:00
Timo K
c5ffdea370 lint 2026-06-02 13:56:16 +02:00
Timo K
9117b40e7b fix tests 2026-06-02 13:52:43 +02:00
Timo
25a6cefa34 Merge pull request #3905 from manfrommedan/fix/promise-withresolvers-polyfill
Add Promise.withResolvers polyfill for older WebViews (Chrome < 119)
2026-06-02 19:43:12 +08:00
Robin
399b7d83da Merge pull request #3988 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.19.0
2026-06-02 11:50:10 +02:00
Timo
e2128776b3 Merge pull request #3935 from element-hq/toger5/contribut-issue-first
Contributing.md explain "issue first" contribution approach
2026-06-02 17:35:56 +08:00
Timo
327a0ad9b0 Merge pull request #3951 from element-hq/valere/improve_error_message
Improve error messages for sfu auth problems
2026-06-02 17:33:56 +08:00
Robin
47326c28d3 Fix type error 2026-06-02 11:33:17 +02:00
Robin
084fa20b3d Merge pull request #4003 from element-hq/device-switch-fixes
Show the right labels in device switcher menus
2026-06-02 11:29:28 +02:00
Timo K
e05af09c28 Add test 2026-06-02 11:27:59 +02:00
Robin
cec78a383d Fix footer stories 2026-06-02 11:14:53 +02:00
Timo K
b86909af52 temp add tests 2026-06-02 10:56:34 +02:00
Robin
faa34aeaaa Merge pull request #3987 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v10.2.0
2026-06-02 10:48:44 +02:00
Robin
5fed562db2 Request full device names when device switchers are open 2026-06-02 10:36:57 +02:00
Robin
164765cd27 Show the right fallback labels in device switcher menus 2026-06-02 10:15:40 +02:00
renovate[bot]
489492d9b9 Update dependency livekit-client to v2.19.0 2026-06-02 06:50:03 +00:00
renovate[bot]
bec34ee360 Update dependency @vector-im/compound-design-tokens to v10.2.0 2026-06-02 06:49:28 +00:00
Johannes Marbach
79298a6620 Merge pull request #3998 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-06-02 08:47:58 +02:00
Robin
af8a876adc Merge pull request #3999 from element-hq/renovate/embedded-package-dependencies
Update Gradle to v8.14.5
2026-06-02 08:42:39 +02:00
Johannes Marbach
f4d6517bbb Switch from global to globalThis to fix import issues 2026-06-02 08:29:03 +02:00
renovate[bot]
576119663f Update all non-major dependencies 2026-06-02 01:16:51 +00:00
Timo K
d6635b7818 add docs 2026-06-01 16:13:03 +02:00
Timo K
a6fc710d8c Add onPipMediaOrientationUpdate to controls api. 2026-06-01 16:12:57 +02:00
renovate[bot]
e67d2f2f2b Update Gradle to v8.14.5 2026-06-01 03:52:15 +00:00
Valere
3799ea2b4e fixup: Missing translation 2026-05-28 14:43:42 +02:00
Valere
2bd16beccf Merge branch 'livekit' into valere/improve_error_message 2026-05-28 14:27:17 +02:00
Valere Fedronic
72b0d12d7c Merge pull request #3973 from element-hq/valere/devx/run_dev_on_phone
devx: Allow to run local dev EC on mobile in same wifi
2026-05-28 14:26:07 +02:00
Valere
6777adac54 Merge branch 'livekit' into valere/devx/run_dev_on_phone 2026-05-28 13:17:22 +02:00
Valere Fedronic
083f86b19d Merge pull request #3924 from element-hq/valere/devx/livekit_logs
dev-tool: Add option to enable extended livekit logs
2026-05-28 13:16:57 +02:00
Valere
1fafc3b1ba Merge branch 'livekit' into valere/devx/livekit_logs 2026-05-28 12:45:39 +02:00
Valere Fedronic
cdbb2c92ca Merge pull request #3994 from element-hq/valere/devx/bump_playwright
bump playwright to 1.60
2026-05-28 12:45:19 +02:00
Timo K
ea4144ccc7 Fix play of second leave sound 2026-05-28 12:04:42 +02:00
Valere
2928e34ee2 test: use toHaveCount instead of count()).toBe 2026-05-28 11:56:29 +02:00
Valere
d98e800077 bump playwright
update playwright container image
2026-05-28 11:32:18 +02:00
Valere
8b86c1914d bump playwright
update playwright container image
2026-05-28 11:31:38 +02:00
Valere
e815277fa2 touch commit to unbock gh 2026-05-28 08:59:05 +02:00
Valere
810e263cbe review: better doc 2026-05-28 08:51:00 +02:00
Valere
70a0914017 eslint 2026-05-27 14:18:34 +02:00
Valere
8011f2579c test: Add test for new option
review: Remove uneeed {" "}
2026-05-27 14:03:31 +02:00
Valere
187665748b Merge branch 'livekit' into valere/devx/livekit_logs 2026-05-27 11:46:38 +02:00
Timo
ee1e8c60e7 Merge pull request #3961 from element-hq/toger5/view-model-call-footer-example
Implement fast switcher (+ ViewModel with snapshot example)
2026-05-20 23:38:00 +08:00
Timo
a9fd98c563 Update src/state/ViewModel.ts
Co-authored-by: Robin <robin@robin.town>
2026-05-20 17:16:08 +02:00
Robin
0de6e94647 Merge pull request #3983 from element-hq/one-on-one-portrait-regressions
Fix a couple more regressions due to the one-on-one portrait layout
2026-05-20 17:03:54 +02:00
Timo K
dc52573ce2 fix backgroundBlurToggleClick -> videoBlurToggleClick 2026-05-20 17:03:45 +02:00
Timo K
509e4d739f Update InCallView.test.tsx 2026-05-20 16:47:41 +02:00
Timo K
355ec4fe62 fix visibility test 2026-05-20 16:39:25 +02:00
Timo K
d9c0de73b7 Merge branch 'livekit' into toger5/view-model-call-footer-example 2026-05-20 16:28:04 +02:00
Robin
8d07d552d7 Ensure landscape layout is always edge-to-edge on mobile
The spotlight landscape layout was edge-to-edge previously, and should remain that way.
2026-05-20 16:11:07 +02:00
Robin
8324ce2ce0 Avoid overlapping spotlight tiles with the app bar
The default inset that is given to the fixed layout layer needs to account for the app bar now.
2026-05-20 16:11:07 +02:00
Timo
ae20f59dea Merge pull request #3978 from element-hq/app-bar-media-foreground
Ensure that foreground elements of media tile do not overlap app bar
2026-05-20 21:36:43 +08:00
Robin
eaa049433a Merge pull request #3979 from element-hq/settings-footer-glitch
Stop the settings button from appearing while footer is fading out
2026-05-20 15:15:37 +02:00
Valere Fedronic
06b6122ec9 Merge pull request #3980 from element-hq/valere/fix_control_shown_in_PIP
fix(regression): control buttons should be hidden on mobile PIP
2026-05-20 15:07:24 +02:00
Timo K
ec3b52f963 del: windowMode$ from public callViewModel
replace with edgeToEdge$
2026-05-20 15:00:28 +02:00
Valere Fedronic
2fb5de3ba2 review: eqeqe
Co-authored-by: Robin <robin@robin.town>
2026-05-20 14:50:56 +02:00
Timo K
c9fa586db1 merge fixups 2026-05-20 14:25:40 +02:00
Timo K
3301d2fe03 Merge branch 'livekit' into toger5/view-model-call-footer-example 2026-05-20 14:25:25 +02:00
Timo
3adddf4b70 Merge pull request #3960 from element-hq/toger5/storybook-tests
Use vite storybook plugin for storybook tests
2026-05-20 19:57:21 +08:00
Robin
4b175b814e Update and simplify tests 2026-05-20 13:43:33 +02:00
Timo
bc3da2dda2 Merge pull request #3975 from Johennes/johannes/dev-deps
List additional dependencies needed for development
2026-05-20 19:34:25 +08:00
Timo K
e7c87e3439 Merge branch 'livekit' into toger5/storybook-tests 2026-05-20 13:28:03 +02:00
Timo K
ddde05c057 review 2026-05-20 13:26:52 +02:00
Valere
265781ea5e fix(regression): control buttons should be hidden on mobile PIP 2026-05-20 13:08:51 +02:00
Valere
1865110492 reproduce bug internal #578
Regression: Controls are shown in Mobile PIP
2026-05-20 13:08:11 +02:00
Robin
43f1b89535 Stop the settings button from appearing while footer is fading out
The designs actually never want us to show the settings button in the footer if an app bar is in use (as in Element X mobile apps), so just avoid showing it at all in that case.
2026-05-20 12:38:18 +02:00
Robin
284d76e333 Merge pull request #3977 from element-hq/posthog-updates
Allow PostHog dependency updates again
2026-05-20 12:22:51 +02:00
Robin
7b32135328 Ensure that foreground elements of media tile do not overlap app bar
Because the type of header that we use in Element X (an 'app bar') lives in a different place in the document than the other headers, it needs a special branch to propagate the right insets.
2026-05-20 11:55:27 +02:00
fkwp
f0db134b6e Posthog: drop $initial_person_info from outgoing events (#3968)
* Posthog: drop $initial_person_info from outgoing events

* Posthog: migrate from sanitize_properties to before_send

* strip URL fields from $set / $set_once

* enable mask_personal_data_properties

* review

* update tests to check for `delete` (not anymore `=null`)
rename: `applyPrivacyFilters`->`santizeSensitiveData`

---------

Co-authored-by: Timo K <toger5@hotmail.de>
2026-05-19 18:06:59 +02:00
Timo
0e3aaaafe8 Fix tap-to-show call controls on iOS (ReactMouseEvent -> ReactPointerEvent) (#3966) 2026-05-19 23:50:55 +08:00
Timo
78d76ad8a6 Bump babel/plugin-transform-modules-systemjs (#3974) 2026-05-19 17:03:25 +02:00
Robin
6b261611a5 Log unhandled promise rejections in rageshakes (#3972)
For more comprehensive rageshake logs. Copied from Element Web's rageshake.ts.
2026-05-19 13:44:16 +02:00
Johannes Marbach
6f7d4ec8eb List additional dependencies needed for development
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-19 11:14:53 +02:00
Timo K
a494b64055 Update MediaMuteAndSwitchButton.test.tsx 2026-05-18 21:00:55 +02:00
Timo K
e19d5865b0 cleanup 2026-05-18 20:08:39 +02:00
Timo K
bc81da43c2 use previous device selection logic 2026-05-18 19:39:29 +02:00
Timo K
68a487c9de translation fixes 2026-05-18 19:17:43 +02:00
Timo K
d5bebcc3a5 refactor video blur toggle 2026-05-18 18:11:32 +02:00
Timo K
dd79ef659c lint 2026-05-18 16:28:25 +02:00
Timo K
e22ab9355c Review (everything except translation feedback) 2026-05-18 14:31:03 +02:00
Valere
dfbb6c770e devx: Allow to run local dev EC on mobile in same wifi 2026-05-18 13:25:56 +02:00
Timo K
88f660e43f review 2026-05-15 18:37:25 +02:00
fkwp
04b0165961 Fix tap-to-show call controls on iOS (ReactMouseEvent -> ReactPointerEvent) 2026-05-15 12:29:26 +02:00
Timo K
e10bc6c7cf fix unit tests 2026-05-13 13:35:12 +02:00
Timo K
0082ade234 reuse exisng mute buttons (fixes most playwright issues) 2026-05-13 13:16:24 +02:00
Timo K
07e7090cc7 fix unit tests 2026-05-12 16:50:45 +02:00
Timo K
7ad8f9f182 Fix tests (accessibility issues with new buttons) 2026-05-12 16:48:08 +02:00
Timo K
7842f5a8d0 test coverage? 2026-05-12 14:11:48 +02:00
Timo K
c3cd6248ae simple lobby snapshot tests
(we dont want this to get too invested as we plan to refactor the lobby(
2026-05-12 13:14:34 +02:00
Timo K
f3ef7c074b lint 2026-05-12 12:52:23 +02:00
Timo K
246db5a820 simplifications docs and tests 2026-05-12 12:46:36 +02:00
Timo K
b042f2594d fix lints and format 2026-05-11 20:09:55 +02:00
Timo K
102542c1b0 update tests 2026-05-11 20:02:56 +02:00
Timo K
ff3b4c6ff4 cleanups 2026-05-11 20:02:56 +02:00
Timo K
7514bd4811 use footer view model in InCallview 2026-05-11 20:02:56 +02:00
Timo K
d29b920581 use footer view model in lobby 2026-05-11 20:02:56 +02:00
Timo K
936dfb193f add the CallFooterViewModel 2026-05-11 20:02:56 +02:00
Timo K
7615e146a5 Use ViewModel approach for the CallFooterView + interaction tests
(this is just imaginary. There is no view model yet.)
2026-05-11 20:02:56 +02:00
Timo K
64d5e8ca24 Introduce ViewModel.ts 2026-05-11 20:02:56 +02:00
Timo K
9f6ae4ba55 small device selection refactor 2026-05-11 20:02:56 +02:00
Timo K
d37c064df3 custom container does not work for playwright tests 2026-05-11 20:01:18 +02:00
Timo K
34486e6486 use container to skip playwright install 2026-05-11 19:57:36 +02:00
Timo K
ded6e4c1a0 fix tests dep optimization issues 2026-05-11 19:50:32 +02:00
Timo K
5bdbd80f37 try storybook only 2026-05-11 19:29:07 +02:00
Timo K
4f28bac749 without playwright container 2026-05-11 19:18:10 +02:00
Timo K
f37128414a try with firefox 2026-05-11 19:16:07 +02:00
Timo K
7ca890ea53 Delete vitest.setup.ts 2026-05-11 19:08:45 +02:00
Timo K
85762001a4 try file parallelism 2026-05-11 19:08:40 +02:00
Timo K
aa125d917d no setup file for storybook 2026-05-11 19:01:47 +02:00
Timo K
88f14ffcde try with playwirght container 2026-05-11 18:56:52 +02:00
Timo K
bc2be4dfab test in CI 2026-05-11 18:44:16 +02:00
Timo K
53626785b3 install playwright for unit tests 2026-05-11 18:22:58 +02:00
Valere
f1b61a9f1c update developer settings tab snapshot 2026-05-11 18:07:40 +02:00
Valere
2ac221661b review: Use Initializer instead of creating a component 2026-05-11 17:43:34 +02:00
Valere
25ac565f8b Merge branch 'livekit' into valere/devx/livekit_logs 2026-05-11 17:28:07 +02:00
Timo K
20d14fa4e0 use vite storybook plugin for storybook tests.
run via pnpm test:storybook
2026-05-11 16:40:05 +02:00
Timo K
5dfd1648c5 remove yarn lock 2026-05-11 16:30:57 +02:00
Timo K
1d5ac67324 Merge branch 'livekit' into toger5/fix-depnedabot-security-alerts 2026-05-11 16:28:48 +02:00
Valere
d0bfef0682 Improve error messages for sfu auth problems 2026-05-07 16:21:30 +02:00
Timo K
121d87b1d0 Merge branch 'livekit' into toger5/contribut-issue-first 2026-05-05 14:39:52 +02:00
Timo K
d5b218283d add max reply SLA 2026-04-30 13:14:13 +02:00
Timo K
d4a3c8bd80 Contributing.md update. Issue First policy 2026-04-27 13:57:38 +02:00
Valere
3bc8c0590d dev-tool: Add option to enable extended livekit logs 2026-04-24 18:27:05 +02:00
fkwp
f919cfa4c8 Translations updates 2026-04-22 12:29:57 +00:00
manfrommedan
d1b14d8bfb Add Promise.withResolvers polyfill for older WebViews (Chrome < 119) 2026-04-21 23:29:47 +03:00
Timo K
2faab3280a bump flatted and unidici 2026-03-25 16:07:01 +01:00
124 changed files with 10189 additions and 5000 deletions

View File

@@ -1,4 +1,11 @@
<!-- Thanks for submitting a PR! Please ensure the following requirements are met in order for us to review your PR -->
<!-- Thanks for submitting a PR! Please read CONTRIBUTING.md before you start. -->
> [!IMPORTANT]
> **Features and UI changes require a pre-approved issue.**
> Every PR must have a linked issue
> that a maintainer has reviewed and approved **before you started writing code**.
> PRs that don't meet this requirement will not be reviewed.
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) for ElementCall decided for this approach.
## Content
@@ -6,34 +13,34 @@
## Motivation and context
<!-- Provide link to the corresponding issue if applicable or explain the context -->
<!-- Provide a link to the pre-approved issue, or explain the context for a bug fix -->
## Screenshots / GIFs
<!--
You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|
You can use a table like this to show a before/after comparison.
Uncomment the markdown table below and fill in the last line:
|Before|After|
|-|-|
|||
-->
-->
## Tests
<!-- Explain how you tested your development -->
<!-- Explain how you tested your changes -->
- Step 1
- Step 2
- Step ...
-
## Checklist
- [ ] I have read through [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md).
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Tests written for new code (and old code if feasible).
- [ ] A linked, pre-approved issue exists for this feature or UI change.
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) in full.
- [ ] Pull request includes screenshots or videos for any UI changes.
- [ ] Tests written for new code (and existing touched code where feasible).
- [ ] Linter and other CI checks pass.
- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-call)

View File

@@ -237,7 +237,6 @@ jobs:
with:
repository: element-hq/element-call-swift
path: element-call-swift
token: ${{ secrets.SWIFT_RELEASE_TOKEN }}
persist-credentials: false
- name: Copy files
@@ -268,9 +267,10 @@ jobs:
GITHUB_EVENT_RELEASE_HTML_URL: ${{ github.event.release.html_url }}
- name: Push
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
working-directory: element-call-swift
run: |
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags
env:
SWIFT_RELEASE_TOKEN: ${{ secrets.SWIFT_RELEASE_TOKEN }}

View File

@@ -2,11 +2,15 @@ name: Test
on:
pull_request: {}
push:
branches: [livekit, full-mesh]
branches: [livekit]
jobs:
vitest:
name: Run unit tests
runs-on: ubuntu-latest
container:
# Make sure to grab the latest version of the Playwright image
# https://playwright.dev/docs/docker#pull-the-image
image: mcr.microsoft.com/playwright:v1.60.0-noble
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -25,7 +29,7 @@ jobs:
- name: Vitest
run: "pnpm run test:coverage"
- name: Upload to codecov
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:

125
.oxlintrc.json Normal file
View File

@@ -0,0 +1,125 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["jsdoc", "jsx-a11y", "react", "typescript"],
"jsPlugins": [
"eslint-plugin-matrix-org"
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "eslint-plugin-rxjs"
],
"categories": {
"correctness": "off"
},
"options": {
"typeAware": true
},
"env": {
"builtin": true
},
"rules": {
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
"matrix-org/require-copyright-header": [
"error",
"/*\nCopyright %%CURRENT_YEAR%% New Vector Ltd.\n\nSPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial\nPlease see LICENSE in the repository root for full details.\n*/\n\n"
],
"react/display-name": "error",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [
// "error",
// {
// "names": {
// "^this$": false
// }
// }
// ],
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "matrix-widget-api/src",
"message": "Please use matrix-widget-api instead"
},
{
"name": "matrix-widget-api/src/",
"message": "Please use matrix-widget-api instead"
},
{
"name": "matrix-widget-api/src/index",
"message": "Please use matrix-widget-api instead"
},
{
"name": "matrix-widget-api/lib",
"message": "Please use matrix-widget-api instead"
},
{
"name": "matrix-widget-api/lib/",
"message": "Please use matrix-widget-api instead"
},
{
"name": "matrix-widget-api/lib/index",
"message": "Please use matrix-widget-api instead"
},
{
"name": "matrix-js-sdk/src",
"message": "Please use matrix-js-sdk instead"
},
{
"name": "matrix-js-sdk/src/",
"message": "Please use matrix-js-sdk instead"
},
{
"name": "matrix-js-sdk/src/index",
"message": "Please use matrix-js-sdk instead"
},
{
"name": "matrix-js-sdk/lib",
"message": "Please use matrix-js-sdk instead"
},
{
"name": "matrix-js-sdk/lib/",
"message": "Please use matrix-js-sdk instead"
},
{
"name": "matrix-js-sdk/lib/index",
"message": "Please use matrix-js-sdk instead"
}
],
"patterns": [
{
"group": [
"matrix-widget-api/src",
"matrix-widget-api/src/",
"matrix-widget-api/src/**",
"matrix-widget-api/lib",
"matrix-widget-api/lib/",
"matrix-widget-api/lib/**"
],
"message": "Please use matrix-widget-api instead"
},
{
"group": [
"matrix-js-sdk/src",
"matrix-js-sdk/src/",
"matrix-js-sdk/src/**"
],
"message": "Please use matrix-js-sdk instead"
}
]
}
],
"typescript/no-floating-promises": "error",
"typescript/no-misused-promises": "error",
"typescript/promise-function-async": "error",
"typescript/require-await": "error",
"typescript/await-thenable": "error",
"typescript/consistent-type-imports": [
"error",
{
"fixStyle": "inline-type-imports"
}
]
}
}

View File

@@ -9,7 +9,28 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-docs"],
addons: ["@storybook/addon-docs", "@storybook/addon-vitest"],
framework: "@storybook/react-vite",
// THIS IS IMPORTANT
// vitest runs without Vite's normal dependency optimization, so we need to manually include the polyfills for the stories to work.
// otherwise we will get: new dependencies optimized: ...
// and
// ```
// [vitest] Vite unexpectedly reloaded a test. This may cause tests to fail, lead to flaky behaviour or duplicated test runs.
// For a stable experience, please add mentioned dependencies to your config's `optimizeDeps.include` field manually.
// ```
// which breaks the storybook ci on the first and only run.
viteFinal(config) {
config.optimizeDeps = {
...config.optimizeDeps,
include: [
...(config.optimizeDeps?.include ?? []),
"vite-plugin-node-polyfills/shims/buffer",
"vite-plugin-node-polyfills/shims/global",
"vite-plugin-node-polyfills/shims/process",
],
};
return config;
},
};
export default config;

View File

@@ -1,3 +1,71 @@
# Contributing code to Element
Element follows the same pattern as the [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md).
# Contributing to Element Call
Element Call is a native Matrix video conferencing application built on
[MatrixRTC (MSC4143)](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)
and [LiveKit](https://livekit.io/). It runs in multiple deployment contexts — as a
standalone web app and as a widget embedded in Element Web, Element X iOS, and
Element X Android. It is also the primary R&D foundation for MatrixRTC, which means
its architecture, maintainability, and flexibility are held to a high standard.
We welcome contributions from the community. This document explains how to
contribute effectively so that both you and the maintainers get the best outcome.
## Issue First Policy
> [!IMPORTANT]
> Before writing a single line of code for a new feature or UI change, you **must**
> open an issue and have the approach agreed with the maintainers.
>
> **We will not review or merge feature or UI pull requests that arrive without a
> corresponding, pre-approved issue.**
This is not gatekeeping — it's how we prevent wasted effort on both sides. Element
Call must work correctly across multiple deployment contexts and meet specific product
and design requirements. It is also a fast-moving codebase that underpins ongoing
MatrixRTC development. A PR that looks reasonable in isolation can easily conflict
with in-progress work, planned architecture changes, or design decisions that haven't
been publicly documented yet.
The issue is where we resolve all of that **before** anyone writes code.
**Bug fixes** are no exception — most confirmed bugs should already have an issue anyways, existing issues that are marked as bugs have an implicit maintainer approval. If the solution for the bug is controversial it is highly recommended to discuss the approach in the issue before opening a PR.
## Contribution Workflow
1. **Open an issue** using the [Enhancement request](https://github.com/element-hq/element-call/issues/new?template=enhancement.yml) template.
2. **Wait for feedback.** A maintainer will comment on the issue **within two weeks**. The use case and approach will get dicussed.
This may involve questions, suggestions, or a request to adjust scope.
This also allows to bring design and product into the loop before code gets created.
3. **Get a green light.** Wait for explicit approval from a maintainer before starting
implementation.
4. **Implement.** Write the code against the agreed approach.
5. **Open a PR.** Link to the issue in your PR description and satisfy the checklist
in the PR template.
## Code Quality
Element Call moves fast and the codebase must stay clean and maintainable.
- **Take responsibility for AI-generated code.** AI tools can be a useful aid, but we expect all the generated code to be understood and reasoned about by the contributor. Questions by the maintainers should be answered without just forwarding them to AI. The maintainers also have access to AI tools. If your contribution is just transporting messages between LLM <-> maintaines all our time is better used if the maintainers decide to interact with AI for this specific problem by themselves.
- **Think across deployment contexts.** Changes must work correctly in both standalone
and widget modes. Consider how your change interacts with Element Web, Element X
iOS, and Element X Android.
- **Write tests.** New functionality should be covered by tests. Where it is feasible,
existing uncovered code touched by your PR should also gain tests.
## Contributor License Agreement
All contributors must sign the
[Element Contributor License Agreement](https://cla-assistant.io/element-hq/element-call)
before their contribution can be merged. The CLA assistant bot will prompt you
automatically when you open a PR.
## Getting Help
The best place to ask questions about Element Call development is the MatrixRTC room:
**[#matrixRtc:matrix.org](https://matrix.to/#/#matrixrtc:matrix.org)**

View File

@@ -177,6 +177,13 @@ discuss and coordinate translation efforts.
## 🛠️ Development
### Dependencies
- Node.js (e.g. via [nvm](https://github.com/nvm-sh/nvm))
- [Corepack](https://github.com/nodejs/corepack) (not bundled with Node.js anymore starting from 25.0.0)
- Docker client and runtime + Docker Compose (for the backend)
- On macOS you can install everything with `brew install colima docker docker-compose`
### Frontend
To get started clone and set up this project:
@@ -219,14 +226,15 @@ including federation:
- Minimum TLS reverse proxy for
- Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost`
- Local Element Call development `call.m.localhost` via `yarn dev --host `
- Local Element Call development `call.m.localhost` via `pnpm dev --host `
- Element Web `app.m.localhost` and `app.othersite.m.localhost`
- Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST
These use a test 'secret' published in this repository, so this must be used
only for local development and **_never be exposed to the public Internet._**
Run backend components:
Make sure your Docker runtime is running (e.g. via `colima start`) and then start
the backend components:
```sh
pnpm backend
@@ -244,6 +252,14 @@ pnpm backend
> browser's trusted certificates or by simply copying and pasting each URL into
> your browsers address bar and follow the prompts to add the exception.
### Updating snapshots
To update snapshots used in tests, use Vitest's `-u` flag, e.g.:
```sh
pnpm test DeveloperSettingsTab -u
```
### Playwright tests
Our Playwright tests run automatically as part of our CI along with our other

View File

@@ -1,20 +0,0 @@
module.exports = {
presets: [
[
"@babel/preset-env",
{
targets: {
node: "current",
},
},
],
[
"@babel/preset-react",
{
runtime: "automatic",
},
],
"@babel/preset-typescript",
],
plugins: ["babel-plugin-transform-vite-meta-env"],
};

View File

@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDGjCCAgKgAwIBAgIUbSbx+1UGptOTGefqEn7Zh3yoChIwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHODfkrFsOkqCnXnTb
QWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+1azC
SlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+I9O6
NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBhwLyC
DW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+Liyt
X/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPERYS2H
Ru/RAgMBAAGjUDBOMB0GA1UdDgQWBBTXNfLAKVayGQda/JZLPszrpz6LVzAfBgNV
HSMEGDAWgBTXNfLAKVayGQda/JZLPszrpz6LVzAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQCvGfyopHHgZB+horGH6i/Xg41V+r4d0o092F1Lfr4vh86e
XMakRw92vsyk/iWOnLPNPcpVWzPcvINaCs/bahgnGSOAnrA4jjcXqymyGIy/6xc5
1EeZAxehiL9E5q4LQ841HDX0gps4ZzUO1BRYQcjG9Rdts83JO2ekkfHkZdNj2eQr
KOrr92Na1/w+EQdo/T9Rs2ES623xKEOxPqb8d/rx5Z4DdeuGx1u+3AfS76Lpo4ni
EJ0g1ImqdSUtiOLzeCQh6pqqb+vuFbxAyeyYSAJ49847EtFBvZCmWmPL2JICg9uq
7rKW/qDfEK9GUs0GWCs3+mJkNvOOxBwtMuQrL7ZF
MIIDGjCCAgKgAwIBAgIUOlA2wgQUGZkKqNDvvifFWEsJfvYwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNjA1MTgwOTM0
MzFaFw0yODA3MjYwOTM0MzFaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcImv3pStfIUo7PbOO
XRVUXuDlApBOrg2dCnvZQ1Jfaf4MftGHj/pURkF4eoBuyH4k4+NLzWD0VcU1cM74
RnxowJt4AceCGe5RK/rqal5fapXc2vYMM8P6xaQR86gkxohpufsLgTnSweh74yqN
B5WHUnCX00/X0bh1ho2BMUvGM9+dI4MdgKdaQDgWK4zg9hwp2Z6Yq7SkJ/D8+sGW
WGpn3osDakL8HTBqop+YVJgF40db50yFurfcfQ0gjVtT4JW8ejO9j8PS/S2oQ/s5
mA1B470XhLtT5qTjGm2bp3WpYkTi5widps8PDzBp5eNr0HrvJqcw7BGpbvBlLa+3
7dhLAgMBAAGjUDBOMB0GA1UdDgQWBBRDfyRM4yKUqW6vu/2KUSXGb8vswDAfBgNV
HSMEGDAWgBRDfyRM4yKUqW6vu/2KUSXGb8vswDAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQBoAhD4W4Yi/VJ2pTKrzhstn1UF1rgQnRddnn97v5BaEV/X
uuBXbSO+/ewjQUupRjePZFp9FFe9co1OiduKcDExlvPU1eIqkWAwDWjMDpI+Lw5q
KI5yHzplmMrT/7jn9Tepl9atrIcfDeFkP1dGtdRPyU6ARJEEWJSKeH9ftmImAsbM
ykXAqSyRl8+bPx1ISG4cNihOxFd38VPDHIW53umaRBgRcN4GcvloKBGrVtRFNM//
H+md8HmNQMP+e7FamETxs28DxjsdpygxjiFNY/T2eD67dH50ZxC3qCxEG6TJsoAg
TYJafnqEcGDfiWQyNZRBypuaRsRmmTR27hCPVgi8
-----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHODfkrFsOkqCn
XnTbQWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+
1azCSlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+
I9O6NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBh
wLyCDW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+
LiytX/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPER
YS2HRu/RAgMBAAECggEABhB9CxYAE5p9D3s9nWsJcSDUdELRQSYlOoPFLmeMkF9c
dcvq7LmduMh1Q8TnoivOBxRIwbj7pZHEYfYJM0TmH82wrQzXu5KLVltm4gTkVt9b
DR8vjBgYdb8HVpM17Cl2xhW62XpJIiseFRUsHc/9sf2Egc3MIpPuIleGR0budbSW
ybBkqEokTYTSiAztcu3G+VN0U9MsJgLMa8HApya7M48ojdrhzngVHZRUOXul9o7u
zYJWSxPHIIYp5C4pYQBAx8OttThwKK1A9lwbQ2EJx0KnTbBC6O5Gna/jENpGd1h2
rzK/9MONtsjln7IejP+4mDlNupS6SF3zzHPBHjqKAQKBgQDtXUIKPiVTFS45yWtK
XD62s3j8jfIi+22b/C30fCPtppn0cm/0zY+vovgWVUBnQXkExafRthZCuxnE8ry7
E29S40+4z9yivAC9dz7vHZUbyIFP6VG9WyhUYo+/WqOIePyh+iBISQ9TA1DneIYz
+VZ8iU5GvdybUPl2C5WN8seaoQKBgQDW3EwVN2EEkChLRJbQYN2qpjn+0vYESMJ8
K0sgMRtgh4+/T2Xb9b8O/dd87Fi/4oaUqWZ2E2sdsXq8P/IEo0cv6SRfHMy7GyxL
RM7ztwUfMC4LVWi0ZIXMrm4gRDGN2XjGvhkX6fU2lSf6azWL1K3wI3amNV2b7P7d
ItpvdkH3MQKBgQCXf29YJEQkXB9t6J3fDzND3xb4cwy5wSo7ZeBa7CTuWOhoeeX1
JIJyAp0/e9goT0SThChRlFtu6gZPivJkoMnr6IOInLrg7we15fc4HPR/kCDgxTVT
m2wJOAMxigNYZogwRfn2yRLL1BD+PBHD+H936xcX1bSJOUyPSGOC/xLhIQKBgQCb
kCDd85ygyycBaAWxlZCor3WqFF/fNjbp5Aaepi9mMoBXSUs8eK7+UbelURHozEAY
fpYaw3B4rTlp9vppdTZjb+/PlXB9v+zQCl+0gTyKGj4cIpiOk4F0co51eipOw7f4
XUaZ0+CgxlmNq/W26iONjH+pU1YVQQA+Z6+zp/GW4QKBgQCrzYgeugxxqgJzyIRu
0njJkIg+T5gHvsQrtpzq7LVob+HBiBiT7eDOeGDXTK8F//sk969QVrDMQsTMvGW9
sG1oTqxciALTMqkJTf8+hT9Uogir0/iTbJUzTt5vPYpQOEQwQHIXMUTjZ9C6NDKT
QlmeMCxeWyPYqoMfwKmdtDP/Iw==
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcImv3pStfIUo7
PbOOXRVUXuDlApBOrg2dCnvZQ1Jfaf4MftGHj/pURkF4eoBuyH4k4+NLzWD0VcU1
cM74RnxowJt4AceCGe5RK/rqal5fapXc2vYMM8P6xaQR86gkxohpufsLgTnSweh7
4yqNB5WHUnCX00/X0bh1ho2BMUvGM9+dI4MdgKdaQDgWK4zg9hwp2Z6Yq7SkJ/D8
+sGWWGpn3osDakL8HTBqop+YVJgF40db50yFurfcfQ0gjVtT4JW8ejO9j8PS/S2o
Q/s5mA1B470XhLtT5qTjGm2bp3WpYkTi5widps8PDzBp5eNr0HrvJqcw7BGpbvBl
La+37dhLAgMBAAECggEAEsS4gc5jBk50I+bo3KYn2DqHgj/qpOqbTFNkS9uh3UJa
fZoJCeiuyM6hNCBVq/uB3mFeg1Au5XAiAqiK2KFwdw8gIS7lkqgXU76brO4YZhPj
6+aOSS03079KV7YYckNDRqJKoTlpgAI7Nhk6ljVhLiEk07tdD65wJACGpg8M8sg9
dyAz+ANs9gs65iF5LYjH61O/AFlLqCRQh5/z0mjGX6G9uN27nxeUY4+n4QMAcd9D
Gcygxjt+4nlQayNAlKMwVfps9bWNtI3Ye9knY4WGkrv5cJbW3bgjV6qrvQsbukbq
xEYzcIlUiWGO9Tv7MN6rk5uQOyoKT/KUnfRmdVd3YQKBgQDMhWm6Q+WuI7Pyn57R
tmY4rs+fSqTmv6xAOcozKJxffGaEwSUuNA15NvR/7iedcNqmH3XT9j90ZNVHe090
ocm1HDUvzC9G5GRrdO6JTTksRaIMZEhosWxqH3DIuBJPLGbF/4obGE7//PJtmDEp
QVL9Aa0WrcwAWhRzUdvCE+taMwKBgQDDbyZIvtlEr1w2V0bjXO536rRksBapc2ZJ
XRKtrXivuVtiZYNDB0I7CCJ52cka61n3kyZz2mhQmLq4cAZXyKYWE2i643O+kc3S
lpZEFSfDZ+3YlhxMxG9oEcgUSwVdbPlAhd/UR8V6n8o2Fm+gug9h6E2zY4fgHLJF
8hOWoD4hiQKBgA7YXD1F8mT6eHRS+78zIyZYIf/o9iE9pm4fA7tE5lzT9ckLD/zT
kGrM/2BN1BhMecJ3JCFXjXGQZB7FJ5ZKrA52VrH6ezAFIfjeyvWyYkUBZOrLWKoo
vrrRP2mCWuneSjNzAf5HfGx+WsZztpXNBQ4SUhMEWHtqDnP0bCQhOAMbAoGAPfLv
qcOFT3ZevoLv34ZHuQ9W20vOAyynUb4E+7SvOtSAmTIgZ5DXd6recs2MJ9JOlGG6
oKKsyk9/cJNiD1V1AC5q1kLfH5tMKOK/AxnJnvFEvZDnq5Xg0pZAW95j9vdiEwfc
qYeOm44nJPn7rHEOCzT93E1CdtHh2LYha2+kAjECgYEAh4qODleBi+2fnf2eq494
/tAot3szu2+gjyCN00vGjtzoAuDKTYgo0cbU1ILk0Pgpp1NcIvdHz/wQnG9RLX7e
Dfy1Q+UkyBK67SJUPvcYqBEaZ6ddyijJDunqh+U3nIBGP+IntKIKMIKiLF6wzTKz
NRpK1HNmllp+O692ZtxoNDU=
-----END PRIVATE KEY-----

View File

@@ -1,21 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDgDCCAmigAwIBAgIUT9NYpZbrAKokSPSTE3zzsAMowvEwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDK1DNwTQWmyK71Ar56NvmSMQ8s
qUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgfJlZMd4lEvt6V
EEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+7Z22i6cGMOnQ
0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6WxJmFF+eV9P/ax
fxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiVG1jCrVcaKNGT
PYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrncneIJR24/AgMB
AAGjgbswgbgwHwYDVR0jBBgwFoAU1zXywClWshkHWvyWSz7M66c+i1cwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwSQYDVR0RBEIw
QIIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
cnNpdGUubS5sb2NhbGhvc3QwHQYDVR0OBBYEFIkGX+cEJ1ISKIwuT1zzp7uHJ90e
MA0GCSqGSIb3DQEBCwUAA4IBAQBnnnfB7KmyYo16ZYUCmoqGhbM4p8npeYTh5ySb
K01YwGCnMU1qGfJnKHaRwQ2+KtVGZnpBdjmsHcOUetA3V2BirPaYowMCMtaI36LD
LnxvboSZLX0mgEYuN7HmxW4a7fSelDecTYa7xti1sNhE/w8xW7Lky046/DousyUy
d9x3wJ183GGj1W2p6bR1E4sqTr/VbmoULQxnqA3GUNOxW3lRL5e8lQ6jJVRmMF4k
92BtMPrI/m7jwHj0f/WBLI8mdJ/O/W/NxQOG475FZePDfrg+MkeXPChPggf42/ou
AMm56FNB7e1l0b1Fots730RfpCPuXpiAxL4pisS0X1dMVeeM
MIIDijCCAnKgAwIBAgIUWkx2ad/F7QIj1JDaYfbLhiRV+EswDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNjA1MTgwOTM0
MzFaFw0yODA3MjYwOTM0MzFaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0hora/UCYMtrLJc6BOjonPUPi
bYpbNiZYnvnqI4doKbV0LBT2TfokT7tpgdPCtHKV0RknsVSL8vhlXpkRqIiWPml8
sZaa0+5NDGCQxexS2WVBlsoNCmAaqi/HNSFop6xaxGpQ3bu0iV3oIkUihveXAl6H
C0VYyGifQ8D5onzepW2ayhemu47YRNSo8wETY5vIi0i/iajTTaw6JvwS+8Kv5/QV
5prdvcFlG/oBs12p0+KoRyxskyzcdBdyIarvfY+9nDZwym5GfN32xO/iqtDuDQzw
Q09h2OsfHJCw70IpHcgXLlEQF2DsFbmbVpWSU6HcMm6B7Yw1YeE64W4PRJp3AgMB
AAGjgcUwgcIwHwYDVR0jBBgwFoAUQ38kTOMilKlur7v9ilElxm/L7MAwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwUwYDVR0RBEww
SoIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
cnNpdGUubS5sb2NhbGhvc3SCCCoubmlwLmlvMB0GA1UdDgQWBBSd0sKIKmZzTnxT
gNHHjsJNnFcYaTANBgkqhkiG9w0BAQsFAAOCAQEAeffRTrD9o9PVRIoul5r2chwP
WF7JtvPdC5xWy9rlCfmIKRNzNRnpVw/mDF/jdhlWcENt3psN8Vb1NM3SECKve9KL
8bDD2rJEoLBHIFQPS+XpEPqVGLHQcfBtGgs2XdILKvgXJyBHY/pgNZkQmXxYDVoc
bH9PjJJ4V3t6+tiVWZ792739EU/pHaSz7tab+ycTiggs7mo18E5jpYILhWsDqIVs
Kz3uczK2OR8537Ix64Z9kmKiklVAqE53odV7Qx2B+7DoOD/7KBN7SMy1KvR1ae6I
p1ivtDKpBZWbb1ccFxp2cQ30qRHLJrt2YRwz268gx/A6rGXuW6UQPYf4ISNR4Q==
-----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK1DNwTQWmyK71
Ar56NvmSMQ8sqUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgf
JlZMd4lEvt6VEEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+
7Z22i6cGMOnQ0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6Wx
JmFF+eV9P/axfxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiV
G1jCrVcaKNGTPYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrnc
neIJR24/AgMBAAECggEANRp6vzfDN4gKWoaV5TyYegCTNv+4rRl74cd9pjmx6Jam
uWaUXCx1etpNqPPWcG1Z9OKLLRnk+kjgKGOqq4mObGvGreNeBot7bHOJZADtwMMI
YG+Gp7StlclS1YoEHoDmezA/AcqDgTXa9KF0rdMBb1sGFJCLAuBNSJCxtVV6CQIz
X26uT0m+Wx8MQyQWA7Sqy6DQNJo++IZkvr7a3cidqBOUPs+QvnIV5JsUb2gp5tGn
zk+ObeRjoFFWYAN/NK7bneRenkP40m3MSL8ZfaEuuonui7CrxM1SiQyq2N1u/Aoy
OE1JtNaVPbLBo6kG5al7Sj4Z0zhRt+iv93S2lZMkBQKBgQD2+FpLTqyLO1NDOFkE
kxU+LdLOx0OV9wASC0ApPOu1dHMG6ksByr7TWeiu6GJDgajusPB7NVPOt2cm4iWU
xPxXPO5l87uiSvu80h5uG4Qdj8KEijHkdap2wbVkU/mm8lBKC36jyBQIlJKySyXY
zSEMfLK9jQPKz5cKKT3dVj/fAwKBgQDSPq9oks6K96MAB66o6cm214otQlnTQkPM
xgjtjddX+Lp9tgihGvtSfPbyy89oUDHCfKvW/AHG52e5dec5YUi6mVdHEWbk33Kt
BoQuxeK3XseIDlD/JD9Dd7KfUyO5w2jtYLfNdqez41O4qj2N52m1KwJYTwMsc8Kq
izVgkC5hFQKBgBFAc/5CtqbbNAvECePZ6mf3h3xOSxhUsrqP8qFu0gBQ7CAVibvM
T9wvsaNWNFcG3age0A2rQfl0sk3zCjEEOaRWa0jP59GEb2VXQCzs2yO9gRcFGEsf
NRMqoOMrQos47gbeGrCSL2QSDNVLjo9AdQiMRWgcS6GFMsXQ77NgbQHFAoGBAI4a
YGTGFWRITJvQlXUFz5kNxg8hMaVgvILDt3UY0dxb+XDOgLajjgsK+77Pkrhmu7tA
mMUOQAU4kxr/XfGil43H5v3Z/Tnk7ZWVOfKDPeHC5gpH4ucQkNIBLXISt6rvMRSA
srrk4CTuGcBPEJvBNemF0Gfvv61j8MdkoAdMbIyhAoGAfGR6yZLBmRMsW5PKmcpT
nq2oSeUpmtGZra6pWz/3XU7AgrCLcx1DmqEjm4w7y5NQJmxyMZqqdTJILCjr3Srt
+2F0NqQL6Li+xQGibAvDj0Jxyol38RvFC0J/w2vQmuF0hTuH95yknSd7FPXK+DPG
qYgXLjun9dht6kx9vGJ69wI=
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0hora/UCYMtrL
Jc6BOjonPUPibYpbNiZYnvnqI4doKbV0LBT2TfokT7tpgdPCtHKV0RknsVSL8vhl
XpkRqIiWPml8sZaa0+5NDGCQxexS2WVBlsoNCmAaqi/HNSFop6xaxGpQ3bu0iV3o
IkUihveXAl6HC0VYyGifQ8D5onzepW2ayhemu47YRNSo8wETY5vIi0i/iajTTaw6
JvwS+8Kv5/QV5prdvcFlG/oBs12p0+KoRyxskyzcdBdyIarvfY+9nDZwym5GfN32
xO/iqtDuDQzwQ09h2OsfHJCw70IpHcgXLlEQF2DsFbmbVpWSU6HcMm6B7Yw1YeE6
4W4PRJp3AgMBAAECggEAIgdIbk4VmnrfjjKCsg5JPvNH9AsE7PuQj9zrq+xljkdq
aksS6ni5YZXb9F/iDE4aWU4waTB+iODUXLtPrCnyESwTk0sgYe/39/MQ0slUKivL
b+keDgY6JlyVI/5KXWFZ1kQ27CZXxwiruGGZWZBKZF8wdVE1Ea65Neg+HHA6DHee
Jck002gtgO/J1MMbB1MzdtGcsejYLrA+mO6YddQhA65xdQMljTEfyUwgTVv0pWde
biyKegGK77vlsOyoCkMpVYORG5NMV1Kxs+htA79yuIW71tWHqVbcRMyoM+BaHzPh
7uprs+8vYDFrO39LseczA8gURWwUsCgQ0yQ6Ix5W7QKBgQDnSK4AzjPpDEArdHuV
VGKyzrfPtzH0VV/yTH9hvByNG6i/x8sE/r2KPi5nRMi4PAjjqmxyO1G5qwDOfzvK
sBvwFrTRpmbnqGITVKPPivdoI9+RveN+FxhOXVA8NylAOv/dtSoakYwg3e507UsC
RuFW3Re0Oc+0XFq4C8rQyLkIOwKBgQDH0T9gww+XbwIGCtiNpnEziU9FXBKSVwXf
dCxYcTLPATq3BqHmP4OUA0v+sa3wPcnBkXF7q6eoB9+S6ZYQA/b2BXGU5/j9xYd4
29cF4DlPkhTwF9S8b+h1zhlGIn96Lw/vZuj7Bc3wuwxvB17d8dpyo8bZynIe7BvF
KSPJz+2O9QKBgFFyd8xS0VcFeGeVKpwozmUXhQWCBvZ7RkGGjOk3HHrYvbFjw2vr
5YWUZjT5tRGkGqFJ98y2dQ5EWRFfHwg+wmfnJyAZUG3OD1OtX86Lqpqi321siHtz
2JxoIgRCjKVQ4aAK11vp24YLgZjto5eWrG4xh9Jw9WMXjt73UCH8PaTXAoGBAIff
TY1qlmuO3H1nWqHXkBpPQEwVs7s22ZN817q8HqSMXXSfWe/LOJmpND/YakJ2gX7S
e6xwqOylje3EUHpLd98LDJUIuFM3wkr4klo4gkANQZeRXONV5WhV4PHD+5MF9XwB
KmOnKsaLKoVFKckZ8EUMAOePtdI5ExkaRG+yqAMRAoGAJyUFK+V9ST1N/6wYgqor
vywMSRE2cF2WvVIxdMvWffmpoj40bG6lAlaSWm29E2T8SVvAKsRid0wDgCQ4QTEn
ft7yUDjqVALCJVCrOFHDY0BPStkm6njMWagr/0lGr9zUWqbBOKJhNfDJlykv8gaF
8kWTgabrMCKmpTi7fBWbzZA=
-----END PRIVATE KEY-----

View File

@@ -3,7 +3,7 @@
# Step 1: Create a Root CA key and cert
openssl genrsa -out dev_tls_local-ca.key 2048
openssl req -x509 -new -nodes \
-days 3650 \
-days 800 \
-subj "/CN=Element Call Dev CA" \
-key dev_tls_local-ca.key \
-out dev_tls_local-ca.crt \
@@ -21,7 +21,7 @@ openssl x509 \
-CA dev_tls_local-ca.crt -CAkey dev_tls_local-ca.key \
-CAcreateserial \
-out dev_tls_m.localhost.crt \
-days 3650 \
-days 800 \
-sha256 \
-extfile <( cat <<EOF
authorityKeyIdentifier=keyid,issuer
@@ -35,5 +35,6 @@ DNS.1 = localhost
DNS.2 = m.localhost
DNS.3 = *.m.localhost
DNS.4 = *.othersite.m.localhost
DNS.5 = *.nip.io
EOF
)

View File

@@ -12,6 +12,7 @@
"feature_use_device_session_member_events": true
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_mode": "legacy",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,

112
docs/_developer.md Normal file
View File

@@ -0,0 +1,112 @@
# Developer help
## Testing on Mobile Devices
When developing Element Call locally, you may want to test on physical mobile devices (iOS/Android)
on the same WiFi network.
**Known Limitations:** For now this setup allows to use your local EC server but not yet the SFUs and Synapses.
### Prerequisites
1. **Start the dev server**
```bash
pnpm dev
```
Check the output for the `➜ Network` (this will contain the local IP address of your laptop)
```
➜ Local: https://m.localhost:3000/ 12:06:48
➜ Local: https://vite.m.localhost:3000/ 12:06:48
➜ Local: https://vite.othersite.m.localhost:3000/ 12:06:48
➜ Local: https://vite.nip.io:3000/ 12:06:48
➜ Network: https://192.168.0.122:3000/
```
2. **Transfer the CA certificate to your phone**
The file is located at `backend/dev_tls_local-ca.crt`. Transfer it via:
- Matrix room
- AirDrop for iphone
### IOS Setup
**Install the certificate profile on iPhone**
- Open the `dev_tls_local-ca.crt` file on your iPhone
- You'll see "Profile Downloaded"
- Go to **Settings → General → VPN & Device Management** (or **Settings → General → Profiles**)
- Tap the "Element Call Dev CA" profile
- Tap **Install** and enter your passcode
- Confirm by tapping **Install** again
**Enable full trust (Critical!)**
- Go to **Settings → General → About → Certificate Trust Settings**
- Under "Enable Full Trust for Root Certificates"
- Toggle **ON** for "Element Call Dev CA"
- Confirm the security warning
**Access Element Call**
Find your laptop's IP address (e.g., `192.168.0.122`) and use one of these URLs in Safari to validate:
```
https://192-168-0-122.nip.io:3000/
```
**For Element X iOS Developer Tools**
In Element X's developer settings, set the Element Call URL to the nip.io url (replace . with - in the IP address):
```
https://192-168-0-122.nip.io:3000/room
```
### Android Setup
**Transfer the CA certificate to your Android device**
The file is located at `backend/dev_tls_local-ca.crt`.
**Install the certificate**
This might vary by Android version and manufacturer, but generally:
- Open **Settings** search for "CA Certificate"/"Certificate"
- Tap **Install a certificate** or **Install from storage**
- Select **CA certificate**
- Confirm the security warning
- Navigate to and select the `dev_tls_local-ca.crt` file
- Give it a name like "Element Call Dev CA"
**Access Element Call**
Find your laptop's IP address (e.g., `192.168.0.122`) and use one of these URLs in Chrome to validate:
```
https://192-168-0-122.nip.io:3000/
```
**For Element X Android Developer Tools**
In Element X's developer settings, set the Element Call URL to the nip.io url (replace . with - in the IP address):
```
https://192-168-0-122.nip.io:3000/room
```
### Why nip.io?
[nip.io](https://nip.io) is a free wildcard DNS service that automatically resolves domain names containing IP addresses. For example, `192-168-0-122.nip.io` automatically resolves to `192.168.0.122`. This means:
- No need to regenerate certificates when your laptop's IP changes
- Works from any device without DNS configuration
- iOS/Android treat it as a proper domain name, not an IP address
- One-time certificate setup works for all future IP addresses
> [!IMPORTANT]
> Make sure your network router doesn't enforce DNS rebinding protection (which will
> break nip.io). If it does, try allow-listing nip.io in your router's administration interface.

View File

@@ -7,6 +7,9 @@ A few aspects of Element Call's interface can be controlled through a global API
- `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.
- `controls.onPipMediaOrientationUpdate: ((orientation: "landscape"|"portrait") => void) | undefined` Callback called whenever the PiP media orientation changes.
The client should track this value to already initiate the pip in the right orientation.
It should update the orientation of the current Pip window when called.
## Audio devices

View File

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

View File

@@ -1,32 +0,0 @@
import type { UserConfig } from "i18next-parser";
const config: UserConfig = {
keySeparator: ".",
namespaceSeparator: false,
contextSeparator: "|",
pluralSeparator: "_",
createOldCatalogs: false,
defaultNamespace: "app",
lexers: {
ts: [
{
lexer: "JavascriptLexer",
functions: ["t", "translatedError"],
namespaceFunctions: ["useTranslation", "withTranslation"],
},
],
tsx: [
{
lexer: "JsxLexer",
functions: ["t", "translatedError"],
namespaceFunctions: ["useTranslation", "withTranslation"],
},
],
},
locales: ["en"],
output: "locales/$LOCALE/$NAMESPACE.json",
input: ["src/**/*.{ts,tsx}"],
sort: true,
};
export default config;

20
i18next.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from "i18next-cli";
export default defineConfig({
locales: ["en"],
extract: {
input: ["src/**/*.{ts,tsx}"],
output: "locales/{{language}}/{{namespace}}.json",
defaultNS: "app",
keySeparator: ".",
nsSeparator: false,
contextSeparator: "|",
extractFromComments: false,
functions: ["t", "*.t", "translatedError", "i18nKey"],
transComponents: ["Trans"],
},
types: {
input: ["locales/{{language}}/{{namespace}}.json"],
output: "src/types/i18next.d.ts",
},
});

View File

@@ -16,6 +16,19 @@
<script>
window.global = window;
</script>
<!-- Polyfill for Chrome < 119 (Huawei WebView, etc.) -->
<script>
if (!Promise.withResolvers) {
Promise.withResolvers = function () {
var resolve, reject;
var promise = new Promise(function (a, b) {
resolve = a;
reject = b;
});
return { promise: promise, resolve: resolve, reject: reject };
};
}
</script>
<% if (packageType === "full") { %>
<!-- Open graph meta tags -->

View File

@@ -11,7 +11,7 @@ export default {
vite: {
config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"],
},
entry: ["src/main.tsx", "i18next-parser.config.ts"],
entry: ["src/main.tsx", "i18next.config.ts"],
ignoreBinaries: [
// This is deprecated, so Knip doesn't actually recognize it as a globally
// installed binary. TODO We should switch to Compose v2:
@@ -24,13 +24,7 @@ export default {
"normalize.css",
// Used for its global type declarations
"@types/grecaptcha",
// Because we use matrix-js-sdk as a Git dependency rather than consuming
// the proper release artifacts, and also import directly from src/, we're
// forced to re-install some of the types that it depends on even though
// these look unused to Knip
"@types/content-type",
"@types/sdp-transform",
"@types/uuid",
// We obviously use this, but if the package has been linked with pnpm link,
// then Knip will flag it as a false positive
// https://github.com/webpro-nl/knip/issues/766

View File

@@ -11,22 +11,29 @@
"register": "Регистрация",
"remove": "Премахни",
"sign_in": "Влез",
"sign_out": "Излез"
"sign_out": "Излез",
"submit": "Израти"
},
"analytics_notice": "Когато участвате в тази бета, вие съгласявате се с събирането на анонимни данни, които използваме, за да подобрим продукта. Повечето информация за данните, които следим, можете да намерите в нашата <2>Политика за поверителност</2> и нашата <6>Политика за бисквитки</6>.",
"call_ended_view": {
"create_account_button": "Създай акаунт",
"create_account_prompt": "<0>Защо не настройте парола за да запазите акаунта си?</0><1>Ще можете да запазите името и аватара си за бъдещи разговори</1>",
"not_now_button": "Не сега, върни се на началния екран"
"feedback_done": "<0>Благодаря за обратната връзка!</0>",
"headline": "{{displayName}}, разговорът Ви приключи.",
"not_now_button": "Не сега, върни се на началния екран",
"survey_prompt": "Как мина?"
},
"common": {
"audio": "Звук",
"avatar": "Аватар",
"display_name": "Име/псевдоним",
"encrypted": "Шифровано",
"home": "Начало",
"loading": "Зареждане…",
"password": "Парола",
"profile": "Профил",
"settings": "Настройки",
"unencrypted": "Нешифровано",
"username": "Потребителско име",
"video": "Видео"
},

View File

@@ -49,6 +49,7 @@
"profile": "Profil",
"reaction": "Reakce",
"reactions": "Reakce",
"reconnecting": "Opětovné spojení...",
"settings": "Nastavení",
"unencrypted": "Nešifrováno",
"username": "Uživatelské jméno",
@@ -57,6 +58,14 @@
"developer_mode": {
"always_show_iphone_earpiece": "Zobrazit možnost sluchátek pro iPhone na všech platformách",
"crypto_version": "Kryptografická verze: {{version}}",
"custom_livekit_url": {
"current_url": "Aktuálně nastaveno na: ",
"from_config": "Aktuálně není nastaveno žádné přepsání. Používá se URL z well-known nebo konfigurace.",
"label": "Vlastní Livekit-url",
"reset": "Resetovat přepsání",
"save": "Uložit",
"saving": "Ukládání..."
},
"debug_tile_layout_label": "Ladění rozložení dlaždic",
"device_id": "ID zařízení: {{id}}",
"duplicate_tiles_label": "Počet dalších kopií dlaždic na účastníka",
@@ -64,13 +73,25 @@
"hostname": "Název hostitele: {{hostname}}",
"livekit_server_info": "Informace o serveru LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Kompatibilní s domovskými servery, které nepodporují přilnavé události (ale všechny ostatní EC klienti jsou v0.17.0 nebo novější)",
"label": "Kompatibilita: stavové události a více SFU"
},
"Legacy": {
"description": "Kompatibilní se starými verzemi EC, které nepodporují multi SFU",
"label": "Zastaralé: stavové události a nejstarší členské SFU"
},
"Matrix_2_0": {
"description": "Kompatibilní pouze s domovskými servery podporujícími přilnavé události a všemi klienty EC v0.17.0 nebo novějšími.",
"label": "Matrix 2.0: přilnavé události a multi SFU"
},
"title": "Režim MatrixRTC"
},
"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_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"
"url_params": "Parametry URL"
},
"disconnected_banner": "Připojení k serveru bylo ztraceno.",
"error": {
@@ -85,9 +106,11 @@
"generic_description": "Odeslání protokolů ladění nám pomůže vystopovat problém.",
"insufficient_capacity": "Nedostatečná kapacita",
"insufficient_capacity_description": "Server dosáhl své maximální kapacity a v tuto chvíli se nemůžete připojit k hovoru. Zkuste to později nebo se obraťte na správce serveru, pokud problém přetrvává.",
"matrix_rtc_focus_missing": "Server není nakonfigurován pro práci s {{brand}}. Obraťte se na správce serveru (Doména: {{domain}}, Kód chyby: {{ errorCode }}).",
"matrix_rtc_transport_missing": "Server není nakonfigurován pro práci s {{brand}}. Obraťte se na správce serveru (Doména: {{domain}}, Kód chyby: {{ errorCode }}).",
"open_elsewhere": "Otevřeno na jiné kartě",
"open_elsewhere_description": "{{brand}} byl otevřen v jiné záložce. Pokud to nezní správně, zkuste stránku znovu načíst.",
"room_creation_restricted": "Nepodařilo se vytvořit hovor",
"room_creation_restricted_description": "Vytváření hovorů může být omezeno pouze na oprávněné uživatele. Zkuste to znovu později nebo se obraťte na správce serveru, pokud problém přetrvává.",
"unexpected_ec_error": "Došlo k neočekávané chybě (<0>Error Code:</0> <1>{{ errorCode }}</1>). Obraťte se prosím na správce serveru."
},
"group_call_loader": {
@@ -219,7 +242,6 @@
"video_tile": {
"always_show": "Vždy zobrazit",
"camera_starting": "Načítání videa...",
"change_fit_contain": "Přizpůsobit rámu",
"collapse": "Sbalit",
"expand": "Rozbalit",
"mute_for_me": "Pro mě ztlumit",

View File

@@ -49,6 +49,7 @@
"profile": "Profil",
"reaction": "Reaktion",
"reactions": "Reaktioner",
"reconnecting": "Genopretter forbindelse…",
"settings": "Indstillinger",
"unencrypted": "Ikke krypteret",
"username": "Brugernavn",
@@ -67,10 +68,7 @@
"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"
"url_params": "URL-parametre"
},
"disconnected_banner": "Forbindelsen til serveren er gået tabt.",
"error": {
@@ -85,7 +83,6 @@
"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",
@@ -219,7 +216,6 @@
"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",

View File

@@ -58,6 +58,14 @@
"developer_mode": {
"always_show_iphone_earpiece": "iPhone-Ohrhörer-Option auf allen Plattformen anzeigen",
"crypto_version": "Krypto-Version: {{version}}",
"custom_livekit_url": {
"current_url": "Derzeit eingestellt auf: ",
"from_config": "Derzeit ist keine spezielle (benutzerdefinierte) URL eingestellt. Daher wird automatisch die URL verwendet, die entweder via „.well-known“ oder in der Webbrowser-Konfiguration („config“) hinterlegt ist.",
"label": "Benutzerdefinierte Livekit-URL",
"reset": "Zurücksetzen der benutzerdefinierten URL",
"save": "Speichern",
"saving": "Speichern..."
},
"debug_tile_layout_label": "Kachel-Layout debuggen",
"device_id": "Geräte-ID: {{id}}",
"duplicate_tiles_label": "Anzahl zusätzlicher Kachelkopien pro Teilnehmer",
@@ -65,13 +73,25 @@
"hostname": "Hostname: {{hostname}}",
"livekit_server_info": "LiveKit-Server Informationen",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Kompatibel mit Homeservern ohne Sticky Events Support, wobei alle beteiligten Element Call Clients v0.17.0 oder neuer sein müssen.",
"label": "Kompatibilität: State Events & Multi-SFU"
},
"Legacy": {
"description": "Kompatibel mit älteren Versionen von Element Call, welche Multi-SFU nicht unterstützen",
"label": "Legacy: State Events und \"Oldest Membership\" SFU"
},
"Matrix_2_0": {
"description": "Nur mit Homeservern kompatibel, die Sticky Events unterstützen, wobei alle beteiligten Element Call Clients Version v0.17.0 oder neuer sein müssen.",
"label": "Matrix 2.0: Sticky Events und Multi-SFU"
},
"title": "MatrixRTC Modus"
},
"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_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."
"url_params": "URL-Parameter"
},
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
"error": {
@@ -82,13 +102,17 @@
"connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.",
"e2ee_unsupported": "Inkompatibler Browser",
"e2ee_unsupported_description": "Ihr Webbrowser unterstützt keine verschlüsselten Anrufe. Zu den unterstützten Browsern gehören Chrome, Safari und Firefox 117+.",
"failed_to_start_livekit": "LiveKit-Verbindung konnte nicht hergestellt werden",
"generic": "Etwas ist schief gelaufen",
"generic_description": "Durch das Senden von Debugprotokollen können wir das Problem leichter eingrenzen.",
"insufficient_capacity": "Unzureichende Kapazität",
"insufficient_capacity_description": "Der Server hat seine maximale Kapazität erreicht, daher ist ein Beitritt zum Anruf derzeit nicht möglich. Bitte später erneut versuchen oder den Serveradministrator kontaktieren, falls das Problem weiterhin besteht.",
"matrix_rtc_focus_missing": "Der Server ist nicht für die Verwendung mit {{brand}} konfiguriert. Bitte den Serveradministrator kontaktieren (Domain: {{domain}}, Fehlercode: {{ errorCode }}).",
"matrix_rtc_transport_missing": "Der Server ist nicht für die Verwendung mit {{brand}} konfiguriert. Bitte den Server Admin kontaktieren (Domain: {{domain}}, Fehlercode: {{ errorCode }}).",
"membership_manager": "Fehler im MatrixRTC Mitgliedschaftsmanager",
"membership_manager_description": "Der MatrixRTC Mitgliedschaftsmanager wurde unerwartet aufgrund fehlgeschlagener Netzwerkanfragen beendet.",
"no_matrix_2_authorization_service": "Der Autorisierungsdienst des Medien Servers (SFU) ist veraltet.",
"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.",
"open_elsewhere_description": "{{brand}} wurde in einem anderen Tab geöffnet. Wenn das nicht richtig klingt, versuche, 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."
@@ -194,9 +218,9 @@
"opt_in_description": "<0></0><1></1>Du kannst deine Zustimmung durch Abwählen dieses Kästchens zurückziehen. Falls du dich aktuell in einem Anruf befindest, wird diese Einstellung nach dem Ende des Anrufs wirksam.",
"preferences_tab": {
"developer_mode_label": "Entwickler-Modus",
"developer_mode_label_description": "Aktivieren Sie den Entwicklermodus und zeigen Sie die Registerkarte mit den Entwicklereinstellungen an.",
"developer_mode_label_description": "Aktiviere den Entwicklermodus und zeige Entwicklereinstellungen an.",
"introduction": "Hier können zusätzliche Optionen für individuelle Anforderungen eingestellt werden.",
"reactions_play_sound_description": "Spielen Sie einen Soundeffekt ab, wenn jemand eine Reaktion auf einen Anruf sendet.",
"reactions_play_sound_description": "Spiele einen Soundeffekt ab, wenn jemand eine Reaktion auf einen Anruf sendet.",
"reactions_play_sound_label": "Reaktionstöne abspielen",
"reactions_show_description": "Zeige eine Animation, wenn jemand eine Reaktion sendet.",
"reactions_show_label": "Reaktionen anzeigen",
@@ -219,12 +243,14 @@
"version": "{{productName}} Version: {{version}}",
"video_tile": {
"always_show": "Immer anzeigen",
"call_ended": "Anruf beendet",
"calling": "Anruf…",
"camera_starting": "Video wird geladen...",
"change_fit_contain": "An Fenster anpassen",
"collapse": "Minimieren",
"expand": "Erweitern",
"mute_for_me": "Für mich stumm schalten",
"muted_for_me": "Für mich stumm geschaltet",
"screen_share_volume": "Lautstärke der Bildschirmfreigabe",
"volume": "Lautstärke",
"waiting_for_media": "Warten auf Medien..."
}

View File

@@ -65,7 +65,6 @@
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Αναγνωριστικό Matrix: {{id}}",
"show_connection_stats": "Εμφάνιση στατιστικών σύνδεσης",
"show_non_member_tiles": "Εμφάνιση πλακιδίων για μέσα μη-μελών",
"url_params": "Παράμετροι URL"
},
"header_label": "Element Κεντρική Οθόνη Κλήσεων",

View File

@@ -3,6 +3,7 @@
"user_menu": "User menu"
},
"action": {
"blur_background": "Blur background",
"close": "Close",
"copy_link": "Copy link",
"edit": "Edit",
@@ -107,14 +108,20 @@
"generic_description": "Submitting debug logs will help us track down the problem.",
"insufficient_capacity": "Insufficient capacity",
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
"livekit_connection_error": "Failed to connect to Livekit server",
"livekit_connection_error_description": "An error occurred while connecting to the Livekit server (<1>Reason:</1> <2>{{ reason }}</2>).",
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"membership_manager": "Membership Manager Error",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consequtive failed network requests.",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.",
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
"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.",
"peer_connection_timeout": "Connection timeout",
"peer_connection_timeout_description": "Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our <0>troubleshooting guide</0> or contact your server administrator.",
"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.",
"sticky_events_required": "Homeserver does not support Matrix 2.0 calls",
"sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
},
"group_call_loader": {
@@ -237,6 +244,7 @@
"stop_video_button_label": "Stop video",
"submitting": "Submitting…",
"switch_camera": "Switch camera",
"technical_details": "Technical details",
"unauthenticated_view_body": "Not registered yet? <2>Create an account</2>",
"unauthenticated_view_login_button": "Login to your account",
"unauthenticated_view_ssla_caption": "By clicking \"Go\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",

View File

@@ -5,14 +5,21 @@
"action": {
"close": "Cerrar",
"copy_link": "Copiar vínculo",
"edit": "Editar",
"go": "Comenzar",
"invite": "Invitar",
"lower_hand": "Bajar mano",
"no": "No",
"pick_reaction": "Elige reacción",
"raise_hand": "Levantar la mano",
"register": "Registrarse",
"remove": "Eliminar",
"show_less": "Mostrar menos",
"show_more": "Mostrar más",
"sign_in": "Iniciar sesión",
"sign_out": "Cerrar sesión",
"submit": "Enviar"
"submit": "Enviar",
"upload_file": "Cargar archivo"
},
"analytics_notice": "Al participar en esta beta, consientes a la recogida de datos anónimos, los cuales usaremos para mejorar el producto. Puedes encontrar más información sobre que datos recogemos en nuestra <2>Política de privacidad</2> y en nuestra <5>Política sobre Cookies</5>.",
"call_ended_view": {
@@ -22,30 +29,142 @@
"feedback_prompt": "<0>Nos encantaría conocer tu opinión para que podamos mejorar tu experiencia</0>",
"headline": "{{displayName}}, tu llamada ha finalizado.",
"not_now_button": "Ahora no, volver a la pantalla de inicio",
"reconnect_button": "Reconnectar",
"survey_prompt": "¿Cómo ha ido?"
},
"call_name": "Nombre de la llamada",
"common": {
"analytics": "Analíticas",
"audio": "Audio",
"avatar": "Avatar",
"back": "Regresar",
"display_name": "Nombre a mostrar",
"encrypted": "Cifrado",
"home": "Inicio",
"loading": "Cargando…",
"next": "Próximo",
"options": "Opciones",
"password": "Contraseña",
"preferences": "Preferencias",
"profile": "Perfil",
"reaction": "Reacción",
"reactions": "Reacciones",
"reconnecting": "Reconectando…",
"settings": "Ajustes",
"username": "Nombre de usuario"
"unencrypted": "Sin cifrar",
"username": "Nombre de usuario",
"video": "Vídeo"
},
"developer_mode": {
"always_show_iphone_earpiece": "Mostrar la opción de auricular del iPhone en todas las plataformas",
"crypto_version": "Versión criptográfica: {{version}}",
"custom_livekit_url": {
"current_url": "Actualmente configurado: ",
"from_config": "Actualmente, no hay ninguna sobrescritura configurada. Se utiliza la URL de well-known o config.",
"label": "URL personalizada de Livekit",
"reset": "Restablecer sobrescritura",
"save": "Guardar",
"saving": "Guardando..."
},
"debug_tile_layout_label": "Depurar diseño de mosaicos",
"device_id": "ID del dispositivo: {{id}}",
"duplicate_tiles_label": "Número de copias adicionales de fichas por participante",
"environment_variables": "Variables de entorno",
"hostname": "Nombre del Host: {{hostname}}",
"livekit_server_info": "Información servidor LiveKit",
"livekit_sfu": "LiveKit SFU:{{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Compatible con servidores privados que no admiten eventos persistentes (pero todos los demás clientes de EC son v0.17.0 o posteriores)",
"label": "Compatibilidad: eventos de estado y SFU múltiple"
},
"Legacy": {
"description": "Compatible con versiones antiguas de EC que no admiten SFU múltiple.",
"label": "Legado: eventos estatales y membresía más antigua SFU"
},
"Matrix_2_0": {
"description": "Compatible solo con servidores domésticos que admiten eventos persistentes y todos los clientes EC v0.17.0 o posterior",
"label": "Matrix 2.0: eventos persistentes y SFU múltiple"
},
"title": "Modo MatrixRTC"
},
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Silenciar todo el audio (participantes, reacciones, sonidos de unirse)",
"show_connection_stats": "Mostrar estadísticas de conexión",
"url_params": "Parámetros URL"
},
"disconnected_banner": "Se perdió la conectividad con el servidor.",
"error": {
"call_is_not_supported": "La llamada no es compatible",
"call_not_found": "Llamada no encontrada",
"call_not_found_description": "<0>Ese enlace no parece pertenecer a ninguna llamada existente. Comprueba que tienes el enlace correcto o <2>crea uno nuevo</2>.</0>",
"connection_lost": "Conexión interrumpida",
"connection_lost_description": "Se cortadó la llamada.",
"e2ee_unsupported": "Navegador incompatible",
"e2ee_unsupported_description": "Tu navegador web no admite llamadas cifradas. Los navegadores compatibles son Chrome, Safari y Firefox 117+.",
"failed_to_start_livekit": "No se ha podido iniciar la conexión Livekit.",
"generic": "Algo salió mal",
"generic_description": "Enviar registros de depuración nos ayudará a localizar el problema.",
"insufficient_capacity": "Capacidad insuficiente",
"insufficient_capacity_description": "El servidor ha alcanzado su capacidad máxima y no puedes unirte a la llamada en el momento. Inténtalo más tarde o contacta el administrador del servidor si el problema persiste.",
"matrix_rtc_transport_missing": "El servidor no está configurado para trabajar con{{brand}} . Por favor, póngase en contacto con el administrador de su servidor (Dominio:{{domain}} Código de error:{{ errorCode }} ).",
"membership_manager": "Error del administrador de miembros",
"membership_manager_description": "El Administrador de Membresías tuvo que cerrarse debido a numerosas solicitudes de red fallidas consecutivas.",
"no_matrix_2_authorization_service": "El servicio de autorización de su servidor multimedia (SFU) está desactualizado.",
"open_elsewhere": "Abierto en otra pestaña",
"open_elsewhere_description": "{{brand}}Se ha abierto en otra pestaña. Si no suena bien, intenta recargar la página.",
"room_creation_restricted": "Falló crear llamada",
"room_creation_restricted_description": "La creación de llamadas podría estar restringida solo a usuarios autorizados. Inténtelo de nuevo más tarde o póngase en contacto con el administrador del servidor si el problema persiste.",
"unexpected_ec_error": "Se produjo un error inesperado (<0> Código de error:</0><1>{{ errorCode }}</1> ) Por favor, contacta el administrador de su servidor."
},
"group_call_loader": {
"banned_body": "Has sido expulsado de la sala.",
"banned_heading": "Bloqueado",
"call_ended_body": "Te han retirado de la llamada.",
"call_ended_heading": "Llamada finalizada",
"knock_reject_body": "Su solicitud para unirse fue rechazada.",
"knock_reject_heading": "Acceso denegado",
"reason": "Razón:{{reason}}"
},
"handset": {
"overlay_back_button": "Volver al modo altavoz",
"overlay_description": "Solo funciona mientras se utiliza la aplicación.",
"overlay_title": "Modo teléfono"
},
"hangup_button_label": "Finalizar llamada",
"header_label": "Inicio de Element Call",
"header_participants_label": "Participantes",
"invite_modal": {
"link_copied_toast": "Enlace copiado al portapapeles",
"title": "Invita a esta llamada"
},
"join_existing_call_modal": {
"join_button": "Si, unirse a la llamada",
"text": "Esta llamada ya existe, ¿te gustaría unirte?",
"title": "¿Unirse a llamada existente?"
},
"layout_grid_label": "Grilla",
"layout_spotlight_label": "Foco",
"lobby": {
"join_button": "Unirse a la llamada"
"ask_to_join": "Solicitar unirse a la llamada",
"join_as_guest": "Unirse como invitado",
"join_button": "Unirse a la llamada",
"leave_button": "Volver a recientes",
"waiting_for_invite": "¡Solicitud enviada! Esperando permiso para unir..."
},
"log_in": "Iniciar sesión",
"logging_in": "Iniciando sesión…",
"login_auth_links": "<0>Crear una cuenta</0> o <2>Acceder como invitado</2>",
"login_auth_links_prompt": "¿Aún no se ha registrado?",
"login_subheading": "Continuar a Element",
"login_title": "Iniciar sesión",
"microphone_off": "Micrófono desactivado",
"microphone_on": "Micrófono activado",
"mute_microphone_button_label": "Silenciar micrófono",
"participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"qr_code": "CÓDIGO QR",
"rageshake_button_error_caption": "Reintentar enviar registros",
"rageshake_request_modal": {
"body": "Otro usuario en esta llamada está teniendo problemas. Para diagnosticar estos problemas nos gustaría recopilar un registro de depuración.",
"title": "Petición de registros de depuración"
@@ -53,30 +172,83 @@
"rageshake_send_logs": "Enviar registros de depuración",
"rageshake_sending": "Enviando…",
"rageshake_sending_logs": "Enviando registros de depuración…",
"rageshake_sent": "¡Gracias!",
"recaptcha_dismissed": "Recaptcha cancelado",
"recaptcha_not_loaded": "No se ha cargado el Recaptcha",
"recaptcha_ssla_caption": "Este sitio está protegido por ReCAPTCHA y se aplican las <2> política de privacidad</2> y<6> Condiciones de servicio</6>de Google aplican.<9></9> Al hacer clic en \"Registrarse\", se acepta nuestros <12> Acuerdo de licencia de software y servicios (SSLA)</12>",
"register": {
"passwords_must_match": "Las contraseñas deben coincidir",
"registering": "Registrando…"
},
"register_auth_links": "<0>¿Ya tienes una cuenta?</0><1><0>Iniciar sesión</0> o <2>Acceder como invitado</2></1>",
"register_confirm_password_label": "Confirmar contraseña",
"register_heading": "Crear tu cuenta",
"return_home_button": "Volver a la pantalla de inicio",
"room_auth_view_continue_button": "Continuar",
"room_auth_view_ssla_caption": "Al hacer clic en \"Unirse a la llamada ahora\", acepta nuestros<2> Acuerdo de licencia de software y servicios (SSLA)</2>",
"screenshare_button_label": "Compartir pantalla",
"settings": {
"audio_tab": {
"effect_volume_description": "Ajusta el volumen al que se reproducen las reacciones y los efectos de subir la mano.",
"effect_volume_label": "Volumen de efectos de sonido"
},
"background_blur_header": "Fondo",
"background_blur_label": "Desenfocar el fondo del vídeo",
"blur_not_supported_by_browser": "(El desenfoque de fondo no esta sopportado de este dispositivo).",
"developer_tab_title": "Desarrollador",
"devices": {
"camera": "Cámara",
"camera_numbered": "Cámara {{n}}",
"change_device_button": "Cambiar dispositivo de audio",
"default": "Por defecto",
"default_named": "Por defecto<2> ({{name}})</2>",
"handset": "Dispositivo",
"loudspeaker": "Altavoz",
"microphone": "Micrófono",
"microphone_numbered": "Micrófono {{n}}",
"speaker": "Altavoz",
"speaker_numbered": "Altavoz {{n}}"
},
"feedback_tab_body": "Si tienes algún problema o simplemente quieres darnos tu opinión, por favor envíanos una breve descripción.",
"feedback_tab_description_label": "Tus comentarios",
"feedback_tab_h4": "Enviar comentarios",
"feedback_tab_send_logs_label": "Incluir registros de depuración",
"feedback_tab_thank_you": "¡Gracias, hemos recibido tus comentarios!",
"feedback_tab_title": "Danos tu opinión",
"opt_in_description": "<0></0><1></1>Puedes retirar tu consentimiento desmarcando esta casilla. Si estás en una llamada, este ajuste se aplicará al final de esta."
"opt_in_description": "<0></0><1></1>Puedes retirar tu consentimiento desmarcando esta casilla. Si estás en una llamada, este ajuste se aplicará al final de esta.",
"preferences_tab": {
"developer_mode_label": "Modo desarrollador",
"developer_mode_label_description": "Activa el modo de desarrollador y muestra la pestaña de configuración de desarrollador.",
"introduction": "Aquí puedes configurar opciones adicionales para una experiencia mejorada.",
"reactions_play_sound_description": "Reproduce un sonido cuando alguien envíe una reacción en una llamada.",
"reactions_play_sound_label": "Reproduce sonidos de reacción",
"reactions_show_description": "Muestra una animación cuando alguien envíe una reacción.",
"reactions_show_label": "Mostrar reacciones",
"show_hand_raised_timer_description": "Mostrar un temporizador cuando un participante levante la mano",
"show_hand_raised_timer_label": "Mostrar la duración de la subida de la mano"
}
},
"star_rating_input_label_one": "{{count}} estrella",
"star_rating_input_label_other": "{{count}} estrellas",
"start_new_call": "Iniciar nueva llamada",
"start_video_button_label": "Iniciar vídeo",
"stop_screenshare_button_label": "Compartiendo pantalla",
"stop_video_button_label": "Parar vídeo",
"submitting": "Enviando…",
"switch_camera": "Cambiar cámara",
"unauthenticated_view_body": "¿No estás registrado todavía? <2>Crear una cuenta</2>",
"unauthenticated_view_login_button": "Iniciar sesión en tu cuenta",
"version": "Versión: {{version}}"
"unauthenticated_view_ssla_caption": "Al hacer clic en «Continuar», aceptas nuestro Acuerdo de licencia de software y servicios (SSLA) de <2>.</2>",
"unmute_microphone_button_label": "Activar micrófono",
"version": "Versión: {{version}}",
"video_tile": {
"always_show": "Mostrar siempre",
"camera_starting": "Cargando video...",
"collapse": "Colapsar",
"expand": "Expandir",
"mute_for_me": "Silenciar para mí",
"muted_for_me": "Silenciado para mí",
"volume": "Volumen",
"waiting_for_media": "Esperando medios..."
}
}

View File

@@ -49,6 +49,7 @@
"profile": "Profiil",
"reaction": "Reaktsioon",
"reactions": "Reageerimised",
"reconnecting": "Ühendan uuesti…",
"settings": "Seadistused",
"unencrypted": "Krüptimata",
"username": "Kasutajanimi",
@@ -57,6 +58,14 @@
"developer_mode": {
"always_show_iphone_earpiece": "Näita iPhone'i kuulari valikut kõikidel platvormidel",
"crypto_version": "Krüptoteekide versioon: {{version}}",
"custom_livekit_url": {
"current_url": "Hetkel määratud olekuks: ",
"from_config": "Hetkel on ülekirjutamine määratlemata. Kasutusel on võrguaadress „well-known“-failist või seadistustest.",
"label": "Sisu määratud Livekit-url",
"reset": "Lähtesta ülekirjutamine",
"save": "Salvesta",
"saving": "Salvestan..."
},
"debug_tile_layout_label": "Meediapaanide paigutus",
"device_id": "Seadme tunnus: {{id}}",
"duplicate_tiles_label": "Täiendavaid vaadete koopiaid osaleja kohta",
@@ -64,13 +73,13 @@
"hostname": "Hosti nimi: {{hostname}}",
"livekit_server_info": "LiveKiti serveri teave",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrixRTCMode": {
"title": "MatrixRTC režiim"
},
"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_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"
"url_params": "Võrguaadressi parameetrid"
},
"disconnected_banner": "Võrguühendus serveriga on katkenud.",
"error": {
@@ -81,11 +90,14 @@
"connection_lost_description": "Sinu ühendus selle kõnega on katkenud.",
"e2ee_unsupported": "Mitteühilduv brauser",
"e2ee_unsupported_description": "Sinu veebibrauser ei toeta krüptitud kõnesid. Toimivad veebibrauserid on Chrome, Safari, ja Firefox 117+.",
"failed_to_start_livekit": "Ei õnnestunud käivitada Livekiti ühendust",
"generic": "Midagi läks valesti",
"generic_description": "Silumis- ja vealogide saatmine võib aidata meid vea põhjuseni jõuda.",
"insufficient_capacity": "Mittepiisav jõudlus",
"insufficient_capacity_description": "Serveri jõudluse ülempiir on hetkel ületatud ja sa ei saa hetkel selle kõnega liituda. Proovi hiljem uuesti või kui probleem kestab kauem, siis võta ühendust serveri haldajaga.",
"matrix_rtc_focus_missing": "See server pole seadistatud töötama rakendusega {{brand}}. Palun võta ühendust serveri halduriga (domeen: {{domain}}, veakood: {{ errorCode }}).",
"matrix_rtc_transport_missing": "See server pole seadistatud töötama rakendusega {{brand}}. Palun võta ühendust serveri halduriga (domeen: {{domain}}, veakood: {{ errorCode }}).",
"membership_manager": "Viga liikmelisuse haldamisel",
"membership_manager_description": "Liikmelisuse haldur pidi oma töö lõpetama. Selle põhjuseks olid paljud järjestikused ebaõnnestunud võrgupäringud.",
"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",
@@ -103,7 +115,8 @@
},
"handset": {
"overlay_back_button": "Tagasi esineja vaatesse",
"overlay_description": "See toimib vaid rakenduse kasutamise ajal"
"overlay_description": "See toimib vaid rakenduse kasutamise ajal",
"overlay_title": "Telefonirežiim"
},
"hangup_button_label": "Lõpeta kõne",
"header_label": "Avaleht: Element Call",
@@ -176,6 +189,7 @@
"change_device_button": "Muuda heliseadet",
"default": "Vaikimisi",
"default_named": "Vaikimisi <2>({{name}})</2>",
"handset": "Telefon",
"loudspeaker": "Valjuhääldi",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
@@ -217,7 +231,6 @@
"video_tile": {
"always_show": "Näita alati",
"camera_starting": "Video on laadimisel...",
"change_fit_contain": "Mahuta aknasse",
"collapse": "Näita vähem",
"expand": "Näita rohkem",
"mute_for_me": "Summuta minu jaoks",

View File

@@ -49,13 +49,23 @@
"profile": "Profiili",
"reaction": "Reaktio",
"reactions": "Reaktiot",
"reconnecting": "Yhdistetään uudelleen...",
"settings": "Asetukset",
"unencrypted": "Ei salattu",
"username": "Käyttäjänimi",
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Näytä iPhone korvakaiutinvaihtoehto kaikilla alustoilla",
"crypto_version": "Kryptoversio: {{version}}",
"custom_livekit_url": {
"current_url": "Tällä hetkellä asetettu: ",
"from_config": "Tällä hetkellä ei ole asetettu päällekirjoitusta. Käytetään URL-osoitetta well-known tiedostosta tai konfiguraatiosta.",
"label": "Mukautettu Livekit-url",
"reset": "Palauta päällekirjoitus",
"save": "Tallenna",
"saving": "Tallennetaan..."
},
"debug_tile_layout_label": "Laattojen asettelun vianmääritys",
"device_id": "Laitteen tunnus: {{id}}",
"duplicate_tiles_label": "Lisälaattakopioiden määrä osallistujaa kohti",
@@ -63,29 +73,48 @@
"hostname": "Isäntänimi: {{hostname}}",
"livekit_server_info": "LiveKit-palvelimen tiedot",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Yhteensopiva kotipalvelimien kanssa, jotka eivät tue tarttuvia tapahtumia (mutta kaikki muut EC-sovellukset ovat v0.17.0 tai uudempia)",
"label": "Yhteensopivuus: tilatapahtumat ja useat SFU:t"
},
"Legacy": {
"description": "Yhteensopiva vanhempien EC-versioiden kanssa, jotka eivät tue useita SFU:ita",
"label": "Vanha: tilatapahtumat ja vanhimman jäsenen SFU"
},
"Matrix_2_0": {
"description": "Yhteensopiva vain tarttuvia tapahtumia tukevien kotipalvelimien ja kaikkien EC-sovelluksien v0.17.0 tai uudempien kanssa",
"label": "Matrix 2.0: tarttuvat tapahtumat ja useat SFU:t"
},
"title": "MatrixRTC-tila"
},
"matrix_id": "Matrix tunnus: {{id}}",
"mute_all_audio": "Mykistä kaikki ääni (osallistujat, reaktiot, liittymisäänet)",
"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"
"url_params": "URL-parametrit"
},
"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>",
"call_not_found_description": "<0>Kyseinen linkki ei näytä kuuluvan mihinkään olemassa olevaan puheluun. Tarkista, että sinulla on oikea linkki, tai <2>luo uusi linkki</2>.</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+.",
"failed_to_start_livekit": "Livekit-yhteyden muodostaminen epäonnistui.",
"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 }}).",
"matrix_rtc_transport_missing": "Palvelinta ei ole määritetty toimimaan {{brand}} -sovelluksen kanssa. Ota yhteyttä palvelimen ylläpitäjään (Verkkotunnus: {{domain}}, Virhekoodi: {{ errorCode }}).",
"membership_manager": "Jäsenyydenhallinnan virhe",
"membership_manager_description": "Jäsenyyshallinta jouduttiin sulkemaan. Tämä johtui useista peräkkäisistä epäonnistuneista verkkopyynnöistä.",
"no_matrix_2_authorization_service": "Mediapalvelimesi (SFU) valtuutuspalvelu on vanhentunut.",
"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.",
"room_creation_restricted": "Puhelun luominen epäonnistui",
"room_creation_restricted_description": "Puheluiden luominen saattaa olla rajoitettu vain valtuutetuille käyttäjille. Yritä myöhemmin uudelleen tai ota yhteyttä palvelimen ylläpitäjään, jos ongelma jatkuu.",
"unexpected_ec_error": "Tapahtui odottamaton virhe (<0>Virhekoodi:</0> <1>{{ errorCode }}</1>). Ota yhteyttä palvelimen ylläpitäjään."
},
"group_call_loader": {
@@ -97,6 +126,11 @@
"knock_reject_heading": "Pääsy kielletty",
"reason": "Syy: {{reason}}"
},
"handset": {
"overlay_back_button": "Takaisin kaiutintilaan",
"overlay_description": "Toimii vain sovellusta käytettäessä",
"overlay_title": "Luuritila"
},
"hangup_button_label": "Lopeta puhelu",
"header_label": "Element Call Etusivu",
"header_participants_label": "Osallistujat",
@@ -165,8 +199,11 @@
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Vaihda äänilaite",
"default": "Oletus",
"default_named": "Oletus <2>({{name}})</2>",
"handset": "Luuri",
"loudspeaker": "Kaiutin",
"microphone": "Mikrofoni",
"microphone_numbered": "Mikrofoni {{n}}",
"speaker": "Kaiutin",
@@ -206,12 +243,14 @@
"version": "{{productName}} versio: {{version}}",
"video_tile": {
"always_show": "Näytä aina",
"call_ended": "Puhelu päättyi",
"calling": "Soitetaan…",
"camera_starting": "Videota ladataan...",
"change_fit_contain": "Sovita kehykseen",
"collapse": "Supista",
"expand": "Laajenna",
"mute_for_me": "Mykistä minulle",
"muted_for_me": "Mykistetty minulle",
"screen_share_volume": "Näytönjaon äänenvoimakkuus",
"volume": "Äänenvoimakkuus",
"waiting_for_media": "Odotetaan mediaa..."
}

View File

@@ -5,14 +5,21 @@
"action": {
"close": "Fermer",
"copy_link": "Copier le lien",
"edit": "Modifier",
"go": "Commencer",
"invite": "Inviter",
"lower_hand": "Baisser la main",
"no": "Non",
"pick_reaction": "Choisir une réaction",
"raise_hand": "Lever la main",
"register": "Senregistrer",
"remove": "Supprimer",
"show_less": "Afficher moins",
"show_more": "Afficher plus",
"sign_in": "Connexion",
"sign_out": "Déconnexion",
"submit": "Envoyer"
"submit": "Envoyer",
"upload_file": "Téléverser un fichier"
},
"analytics_notice": "En participant à cette beta, vous consentez à la collecte de données anonymes, qui seront utilisées pour améliorer le produit. Vous trouverez plus dinformations sur les données collectées dans notre <2>Politique de vie privée</2> et notre <5>Politique de cookies</5>.",
"call_ended_view": {
@@ -27,20 +34,67 @@
},
"call_name": "Nom de lappel",
"common": {
"analytics": "Statistiques d'utilisation",
"audio": "Audio",
"avatar": "Avatar",
"back": "Retour",
"display_name": "Nom daffichage",
"encrypted": "Chiffré",
"home": "Accueil",
"loading": "Chargement…",
"next": "Suivant",
"options": "Options",
"password": "Mot de passe",
"preferences": "Préférences",
"profile": "Profil",
"reaction": "Réaction",
"reactions": "Réactions",
"reconnecting": "Reconnexion",
"settings": "Paramètres",
"unencrypted": "Non chiffré",
"username": "Nom dutilisateur",
"video": "Vidéo"
},
"developer_mode": {
"always_show_iphone_earpiece": "Afficher l'option écouteur iPhone sur toutes les plateformes",
"crypto_version": "Version crypto: {{version}}",
"debug_tile_layout_label": "Disposition des tuiles de débogage",
"device_id": "Id. de l'appareil",
"duplicate_tiles_label": "Nombre de copies de tuiles supplémentaires par participant",
"environment_variables": "Variables d'environnement",
"hostname": "Nom d'hôte: {{hostname}}",
"livekit_server_info": "Info du serveur LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "ID Matrix: {{id}}",
"mute_all_audio": "Couper tous les sons (participants, réactions, sons de participation)",
"show_connection_stats": "Afficher les statistiques de connexion",
"url_params": "Paramètres d'URL"
},
"disconnected_banner": "La connexion avec le serveur a été perdue.",
"error": {
"call_is_not_supported": "L'appel n'est pas pris en charge",
"call_not_found": "Appel non trouvé",
"call_not_found_description": "<0>Ce ne correspond à aucun appel existant. Vérifier que vous avez le bon lien, ou <1>créer un nouveau</1>.</0>",
"connection_lost": "Connexion perdue",
"connection_lost_description": "Vous avez été déconnecté de lappel",
"e2ee_unsupported": "Moteur de recherche incompatible",
"generic": "Un problème est survenu",
"insufficient_capacity": "Capacité insuffisante",
"insufficient_capacity_description": "Le serveur a atteint sa capacité maximale et vous ne pouvez pas rejoindre l'appel pour le moment. Veuillez réessayer plus tard ou contacter l'administrateur du serveur si le problème persiste.",
"unexpected_ec_error": "Une erreur inattendue s'est produite (<0>Code d'erreur :</0> <1>{{ errorCode }}</1>). Veuillez contacter l'administrateur de votre serveur."
},
"group_call_loader": {
"banned_body": "Vous avez été banni du salon.",
"banned_heading": "Banni",
"call_ended_body": "Vous avez été retiré de lappel.",
"call_ended_heading": "Appel terminé",
"knock_reject_body": "Les membres du salon ont refusé votre demande de participation.",
"knock_reject_heading": "Non autorisé à rejoindre",
"reason": "Motif"
},
"hangup_button_label": "Terminer lappel",
"header_label": "Accueil Element Call",
"header_participants_label": "Participants",
"invite_modal": {
"link_copied_toast": "Lien copié dans le presse-papier",
"title": "Inviter dans cet appel"
@@ -53,15 +107,24 @@
"layout_grid_label": "Grille",
"layout_spotlight_label": "Premier plan",
"lobby": {
"ask_to_join": "Demandez à rejoindre l'appel",
"join_as_guest": "Rejoindre en tant qu'invité",
"join_button": "Rejoindre lappel",
"leave_button": "Revenir à lhistorique des appels"
"leave_button": "Revenir à lhistorique des appels",
"waiting_for_invite": "Demande envoyée"
},
"log_in": "Se connecter",
"logging_in": "Connexion…",
"login_auth_links": "<0>Créer un compte</0> Or <2>Accès invité</2>",
"login_auth_links_prompt": "Pas encore inscrit?",
"login_subheading": "Pour continuer vers Element",
"login_title": "Connexion",
"microphone_off": "Microphone éteint",
"microphone_on": "Microphone allumé",
"mute_microphone_button_label": "Couper le microphone",
"participant_count_one": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"qr_code": "Code QR",
"rageshake_button_error_caption": "Réessayer denvoyer les journaux",
"rageshake_request_modal": {
"body": "Un autre utilisateur dans cet appel a un problème. Pour nous permettre de résoudre le problème, nous aimerions récupérer un journal de débogage.",
@@ -79,17 +142,33 @@
},
"register_auth_links": "<0>Vous avez déjà un compte ?</0><1><0>Se connecter</0> Ou <2>Accès invité</2></1>",
"register_confirm_password_label": "Confirmer le mot de passe",
"register_heading": "Créer votre compte",
"return_home_button": "Retour à laccueil",
"room_auth_view_continue_button": "Continuer",
"screenshare_button_label": "Partage décran",
"settings": {
"audio_tab": {
"effect_volume_description": "Régler le volume des effets de réactions et de mains levées.",
"effect_volume_label": "Volume des effets sonores"
},
"background_blur_label": "Flouter l'arrière-plan de la vidéo",
"blur_not_supported_by_browser": "(Le flou d'arrière-plan n'est pas pris en charge par cet appareil.)",
"developer_tab_title": "Développeur",
"devices": {
"speaker_numbered": "Haut-parleur {{n}}"
},
"feedback_tab_body": "Si vous rencontrez des problèmes, ou vous voulez simplement faire un commentaire, faites-en une courte description ci-dessous.",
"feedback_tab_description_label": "Votre commentaire",
"feedback_tab_h4": "Envoyer un commentaire",
"feedback_tab_send_logs_label": "Inclure les journaux de débogage",
"feedback_tab_thank_you": "Merci, nous avons reçu vos commentaires !",
"feedback_tab_title": "Commentaires",
"opt_in_description": "<0></0><1></1>Vous pouvez retirer votre consentement en décochant cette case. Si vous êtes actuellement en communication, ce paramètre prendra effet à la fin de lappel."
"opt_in_description": "<0></0><1></1>Vous pouvez retirer votre consentement en décochant cette case. Si vous êtes actuellement en communication, ce paramètre prendra effet à la fin de lappel.",
"preferences_tab": {
"reactions_play_sound_label": "Jouer le son des réactions",
"reactions_show_label": "Afficher les réactions",
"show_hand_raised_timer_label": "Afficher la durée de la main levée"
}
},
"star_rating_input_label_one": "{{count}} favori",
"star_rating_input_label_other": "{{count}} favoris",
@@ -101,5 +180,12 @@
"unauthenticated_view_body": "Pas encore de compte ? <2>En créer un</2>",
"unauthenticated_view_login_button": "Connectez vous à votre compte",
"unmute_microphone_button_label": "Allumer le microphone",
"version": "Version : {{version}}"
"version": "Version : {{version}}",
"video_tile": {
"always_show": "Toujours afficher",
"collapse": "Réduire",
"expand": "Développer",
"mute_for_me": "Muet pour moi",
"volume": "Volume"
}
}

View File

@@ -21,7 +21,7 @@
"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.",
"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 <6>Kebijakan Kuki</6> kami.",
"call_ended_view": {
"create_account_button": "Buat akun",
"create_account_prompt": "<0>Kenapa tidak selesaikan dengan mengatur sebuah kata sandi untuk menjaga akun Anda?</0><1>Anda akan dapat tetap menggunakan nama Anda dan atur sebuah avatar untuk digunakan dalam panggilan di masa mendatang</1>",
@@ -49,12 +49,14 @@
"profile": "Profil",
"reaction": "Reaksi",
"reactions": "Reaksi",
"reconnecting": "Menghubungkan kembali…",
"settings": "Pengaturan",
"unencrypted": "Tidak terenkripsi",
"username": "Nama pengguna",
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Tampilkan opsi lubang suara iPhone di semua platform",
"crypto_version": "Versi kripto: {{version}}",
"debug_tile_layout_label": "Awakutu tata letak ubin",
"device_id": "ID perangkat: {{id}}",
@@ -64,17 +66,15 @@
"livekit_server_info": "Info Server LiveKit",
"livekit_sfu": "SFU LiveKit: {{url}}",
"matrix_id": "ID Matrix: {{id}}",
"mute_all_audio": "Bisukan semua audio (suara peserta, reaksi, bergabung)",
"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"
"url_params": "Parameter URL"
},
"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>",
"call_not_found_description": "<0>Tautan itu tampaknya bukan milik panggilan yang ada. Periksa apakah Anda memiliki tautan yang tepat, atau <2> buat yang baru</2>. </0>",
"connection_lost": "Koneksi terputus",
"connection_lost_description": "Anda terputus dari panggilan.",
"e2ee_unsupported": "Peramban tidak kompatibel",
@@ -83,9 +83,10 @@
"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.",
"room_creation_restricted": "Gagal membuat panggilan",
"room_creation_restricted_description": "Pembuatan panggilan mungkin hanya terbatas untuk pengguna yang diizinkan. Coba lagi nanti, atau hubungi admin server Anda jika masalah berlanjut.",
"unexpected_ec_error": "Terjadi kesalahan tak terduga (<0> Kode Kesalahan:</0><1>{{ errorCode }}</1>). Silakan hubungi admin server Anda."
},
"group_call_loader": {
@@ -97,6 +98,11 @@
"knock_reject_heading": "Akses ditolak",
"reason": "Alasan: {{reason}}"
},
"handset": {
"overlay_back_button": "Kembali ke Mode Pembicara",
"overlay_description": "Hanya berfungsi saat menggunakan aplikasi",
"overlay_title": "Mode Ponsel"
},
"hangup_button_label": "Akhiri panggilan",
"header_label": "Beranda Element Call",
"header_participants_label": "Peserta",
@@ -164,8 +170,11 @@
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Ubah perangkat audio",
"default": "Bawaan",
"default_named": "Bawaan <2>({{name}})</2>",
"handset": "Ponsel",
"loudspeaker": "Pengeras suara",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Speaker",
@@ -190,7 +199,6 @@
"show_hand_raised_timer_label": "Tampilkan durasi angkat tangan"
}
},
"star_rating_input_label_one": "{{count}} bintang",
"star_rating_input_label_other": "{{count}} bintang",
"start_new_call": "Mulai panggilan baru",
"start_video_button_label": "Nyalakan video",
@@ -202,11 +210,10 @@
"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 {{productName}}: {{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",

View File

@@ -21,7 +21,7 @@
"submit": "Invia",
"upload_file": "Carica file"
},
"analytics_notice": "Partecipando a questa beta, acconsenti alla raccolta di dati anonimi che usiamo per migliorare il prodotto. Puoi trovare più informazioni su quali dati monitoriamo nella nostra <2>informativa sulla privacy</2> e nell'<5>informativa sui cookie</5>.",
"analytics_notice": "Partecipando a questa beta, acconsenti alla raccolta di dati anonimi che usiamo per migliorare il prodotto. Puoi trovare più informazioni su quali dati monitoriamo nella nostra <2>informativa sulla privacy</2> e nell'<6>informativa sui cookie</6>.",
"call_ended_view": {
"create_account_button": "Crea profilo",
"create_account_prompt": "<0>Ti va di terminare impostando una password per mantenere il profilo?</0><1>Potrai mantenere il tuo nome e impostare un avatar da usare in chiamate future</1>",
@@ -49,23 +49,49 @@
"profile": "Profilo",
"reaction": "Reazione",
"reactions": "Reazioni",
"reconnecting": "Riconnessione…",
"settings": "Impostazioni",
"unencrypted": "Non cifrata",
"username": "Nome utente",
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Mostra l'opzione auricolare iPhone su tutte le piattaforme",
"crypto_version": "Versione crittografica: {{version}}",
"custom_livekit_url": {
"current_url": "Attualmente impostato a: ",
"from_config": "Al momento non è impostata alcuna sovrascrittura. Viene usato l'URL da well-known o config.",
"label": "URL Livekit personalizzato",
"reset": "Reimposta sovrascrittura",
"save": "Salva",
"saving": "Salvataggio..."
},
"debug_tile_layout_label": "Debug della disposizione dei riquadri",
"device_id": "ID dispositivo: {{id}}",
"duplicate_tiles_label": "Numero di copie di riquadri aggiuntivi per partecipante",
"environment_variables": "Variabili di ambiente",
"hostname": "Nome host: {{hostname}}",
"livekit_server_info": "Informazioni sul server LiveKit",
"livekit_sfu": "SFU LiveKit: {{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Compatibile con homeserver che non supportano eventi sticky (ma tutte le altre applicazioni EC sono alla v0.17.0 o successive)",
"label": "Compatibilità: eventi di stato e multi SFU"
},
"Legacy": {
"description": "Compatibile con le vecchie versioni di EC che non supportano multi SFU",
"label": "Classico: event di stato e appartenenza più antica alla SFU"
},
"Matrix_2_0": {
"description": "Compatibile solo con homeserver che supportano eventi sticky e tutte le applicazioni EC alla v0.17.0 o successive",
"label": "Matrix 2.0: eventi sticky e multi SFU"
},
"title": "Modalità MatrixRTC"
},
"matrix_id": "ID Matrix: {{id}}",
"mute_all_audio": "Disattiva tutti gli audio (partecipanti, reazioni, suoni di partecipazione)",
"show_connection_stats": "Mostra le statistiche di connessione",
"show_non_member_tiles": "Mostra i riquadri per i file multimediali non-membri",
"use_new_membership_manager": "Usa la nuova implementazione della chiamata MembershipManager"
"url_params": "Parametri URL"
},
"disconnected_banner": "La connessione al server è stata persa.",
"error": {
@@ -76,13 +102,18 @@
"connection_lost_description": "Sei stato disconnesso dalla chiamata.",
"e2ee_unsupported": "Browser incompatibile",
"e2ee_unsupported_description": "Il tuo browser non supporta le chiamate crittografate. I browser supportati sono Chrome, Safari e Firefox 117+.",
"failed_to_start_livekit": "Impossibile avviare la connessione Livekit",
"generic": "Qualcosa è andato storto",
"generic_description": "L'invio dei registri di debug ci aiuterà a rintracciare il problema.",
"insufficient_capacity": "Capacità insufficiente",
"insufficient_capacity_description": "Il server ha raggiunto la capacità massima e non è possibile partecipare alla chiamata in questo momento. Riprova più tardi o contatta l'amministratore del server se il problema persiste.",
"matrix_rtc_focus_missing": "Il server non è configurato per funzionare con {{brand}}. Contatta l'amministratore del tuo server (Dominio: {{domain}}, codice di errore: {{ errorCode }}).",
"matrix_rtc_transport_missing": "Il server non è configurato per funzionare con {{brand}}. Contatta l'amministratore del tuo server (Dominio: {{domain}}, codice di errore: {{ errorCode }}).",
"membership_manager": "Errore del gestore dei membri",
"membership_manager_description": "Il gestore dei membri ha dovuto chiudersi. Ciò è stato causato da numerose richieste di rete consecutive non riuscite.",
"open_elsewhere": "Aperto in un'altra scheda",
"open_elsewhere_description": "{{brand}} è stato aperto in un'altra scheda. Se non ti sembra corretto, prova a ricaricare la pagina.",
"room_creation_restricted": "Impossibile creare la chiamata",
"room_creation_restricted_description": "La creazione di chiamate potrebbe essere limitata solo agli utenti autorizzati. Riprova più tardi o contatta l'amministratore del server se il problema persiste.",
"unexpected_ec_error": "Si è verificato un errore imprevisto (<0>Codice errore:</0> <1>{{ errorCode }}</1>). Contatta l'amministratore del tuo server."
},
"group_call_loader": {
@@ -94,6 +125,11 @@
"knock_reject_heading": "Partecipazione non consentita",
"reason": "Motivo"
},
"handset": {
"overlay_back_button": "Torna alla modalità altoparlante",
"overlay_description": "Funziona solo mentre si usa l'app",
"overlay_title": "Modalità cornetta"
},
"hangup_button_label": "Termina chiamata",
"header_label": "Inizio di Element Call",
"header_participants_label": "Partecipanti",
@@ -138,6 +174,7 @@
"rageshake_sent": "Grazie!",
"recaptcha_dismissed": "Recaptcha annullato",
"recaptcha_not_loaded": "Recaptcha non caricato",
"recaptcha_ssla_caption": "Questo sito è protetto da ReCAPTCHA e si applicano l'<2>informativa sulla privacy</2> e i <6>termini di servizio</6> di Google.<9></9>Cliccando \"Registra\", accetti il nostro <12>Software and Services License Agreement (SSLA)</12>",
"register": {
"passwords_must_match": "Le password devono coincidere",
"registering": "Registrazione…"
@@ -147,18 +184,25 @@
"register_heading": "Crea il tuo account",
"return_home_button": "Torna alla schermata di iniziale",
"room_auth_view_continue_button": "Continua",
"room_auth_view_ssla_caption": "Cliccando \"Partecipa ora alla chiamata\", accetti il nostro <2>Software and Services License Agreement (SSLA)</2>",
"screenshare_button_label": "Condividi schermo",
"settings": {
"audio_tab": {
"effect_volume_description": "Regola il volume delle reazioni e degli effetti di alzata di mani.",
"effect_volume_label": "Volume degli effetti sonori"
},
"background_blur_header": "Sfondo",
"background_blur_label": "Sfoca lo sfondo del video",
"blur_not_supported_by_browser": "(La sfocatura dello sfondo non è supportata da questo dispositivo.)",
"developer_tab_title": "Sviluppatore",
"devices": {
"camera": "Fotocamera",
"camera_numbered": "Fotocamera {{n}}",
"change_device_button": "Cambia dispositivo audio",
"default": "Predefinito",
"default_named": "Predefinito <2>({{name}})</2>",
"handset": "Cornetta",
"loudspeaker": "Altoparlante",
"microphone": "Microfono",
"microphone_numbered": "Microfono {{n}}",
"speaker": "Altoparlante",
@@ -193,12 +237,12 @@
"switch_camera": "Cambia fotocamera",
"unauthenticated_view_body": "Non hai ancora un profilo? <2>Creane uno</2>",
"unauthenticated_view_login_button": "Accedi al tuo profilo",
"unauthenticated_view_ssla_caption": "Cliccando \"Vai\", accetti il nostro <2>Software and Services License Agreement (SSLA)</2>",
"unmute_microphone_button_label": "Riaccendi il microfono",
"version": "Versione: {{version}}",
"version": "Versione di {{productName}}: {{version}}",
"video_tile": {
"always_show": "Mostra sempre",
"camera_starting": "Caricamento del video...",
"change_fit_contain": "Adatta al frame",
"collapse": "Riduci",
"expand": "Espandi",
"mute_for_me": "Disattiva l'audio per me",

View File

@@ -110,7 +110,6 @@
"unmute_microphone_button_label": "マイクのミュート解除",
"version": "バージョン:{{version}}",
"video_tile": {
"change_fit_contain": "フレームに合わせる",
"mute_for_me": "ミュートする",
"volume": "ボリューム"
}

View File

@@ -49,13 +49,23 @@
"profile": "Profils",
"reaction": "Reakcija",
"reactions": "Reakcijas",
"reconnecting": "Notiek savienojuma atjaunošana...",
"settings": "Iestatījumi",
"unencrypted": "Nav šifrēts",
"username": "Lietotājvārds",
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Rādīt iPhone austiņu opciju visās platformās",
"crypto_version": "Crypto versija: {{version}}",
"custom_livekit_url": {
"current_url": "Iestatīts uz: ",
"from_config": "Šobrīd nav pārrakstīts. Tiek izmantots URL no well-known vai konfigurācijas.",
"label": "Pielāgots Livekit URL",
"reset": "Atiestatīt pārrakstīto",
"save": "Saglabāt",
"saving": "Saglabāju..."
},
"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",
@@ -63,11 +73,25 @@
"hostname": "Saimniekdatora nosaukums: {{hostname}}",
"livekit_server_info": "LiveKit Server informācija",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Savietojams ar mājas serveriem, kas neatbalsta fiksētos notikumus (bet visi pārējie EC klienti ir v0.17.0 vai jaunāki)",
"label": "Savietojamība: state notikumi & ulti SFU"
},
"Legacy": {
"description": "Savietojams ar vecākām EC versijām, kas neatbalsta multi SFU",
"label": "Mantojums: state events & vecākā SFU dalība"
},
"Matrix_2_0": {
"description": "Savietojams tikai ar mājas serveriem, kas atbalsta fiksētos notikumus, un visiem EC klientiem v0.17.0 vai jaunāku versiju.",
"label": "Matrix 2.0: fiksētie notikumi un multi SFU"
},
"title": "MatrixRTC režīms"
},
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Izslēgt visu audio (dalībnieku, reakciju, pievienošanās skaņu)",
"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"
"url_params": "URL parametri"
},
"disconnected_banner": "Ir zaudēts savienojums ar serveri.",
"error": {
@@ -78,13 +102,19 @@
"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+.",
"failed_to_start_livekit": "Neizdevās uzsākt Livekit savienojumu",
"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 }}).",
"matrix_rtc_transport_missing": "Serveris nav konfigurēts darbam ar{{brand}}. Lūdzu, sazinieties ar sava servera administratoru (Domēns: {{domain}}, Kļūdas kods: {{ errorCode }}).",
"membership_manager": "Dalības pārvaldnieka kļūda",
"membership_manager_description": "Dalības pārvaldnieks bija jāslēdz. To izraisīja daudzi secīgi, neveiksmīgi tīkla pieprasījumi.",
"no_matrix_2_authorization_service": "Jūsu multivides servera (SFU) autorizācijas pakalpojums ir novecojis.",
"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.",
"room_creation_restricted": "Neizdevās izveidot zvanu",
"room_creation_restricted_description": "Zvanu izveide, iespējams, ir atļauta tikai pilnvarotiem lietotājiem. Mēģiniet vēlreiz vēlāk vai sazinieties ar servera administratoru, ja problēma joprojām pastāv.",
"unexpected_ec_error": "Negaidīta kļūda (<0>kļūdas kods: </0> <1> {{ errorCode }}</1>). Lūdzu, sazinieties ar servera administratoru."
},
"group_call_loader": {
@@ -96,6 +126,11 @@
"knock_reject_heading": "Piekļuve liegta",
"reason": "Iemesls: {{reason}}"
},
"handset": {
"overlay_back_button": "Atpakaļ uz skaļruņa režīmu",
"overlay_description": "Darbojas tikai lietotnes lietošanas laikā",
"overlay_title": "Klausules režīms"
},
"hangup_button_label": "Beigt zvanu",
"header_label": "Element Call sākums",
"header_participants_label": "Dalībnieki",
@@ -158,12 +193,18 @@
"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"
},
"background_blur_header": "Fons",
"background_blur_label": "Izplūdināt video fonu",
"blur_not_supported_by_browser": "(Šī ierīce neatbalsta fona izplūšanu.)",
"developer_tab_title": "Izstrādātājs",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Mainīt audio ierīci",
"default": "Noklusējums",
"default_named": "Noklusējums <2> ({{name}} )</2>",
"handset": "Klausule",
"loudspeaker": "Skaļrunis",
"microphone": "Mikrofons",
"microphone_numbered": "Mikrofons {{n}}",
"speaker": "Skaļrunis",
@@ -205,7 +246,6 @@
"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",

96
locales/nl/app.json Normal file
View File

@@ -0,0 +1,96 @@
{
"a11y": {
"user_menu": "Gebruikersmenu"
},
"action": {
"close": "Sluiten",
"copy_link": "Link kopiëren",
"edit": "Bewerken",
"go": "Ga",
"invite": "Uitnodigen",
"lower_hand": "Hand laten zakken",
"no": "Nee",
"pick_reaction": "Reactie kiezen",
"raise_hand": "Hand opsteken",
"register": "Registreren",
"remove": "Verwijderen",
"show_less": "Minder weergeven",
"show_more": "Meer weergeven",
"sign_in": "Aanmelden",
"sign_out": "Afmelden",
"submit": "Indienen",
"upload_file": "Bestand uploaden"
},
"analytics_notice": "Door deel te nemen aan deze bètaversie stemt u in met het verzamelen van anonieme gegevens, die we gebruiken om het product te verbeteren. Meer informatie over welke gegevens we bijhouden, vindt u in ons privacybeleid <2>Privacybeleid</2> en ons cookiebeleid <6>Cookiebeleid</6>.",
"call_ended_view": {
"create_account_button": "Account aanmaken",
"create_account_prompt": "<0>Waarom sluit u niet af met het instellen van een wachtwoord om uw account te bewaren?</0><1>U kunt uw naam behouden en een avatar instellen voor gebruik bij toekomstige gesprekken.</1>",
"feedback_done": "<0>Bedankt voor je feedback!</0>",
"feedback_prompt": "<0>We horen graag uw feedback, zodat we uw ervaring kunnen verbeteren.</0>",
"headline": "{{displayName}}, uw gesprek is beëindigd.",
"not_now_button": "Niet nu, ga terug naar het startscherm.",
"reconnect_button": "Opnieuw verbinden",
"survey_prompt": "Hoe is het gegaan?"
},
"call_name": "Naam van de oproep",
"common": {
"analytics": "Statistieken",
"audio": "Audio",
"avatar": "Avatar",
"back": "Terug",
"display_name": "Weergavenaam",
"encrypted": "Versleuteld",
"home": "Startpagina",
"loading": "Bezig met laden...",
"next": "Volgende",
"options": "Opties",
"password": "Wachtwoord",
"preferences": "Voorkeuren",
"profile": "Profiel",
"reaction": "Reactie",
"reactions": "Reacties",
"reconnecting": "Opnieuw verbinden...",
"settings": "Instellingen",
"unencrypted": "Niet versleuteld",
"username": "Gebruikersnaam",
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "iPhone-oortelefoonoptie op alle platformen weergeven",
"custom_livekit_url": {
"save": "Opslaan",
"saving": "Bezig met opslaan..."
},
"matrixRTCMode": {
"title": "MatrixRTC modus"
},
"matrix_id": "Matrix ID:{{id}}",
"mute_all_audio": "Alle audio dempen (deelnemers, reacties, geluiden bij deelname)",
"show_connection_stats": "Verbindingsstatistieken weergeven"
},
"error": {
"matrix_rtc_transport_missing": "De server is niet geconfigureerd om te werken met {{brand}}. Neem contact op met uw serverbeheerder (Domein: {{domain}}, Foutcode: {{ errorCode }}).",
"no_matrix_2_authorization_service": "De autorisatieservice voor uw mediaserver (SFU) is verouderd."
},
"mute_microphone_button_label": "Microfoon dempen",
"settings": {
"preferences_tab": {
"developer_mode_label_description": "Schakel de ontwikkelaarsmodus is en geef het tabblad met ontwikkelaarsinstellingen weer.",
"reactions_show_description": "Geef een animatie weer wanneer iemand een reactie verstuurt.",
"reactions_show_label": "Reacties weergeven",
"show_hand_raised_timer_description": "Geef een timer weer wanneer een deelnemer zijn hand opsteekt",
"show_hand_raised_timer_label": "Duur van het opsteken van de hand weergeven"
}
},
"unmute_microphone_button_label": "Microfoon dempen opheffen",
"video_tile": {
"always_show": "Altijd weergeven",
"camera_starting": "Video wordt geladen...",
"collapse": "Samenvouwen",
"expand": "Uitbreiden",
"mute_for_me": "Dempen voor mij",
"muted_for_me": "Gedempt voor mij",
"volume": "Volume",
"waiting_for_media": "Wachten op media..."
}
}

View File

@@ -49,12 +49,14 @@
"profile": "Profil",
"reaction": "Reakcja",
"reactions": "Reakcje",
"reconnecting": "Próba ponownego połączenia...",
"settings": "Ustawienia",
"unencrypted": "Nie szyfrowane",
"username": "Nazwa użytkownika",
"video": "Wideo"
},
"developer_mode": {
"always_show_iphone_earpiece": "Pokaż opcję słuchawki iPhone na wszystkich platformach",
"crypto_version": "Wersja krypto: {{version}}",
"debug_tile_layout_label": "Układ kafelków Debug",
"device_id": "ID urządzenia: {{id}}",
@@ -64,10 +66,9 @@
"livekit_server_info": "Informacje serwera LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "ID Matrix: {{id}}",
"mute_all_audio": "Wycisz cały dźwięk (uczestnicy, reakcje, dźwięki dołączenia)",
"show_connection_stats": "Pokaż statystyki połączenia",
"show_non_member_tiles": "Pokaż kafelki dla mediów, które nie są od członków",
"url_params": "Parametry URL",
"use_new_membership_manager": "Użyj nowej implementacji połączenia MembershipManager"
"url_params": "Parametry URL"
},
"disconnected_banner": "Utracono połączenie z serwerem.",
"error": {
@@ -82,9 +83,10 @@
"generic_description": "Wysłanie dziennika debug, pozwoli nam namierzyć problem.",
"insufficient_capacity": "Za mało miejsca",
"insufficient_capacity_description": "Serwer osiągnął maksymalną pojemność, przez co nie możesz dołączyć do połączenia. Spróbuj ponownie później lub skontaktuj się z administratorem serwera, jeśli problem nie zniknie.",
"matrix_rtc_focus_missing": "Serwer nie jest skonfigurowany do pracy z {{brand}}. Prosimy o kontakt z administratorem serwera (Domena: {{domain}}, Kod błędu: {{ errorCode }}).",
"open_elsewhere": "Otwarto w innej karcie",
"open_elsewhere_description": "{{brand}} został otwarty w innej karcie. Jeśli tak nie jest, spróbuj odświeżyć stronę.",
"room_creation_restricted": "Nie udało się utworzyć połączenia",
"room_creation_restricted_description": "Tworzenie połączeń mogło zostać ograniczone tylko do autoryzowanych użytkowników. Spróbuj ponownie później lub skontaktuj się z administratorem serwera, jeśli problem nie minie.",
"unexpected_ec_error": "Wystąpił nieoczekiwany błąd (<0>Kod błędu:</0> <1>{{ errorCode }}</1>). Skontaktuj się z administratorem serwera."
},
"group_call_loader": {
@@ -96,6 +98,11 @@
"knock_reject_heading": "Nie możesz dołączyć",
"reason": "Powód: {{reason}}"
},
"handset": {
"overlay_back_button": "Powrót do trybu głośnika",
"overlay_description": "Działa tylko podczas korzystania z aplikacji",
"overlay_title": "Tryb słuchawkowy"
},
"hangup_button_label": "Zakończ połączenie",
"header_label": "Strona główna Element Call",
"header_participants_label": "Uczestnicy",
@@ -158,12 +165,18 @@
"effect_volume_description": "Dostosuj głośność, z jaką odtwarzane są efekty reakcji i podniesionej ręki",
"effect_volume_label": "Głośność efektu dźwiękowego"
},
"background_blur_header": "Tło",
"background_blur_label": "Rozmycie tła wideo",
"blur_not_supported_by_browser": "(Rozmycie tła nie jest wspierane przez to urządzenie.)",
"developer_tab_title": "Programista",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Zmień urządzenie audio",
"default": "Domyślne",
"default_named": "Domyślne <2>({{name}})</2>",
"handset": "Słuchawka",
"loudspeaker": "Głośnomówiący",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Głośnik",
@@ -204,7 +217,6 @@
"video_tile": {
"always_show": "Zawsze pokazuj",
"camera_starting": "Wczytywanie wideo...",
"change_fit_contain": "Dopasuj do obramowania",
"collapse": "Zwiń",
"expand": "Rozwiń",
"mute_for_me": "Wycisz dla mnie",

View File

@@ -49,24 +49,26 @@
"profile": "Profil",
"reaction": "Reacție",
"reactions": "Reacții",
"reconnecting": "Se restabilește conexiunea…",
"settings": "Setări",
"unencrypted": "Nu este criptat",
"username": "Numele utilizatorului",
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Afișează opțiunea pentru căștile iPhone pe toate platformele",
"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}}",
"livekit_server_info": "Informaţii despre serverul livekit",
"livekit_sfu": "Unitate de expediere selectivă LiveKit: {{url}}",
"matrix_id": "ID-ul matricei: {{id}}",
"mute_all_audio": "Dezactivează toate sunetele (participanți, reacții, sunete de conectare)",
"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."
"url_params": "Parametrii linkului"
},
"disconnected_banner": "Conexiunea către server s-a încheiat abrupt",
"error": {
@@ -81,9 +83,10 @@
"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.",
"room_creation_restricted": "Apelul nu a putut fi creat",
"room_creation_restricted_description": "Crearea apelurilor ar putea fi restricționată doar utilizatorilor autorizați. Încercați din nou mai târziu sau contactați administratorul serverului dacă problema persistă.",
"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ă."
},
"group_call_loader": {
@@ -95,6 +98,11 @@
"knock_reject_heading": "Acces refuzat",
"reason": "Motivul"
},
"handset": {
"overlay_back_button": "Înapoi la modul Difuzor",
"overlay_description": "Funcționează doar în timp ce se utilizează aplicația",
"overlay_title": "Mod telefon"
},
"hangup_button_label": "Încheiați apelul",
"header_label": "Element Call Home",
"header_participants_label": "Participanți",
@@ -125,6 +133,9 @@
"microphone_off": "Microfon oprit",
"microphone_on": "Microfon pornit",
"mute_microphone_button_label": "Dezactivați microfonul",
"participant_count_one": "{{count, number}}",
"participant_count_few": "{{count, number}}",
"participant_count_other": "{{count, number}}",
"qr_code": "COD QR",
"rageshake_button_error_caption": "Încearcă din nou trimiterea jurnalelor",
"rageshake_request_modal": {
@@ -137,6 +148,7 @@
"rageshake_sent": "Multumesc!",
"recaptcha_dismissed": "Recaptcha a fost respins",
"recaptcha_not_loaded": "Recaptcha nu a fost încărcat",
"recaptcha_ssla_caption": "Acest site este protejat de reCAPTCHA. Se aplică atât Politica de <2> confidențialitate Google </2> cât și <6> Termenii și condițiile. </6> <9></9>Făcând clic pe \"Înregistrare\", sunteți de acord cu Acordul <12> nostru de licență pentru software și servicii (SSLA) </12>",
"register": {
"passwords_must_match": "Parolele trebuie să se potrivească",
"registering": "Înregistrare..."
@@ -146,16 +158,29 @@
"register_heading": "Creează-ți contul",
"return_home_button": "Reveniți la ecranul de pornire",
"room_auth_view_continue_button": "Continuă",
"room_auth_view_ssla_caption": "Făcând clic pe „Alăturați-vă apelului acum”, sunteți de acord cu Acordul <2> nostru de licență pentru software și servicii (SSLA) </2>",
"screenshare_button_label": "Partajare ecran",
"settings": {
"audio_tab": {
"effect_volume_description": "Reglați volumul la care reacționează reacțiile și efectele ridicate de mână",
"effect_volume_label": "Volumul efectului sonor"
},
"background_blur_header": "Fundal",
"background_blur_label": "Estompează fundalul videoclipului",
"blur_not_supported_by_browser": "(Funcția de estompare a fundalului nu este acceptată de acest device.)",
"developer_tab_title": "dezvoltator",
"devices": {
"camera": "Cameră",
"camera_numbered": "Camera {{n}}",
"change_device_button": "Schimbați dispozitivul audio",
"default": "Implicit",
"default_named": "Dispozitiv implicit <2>({{name}})</2>",
"handset": "Telefon",
"loudspeaker": "Difuzor",
"microphone": "Microfon",
"speaker": "Difuzor"
"microphone_numbered": "Microfon {{n}}",
"speaker": "Difuzor",
"speaker_numbered": "Difuzor {{n}}"
},
"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",
@@ -176,6 +201,9 @@
"show_hand_raised_timer_label": "Afișați durata ridicării mâinii"
}
},
"star_rating_input_label_one": "{{count}} stea",
"star_rating_input_label_few": "{{count}} stele",
"star_rating_input_label_other": "{{count}} stele",
"start_new_call": "Începe un nou apel",
"start_video_button_label": "Începeți videoclipul",
"stop_screenshare_button_label": "Partajarea ecranului",
@@ -184,12 +212,12 @@
"switch_camera": "Comutați camera",
"unauthenticated_view_body": "Nu sunteți încă înregistrat? <2>Creați un cont </2>",
"unauthenticated_view_login_button": "Conectați-vă la contul dvs.",
"unauthenticated_view_ssla_caption": "Făcând clic pe \"Înainte\", sunteți de acord cu Acordul nostru de licență pentru <2> software și servicii (SSLA) </2>",
"unmute_microphone_button_label": "Anulează microfonul",
"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",

View File

@@ -49,6 +49,7 @@
"profile": "Профиль",
"reaction": "Реакция",
"reactions": "Реакции",
"reconnecting": "Восстановление связи…",
"settings": "Настройки",
"unencrypted": "Не зашифровано",
"username": "Имя пользователя",
@@ -57,6 +58,14 @@
"developer_mode": {
"always_show_iphone_earpiece": "Показать опцию наушников для iPhone на всех платформах",
"crypto_version": "Версия криптографии: {{version}}",
"custom_livekit_url": {
"current_url": "В настоящее время установлено: ",
"from_config": "В настоящее время перезапись не установлена. Используется URL из известного источника или конфигурации.",
"label": "Пользовательский Livekit-url",
"reset": "Сбросить перезапись",
"save": "Сохранить",
"saving": "Сохранение..."
},
"debug_tile_layout_label": "Отладка расположения плиток",
"device_id": "Идентификатор устройства: {{id}}",
"duplicate_tiles_label": "Количество дополнительных копий плиток на участника",
@@ -64,30 +73,48 @@
"hostname": "Имя хоста: {{hostname}}",
"livekit_server_info": "Информация о сервере LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Совместимо с домашними серверами, которые не поддерживают закреплённые события (но все остальные клиенты EC имеют версию v0.17.0 или более позднюю)",
"label": "Совместимость: события состояния и множество SFU"
},
"Legacy": {
"description": "Совместимо со старыми версиями EC, не поддерживающими несколько SFU",
"label": "Legacy: события состояния и старые SFU членство"
},
"Matrix_2_0": {
"description": "Совместимо только с домашними серверами, поддерживающими «закреплённые события», и со всеми клиентами EC версии v0.17.0 или более поздней",
"label": "matrix 2.0: липкие события и multi SFU"
},
"title": "Режим MatrixRTC"
},
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Отключить все звуки (участников, реакции, звуки присоединения)",
"show_connection_stats": "Показать статистику подключений",
"show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками",
"url_params": "Параметры URL-адреса",
"use_new_membership_manager": "Используйте новую реализацию вызова MembershipManager",
"use_to_device_key_transport": "Используйте для передачи ключей устройства. Это позволит вернуться к передаче ключей комнаты, когда другой участник вызова отправит ключ комнаты"
"url_params": "Параметры URL-адреса"
},
"disconnected_banner": "Связь с сервером была потеряна.",
"error": {
"call_is_not_supported": "Вызов не поддерживается",
"call_not_found": "Звонок не найден",
"call_not_found_description": "<0>Эта ссылка, похоже, не принадлежит ни к одному существующему звонку. Убедитесь, что у вас есть нужная ссылка, или <1>создайте новую</1>.</0>",
"call_not_found_description": "<0>Похоже, эта ссылка не принадлежит ни одному существующему вызову. Убедитесь, что у вас правильная ссылка, или <2>создайте новую</2>.</0>",
"connection_lost": "Соединение потеряно",
"connection_lost_description": "Вы были отключены от звонка.",
"e2ee_unsupported": "Несовместимый браузер",
"e2ee_unsupported_description": "Ваш веб-браузер не поддерживает зашифрованные звонки. Поддерживаются следующие браузеры: Chrome, Safari и Firefox 117+.",
"failed_to_start_livekit": "Не удалось установить соединение с Livekit",
"generic": "Произошла ошибка",
"generic_description": "Отправка журналов отладки поможет нам отследить проблему.",
"insufficient_capacity": "Недостаточная пропускная способность",
"insufficient_capacity_description": "Сервер достиг максимальной пропускной способности, и вы не можете присоединиться к звонку в данный момент. Повторите попытку позже или обратитесь к администратору сервера, если проблема сохраняется.",
"matrix_rtc_focus_missing": "Сервер не настроен для работы с {{brand}}. Пожалуйста, свяжитесь с администратором вашего сервера (Домен: {{domain}}, Код ошибки: {{ errorCode }}).",
"matrix_rtc_transport_missing": "Сервер не настроен для работы с {{brand}}. Пожалуйста, свяжитесь с администратором вашего сервера (Домен: {{domain}}, Код ошибки: {{ errorCode }}).",
"membership_manager": "Ошибка менеджера участников",
"membership_manager_description": "Модуль «Memebership Manager» был вынужден завершить работу. Причиной этого стало большое количество последовательных неудачных сетевых запросов.",
"no_matrix_2_authorization_service": "Ваша служба авторизации для медиасервера (SFU) не обновлена до последней версии.",
"open_elsewhere": "Открыто в другой вкладке",
"open_elsewhere_description": "{{brand}} был открыт в другой вкладке. Если это неверно, попробуйте перезагрузить страницу.",
"room_creation_restricted": "Не удалось создать вызов",
"room_creation_restricted_description": "Создание вызовов может быть доступно только авторизованным пользователям. Повторите попытку позже или обратитесь к администратору сервера, если проблема не исчезнет.",
"unexpected_ec_error": "Произошла непредвиденная ошибка (<0> Код ошибки:</0><1>{{ errorCode }}</1> ). Обратитесь к администратору вашего сервера."
},
"group_call_loader": {
@@ -99,6 +126,11 @@
"knock_reject_heading": "Не разрешено присоединиться",
"reason": "Причина: {{reason}}"
},
"handset": {
"overlay_back_button": "Вернуться в режим динамика",
"overlay_description": "Работает только при использовании приложения",
"overlay_title": "Режим трубки"
},
"hangup_button_label": "Завершить звонок",
"header_label": "Главная Element Call",
"header_participants_label": "Участники",
@@ -171,6 +203,8 @@
"change_device_button": "Изменить аудиоустройство",
"default": "По умолчанию",
"default_named": "По умолчанию <2>({{name}})</2>",
"handset": "Динамик телефона",
"loudspeaker": "Громкоговоритель",
"microphone": "Микрофон",
"microphone_numbered": "Микрофон {{n}}",
"speaker": "Динамик",
@@ -211,12 +245,14 @@
"version": "{{productName}} версия: {{version}}",
"video_tile": {
"always_show": "Показывать всегда",
"call_ended": "Вызов завершен",
"calling": "Вызов…",
"camera_starting": "Загрузка видео...",
"change_fit_contain": "По размеру окна",
"collapse": "Свернуть",
"expand": "Развернуть",
"mute_for_me": "Заглушить звук для меня",
"muted_for_me": "Приглушить для меня",
"screen_share_volume": "Громкость демонстрации экрана",
"volume": "Громкость",
"waiting_for_media": "В ожидании медиа..."
}

View File

@@ -49,6 +49,7 @@
"profile": "Profil",
"reaction": "Reakcia",
"reactions": "Reakcie",
"reconnecting": "Opätovné pripájanie...",
"settings": "Nastavenia",
"unencrypted": "Nie je zašifrované",
"username": "Meno používateľa",
@@ -57,6 +58,14 @@
"developer_mode": {
"always_show_iphone_earpiece": "Zobraziť možnosť slúchadla iPhone na všetkých platformách",
"crypto_version": "Krypto verzia: {{version}}",
"custom_livekit_url": {
"current_url": "Aktuálne nastavené na: ",
"from_config": "Momentálne nie je nastavené žiadne prepísanie. Používa sa URL adresa zo známeho zdroja alebo konfigurácie.",
"label": "Vlastná Livekit URL adresa",
"reset": "Obnoviť prepísanie",
"save": "Uložiť",
"saving": "Ukladá sa…"
},
"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",
@@ -64,13 +73,25 @@
"hostname": "Názov hostiteľa: {{hostname}}",
"livekit_server_info": "Informácie o serveri LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Kompatibilné s domovskými servermi, ktoré nepodporujú sticky udalosti (ale všetci ostatní EC klienti sú v0.17.0 alebo novší)",
"label": "Kompatibilita: stavové udalosti a multi SFU"
},
"Legacy": {
"description": "Kompatibilné so staršími verziami EC, ktoré nepodporujú viacero SFU",
"label": "Staršie: stavové udalosti a najstaršie členstvo SFU"
},
"Matrix_2_0": {
"description": "Kompatibilné iba s domovskými servermi podporujúcimi prilepené udalosti a všetkými EC klientmi v0.17.0 alebo novšími.",
"label": "Matrix 2.0: prilepené udalosti a viacero SFU"
},
"title": "Režim MatrixRTC"
},
"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á",
"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"
"url_params": "Parametre URL adresy"
},
"disconnected_banner": "Spojenie so serverom sa stratilo.",
"error": {
@@ -81,13 +102,19 @@
"connection_lost_description": "Boli ste odpojení od hovoru.",
"e2ee_unsupported": "Nekompatibilný prehliadač",
"e2ee_unsupported_description": "Váš webový prehliadač nepodporuje šifrované hovory. Medzi podporované prehliadače patria Chrome, Safari a Firefox 117+.",
"failed_to_start_livekit": "Nepodarilo sa spustiť pripojenie Livekit",
"generic": "Niečo sa pokazilo",
"generic_description": "Odoslanie záznamov ladenia nám pomôže nájsť problém.",
"insufficient_capacity": "Nedostatočná kapacita",
"insufficient_capacity_description": "Server dosiahol svoju maximálnu kapacitu a momentálne sa nemôžete pripojiť k hovoru. Skúste to znova neskôr alebo kontaktujte správcu servera, ak problém pretrváva.",
"matrix_rtc_focus_missing": "Server nie je nakonfigurovaný na prácu s aplikáciou {{brand}}. Kontaktujte správcu svojho servera (Doména:{{domain}}, Kód chyby:{{ errorCode }}).",
"matrix_rtc_transport_missing": "Server nie je nastavený na prácu s aplikáciou {{brand}}. Kontaktujte prosím správcu svojho servera (Doména: {{domain}}, Kód chyby: {{ errorCode }}).",
"membership_manager": "Chyba správcu členstva",
"membership_manager_description": "Správca členstva musel byť vypnutý. Príčinou je mnoho po sebe idúcich neúspešných sieťových požiadaviek.",
"no_matrix_2_authorization_service": "Autorizačná služba pre váš mediálny server (SFU) je zastaraná.",
"open_elsewhere": "Otvorené na inej karte",
"open_elsewhere_description": "Aplikácia {{brand}} bola otvorená na inej karte. Ak sa vám to nezdá, skúste znovu načítať stránku.",
"room_creation_restricted": "Nepodarilo sa vytvoriť hovor",
"room_creation_restricted_description": "Uskutočňovanie hovorov môže byť obmedzené iba na autorizovaných používateľov. Skúste to znova neskôr alebo kontaktujte správcu servera, ak problém pretrváva.",
"unexpected_ec_error": "Vyskytla sa neočakávaná chyba (<0>Kód chyby:</0> <1>{{ errorCode }}</1>). Kontaktujte prosím správcu vášho servera."
},
"group_call_loader": {
@@ -99,6 +126,11 @@
"knock_reject_heading": "Nie je povolené pripojiť sa",
"reason": "Dôvod"
},
"handset": {
"overlay_back_button": "Späť do režimu reproduktora",
"overlay_description": "Funguje iba pri používaní aplikácie",
"overlay_title": "Režim slúchadla"
},
"hangup_button_label": "Ukončiť hovor",
"header_label": "Domov Element Call",
"header_participants_label": "Účastníci",
@@ -171,6 +203,7 @@
"change_device_button": "Zmeniť zvukové zariadenie",
"default": "Predvolené",
"default_named": "Predvolené <2>({{name}})</2>",
"handset": "Slúchadlo",
"loudspeaker": "Reproduktor",
"microphone": "Mikrofón",
"microphone_numbered": "Mikrofón {{n}}",
@@ -213,7 +246,6 @@
"video_tile": {
"always_show": "Vždy zobraziť",
"camera_starting": "Načítavanie videa...",
"change_fit_contain": "Orezať na mieru",
"collapse": "Zbaliť",
"expand": "Rozbaliť",
"mute_for_me": "Pre mňa stlmiť",

View File

@@ -49,6 +49,7 @@
"profile": "Profil",
"reaction": "Reaktion",
"reactions": "Reaktioner",
"reconnecting": "Återansluter …",
"settings": "Inställningar",
"unencrypted": "Inte krypterad",
"username": "Användarnamn",
@@ -57,6 +58,14 @@
"developer_mode": {
"always_show_iphone_earpiece": "Visa iPhone-hörsnäckealternativ på alla plattformar",
"crypto_version": "Kryptoversion: {{version}}",
"custom_livekit_url": {
"current_url": "För närvarande inställd på: ",
"from_config": "För närvarande är ingen överskrivning inställd. URL från well-known eller konfig används.",
"label": "Anpassad Livekit-url",
"reset": "Återställ överskrivning",
"save": "Spara",
"saving": "Sparar…"
},
"debug_tile_layout_label": "Felsök panelarrangemang",
"device_id": "Enhets-ID: {{id}}",
"duplicate_tiles_label": "Antal ytterligare panelkopior per deltagare",
@@ -64,13 +73,25 @@
"hostname": "Värdnamn: {{hostname}}",
"livekit_server_info": "LiveKit-serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Kompatibel med hemservrar som inte stöder sticky events (men alla andra EC-klienter är v0.17.0 eller senare)",
"label": "Kompatibilitet: tillståndshändelser och multi-SFU"
},
"Legacy": {
"description": "Kompatibel med äldre versioner av EC som inte stöder multi SFU",
"label": "Legacy: state events och oldest membership SFU"
},
"Matrix_2_0": {
"description": "Endast kompatibel med hemservrar som stöder sticky events och alla EC-klienter v0.17.0 eller senare",
"label": "Matrix 2.0: sticky events & multi SFU"
},
"title": "MatrixRTC-läge"
},
"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_to_device_key_transport": "Använd \"till enhet\"-nyckeltransport. Detta kommer att falla tillbaka till rumsnyckeltransport om en annan samtalsmedlem skickar en rumsnyckel."
"url_params": "URL-parametrar"
},
"disconnected_banner": "Anslutningen till servern har brutits.",
"error": {
@@ -81,11 +102,15 @@
"connection_lost_description": "Du kopplades bort från samtalet.",
"e2ee_unsupported": "Inkompatibel webbläsare",
"e2ee_unsupported_description": "Din webbläsare stöder inte krypterade samtal. Webbläsare som stöds inkluderar Chrome, Safari och Firefox 117+.",
"failed_to_start_livekit": "Misslyckades att starta Livekit-anslutning",
"generic": "Något gick fel",
"generic_description": "Att skicka felsökningsloggar hjälper oss att spåra problemet.",
"insufficient_capacity": "Otillräcklig kapacitet",
"insufficient_capacity_description": "Servern har nått sin maximala kapacitet och du kan inte gå med i samtalet just nu. Försök igen senare, eller kontakta serveradministratören om problemet kvarstår.",
"matrix_rtc_focus_missing": "Servern är inte konfigurerad för att fungera med {{brand}}. Vänligen kontakta serveradministratören (Domän: {{domain}}, Felkod: {{ errorCode }}).",
"matrix_rtc_transport_missing": "Servern är inte konfigurerad för att fungera med {{brand}}. Vänligen kontakta din serveradministratör (Domän: {{domain}}, Felkod: {{ errorCode }}).",
"membership_manager": "Fel i medlemskapshanteraren",
"membership_manager_description": "Medlemskapshanteraren var tvungen att stängas av. Detta orsakas av många misslyckade nätverksförfrågningar i rad.",
"no_matrix_2_authorization_service": "Auktoriseringstjänsten för din medieserver (SFU) är föråldrad.",
"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",
@@ -219,7 +244,6 @@
"video_tile": {
"always_show": "Visa alltid",
"camera_starting": "Video laddar …",
"change_fit_contain": "Anpassa till ram",
"collapse": "Kollapsa",
"expand": "Expandera",
"mute_for_me": "Tysta för mig",

View File

@@ -63,8 +63,7 @@
"livekit_server_info": "LiveKit Sunucu Bilgisi",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix Kimliği: {{id}}",
"show_connection_stats": "Bağlantı istatistiklerini göster",
"show_non_member_tiles": "Üye olmayan kullanıcılar için ortam döşemelerini göster"
"show_connection_stats": "Bağlantı istatistiklerini göster"
},
"disconnected_banner": "Sunucuyla bağlantı kesildi.",
"error": {
@@ -192,7 +191,6 @@
"video_tile": {
"always_show": "Her zaman göster",
"camera_starting": "Video paylaşımı başlatılıyor...",
"change_fit_contain": "Çerçeveye sığdır",
"collapse": "Daralt",
"expand": "Genişlet",
"mute_for_me": "Benim için sessize al",

View File

@@ -49,13 +49,23 @@
"profile": "Профіль",
"reaction": "Реакція",
"reactions": "Реакції",
"reconnecting": "Повторне з'єднання…",
"settings": "Налаштування",
"unencrypted": "Не зашифровано",
"username": "Ім'я користувача",
"video": "Відео"
},
"developer_mode": {
"always_show_iphone_earpiece": "Показувати опцію виводу звуку в динамік iPhone на всіх платформах",
"crypto_version": "Крипто-версія: {{version}}",
"custom_livekit_url": {
"current_url": "Наразі налаштовано: ",
"from_config": "Наразі перезапис не налаштовано. Використовується URL-адреса з відомого ресурсу або конфігурації.",
"label": "Власний Livekit-url",
"reset": "Скинути перезапис",
"save": "Зберегти",
"saving": "Збереження..."
},
"debug_tile_layout_label": "Налагоджування макету плиток",
"device_id": "ID пристрою: {{id}}",
"duplicate_tiles_label": "Кількість додаткових копій плиток на одного учасника",
@@ -63,11 +73,25 @@
"hostname": "Ім'я хоста: {{hostname}}",
"livekit_server_info": "Інформація про сервер LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "Сумісно з домашніми серверами, які не підтримують закріплені події (але всі інші клієнти EC мають версію 0.17.0 або новішу)",
"label": "Сумісність: події стану та кілька SFU"
},
"Legacy": {
"description": "Сумісно з давнішими версіями EC, які не підтримують кілька SFU",
"label": "Застаріле: події стану та найдавніше членство SFU"
},
"Matrix_2_0": {
"description": "Сумісно лише з домашніми серверами, що підтримують закріплені події, та всіма клієнтами EC версії 0.17.0 або новіших.",
"label": "Matrix 2.0: закріплені події та мульти-SFU"
},
"title": "Режим MatrixRTC"
},
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Вимкнути всі звуки (учасників, реакцій, звуків приєднання)",
"show_connection_stats": "Показувати статистику підключення",
"show_non_member_tiles": "Показувати плитки для медіа, які не є учасниками",
"url_params": "Параметри URL",
"use_new_membership_manager": "Використовуйте нову реалізацію виклику MembershipManager"
"url_params": "Параметри URL"
},
"disconnected_banner": "Втрачено зв'язок з сервером.",
"error": {
@@ -78,13 +102,19 @@
"connection_lost_description": "Вас було відключено від дзвінка.",
"e2ee_unsupported": "Несумісний браузер",
"e2ee_unsupported_description": "Ваш веб-браузер не підтримує зашифровані дзвінки. Підтримувані браузери включають Chrome, Safari та Firefox 117+.",
"failed_to_start_livekit": "Не вдалося розпочати з’єднання Livekit",
"generic": "Щось пішло не так",
"generic_description": "Надсилання журналів налагодження допоможе нам відстежити проблему.",
"insufficient_capacity": "Недостатньо обсягу",
"insufficient_capacity_description": "Сервер досяг максимального обсягу, і ви на разі не можете приєднатися до виклику. Спробуйте пізніше або зверніться до адміністратора сервера, якщо проблема не зникне.",
"matrix_rtc_focus_missing": "Сервер не налаштований щоб працювати з {{brand}}. Будь ласка, зв'яжіться з адміністратором сервера (Домен: {{domain}}, Код помилки: {{ errorCode }}).",
"matrix_rtc_transport_missing": "Сервер не налаштовано для роботи з{{brand}}. Зверніться до адміністратора вашого сервера (домен: {{domain}} , Код помилки: {{ errorCode }}).",
"membership_manager": "Помилка менеджера членства",
"membership_manager_description": "Менеджер членства був змушений припинити роботу. Це сталося через численні послідовні невдалі мережеві запити.",
"no_matrix_2_authorization_service": "Служба авторизації для вашого медіасервера (SFU) застаріла.",
"open_elsewhere": "Відкрито в іншій вкладці",
"open_elsewhere_description": "{{brand}} було відкрито в іншій вкладці. Якщо це звучить неправильно, спробуйте перезавантажити сторінку.",
"room_creation_restricted": "Не вдалося створити виклик",
"room_creation_restricted_description": "Створення викликів може бути обмежено лише для авторизованих користувачів. Спробуйте пізніше або зверніться до адміністратора сервера, якщо проблема не зникне.",
"unexpected_ec_error": "Сталася несподівана помилка (<0>Код помилки: </0> <1> {{ errorCode }}</1>). Будь ласка, зв'яжіться з адміністратором сервера."
},
"group_call_loader": {
@@ -96,6 +126,11 @@
"knock_reject_heading": "Доступ заборонено",
"reason": "Причина: {{reason}}"
},
"handset": {
"overlay_back_button": "Повернутися до режиму динаміка",
"overlay_description": "Працює лише під час використання застосунку",
"overlay_title": "Режим гарнітури"
},
"hangup_button_label": "Завершити виклик",
"header_label": "Домівка Element Call",
"header_participants_label": "Учасники",
@@ -158,12 +193,18 @@
"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>",
"handset": "Гарнітура",
"loudspeaker": "Гучномовець",
"microphone": "Мікрофон",
"microphone_numbered": "Мікрофон {{n}}",
"speaker": "Динамік",
@@ -205,7 +246,6 @@
"video_tile": {
"always_show": "Показувати завжди",
"camera_starting": "Завантаження відео...",
"change_fit_contain": "Допасувати до рамки",
"collapse": "Згорнути",
"expand": "Розгорнути",
"mute_for_me": "Вимкнути звук для мене",

View File

@@ -5,16 +5,23 @@
"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>和<5>Cookie政策</5>中找到有关我们跟踪哪些数据以及更多信息。",
"analytics_notice": "参与本次测试即表示您同意我们收集匿名数据,这些数据将用于改进产品。有关我们收集的数据的更多信息,请参阅我们的<2>隐私政策</2> 以及我们的<6>Cookie 政策</6> 。",
"call_ended_view": {
"create_account_button": "创建账户",
"create_account_prompt": "<0>为何不设置密码来保留你的账户?</0><1>保留昵称并设置头像,以便在未来的通话中使用。</1>",
@@ -30,25 +37,99 @@
"analytics": "分析",
"audio": "音频",
"avatar": "头像",
"back": "返回",
"display_name": "显示名称",
"encrypted": "已加密",
"home": "主页",
"loading": "加载中……",
"next": "下一步",
"options": "选项",
"password": "密码",
"preferences": "偏好",
"profile": "个人信息",
"reaction": "反应",
"reactions": "反应",
"reconnecting": "正在重新连接…",
"settings": "设置",
"unencrypted": "未加密",
"username": "用户名",
"video": "视频"
},
"developer_mode": {
"always_show_iphone_earpiece": "在所有平台上显示 iPhone 听筒选项",
"crypto_version": "加密组件版本:{{version}}",
"custom_livekit_url": {
"current_url": "当前设置为: ",
"from_config": "当前未设置任何覆盖值。使用来自 well-known 或配置文件中的 URL。",
"label": "自定义 LiveKit URL",
"reset": "重置覆盖值",
"save": "保存",
"saving": "正在保存…"
},
"debug_tile_layout_label": "调试图块布局",
"device_id": "设备 ID{{id}}",
"duplicate_tiles_label": "每个参与者的图块副本数量",
"environment_variables": "环境变量",
"hostname": "主机名:{{hostname}}",
"livekit_server_info": "LiveKit 服务器信息",
"livekit_sfu": "LiveKit SFU{{url}}",
"matrixRTCMode": {
"Comptibility": {
"description": "兼容不支持黏着事件的主服务器(但要求所有其它 EC 客户端均为 v0.17.0 或更高版本)",
"label": "兼容性:状态事件 & 多重 SFU"
},
"Legacy": {
"description": "兼容不支持多重 SFU 的旧版本 EC",
"label": "旧版: 状态事件 & 传统人际 SFU"
},
"Matrix_2_0": {
"description": "仅兼容支持黏着事件的主服务器及所有 EC v0.17.0 或更高版本的参与者客户端",
"label": "Matrix 2.0:黏着事件 & 多重 SFU"
},
"title": "MatrixRTC 模式"
},
"matrix_id": "Matrix ID{{id}}",
"mute_all_audio": "静默所有声音(参与者、反应与加入音效)",
"show_connection_stats": "显示连接统计信息",
"url_params": "URL 参数"
},
"disconnected_banner": "与服务器的连接中断。",
"error": {
"call_is_not_supported": "不支持的通话",
"call_not_found": "未找到通话",
"call_not_found_description": "<0>该链接似乎不属于任何现有通话. 请检查链接是否正确,或<1>创建新链接</1>。</0>",
"connection_lost": "连接已丢失",
"connection_lost_description": "你已断开通话。",
"e2ee_unsupported": "不兼容的浏览器",
"e2ee_unsupported_description": "你的浏览器不支持加密通话。支持的浏览器包括 Chrome、Safari 与 Firefox 117+。",
"failed_to_start_livekit": "LiveKit 连接启动失败",
"generic": "出现问题",
"generic_description": "提交调试日志将有助于我们调查问题。",
"insufficient_capacity": "容量不足",
"insufficient_capacity_description": "服务器已达到其最大容量,你目前无法加入通话。请稍后再试,或问题仍然存在时请联系服务器管理员。",
"matrix_rtc_transport_missing": "服务器未配置为用于 {{brand}}。请联系服务器管理员(域名:{{domain}},错误代码:{{ errorCode }} )。",
"membership_manager": "人际管理器错误",
"membership_manager_description": "人际管理器已关闭。这是由于连续多次网络请求失败造成的。",
"no_matrix_2_authorization_service": "媒体服务器SFU的授权服务已过期。",
"open_elsewhere": "在另一标签页打开",
"open_elsewhere_description": "{{brand}} 已在另一标签页中打开。如果这并非预期,请重载页面。",
"room_creation_restricted": "通话创建失败",
"room_creation_restricted_description": "可能仅限被授权的用户创建通话。请于稍候重试,或问题仍然存在的情况下联系服务器管理员。",
"unexpected_ec_error": "出现未知错误(<0>错误代码:</0><1>{{ errorCode }}</1>)。请联系服务器管理员。"
},
"group_call_loader": {
"banned_body": "你已被房间封禁",
"banned_heading": "已被封禁",
"call_ended_body": "你已被移出通话",
"call_ended_heading": "通话结束",
"knock_reject_body": "房间成员拒绝了你的加入请求"
"knock_reject_body": "房间成员拒绝了你的加入请求",
"knock_reject_heading": "拒绝访问",
"reason": "原因:{{reason}}"
},
"handset": {
"overlay_back_button": "返回扬声器模式",
"overlay_description": "仅在使用 app 时有效",
"overlay_title": "听筒模式"
},
"hangup_button_label": "通话结束",
"header_label": "Element Call主页",
@@ -65,8 +146,11 @@
"layout_grid_label": "网格",
"layout_spotlight_label": "聚焦模式",
"lobby": {
"ask_to_join": "请求加入通话",
"join_as_guest": "以访客身份加入",
"join_button": "加入通话",
"leave_button": "返回最近通话"
"leave_button": "返回最近通话",
"waiting_for_invite": "请求已发送!正在等待加入许可……"
},
"log_in": "登录",
"logging_in": "登录中……",
@@ -78,6 +162,7 @@
"microphone_on": "麦克风开启",
"mute_microphone_button_label": "静音麦克风",
"participant_count_other": "{{count, number}}",
"qr_code": "二维码",
"rageshake_button_error_caption": "重传日志",
"rageshake_request_modal": {
"body": "这个通话中的另一个用户出现了问题。为了更好地诊断这些问题,我们想收集调试日志。",
@@ -89,6 +174,7 @@
"rageshake_sent": "谢谢!",
"recaptcha_dismissed": "人机验证失败",
"recaptcha_not_loaded": "recaptcha未加载",
"recaptcha_ssla_caption": "此站点受 ReCAPTCHA 保护,并且适用于 Google <2>隐私政策</2>与<6>服务条款</6> 。<9></9>点击“注册”即表示你同意<12>软件与服务许可条款SSLA</12>",
"register": {
"passwords_must_match": "密码必须匹配",
"registering": "正在注册……"
@@ -97,31 +183,73 @@
"register_confirm_password_label": "确认密码",
"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>",
"handset": "听筒",
"loudspeaker": "扬声器",
"microphone": "麦克风",
"microphone_numbered": "麦克风 {{n}}",
"speaker": "扬声器",
"speaker_numbered": "扬声器 {{n}}"
},
"feedback_tab_body": "如果遇到问题或想提供一些反馈意见,请在下面向我们发送简短描述。",
"feedback_tab_description_label": "您的反馈",
"feedback_tab_h4": "提交反馈",
"feedback_tab_send_logs_label": "包含调试日志",
"feedback_tab_thank_you": "谢谢,我们收到了反馈!",
"feedback_tab_title": "反馈",
"opt_in_description": "<0></0><1></1>您可以取消选中复选框来撤回同意。如果正在通话中,此设置将在通话结束时生效。"
"opt_in_description": "<0></0><1></1>您可以取消选中复选框来撤回同意。如果正在通话中,此设置将在通话结束时生效。",
"preferences_tab": {
"developer_mode_label": "开发者模式",
"developer_mode_label_description": "启用开发者模式并显示开发者设置标签",
"introduction": "你可以配置此处的额外选项以改善体验。",
"reactions_play_sound_description": "当任何人在通话中发送反应时播放音效",
"reactions_play_sound_label": "播放反应音效",
"reactions_show_description": "当任何人发送反应时显示动画效果。",
"reactions_show_label": "显示反应",
"show_hand_raised_timer_description": "当有参与者举手时显示计时器",
"show_hand_raised_timer_label": "显示举手持续时间"
}
},
"star_rating_input_label_one": "{{count}} 个星",
"star_rating_input_label_other": "{{count}} 个星",
"start_new_call": "开始新通话",
"start_video_button_label": "开始视频",
"stop_screenshare_button_label": "屏幕共享",
"stop_video_button_label": "停止视频",
"submitting": "提交中…",
"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": {
"change_fit_contain": "贴合框架",
"always_show": "始终显示",
"call_ended": "通话结束",
"calling": "呼叫中……",
"camera_starting": "视频加载中……",
"collapse": "折叠",
"expand": "展开",
"mute_for_me": "为我静音",
"volume": "音量"
"muted_for_me": "为我静音",
"screen_share_volume": "屏幕共享音量",
"volume": "音量",
"waiting_for_media": "正在等待媒体…"
}
}

View File

@@ -24,9 +24,11 @@
"lint:eslint-fix": "eslint --max-warnings 0 src playwright --fix",
"lint:knip": "knip",
"lint:types": "tsc",
"i18n": "i18next",
"i18n:check": "i18next --fail-on-warnings --fail-on-update",
"i18n": "npx i18next-cli extract",
"i18n:check": "npx i18next-cli extract --ci",
"test": "vitest",
"test:storybook": "vitest --project=storybook",
"test:unit": "vitest --project=unit",
"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",
@@ -38,10 +40,6 @@
"build-storybook": "storybook build"
},
"devDependencies": {
"@babel/core": "^7.16.5",
"@babel/preset-env": "^7.22.20",
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.0",
"@codecov/vite-plugin": "^1.3.0",
"@fontsource/inconsolata": "^5.1.0",
"@fontsource/inter": "^5.1.0",
@@ -52,21 +50,21 @@
"@livekit/protocol": "^1.42.2",
"@livekit/track-processors": "^0.7.1",
"@mediapipe/tasks-vision": "^0.10.18",
"@playwright/test": "^1.57.0",
"@playwright/test": "^1.60.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": "^10.0.0",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^3.0.0",
"@storybook/addon-docs": "^10.3.3",
"@storybook/react-vite": "^10.3.3",
"@storybook/addon-docs": "^10.3.6",
"@storybook/addon-vitest": "^10.3.6",
"@storybook/react-vite": "^10.3.6",
"@stylistic/eslint-plugin": "^3.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.1",
"@types/content-type": "^1.1.5",
"@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7",
"@types/lodash-es": "^4.17.12",
@@ -76,15 +74,15 @@
"@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": "^10.0.0",
"@vector-im/compound-web": "^9.3.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"@vitest/ui": "4.1.7",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"eslint": "^8.14.0",
@@ -98,19 +96,19 @@
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-storybook": "^10.3.3",
"eslint-plugin-storybook": "^10.3.6",
"eslint-plugin-unicorn": "^56.0.0",
"fetch-mock": "11.1.5",
"global-jsdom": "^26.0.0",
"i18next": "^25.0.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-parser": "^9.1.0",
"i18next-cli": "^1.61.0",
"jsdom": "^26.0.0",
"knip": "^5.86.0",
"livekit-client": "^2.18.1",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^1.16.1",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
@@ -118,7 +116,7 @@
"pako": "^2.0.4",
"postcss": "^8.4.41",
"postcss-preset-env": "^10.0.0",
"posthog-js": "1.160.3",
"posthog-js": "1.374.0",
"prettier": "^3.0.0",
"qrcode": "^1.5.4",
"react": "19",
@@ -128,7 +126,7 @@
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sass": "^1.42.1",
"storybook": "^10.3.3",
"storybook": "^10.3.6",
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
@@ -137,24 +135,12 @@
"vite": "^8.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-node-polyfills": "^0.28.0",
"vite-plugin-node-stdlib-browser": "^0.2.1",
"vite-plugin-svgr": "^4.0.0",
"vite-plugin-wasm": "^3.6.0",
"vitest": "^4.0.18",
"vitest": "^4.1.5",
"vitest-axe": "^1.0.0-pre.3"
},
"pnpm": {
"overrides": {
"@livekit/components-core>rxjs": "^7.8.1",
"@livekit/track-processors>@mediapipe/tasks-vision": "^0.10.18",
"minimatch": "^10.2.3",
"tar": "^7.5.11",
"glob": "^10.5.0",
"qs": "^6.14.1",
"js-yaml": "^4.1.1",
"esbuild": "^0.28.0"
}
},
"packageManager": "pnpm@10.33.0"
"packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f"
}

View File

@@ -77,13 +77,13 @@ mobileTest(
await expect(
guestPage.getByTestId("roomHeader_participants_count"),
).toContainText("2");
expect(await guestPage.getByTestId("videoTile").count()).toBe(2);
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2);
// Same in creator page
await expect(
creatorPage.getByTestId("roomHeader_participants_count"),
).toContainText("2");
expect(await creatorPage.getByTestId("videoTile").count()).toBe(2);
await expect(creatorPage.getByTestId("videoTile")).toHaveCount(2);
// TEST: control audio devices from the invitee page

View File

@@ -54,6 +54,8 @@ test("can only interact with header and footer while reconnecting", async ({
page.getByRole("switch", { name: "Mute microphone" }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Microphone" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click();

6932
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,35 @@
# dependencies where we use branches and hashes in the package.json. But that also use a pre/post install script.
onlyBuiltDependencies:
- "matrix-js-sdk"
allowBuilds:
"@parcel/watcher": true
"@sentry/cli": true
"@swc/core": true
"core-js": true
"esbuild": true
"matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8c95727b6278fe7942c20d0b9485f984dd0694b7": true
"protobufjs": true
overrides:
# We need compatible versions of RxJS in our dependencies and LiveKit's dependencies, but
# LiveKit has pinned it to a very specific version which is now holding us back from updating.
# See livekit/components-js#1101 for a request for a proper solution.
"@livekit/components-core>rxjs": "^7.8.1"
# Dedupe Mediapipe dependencies.
"@livekit/track-processors>@mediapipe/tasks-vision": "^0.10.18"
# Security fix: https://security-tracker.debian.org/tracker/CVE-2026-31802
"tar": "^7.5.11"
# Security fixes:
# - https://github.com/advisories/GHSA-7r86-cg39-jmmj
# - https://github.com/advisories/GHSA-23c5-xmqv-rm74
"minimatch": "^10.2.3"
# Security fix: https://github.com/element-hq/element-call/security/dependabot/109
"glob": "^10.5.0"
# Security fixes:
# - https://github.com/element-hq/element-call/security/dependabot/110
# - https://github.com/element-hq/element-call/security/dependabot/122
"qs": "^6.14.1"
# Security fix: https://github.com/element-hq/element-call/security/dependabot/106
"js-yaml": "^4.1.1"
# Storybook declares support for 0.27.0 only but empirically works fine with 0.28.0.
"esbuild": "^0.28.0"
# Multiple security fixes: https://github.com/nodejs/undici/releases/tag/v6.24.0
"undici": "^6.24.0"
# Security fix: https://github.com/advisories/GHSA-rf6f-7fwh-wjgh
"flatted": "^3.4.2"

View File

@@ -20,3 +20,21 @@
color: var(--cpd-color-text-secondary);
text-align: center;
}
.technicalDetails {
margin-top: var(--cpd-space-1x);
}
.technicalDetailsSummary {
cursor: pointer;
font-weight: bold;
}
.technicalDetailsPre {
margin-top: var(--cpd-space-2x);
padding: var(--cpd-space-2x);
background-color: var(--cpd-color-bg-subtle-secondary);
overflow: auto;
font-size: var(--cpd-font-size-body-sm);
white-space: pre-wrap;
}

View File

@@ -14,8 +14,13 @@ import {
beforeAll,
afterAll,
} from "vitest";
import posthog, { type CaptureResult } from "posthog-js";
import { PosthogAnalytics } from "./PosthogAnalytics";
import {
Anonymity,
santizeSensitiveData,
PosthogAnalytics,
} from "./PosthogAnalytics";
import { mockConfig } from "../utils/test";
describe("PosthogAnalytics", () => {
@@ -88,4 +93,154 @@ describe("PosthogAnalytics", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
});
});
describe("applyPrivacyFilters", () => {
const makeEvent = (properties: Record<string, unknown>): CaptureResult =>
({ event: "anyEvent", properties }) as unknown as CaptureResult;
it("drops $initial_person_info regardless of anonymity", () => {
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://call.example.com/some/private/path",
$initial_person_info: {
r: "https://example.com/referrer",
u: "https://call.example.com/some/private/path",
},
}),
Anonymity.Pseudonymous,
);
expect(out?.properties).not.toHaveProperty("$initial_person_info");
});
it("strips hash from $current_url", () => {
const out = santizeSensitiveData(
makeEvent({ $current_url: "https://call.example.com/#/x/y/z" }),
Anonymity.Pseudonymous,
);
expect(out?.properties["$current_url"]).not.toContain("/x/y/z");
});
it("nulls referrer and device fields when anonymous", () => {
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://x/y",
$referrer: "https://leaky",
$initial_referrer: "https://leaky-too",
$device_id: "uuid",
}),
Anonymity.Anonymous,
);
expect(out?.properties["$referrer"]).toBeUndefined();
expect(out?.properties["$initial_referrer"]).toBeUndefined();
expect(out?.properties["$device_id"]).toBeUndefined();
});
it("passes null events through unchanged", () => {
expect(santizeSensitiveData(null, Anonymity.Pseudonymous)).toBeNull();
});
it("strips URL fields nested inside $set_once", () => {
const secretUrl =
"https://call.example.com/room/#/?password=hunter2&roomId=abc";
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://call.example.com/x",
$set_once: {
$current_url: secretUrl,
$initial_current_url: secretUrl,
$session_entry_url: secretUrl,
$initial_person_info: { r: "x", u: secretUrl },
},
}),
Anonymity.Pseudonymous,
);
const setOnce = out?.properties["$set_once"] as Record<string, unknown>;
expect(setOnce["$current_url"]).not.toContain("password");
expect(setOnce["$initial_current_url"]).not.toContain("password");
expect(setOnce).not.toHaveProperty("$session_entry_url");
expect(setOnce).not.toHaveProperty("$initial_person_info");
});
it("strips URL fields nested inside $set", () => {
const secretUrl =
"https://call.example.com/room/#/?password=hunter2&roomId=abc";
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://call.example.com/x",
$set: {
$current_url: secretUrl,
$session_entry_url: secretUrl,
},
}),
Anonymity.Pseudonymous,
);
const set = out?.properties["$set"] as Record<string, unknown>;
expect(set["$current_url"]).not.toContain("password");
expect(set).not.toHaveProperty("$session_entry_url");
});
it("nulls referrer fields inside $set_once when anonymous", () => {
const out = santizeSensitiveData(
makeEvent({
$current_url: "https://x/y",
$set_once: {
$initial_referrer: "https://leaky",
$initial_referring_domain: "leaky",
},
}),
Anonymity.Anonymous,
);
const setOnce = out?.properties["$set_once"] as Record<string, unknown>;
expect(setOnce["$initial_referrer"]).toBeUndefined();
expect(setOnce["$initial_referring_domain"]).toBeUndefined();
});
});
// Verifies that applyPrivacyFilters is actually wired into posthog.init via
// the before_send hook — guards against typos in the option name or future
// posthog-js bumps renaming/removing the hook. The filter logic itself is
// covered by the applyPrivacyFilters block above.
describe("posthog.init wiring", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("passes events through the privacy filter via before_send", () => {
const initSpy = vi.spyOn(posthog, "init");
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
const beforeSend = initSpy.mock.calls[0][1]?.before_send;
expect(beforeSend).toBeInstanceOf(Function);
const event = {
event: "anyEvent",
properties: {
$current_url: "https://call.example.com/x/y",
$initial_person_info: { r: "x" },
},
} as unknown as CaptureResult;
const out = (beforeSend as (e: CaptureResult) => CaptureResult | null)(
event,
);
expect(out?.properties).not.toHaveProperty("$initial_person_info");
});
});
});

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import posthog, {
type CaptureOptions,
type CaptureResult,
type PostHog,
type Properties,
} from "posthog-js";
@@ -65,6 +66,73 @@ export enum RegistrationType {
Registered,
}
// Sanitize URL / referrer / device fields on a single posthog properties bag.
// Applied to event.properties and to the person-profile bags ($set / $set_once),
// since posthog mirrors the same URL fields into those.
function stripSensitiveFields(
obj: Properties | undefined,
anonymity: Anonymity,
): void {
if (!obj) return;
if (anonymity === Anonymity.Anonymous) {
// drop referrer information for anonymous users
delete obj["$referrer"];
delete obj["$referring_domain"];
delete obj["$initial_referrer"];
delete obj["$initial_referring_domain"];
// drop device ID, which is a UUID persisted in local storage
delete obj["$device_id"];
}
// the url leaks a lot of private data like the call name or the user
// (room password / room ID can land in the hash/query). Strip down to
// scheme + host so we still get host-level insights (develop / main / sfu).
for (const key of ["$current_url", "$initial_current_url"]) {
if (typeof obj[key] === "string") {
try {
const url = new URL(obj[key]);
obj[key] = url.protocol + "//" + url.hostname + url.pathname;
} catch {
obj[key] = null;
}
}
}
// $session_entry_url carries the full untrimmed URL; $initial_person_info
// bundles initial referrer + URL into a nested object that bypasses the
// per-key strips above. Drop both.
delete obj["$session_entry_url"];
delete obj["$initial_person_info"];
}
/**
* Strip PII from posthog's built-in properties (URL, referrer fields,
* device ID, $initial_person_info, $session_entry_url) before events leave
* the client. Also applied to the person-profile bags ($set / $set_once),
* which mirror the same URL fields.
* See src/utils/event-utils.ts in posthog-js (getEventProperties, getPersonInfo)
* for the list of properties posthog sets automatically.
*/
export function santizeSensitiveData(
event: CaptureResult | null,
anonymity: Anonymity,
): CaptureResult | null {
if (event === null) return null;
stripSensitiveFields(event.properties, anonymity);
// posthog can stash person-profile updates either at the top level
// of CaptureResult or nested inside properties depending on the pipeline
// stage; clean both spots so nothing slips through.
stripSensitiveFields(event.$set, anonymity);
stripSensitiveFields(event.$set_once, anonymity);
stripSensitiveFields(event.properties["$set"], anonymity);
stripSensitiveFields(event.properties["$set_once"], anonymity);
return event;
}
interface PlatformProperties {
appVersion: string;
matrixBackend: "embedded" | "jssdk";
@@ -129,13 +197,16 @@ export class PosthogAnalytics {
}
if (apiKey && apiHost) {
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
santizeSensitiveData(event, this.anonymity);
this.posthog.init(apiKey, {
api_host: apiHost,
autocapture: false,
mask_all_text: true,
mask_all_element_attributes: true,
mask_personal_data_properties: true,
capture_pageview: false,
sanitize_properties: this.sanitizeProperties,
before_send: beforeSend,
respect_dnt: true,
advanced_disable_decide: true,
});
@@ -148,34 +219,6 @@ export class PosthogAnalytics {
}
}
private sanitizeProperties = (
properties: Properties,
_eventName: string,
): Properties => {
// Callback from posthog to sanitize properties before sending them to the server.
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
// See utils.js _.info.properties in posthog-js.
if (this.anonymity == Anonymity.Anonymous) {
// drop referrer information for anonymous users
properties["$referrer"] = null;
properties["$referring_domain"] = null;
properties["$initial_referrer"] = null;
properties["$initial_referring_domain"] = null;
// drop device ID, which is a UUID persisted in local storage
properties["$device_id"] = null;
}
// the url leaks a lot of private data like the call name or the user.
// Its stripped down to the bare minimum to only give insights about the host (develop, main or sfu)
properties["$current_url"] = (properties["$current_url"] as string)
.split("/")
.slice(0, 3)
.join("");
return properties;
};
private registerSuperProperties(properties: Properties): void {
if (this.enabled) {
this.posthog.register(properties);

View File

@@ -8,3 +8,17 @@ Please see LICENSE in the repository root for full details.
.endCall > svg {
color: var(--stopgap-color-on-solid-accent);
}
.rotate > svg {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

113
src/button/Button.test.tsx Normal file
View File

@@ -0,0 +1,113 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import { MicButton, VideoButton } from "./Button";
describe("MicButton", () => {
test("calls onClick when not busy", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<TooltipProvider>
<MicButton enabled={true} onClick={onClick} />
</TooltipProvider>,
);
const button = screen.getByRole("switch");
await user.click(button);
expect(onClick).toHaveBeenCalled();
});
test("does not call onClick when busy", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<TooltipProvider>
<MicButton enabled={true} busy={true} onClick={onClick} />
</TooltipProvider>,
);
const button = screen.getByRole("switch");
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAttribute("aria-busy", "true");
await user.click(button);
expect(onClick).not.toHaveBeenCalled();
});
test("does not call onClick when disabled", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<TooltipProvider>
<MicButton enabled={true} disabled={true} onClick={onClick} />
</TooltipProvider>,
);
const button = screen.getByRole("switch");
expect(button).toHaveAttribute("aria-disabled", "true");
await user.click(button);
expect(onClick).not.toHaveBeenCalled();
});
});
describe("VideoButton", () => {
test("calls onClick when not busy", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<TooltipProvider>
<VideoButton enabled={true} onClick={onClick} />
</TooltipProvider>,
);
const button = screen.getByRole("switch");
await user.click(button);
expect(onClick).toHaveBeenCalled();
});
test("does not call onClick when busy", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<TooltipProvider>
<VideoButton enabled={true} busy={true} onClick={onClick} />
</TooltipProvider>,
);
const button = screen.getByRole("switch");
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button).toHaveAttribute("aria-busy", "true");
await user.click(button);
expect(onClick).not.toHaveBeenCalled();
});
test("does not call onClick when disabled", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(
<TooltipProvider>
<VideoButton enabled={true} disabled={true} onClick={onClick} />
</TooltipProvider>,
);
const button = screen.getByRole("switch");
expect(button).toHaveAttribute("aria-disabled", "true");
await user.click(button);
expect(onClick).not.toHaveBeenCalled();
});
});

View File

@@ -16,6 +16,7 @@ import {
import {
MicOnSolidIcon,
MicOffSolidIcon,
SpinnerIcon,
VideoCallSolidIcon,
VideoCallOffSolidIcon,
EndCallIcon,
@@ -32,12 +33,13 @@ import { platform } from "../Platform";
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
busy?: boolean;
size?: "md" | "lg";
}
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
export const MicButton: FC<MicButtonProps> = ({ enabled, busy, ...props }) => {
const { t } = useTranslation();
const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon;
const Icon = busy ? SpinnerIcon : enabled ? MicOnSolidIcon : MicOffSolidIcon;
const label = enabled
? t("mute_microphone_button_label")
: t("unmute_microphone_button_label");
@@ -51,6 +53,11 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
role="switch"
aria-checked={enabled}
{...props}
aria-busy={busy}
className={classNames(props.className, {
[styles.rotate]: !!busy,
})}
disabled={props.disabled || busy}
/>
</Tooltip>
);
@@ -58,12 +65,21 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
busy?: boolean;
size?: "md" | "lg";
}
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
export const VideoButton: FC<VideoButtonProps> = ({
enabled,
busy,
...props
}) => {
const { t } = useTranslation();
const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon;
const Icon = busy
? SpinnerIcon
: enabled
? VideoCallSolidIcon
: VideoCallOffSolidIcon;
const label = enabled
? t("stop_video_button_label")
: t("start_video_button_label");
@@ -77,6 +93,11 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
role="switch"
aria-checked={enabled}
{...props}
aria-busy={busy}
className={classNames(props.className, {
[styles.rotate]: !!busy,
})}
disabled={props.disabled || busy}
/>
</Tooltip>
);

View File

@@ -5,69 +5,117 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { fn } from "storybook/test";
import { expect, fn, userEvent, within } from "storybook/test";
import { BehaviorSubject } from "rxjs";
import { type ReactNode } from "react";
import { type JSX, type ReactNode } from "react";
import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterProps } from "./CallFooter";
import { CallFooter, type FooterSnapshot } from "./CallFooter";
import inCallViewStyles from "../room/InCallView.module.css";
import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
import { type ReactionOption } from "../reactions";
function CallFooterWrapper(props: FooterProps): ReactNode {
return (
<div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext
value={{
supportsReactions: false,
toggleRaisedHand: async () => Promise.resolve(),
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
}}
>
<CallFooter {...props} />
</ReactionsSenderContext>
</div>
);
}
const meta = {
component: CallFooterWrapper,
} satisfies Meta<typeof CallFooterWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
import { type GridMode } from "../state/CallViewModel/CallViewModel";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
// consts for tests
const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
};
const mediaDevices = new MediaDevices(globalScope);
/**
* A wrapper component that is used for:
* - exposing the snapshot via props so the storybook documents the snapshot properties (basically unpack them form the vm)
* - Add additional react context
* The paraeters are all params from the FooterSnapshot,
* the Snapshot of the vm, the wrapper will create a mocked vm from it and pass it to the CallFooter.
* `children` is used for the "Back to Recents" button in the lobby stories, but can be used for anything really.
* @returns A component that renders the CallFooter based on primitive snapshot params (not a view model). Which is what we want for storybook.
*/
function CallFooterStoryWrapper({
children,
...vmSnapshot
}: FooterSnapshot & {
children?: false | JSX.Element | JSX.Element[] | undefined;
}): ReactNode {
const vm = useStaticViewModel(vmSnapshot);
return (
<MediaDevicesContext value={mediaDevices}>
<div className={inCallViewStyles.inRoom}>
<ReactionsSenderContext
value={{
supportsReactions: false,
toggleRaisedHand: async () => Promise.resolve(),
sendReaction: async (reaction: ReactionOption) => Promise.resolve(),
}}
>
<CallFooter vm={vm} />
</ReactionsSenderContext>
</div>
</MediaDevicesContext>
);
}
const meta = {
component: CallFooterStoryWrapper,
} satisfies Meta<typeof CallFooterStoryWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
const fnArgType = {
control: { type: "select" as const },
options: ["MockedCallback", "undefined"],
mapping: { MockedCallback: fn(), undefined: undefined },
};
export const Default: Story = {
args: {
hideLogo: true,
showLogo: false,
layoutMode: "grid",
audioEnabled: true,
audioBusy: false,
videoEnabled: true,
videoBusy: false,
setLayoutMode: fn(),
openSettings: fn(),
toggleAudio: fn(),
toggleVideo: fn(),
toggleScreenSharing: fn(),
toggleBlur: fn(),
videoBlurEnabled: true,
hangup: fn(),
buttonSize: "lg",
showFooter: true,
hideControls: false,
asOverlay: false,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
debugTileLayout: false,
tileStoreGeneration: undefined,
audioOptions: [],
videoOptions: [],
selectedAudio: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
},
parameters: {
layout: "fullscreen",
},
argTypes: {
layoutMode: { control: "radio", options: ["grid", "spotlight"] },
layoutMode: {
control: "radio",
options: ["grid", "spotlight"] satisfies GridMode[],
},
audioOutputSwitcher: {
control: "select",
options: ["NoOutputCallback", "speaker", "earpiece"],
@@ -95,22 +143,42 @@ export const WithAudioAndVideoOptions: Story = {
audioEnabled: false,
videoEnabled: true,
audioOptions: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
videoOptions: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedAudio: "2",
selectedVideo: "1",
},
};
export const AudioBusy: Story = {
...Default,
args: {
...Default.args,
audioEnabled: true,
audioBusy: true,
videoEnabled: true,
},
};
export const VideoBusy: Story = {
...Default,
args: {
...Default.args,
audioEnabled: true,
videoEnabled: true,
videoBusy: true,
},
};
export const WithLogo: Story = {
...Default,
args: {
...Default.args,
hideLogo: false,
showLogo: true,
},
};
@@ -121,6 +189,51 @@ export const AudioVideoEnabled: Story = {
audioEnabled: true,
videoEnabled: true,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Spotlight" });
await userEvent.click(spotlightRadio);
await expect(args.setLayoutMode).toHaveBeenCalledWith("spotlight");
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
/** used to test switching to grid mode */
export const SpotlightMode: Story = {
...Default,
args: {
...Default.args,
layoutMode: "spotlight",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const spotlightRadio = canvas.getByRole("radio", { name: "Grid" });
await userEvent.click(spotlightRadio);
await expect(args.setLayoutMode).toHaveBeenCalledWith("grid");
},
};
export const WithAudioOutputSpeaker: Story = {
@@ -150,7 +263,37 @@ export const Pip: Story = {
...Default,
args: {
...Default.args,
asPip: true,
buttonSize: "md",
layoutMode: undefined,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await expect(
canvas.queryByRole("radio", { name: "Spotlight" }),
).not.toBeInTheDocument();
const micButtonMute = canvas.getByRole("switch", {
name: "Mute microphone",
});
await userEvent.click(micButtonMute);
await expect(args.toggleAudio).toHaveBeenCalled();
const videoMuteButton = canvas.getByRole("switch", {
name: "Stop video",
});
await userEvent.click(videoMuteButton);
await expect(args.toggleVideo).toHaveBeenCalled();
const screenShare = canvas.getByRole("switch", {
name: "Share screen",
});
await userEvent.click(screenShare);
await expect(args.toggleScreenSharing).toHaveBeenCalled();
const endCall = canvas.getByRole("button", {
name: "End call",
});
await userEvent.click(endCall);
await expect(args.hangup).toHaveBeenCalled();
},
};
export const NoControlsWithLogo: Story = {
@@ -158,7 +301,7 @@ export const NoControlsWithLogo: Story = {
args: {
...Default.args,
hideControls: true,
hideLogo: false,
showLogo: true,
},
};
@@ -187,7 +330,7 @@ export const MobileLayout: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
@@ -203,7 +346,7 @@ export const Lobby: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
openSettings: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -217,7 +360,7 @@ export const LobbyMobile: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
@@ -235,7 +378,7 @@ export const LobbyRecentButton: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
@@ -249,7 +392,7 @@ export const LobbyRecentButtonMobile: Story = {
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
showLogo: false,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},

View File

@@ -7,13 +7,12 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
import { BehaviorSubject } from "rxjs";
import { Switch } from "@vector-im/compound-web";
import { t } from "i18next";
import {
SpotlightIcon,
GridIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Switch } from "@vector-im/compound-web";
import { t } from "i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -34,105 +33,127 @@ import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
export interface AudioOutputSwitcher {
targetOutput: string;
switch: () => void;
}
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
/** Children will only be visible if the component is wider than 5*/
children?: JSX.Element | JSX.Element[] | false;
audioEnabled: boolean;
/**
* The Snapshot combines all fields required to populate the view.
*
* It is a combination of Actions and State.
* All Actions and State will be wrappen in behaviors.
* This has the advantage, that actions can mutate.
* (example: a device gets disconnected, the swicht action is not possible anymore, the actions becomes undefined)
* With it being reactive we can use the existance of the action to update the rendering without
* requiring additional state.
*
* Comment: It might not make sense to seperate the two interfaces. Hence the seperation
* just happens on the syntax level with the `type = ... & ...` notation.
*/
export type FooterSnapshot = FooterActions & FooterState;
export interface FooterActions {
/** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined;
videoEnabled: boolean;
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined;
/** Also controls if the layout button is visible */
setLayoutMode: ((mode: GridMode) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
/** Also controls if the hangup button is visible */
hangup: (() => void) | undefined;
}
// we do not use any ? optional properties so that the vm type is including all fields.
export interface FooterState {
audioEnabled: boolean;
audioBusy: boolean;
videoEnabled: boolean;
videoBusy: boolean;
videoBlurEnabled: boolean;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
hideControls?: boolean;
/** hide the entire footer*/
hidden?: boolean;
/** Pip controls buttonSize and hides: settings button, layout switcher and logo */
asPip?: boolean;
hideControls: boolean;
/** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens.*/
asOverlay?: boolean;
* (Over the Call Grid) This saves spaces on small screens. */
asOverlay: boolean;
layoutMode?: GridMode;
/** Also controls if the layout button is visible */
setLayoutMode?: (mode: GridMode) => void;
buttonSize: "md" | "lg";
showLogo: boolean;
sharingScreen?: boolean;
toggleScreenSharing?: () => void;
layoutMode: GridMode | undefined;
/** Also controls if the audio button is visible */
audioOutputSwitcher?: AudioOutputSwitcher;
/** Also controls if the settings button is visible */
openSettings?: () => void;
/** Also controls if the hangup button is visible */
hangup?: () => void;
sharingScreen: boolean;
reactionIdentifier?: string;
reactionData?: ReactionData;
/** Also controls if the audio output button is visible */
audioOutputSwitcher: AudioOutputSwitcher | undefined;
reactionIdentifier: string | undefined;
reactionData: ReactionData | undefined;
hideLogo?: boolean;
// debug stuff
debugTileLayout?: boolean;
tileStoreGeneration?: number;
debugTileLayout: boolean;
tileStoreGeneration: number | undefined;
audioOptions?: MenuOptions[];
videoOptions?: MenuOptions[];
selectedAudio?: string;
selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void;
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[];
selectedAudio: string | undefined;
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
}
export const CallFooter: FC<FooterProps> = ({
ref,
children,
asOverlay,
hidden,
hideControls,
hideLogo,
asPip,
layoutMode,
setLayoutMode,
openSettings,
audioEnabled,
videoEnabled,
toggleAudio,
toggleVideo,
sharingScreen,
toggleScreenSharing,
reactionIdentifier,
reactionData,
audioOutputSwitcher,
hangup,
debugTileLayout,
tileStoreGeneration,
export interface FooterProps {
ref?: Ref<HTMLDivElement>;
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({ ref, children, vm }) => {
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
const layoutMode = useBehavior(vm.layoutMode$);
const setLayoutMode = useBehavior(vm.setLayoutMode$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
const audioBusy = useBehavior(vm.audioBusy$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const videoBusy = useBehavior(vm.videoBusy$);
const toggleAudio = useBehavior(vm.toggleAudio$);
const toggleVideo = useBehavior(vm.toggleVideo$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const toggleScreenSharing = useBehavior(vm.toggleScreenSharing$);
const reactionIdentifier = useBehavior(vm.reactionIdentifier$);
const reactionData = useBehavior(vm.reactionData$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const hangup = useBehavior(vm.hangup$);
const debugTileLayout = useBehavior(vm.debugTileLayout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const videoOptions = useBehavior(vm.videoOptions$);
const selectedVideo = useBehavior(vm.selectedVideo$);
const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$);
audioOptions,
videoOptions,
selectedAudio,
selectedVideo,
selectAudioDevice,
selectVideoDevice,
}) => {
const buttons: JSX.Element[] = [];
const buttonSize = asPip ? "md" : "lg";
const showSettingsButton =
openSettings !== undefined && !asPip && !hideControls;
const showLayoutSwitcher = !asPip && !hideControls;
const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout);
const showLogo = !hideLogo && !asPip;
if (showSettingsButton) {
// add the settings button to the center group of buttons, so it will be visible on small screens.
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible.
if (openSettings !== undefined) {
// Add the settings button to the center group so it's visible on small
// screens. On larger screens the SettingsIconButton with
// showForScreenWidth="wide" in the settingsLogoContainer is used instead.
buttons.push(
<SettingsButton
key="settings"
@@ -150,11 +171,12 @@ export const CallFooter: FC<FooterProps> = ({
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
busy={audioBusy ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioDevice}
onSelect={selectAudioButtonOption}
/>,
);
} else {
@@ -163,12 +185,14 @@ export const CallFooter: FC<FooterProps> = ({
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
busy={audioBusy ?? false}
onClick={toggleAudio}
disabled={toggleAudio === undefined}
disabled={(audioBusy ?? false) || toggleAudio === undefined}
data-testid="incall_mute"
/>,
);
}
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
@@ -176,11 +200,13 @@ export const CallFooter: FC<FooterProps> = ({
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
busy={videoBusy ?? false}
onMuteClick={toggleVideo}
data-testid="incall_videomute"
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoDevice}
onSelect={selectVideoButtonOption}
videoBlurToggleClick={toggleBlur}
videoBlurEnabled={videoBlurEnabled}
/>,
);
} else {
@@ -189,8 +215,9 @@ export const CallFooter: FC<FooterProps> = ({
size={buttonSize}
key="video"
enabled={videoEnabled ?? false}
busy={videoBusy ?? false}
onClick={toggleVideo}
disabled={toggleVideo === undefined}
disabled={(videoBusy ?? false) || toggleVideo === undefined}
data-testid="incall_videomute"
/>,
);
@@ -213,12 +240,7 @@ export const CallFooter: FC<FooterProps> = ({
buttons.push(
<ReactionToggleButton
size={buttonSize}
reactionData={
reactionData ?? {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
}
}
reactionData={reactionData}
key="raise_hand"
className={styles.raiseHand}
identifier={reactionIdentifier}
@@ -269,13 +291,14 @@ export const CallFooter: FC<FooterProps> = ({
return (
<div
ref={ref}
data-testid="footer-container"
className={classNames(styles.footer, {
[styles.overlay]: asOverlay,
[styles.hidden]: hidden,
[styles.hidden]: !showFooter,
})}
>
<div className={styles.settingsLogoContainer}>
{showSettingsButton && (
{openSettings !== undefined && (
<SettingsIconButton
key="settings"
kind="secondary"
@@ -285,10 +308,10 @@ export const CallFooter: FC<FooterProps> = ({
/>
)}
{children}
{showLogoDebugContainer && logoDebugContainer}
{(showLogo || debugTileLayout) && logoDebugContainer}
</div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
{setLayoutMode && layoutMode && showLayoutSwitcher && (
{!hideControls && setLayoutMode && layoutMode && (
<Switch<"spotlight", "grid">
name="layoutMode"
aria-label={t("layout_switch_label")}

View File

@@ -0,0 +1,157 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
get platform(): string {
return platformMock();
},
}));
// Prevent supportsBackgroundProcessors from throwing in jsdom it is not
// exercised by these tests (only used in `videoToggles`, not `videoOptions`).
vi.mock("@livekit/track-processors", () => ({
supportsBackgroundProcessors: (): boolean => false,
}));
/**
* Returns the minimum set of CallViewModel fields required by
* createCallFooterViewModel, with all other properties stubbed to
* simple constant values.
*/
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
return {
layout$: constant(layout),
edgeToEdge$: constant(false),
showHeader$: constant(false),
hangup: (): void => {},
gridMode$: constant("grid"),
setGridMode: (): void => {},
sharingScreen$: constant(false),
toggleScreenSharing: null,
audioOutputSwitcher$: constant(null),
handsRaised$: constant({}),
reactions$: constant({}),
tileStoreGeneration$: constant(0),
showFooter$: constant(true),
settingsOpen$: constant(false),
setSettingsOpen$: constant(() => {}),
} as unknown as CallViewModel;
}
/** A regular grid layout (not PiP). */
const gridLayout: Layout = {
type: "grid",
grid: [],
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: (_: number) => {},
};
/** A PiP layout only the `type` matters for the tests. */
const pipLayout: Layout = {
type: "pip",
spotlight: {} as SpotlightTileViewModel,
};
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "number", number: 1 }],
["mic2", { type: "name", name: "Microphone 2" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
videoInput: {
available$: constant(
new Map<string, DeviceLabel>([
["cam1", { type: "name", name: "Camera 1" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
});
describe("createCallFooterViewModel", () => {
describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void {
platformMock.mockReturnValue(platform);
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(layout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$.value).toEqual([]);
expect(vm.videoOptions$.value).toEqual([]);
}
it("are both empty when the platform is iOS", () => {
checkEmptyFor("ios", gridLayout);
});
it("are both empty when the layout is pip", () => {
checkEmptyFor("desktop", pipLayout);
});
it("are populated when the platform is desktop and the layout is not PiP", () => {
platformMock.mockReturnValue("desktop");
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
);
expect(vm.audioOptions$?.value).toEqual([
{
id: "mic1",
label: {
number: 1,
type: "number",
},
},
{
id: "mic2",
label: {
name: "Microphone 2",
type: "name",
},
},
]);
expect(vm.videoOptions$?.value).toEqual([
{
id: "cam1",
label: {
name: "Camera 1",
type: "name",
},
},
]);
});
});
});

View File

@@ -0,0 +1,281 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, map, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import {
backgroundBlur as backgroundBlurSettings,
debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings";
import { type Behavior, constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { getUrlParams, HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
/**
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
* needed by FooterSnapshot.
*/
function buildMuteBehaviors(
scope: ObservableScope,
muteStates: MuteStates,
): Pick<
ViewModel<FooterSnapshot>,
| "audioEnabled$"
| "audioBusy$"
| "toggleAudio$"
| "videoEnabled$"
| "videoBusy$"
| "toggleVideo$"
> {
return {
audioEnabled$: muteStates.audio.enabled$,
audioBusy$: muteStates.audio.syncing$,
toggleAudio$: scope.behavior(
muteStates.audio.toggle$.pipe(map((t) => t ?? undefined)),
),
videoEnabled$: muteStates.video.enabled$,
videoBusy$: muteStates.video.syncing$,
toggleVideo$: scope.behavior(
muteStates.video.toggle$.pipe(map((t) => t ?? undefined)),
),
};
}
/**
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
*/
function buildDeviceBehaviors(
scope: ObservableScope,
mediaDevices: MediaDevices,
/** return empty arrays for audioOptions and videoOptions*/
disableSwitcher$: Behavior<boolean>,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions$"
| "selectedAudio$"
| "selectAudioButtonOption$"
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "toggleBlur$"
| "videoBlurEnabled$"
> {
return {
audioOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.audioInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
),
selectedAudio$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
videoOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.videoInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
),
selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
toggleBlur$: scope.behavior(
combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
map(([current, switcherDisabled]) => {
return !switcherDisabled && supportsBackgroundProcessors()
? (): void => {
backgroundBlurSettings.setValue(!current);
}
: undefined;
}),
),
),
videoBlurEnabled$: backgroundBlurSettings.value$,
};
}
/**
* Creates the ViewModel for the CallFooter.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
*/
export function createCallFooterViewModel(
scope: ObservableScope,
callModel: CallViewModel,
muteStates: MuteStates,
mediaDevices: MediaDevices,
reactionIdentifier: string | undefined,
): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams();
const showLogo = headerStyle === HeaderStyle.Standard;
const isPip$ = scope.behavior(
callModel.layout$.pipe(map((l) => l.type === "pip")),
);
const disableDeviceSwitcher$ = scope.behavior(
isPip$.pipe(map((isPip) => isPip || platform !== "desktop")),
);
return {
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
hideControls$: constant(!showControls),
asOverlay$: callModel.edgeToEdge$,
buttonSize$: scope.behavior(
isPip$.pipe(map<boolean, "md" | "lg">((pip) => (pip ? "md" : "lg"))),
),
openSettings$: scope.behavior(
combineLatest([
isPip$,
callModel.showHeader$,
callModel.setSettingsOpen$,
]).pipe(
map(([isPip, showHeader, setSettingsOpen]) =>
!isPip &&
!(headerStyle === HeaderStyle.AppBar && showHeader) &&
showControls
? (): void => setSettingsOpen(true)
: undefined,
),
),
),
showLogo$: scope.behavior(isPip$.pipe(map((isPip) => showLogo && !isPip))),
layoutMode$: callModel.gridMode$,
setLayoutMode$: scope.behavior(
isPip$.pipe(
map((isPip) =>
!isPip && showControls ? callModel.setGridMode : undefined,
),
),
),
sharingScreen$: callModel.sharingScreen$,
toggleScreenSharing$: constant(callModel.toggleScreenSharing ?? undefined),
audioOutputSwitcher$: scope.behavior(
callModel.audioOutputSwitcher$.pipe(
map((switcher) => switcher ?? undefined),
),
),
hangup$: constant(callModel.hangup),
reactionIdentifier$: constant(reactionIdentifier),
reactionData$: constant(
reactionIdentifier !== undefined
? {
handsRaised$: callModel.handsRaised$,
reactions$: callModel.reactions$,
}
: undefined,
),
debugTileLayout$: debugTileLayoutSetting.value$,
tileStoreGeneration$: callModel.tileStoreGeneration$,
};
}
/**
* Creates a simplified ViewModel for the CallFooter used in the lobby
* (pre-call) screen. Unlike createCallFooterViewModel, this does not require
* a CallViewModel — it only needs mute states, device lists, and callbacks.
*
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param openSettings - Callback to open the settings modal, or undefined.
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
* @param showLogo - Whether to show the Element Call logo.
*/
export function createLobbyFooterViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
openSettings: (() => void) | undefined,
hangup: (() => void) | undefined,
showLogo: boolean,
): ViewModel<FooterSnapshot> {
return {
...createStaticViewModel({
// we can safly skip any props that we do not need.
// The view model will then have less keys.
// But as soon as we call `useViewModel` and convert back to a snapshot the missing props will
// be correcty matching the snapshot type.
showLogo,
hideControls: false,
asOverlay: false,
buttonSize: "lg",
showLayoutSwitcher: false,
openSettings,
hangup,
debugTileLayout: false,
showFooter: true,
toggleAudio: undefined,
toggleVideo: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
audioEnabled: undefined,
audioBusy: false,
videoEnabled: undefined,
videoBusy: false,
layoutMode: undefined,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,
reactionData: undefined,
tileStoreGeneration: undefined,
audioOptions: undefined,
videoOptions: undefined,
selectedAudio: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectVideoButtonOption: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
};
}

View File

@@ -5,14 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { fn, userEvent, within, expect } from "storybook/test";
import { type JSX } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
const mediaDevices = new MediaDevices(globalScope);
const meta = {
component: MediaMuteAndSwitchButton,
decorators: [
(Story): JSX.Element => (
<MediaDevicesContext value={mediaDevices}>
<Story />
</MediaDevicesContext>
),
],
} satisfies Meta<typeof MediaMuteAndSwitchButton>;
export default meta;
@@ -21,17 +33,11 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "SomeMenu",
iconsAndLabels: {
IconEnabled: AdvancedSettingsIcon,
IconDisabled: AdvancedSettingsIcon,
enabledLabel: "Enabled",
disabledLabel: "Disabled",
optionsButtonLabel: "Options",
},
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: "option 1", id: "1" },
{ label: "option 2", id: "2" },
{ label: { type: "name", name: "Option 1" }, id: "1" },
{ label: { type: "name", name: "Option 2" }, id: "2" },
],
selectedOption: "1",
onMuteClick: fn(),
@@ -46,23 +52,18 @@ export const AudioMute: Story = {
iconsAndLabels: "audio",
enabled: false,
options: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
],
toggles: [
{
label: "example toggle",
id: "t0",
enabled: true,
},
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
videoBlurEnabled: true,
videoBlurToggleClick: fn(),
selectedOption: "2",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// Both the mute button and the chevron trigger currently share the aria-label "Edit"
// (both are TODO placeholders in the component). The mute button is first in the DOM.
const muteButton = canvas.getByLabelText("Unmute microphone");
const muteButton = canvas.getByTestId("incall_mute");
await userEvent.click(muteButton);
await expect(args.onMuteClick).toHaveBeenCalled();
},
@@ -74,10 +75,10 @@ export const AudioUnmute: Story = {
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
toggles: [],
selectedOption: "2",
},
};
@@ -88,10 +89,10 @@ export const VideoMute: Story = {
iconsAndLabels: "video",
enabled: false,
options: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
toggles: [],
selectedOption: "1",
},
};
@@ -102,16 +103,11 @@ export const VideoUnmute: Story = {
iconsAndLabels: "video",
enabled: true,
options: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
],
toggles: [
{
label: "Blur Background",
id: "background_blurring",
enabled: false,
},
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
videoBlurEnabled: true,
videoBlurToggleClick: fn(),
selectedOption: "2",
},
};

View File

@@ -8,14 +8,38 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type JSX, useState } from "react";
import { type JSX, useState, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices } from "../state/MediaDevices";
interface RenderOptions {
requestDeviceNames: () => void;
}
function renderComponent(
component: ReactNode,
{ requestDeviceNames = (): void => {} }: Partial<RenderOptions> = {},
): RenderResult {
return render(
<TooltipProvider>
<MediaDevicesContext
value={{ requestDeviceNames } as unknown as MediaDevices}
>
{component}
</MediaDevicesContext>
</TooltipProvider>,
);
}
describe("MediaMuteAndSwitchButton", () => {
test("renders", () => {
const { container } = render(
<MediaMuteAndSwitchButton title={"Switcher"} />,
const { container } = renderComponent(
<TooltipProvider>
<MediaMuteAndSwitchButton title={"Switcher"} iconsAndLabels={"audio"} />
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
});
@@ -25,7 +49,7 @@ describe("MediaMuteAndSwitchButton", () => {
type: "video" | "audio",
enabled: boolean,
): RenderResult => {
return render(
return renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
iconsAndLabels={type}
@@ -39,23 +63,23 @@ describe("MediaMuteAndSwitchButton", () => {
const renderVideoDisabled = renderLabels("video", false);
expect(
renderAudioEndabled.getByRole("button", { name: "Mute microphone" }),
renderAudioEndabled.getByRole("switch", { name: "Mute microphone" }),
).toBeInTheDocument();
expect(
renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }),
renderAudioDisabled.getByRole("switch", { name: "Unmute microphone" }),
).toBeInTheDocument();
expect(
renderVideoEnabled.getByRole("button", { name: "Start video" }),
renderVideoEnabled.getByRole("switch", { name: "Start video" }),
).toBeInTheDocument();
expect(
renderVideoDisabled.getByRole("button", { name: "Stop video" }),
renderVideoDisabled.getByRole("switch", { name: "Stop video" }),
).toBeInTheDocument();
});
test("calls mute on mute press", async () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = render(
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
@@ -64,22 +88,117 @@ describe("MediaMuteAndSwitchButton", () => {
/>,
);
await user.click(getByRole("button", { name: "Mute microphone" }));
await user.click(getByRole("switch", { name: "Mute microphone" }));
expect(onMute).toHaveBeenCalled();
});
test("disables mute button while busy", async () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
busy={true}
/>,
);
const muteButton = getByRole("switch", { name: "Mute microphone" });
expect(muteButton).toHaveAttribute("aria-disabled", "true");
expect(muteButton).toHaveAttribute("aria-busy", "true");
await user.click(muteButton);
expect(onMute).not.toHaveBeenCalled();
});
test("disables video button while busy", async () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="video"
enabled={true}
busy={true}
/>,
);
const videoButton = getByRole("switch", { name: "Stop video" });
expect(videoButton).toHaveAttribute("aria-disabled", "true");
expect(videoButton).toHaveAttribute("aria-busy", "true");
await user.click(videoButton);
expect(onMute).not.toHaveBeenCalled();
});
test("requests device names when opened", async () => {
const user = userEvent.setup();
const requestDeviceNames = vi.fn();
renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled
/>,
{ requestDeviceNames },
);
expect(requestDeviceNames).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Microphone" }));
expect(requestDeviceNames).toHaveBeenCalled();
});
test("shows numbered devices correctly", async () => {
const user = userEvent.setup();
renderComponent(
<>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled
options={[
{ label: { type: "number", number: 1 }, id: "mic1" },
{ label: { type: "number", number: 2 }, id: "mic2" },
]}
selectedOption="mic1"
/>
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled
options={[
{ label: { type: "number", number: 1 }, id: "cam1" },
{ label: { type: "number", number: 2 }, id: "cam2" },
]}
selectedOption="cam1"
/>
</>,
);
await user.click(screen.getByRole("button", { name: "Microphone" }));
screen.getByRole("menuitem", { name: "Microphone 1" });
screen.getByRole("menuitem", { name: "Microphone 2" });
await user.keyboard("[Escape]");
await user.click(screen.getByRole("button", { name: "Camera" }));
screen.getByRole("menuitem", { name: "Camera 1" });
screen.getByRole("menuitem", { name: "Camera 2" });
});
test("calls select callback on menu click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
@@ -94,14 +213,14 @@ describe("MediaMuteAndSwitchButton", () => {
test("does not call select callback on already selected menu click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
@@ -127,8 +246,8 @@ describe("MediaMuteAndSwitchButton", () => {
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
@@ -142,7 +261,7 @@ describe("MediaMuteAndSwitchButton", () => {
);
}
const { getByRole } = render(<Wrapper />);
const { getByRole } = renderComponent(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
@@ -174,39 +293,40 @@ describe("MediaMuteAndSwitchButton", () => {
test("renders menu with toggle control and calls toggle callback", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
const onVideoBlurToggle = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
iconsAndLabels="video"
enabled={true}
toggles={[{ label: "Background blur", id: "bg_blur", enabled: false }]}
videoBlurToggleClick={onVideoBlurToggle}
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(getByRole("button", { name: "Camera" }));
const toggle = screen.getByRole("menuitemcheckbox", {
name: "Background blur",
name: "Blur background",
});
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-checked", "false");
await user.click(toggle);
expect(onSelect).toHaveBeenCalledWith("bg_blur");
expect(onVideoBlurToggle).toHaveBeenCalled();
});
test("renders check icon to mark the selected menu item", async () => {
const user = userEvent.setup();
const { getByRole } = render(
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic2"
/>,

View File

@@ -5,52 +5,33 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ComponentType, useState, type FC } from "react";
import { type ComponentType, useState, type FC, useEffect } from "react";
import {
Button,
Menu,
MenuItem,
ToggleMenuItem,
} from "@vector-im/compound-web";
import { t } from "i18next";
import {
CheckIcon,
ChevronUpIcon,
ChevronDownIcon,
MicOffSolidIcon,
MicOnIcon,
MicOnSolidIcon,
SpinnerIcon,
VideoCallIcon,
VideoCallOffSolidIcon,
VideoCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger";
import { useTranslation } from "react-i18next";
import styles from "./MediaMuteAndSwitchButton.module.css";
import { MicButton, VideoButton } from "../button";
import { type DeviceLabel } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
export interface MenuOptions {
label: string;
label: DeviceLabel;
id: string;
}
export interface ToggleOption {
label: string;
enabled: boolean;
id: string;
}
export interface IconsAndLabels {
/** The Icon used if the mute button is enabled */
IconEnabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The Icon used if the mute button is disabled */
IconDisabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The icon used for the different options */
IconOptions?: ComponentType<React.SVGAttributes<SVGElement>>;
enabledLabel: string;
disabledLabel: string;
optionsButtonLabel: string;
}
export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
@@ -59,17 +40,15 @@ export interface MediaMuteAndSwitchButtonProps {
enabled?: boolean;
/** Callback if the mute button is clicked */
onMuteClick?: () => void;
iconsAndLabels?: "video" | "audio" | IconsAndLabels;
/** True while mute/unmute operation is syncing. */
busy?: boolean;
iconsAndLabels: "video" | "audio";
/** The options available for the media device selector modal */
options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */
selectedOption?: string;
/**
* The available toggles (including there current state)
* The toggle state is not stored by this component.
* It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled`
*/
toggles?: ToggleOption[];
videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean;
/**
* For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -77,71 +56,92 @@ export interface MediaMuteAndSwitchButtonProps {
onSelect?: (id: string) => void;
}
const BLUR_ID = "blur";
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled,
busy,
onMuteClick,
iconsAndLabels: iconsAndLabelsWithDefaultCases,
iconsAndLabels,
options,
selectedOption,
toggles,
videoBlurEnabled,
videoBlurToggleClick,
onSelect,
}) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
let iconsAndLabels: IconsAndLabels | undefined;
switch (iconsAndLabelsWithDefaultCases) {
const isBusy = busy ?? false;
const { t } = useTranslation();
const devices = useMediaDevices();
useEffect(() => {
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]);
let button;
let toggles: { label: string; enabled: boolean; id: string }[] = [];
switch (iconsAndLabels) {
case "video":
iconsAndLabels = {
IconEnabled: VideoCallSolidIcon,
IconDisabled: VideoCallOffSolidIcon,
IconOptions: VideoCallIcon,
disabledLabel: t("stop_video_button_label"),
enabledLabel: t("start_video_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
button = (
<VideoButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={isBusy || onMuteClick === undefined}
data-testid="incall_videomute"
/>
);
if (videoBlurToggleClick !== undefined) {
toggles = [
{
label: t("action.blur_background"),
enabled: videoBlurEnabled ?? false,
id: BLUR_ID,
},
];
}
break;
case "audio":
iconsAndLabels = {
IconEnabled: MicOnSolidIcon,
IconDisabled: MicOffSolidIcon,
IconOptions: MicOnIcon,
disabledLabel: t("mute_microphone_button_label"),
enabledLabel: t("unmute_microphone_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
break;
default:
iconsAndLabels = iconsAndLabelsWithDefaultCases;
button = (
<MicButton
enabled={enabled ?? false}
busy={isBusy}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
disabled={isBusy || onMuteClick === undefined}
data-testid="incall_mute"
/>
);
break;
}
const {
IconEnabled,
IconDisabled,
IconOptions,
disabledLabel,
enabledLabel,
optionsButtonLabel,
} = iconsAndLabels ?? {
IconEnabled: undefined,
IconDisabled: undefined,
IconOptions: undefined,
disabledLabel: undefined,
enabledLabel: undefined,
optionsButtonLabel: undefined,
};
{
logger.info(
"RENDER WITH: selectedOption !== option.id && plannedSelection === option.id",
selectedOption,
" !==",
"option.id",
" && ",
plannedSelection,
" === ",
"option.id",
);
let IconOptions: ComponentType<React.SVGAttributes<SVGElement>> | undefined;
let optionsButtonLabel: string;
let numberedLabel: (number: number) => string;
switch (iconsAndLabels) {
case "video":
IconOptions = VideoCallIcon;
optionsButtonLabel = t("settings.devices.camera");
numberedLabel = (n): string =>
t("settings.devices.camera_numbered", { n });
break;
case "audio":
IconOptions = MicOnIcon;
optionsButtonLabel = t("settings.devices.microphone");
numberedLabel = (n): string =>
t("settings.devices.microphone_numbered", { n });
break;
}
return (
<div
className={classNames({
@@ -150,19 +150,7 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
})}
>
{/* The mute button lives inside */}
<Button
iconOnly
Icon={enabled ? IconEnabled : IconDisabled}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
kind={enabled ? "secondary" : "primary"}
size="lg"
className={styles.button}
aria-label={enabled ? disabledLabel : enabledLabel}
/>
{button}
<Menu
title={title}
showTitle={true}
@@ -183,44 +171,53 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/>
}
>
{options?.map((option) => (
<MenuItem
hideChevron
label={option.label}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (option.id === selectedOption) return;
setPlannedSelection(option.id);
onSelect?.(option.id);
}}
key={option.id}
>
{selectedOption === option.id && (
<CheckIcon width={24} height={24} />
)}
{selectedOption !== option.id && plannedSelection === option.id && (
<SpinnerIcon width={24} height={24} className={styles.rotate} />
)}
</MenuItem>
))}
{options?.map(({ label, id }) => {
let labelText: string;
switch (label.type) {
case "name":
labelText = label.name;
break;
case "number":
labelText = numberedLabel(label.number);
break;
}
return (
<MenuItem
hideChevron
label={labelText}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (id === selectedOption) return;
setPlannedSelection(id);
onSelect?.(id);
}}
key={id}
>
{selectedOption === id && <CheckIcon width={24} height={24} />}
{selectedOption !== id && plannedSelection === id && (
<SpinnerIcon width={24} height={24} className={styles.rotate} />
)}
</MenuItem>
);
})}
{(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => (
<ToggleMenuItem
label={toggle.label}
onSelect={(e) => {
onSelect?.(toggle.id);
videoBlurToggleClick?.();
e.preventDefault();
}}
checked={toggle.enabled}
checked={toggle.enabled ?? false}
key={toggle.id}
/>
))}

View File

@@ -6,21 +6,40 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
class="container"
>
<button
class="_button_1nw83_8 button _icon-only_1nw83_53"
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_0_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
data-testid="incall_mute"
role="switch"
tabindex="0"
/>
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Microphone"
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_0_"
id="radix-_r_5_"
role="button"
tabindex="0"
type="button"

54
src/config/Config.test.ts Normal file
View File

@@ -0,0 +1,54 @@
/*
Copyright 2026 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 { describe, expect, it, vi, afterEach } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { validateConfig } from "./Config";
import { MatrixRTCMode } from "./ConfigOptions";
describe("validateConfig", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("passes through a missing matrix_rtc_mode unchanged", () => {
const result = validateConfig({});
expect(result.matrix_rtc_mode).toBeUndefined();
});
it.each(Object.values(MatrixRTCMode))(
"keeps a valid matrix_rtc_mode value (%s)",
(mode) => {
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
const result = validateConfig({ matrix_rtc_mode: mode });
expect(result.matrix_rtc_mode).toBe(mode);
expect(warnSpy).not.toHaveBeenCalled();
},
);
it("drops an invalid matrix_rtc_mode value and warns", () => {
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
const result = validateConfig({
// Intentionally bypass the type to simulate bad JSON.
matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode,
});
expect(result.matrix_rtc_mode).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0][0]).toContain("nonsense");
});
it("does not touch unrelated fields when dropping an invalid mode", () => {
vi.spyOn(logger, "warn").mockImplementation(() => {});
const result = validateConfig({
matrix_rtc_mode: "nope" as unknown as MatrixRTCMode,
ssla: "https://example.invalid/ssla",
});
expect(result.matrix_rtc_mode).toBeUndefined();
expect(result.ssla).toBe("https://example.invalid/ssla");
});
});

View File

@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { merge } from "lodash-es";
import { logger } from "matrix-js-sdk/lib/logger";
import { getUrlParams } from "../UrlParams";
import {
@@ -14,6 +15,11 @@ import {
type ResolvedConfigOptions,
} from "./ConfigOptions";
import { isFailure } from "../utils/fetch";
import { MatrixRTCMode } from "./ConfigOptions";
const VALID_MATRIX_RTC_MODES: ReadonlySet<string> = new Set(
Object.values(MatrixRTCMode),
);
export class Config {
private static internalInstance: Config | undefined;
@@ -44,7 +50,11 @@ export class Config {
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
(config) => {
internalInstance.config = merge({}, DEFAULT_CONFIG, config);
internalInstance.config = merge(
{},
DEFAULT_CONFIG,
validateConfig(config),
);
},
);
}
@@ -84,6 +94,17 @@ export class Config {
private initPromise?: Promise<void>;
}
export function validateConfig(config: ConfigOptions): ConfigOptions {
const mode = config.matrix_rtc_mode;
if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {
logger.warn(
`Ignoring invalid matrix_rtc_mode in config.json: ${String(mode)}`,
);
delete config.matrix_rtc_mode;
}
return config;
}
async function downloadConfig(fetchTarget: string): Promise<ConfigOptions> {
const response = await fetch(fetchTarget);

View File

@@ -6,6 +6,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The MatrixRTC mode determines how Element Call interacts with the
* MatrixRTC backend and other participants. Selectable via the Developer
* Settings, or pinned for a deployment via `matrix_rtc_mode` in config.json.
*/
export enum MatrixRTCMode {
/** Legacy single-SFU + user-keyed memberships + legacy JWT endpoint. */
Legacy = "legacy",
/** Multi-SFU transport, legacy JWT endpoint, no sticky events. */
Compatibility = "compatibility",
/**
* Multi-SFU transport with:
* - sticky events
* - hashed RTC backend identity
* - the new endpoint for the jwt token on the local membership (remote memberships will always try the new jwt endpoint first -> then the legacy one)
* - use the hashed identity for the local membership
*/
Matrix_2_0 = "matrix_2_0",
}
export interface ConfigOptions {
/**
* The Posthog endpoint to which analytics data will be sent.
@@ -104,6 +124,13 @@ export interface ConfigOptions {
*/
sync_disconnect_grace_period_ms?: number;
/**
* Pins the {@link MatrixRTCMode} for all clients on this deployment,
* overriding any per-user choice from the Developer Settings. If unset,
* the user's Developer Settings choice (or its default of `Legacy`) wins.
*/
matrix_rtc_mode?: MatrixRTCMode;
/**
* These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options.

View File

@@ -12,6 +12,7 @@ export interface Controls {
canEnterPip(): boolean;
enablePip(): void;
disablePip(): void;
onPipMediaOrientationUpdate?: (orientation: "landscape" | "portrait") => void;
setAvailableAudioDevices(devices: OutputDevice[]): void;
setAudioDevice(id: string): void;

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
.tile.draggable {
cursor: grab;
box-shadow: var(--big-drop-shadow);
--draggable-shadow: var(--big-drop-shadow);
}
.tile.draggable:active {

View File

@@ -23,12 +23,17 @@ import {
createRoutesFromChildren,
matchRoutes,
} from "react-router-dom";
import {
setLogExtension as setLKLogExtension,
setLogLevel as setLKLogLevel,
} from "livekit-client";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { platform } from "./Platform";
import { isFailure } from "./utils/fetch";
import { initializeWidget } from "./widget";
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
// {
@@ -189,6 +194,18 @@ export class Initializer {
// Add the platform to the DOM, so CSS can query it
document.body.setAttribute("data-platform", platform);
// livekit logging configuration
setLKLogExtension((level, msg, context) => {
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
global.mx_rage_logger.log(level, "livekit", msg, context);
});
enableExtendedLivekitLogs.value$.subscribe((enabled) => {
setLKLogLevel(enabled ? "trace" : "info");
});
window.setLKLogLevel = setLKLogLevel;
}
public static init(): Promise<void> | null {

View File

@@ -15,6 +15,7 @@ import {
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { MatrixError } from "matrix-js-sdk";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../utils/test-fixtures";
@@ -63,7 +64,10 @@ describe("getSFUConfigWithOpenID", () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
@@ -75,9 +79,12 @@ describe("getSFUConfigWithOpenID", () => {
);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
void (await fetchMock.flush());
return;
}
@@ -181,13 +188,19 @@ describe("getSFUConfigWithOpenID", () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 500,
body: { error: "Test failure" },
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
@@ -203,8 +216,10 @@ describe("getSFUConfigWithOpenID", () => {
);
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
void (await fetchMock.flush());
}

View File

@@ -5,7 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
import {
type IOpenIDToken,
type MatrixClient,
parseErrorResponse,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
@@ -248,7 +252,7 @@ async function getLiveKitJWT(
});
if (!res.ok) {
throw new Error("SFU Config fetch failed with status code " + res.status);
throw parseErrorResponse(res, await res.text());
}
return await res.json();
}
@@ -308,7 +312,7 @@ export async function getLiveKitJWTWithDelayDelegation(
if (res.status === 404) {
throw new NotSupportedError(msg);
} else {
throw new Error(msg);
throw parseErrorResponse(res, await res.text());
}
}
return await res.json();

View File

@@ -15,10 +15,6 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import { logger } from "matrix-js-sdk/lib/logger";
import {
setLogExtension as setLKLogExtension,
setLogLevel as setLKLogLevel,
} from "livekit-client";
import { App } from "./App";
import { init as initRageshake } from "./settings/rageshake";
@@ -26,16 +22,9 @@ import { Initializer } from "./initializer";
import { AppViewModel } from "./state/AppViewModel";
import { globalScope } from "./state/ObservableScope";
window.setLKLogLevel = setLKLogLevel;
initRageshake().catch((e) => {
logger.error("Failed to initialize rageshake", e);
});
setLKLogLevel("info");
setLKLogExtension((level, msg, context) => {
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
global.mx_rage_logger.log(level, "livekit", msg, context);
});
logger.info(`Element Call ${import.meta.env.VITE_APP_VERSION || "dev"}`);

View File

@@ -1,9 +1,9 @@
.reactionIndicatorWidget {
display: flex;
background-color: #00000030;
border-radius: var(--cpd-radius-pill-effect);
box-shadow: 0 0 var(--cpd-space-2x) #00000040;
background: "ffffff40";
color: var(--cpd-color-text-on-solid-primary);
background: var(--cpd-color-icon-secondary-alpha);
backdrop-filter: blur(10px);
outline: var(--cpd-border-width-1) solid var(--cpd-color-alpha-gray-400);
outline-offset: calc(-1 * var(--cpd-border-width-1));
@@ -33,7 +33,6 @@
.reaction {
margin: var(--cpd-space-1x);
color: white;
display: flex;
align-items: center;
border-radius: var(--cpd-radius-pill-effect);

View File

@@ -16,6 +16,8 @@ import {
} from "react";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { ConnectionError } from "livekit-client";
import { MatrixError } from "matrix-js-sdk";
import {
type CallErrorRecoveryAction,
@@ -25,8 +27,12 @@ import {
ConnectionLostError,
E2EENotSupportedError,
type ElementCallError,
FailToGetOpenIdToken,
InsufficientCapacityError,
LivekitConnectionError,
MatrixRTCTransportMissingError,
PeerConnectionTimeoutError,
StickyEventsRequiredError,
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
@@ -54,6 +60,12 @@ test.each([
expectedDescription:
"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.",
},
{
error: new StickyEventsRequiredError(),
expectedTitle: "Homeserver does not support Matrix 2.0 calls",
expectedDescription:
"This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
},
])(
"should report correct error for $expectedTitle",
async ({ error, expectedTitle, expectedDescription }) => {
@@ -252,3 +264,165 @@ test("should have a close button in widget mode", async () => {
);
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
});
test("should show technical details when error has a matrixError cause", async () => {
const underlyingError = new MatrixError(
{
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
500,
"https://matrix-rtc.m.localhost/livekit/jwt/sfu/get",
);
const error = new FailToGetOpenIdToken(underlyingError);
const TestComponent = (): ReactNode => {
throw error;
};
render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Something went wrong");
// Technical details should be present
const detailsElement = screen.getByText("Technical details");
expect(detailsElement).toBeInTheDocument();
// Verify error details are shown
expect(
screen.getByText(/Failed to look up user info from homeserver/i, {
selector: "pre",
}),
).toBeInTheDocument();
});
test("should not show technical details when error has no matrix error cause", async () => {
const error = new ConnectionLostError();
const TestComponent = (): ReactNode => {
throw error;
};
render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Connection lost");
// Technical details should not be present (ConnectionLostError has no cause)
expect(screen.queryByText("Technical details")).not.toBeInTheDocument();
});
describe("LiveKit ConnectionError variants", () => {
test.each([
{
name: "notAllowed",
error: ConnectionError.notAllowed("Permission denied by server", 403),
expectedReason: "NotAllowed",
},
{
name: "timeout",
error: ConnectionError.timeout("Connection timed out"),
expectedReason: "Timeout",
},
{
name: "serverUnreachable",
error: ConnectionError.serverUnreachable("Server is unreachable", 503),
expectedReason: "ServerUnreachable",
},
{
name: "serviceNotFound",
error: ConnectionError.serviceNotFound(
"RTC service not found",
"v0-rtc" as const,
),
expectedReason: "ServiceNotFound",
},
{
name: "internal",
error: ConnectionError.internal("Internal server error", {
status: 500,
statusText: "Internal Server Error",
}),
expectedReason: "InternalError",
},
])(
"should display LiveKit $name error correctly",
async ({ error, expectedReason }) => {
const TestComponent = (): ReactNode => {
throw new LivekitConnectionError(error);
};
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
// Check title
await screen.findByText("Failed to connect to Livekit server");
// Check that reason is displayed in the description
expect(screen.getByText(/Reason:/i)).toBeInTheDocument();
expect(screen.getByText(expectedReason)).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
},
);
test("should link to troubleshoot guide when timeout error", async () => {
const error = new PeerConnectionTimeoutError();
const TestComponent = (): ReactNode => {
throw error;
};
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
await screen.findByText("Connection timeout");
// Verify the link is present and has correct href
const link = screen.getByText("troubleshooting guide");
expect(link).toHaveAttribute(
"href",
"https://docs.element.io/latest/element-server-suite-pro/configuring-components/configuring-matrix-rtc/#sfu-connectivity-troubleshooting",
);
// Snapshot the complete rendered error
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -23,6 +23,7 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import { MatrixError } from "matrix-js-sdk";
import {
ConnectionLostError,
@@ -34,6 +35,7 @@ import {
import { FullScreenView } from "../FullScreenView.tsx";
import { ErrorView } from "../ErrorView.tsx";
import { type WidgetHelpers } from "../widget.ts";
import styles from "../ErrorView.module.css";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
@@ -78,6 +80,9 @@ const ErrorPage: FC<ErrorPageProps> = ({
});
}
const technicalError =
error.cause instanceof MatrixError ? error.cause : null;
return (
<FullScreenView>
<ErrorView
@@ -87,7 +92,26 @@ const ErrorPage: FC<ErrorPageProps> = ({
widget={widget}
>
<p>
{error.localisedMessage ?? (
{error.localisedMessageKey ? (
<Trans
// @ts-expect-error - Dynamic i18nKey from error object
i18nKey={error.localisedMessageKey}
values={error.localisedMessageValues}
components={[
<a
href={String(error.localisedMessageValues?.linkUrl || "#")}
target="_blank"
rel="noopener noreferrer"
>
{/* Content injected by Trans component */}
</a>,
<b />,
<code />,
]}
/>
) : error.localisedMessage ? (
error.localisedMessage
) : (
<Trans
i18nKey="error.unexpected_ec_error"
components={[<b />, <code />]}
@@ -95,6 +119,16 @@ const ErrorPage: FC<ErrorPageProps> = ({
/>
)}
</p>
{technicalError ? (
<details className={styles.technicalDetails}>
<summary className={styles.technicalDetailsSummary}>
{t("technical_details")}
</summary>
<pre className={styles.technicalDetailsPre}>
{technicalError.message}
</pre>
</details>
) : null}
{actions &&
actions.map((action, index) => (
<Button

View File

@@ -19,13 +19,20 @@ import {
vitest,
} from "vitest";
import { render, waitFor, screen, act } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import {
type MatrixClient,
JoinRule,
type RoomState,
UnsupportedStickyEventsEndpointError,
} from "matrix-js-sdk";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import userEvent, {
PointerEventsCheckLevel,
} from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { useState } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -44,6 +51,7 @@ import {
MockRTCSession,
} from "../utils/test";
import { GroupCallView } from "./GroupCallView";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCTransportMissingError } from "../utils/errors";
@@ -61,7 +69,10 @@ vi.mock("react-use-measure", () => ({
vi.hoisted(
() =>
(global.ImageData = class MockImageData {
// Use globalThis rather than global because vite-plugin-node-polyfills seems
// to rewrite global into an import which then interferes with vitest's hoisting
// which runs before imports.
(globalThis.ImageData = class MockImageData {
public data: number[] = [];
} as unknown as typeof ImageData),
);
@@ -125,6 +136,9 @@ beforeEach(() => {
function createGroupCallView(
widget: WidgetHelpers | null,
joined = true,
options: {
withErrorBoundary?: boolean;
} = {},
): {
rtcSession: MatrixRTCSession;
getByText: ReturnType<typeof render>["getByText"];
@@ -161,24 +175,36 @@ function createGroupCallView(
video: { enabled: false },
// TODO-MULTI-SFU: This cast isn't valid, it's likely the cause of some current test failures
} as unknown as MuteStates;
const groupCallView = (
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
widget={widget}
// TODO-MULTI-SFU: Make joined and setJoined work
joined={true}
setJoined={function (value: boolean): void {}}
/>
);
const { getByText } = render(
<BrowserRouter>
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
<GroupCallView
client={client}
isPasswordlessUser={false}
confineToRoom={false}
preload={false}
skipLobby={false}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
widget={widget}
// TODO-MULTI-SFU: Make joined and setJoined work
joined={true}
setJoined={function (value: boolean): void {}}
/>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary
recoveryActionHandler={vi.fn()}
widget={null}
>
{groupCallView}
</GroupCallErrorBoundary>
) : (
groupCallView
)}
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
@@ -246,12 +272,15 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
expect(leaveRTCSession).toHaveBeenCalledOnce();
});
test.skip("Should close widget when all other left and have time to play a sound", async () => {
test("Should close widget when all other left and play a sound", async () => {
const user = userEvent.setup();
const widgetClosedCalled = Promise.withResolvers<void>();
let widgetClosedCalled = false;
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
Promise.withResolvers<void>();
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
if (action === ElementWidgetActions.Close) {
widgetClosedCalled.resolve();
widgetClosedCalled = true;
widgetClosedResolver();
}
});
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
@@ -267,7 +296,7 @@ test.skip("Should close widget when all other left and have time to play a sound
lazyActions: new LazyEventEmitter(),
};
const resolvePlaySound = Promise.withResolvers<void>();
playSound = vi.fn().mockReturnValue(resolvePlaySound);
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
@@ -278,47 +307,15 @@ test.skip("Should close widget when all other left and have time to play a sound
const leaveButton = getByText("SimulateOtherLeft");
await user.click(leaveButton);
await flushPromises();
expect(widgetSendMock).not.toHaveBeenCalled();
expect(widgetClosedCalled).toBeFalsy();
resolvePlaySound.resolve();
await flushPromises();
expect(playSound).toHaveBeenCalledWith("left");
await widgetClosedCalled.promise;
expect(playSound).toHaveBeenCalledWith("left", 0);
await widgetClosedPromise;
await flushPromises();
expect(widgetClosedCalled).toBeTruthy();
expect(widgetStopMock).toHaveBeenCalledOnce();
});
test("Should close widget when all other left", async () => {
const user = userEvent.setup();
const widgetClosedCalled = Promise.withResolvers<void>();
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
if (action === ElementWidgetActions.Close) {
widgetClosedCalled.resolve();
}
});
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
const widget = {
api: {
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
transport: {
send: widgetSendMock,
reply: vi.fn().mockResolvedValue(undefined),
stop: widgetStopMock,
} as unknown as ITransport,
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const leaveButton = getByText("SimulateOtherLeft");
await user.click(leaveButton);
await flushPromises();
await widgetClosedCalled.promise;
await flushPromises();
expect(widgetStopMock).toHaveBeenCalledOnce();
});
}, 80000);
test("Should not close widget when auto leave due to error", async () => {
const user = userEvent.setup();
@@ -385,6 +382,46 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
screen.getByText("Call is not supported");
});
test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEventsRequiredError screen", async () => {
// Mirror the shape the SDK emits: the MembershipManager scheduler wraps
// the original UnsupportedStickyEventsEndpointError in a generic Error
// but preserves the original on `.cause`.
const stickyError = new UnsupportedStickyEventsEndpointError(
"Server does not support the sticky events",
"sendStickyEvent",
);
const wrappedError = new Error(
"The MembershipManager shut down because of the end condition: " +
String(stickyError),
{ cause: stickyError },
);
const { rtcSession } = createGroupCallView(null, true, {
withErrorBoundary: true,
});
await act(() =>
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, wrappedError),
);
await screen.findByText("Homeserver does not support Matrix 2.0 calls");
});
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
const { rtcSession } = createGroupCallView(null, true, {
withErrorBoundary: true,
});
await act(() =>
rtcSession.emit(
MatrixRTCSessionEvent.MembershipManagerError,
new Error("something else broke"),
),
);
await screen.findByText("Connection lost");
});
test("user can reconnect after a membership manager error", async () => {
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null, true);
@@ -395,7 +432,11 @@ test("user can reconnect after a membership manager error", async () => {
// async state update should be processed automatically by the waitFor call),
// and yet here we are.
await act(async () =>
user.click(screen.getByRole("button", { name: "Reconnect" })),
user
// With css vitest turned on this test thinks that the button has pointer_events: none;.
// TODO investigate if this is a test setup issue or an actual problem.
.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never })
.click(screen.getByRole("button", { name: "Reconnect" })),
);
// In-call controls should be visible again
await waitFor(() => screen.getByRole("button", { name: "Leave" }));

View File

@@ -13,7 +13,12 @@ import {
useMemo,
useState,
} from "react";
import { type MatrixClient, JoinRule, type Room } from "matrix-js-sdk";
import {
type MatrixClient,
JoinRule,
type Room,
UnsupportedStickyEventsEndpointError,
} from "matrix-js-sdk";
import {
Room as LivekitRoom,
isE2EESupported as isE2EESupportedBrowser,
@@ -67,6 +72,7 @@ import {
ConnectionLostError,
E2EENotSupportedError,
ElementCallError,
StickyEventsRequiredError,
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
@@ -120,7 +126,7 @@ export const GroupCallView: FC<Props> = ({
const muteAllAudio = useBehavior(muteAllAudio$);
const leaveSoundContext = useLatest(
useAudioContext({
useAudioContext<CallEventSounds>({
sounds: callEventAudioSounds,
latencyHint: "interactive",
muted: muteAllAudio,
@@ -162,8 +168,22 @@ export const GroupCallView: FC<Props> = ({
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => setExternalError(new ConnectionLostError()),
(error) => {
// When matrix_rtc_mode=matrix_2_0 is in effect but the homeserver does
// not advertise MSC4354 (sticky events), the SDK throws an
// `UnsupportedStickyEventsEndpointError`. The MembershipManager
// scheduler wraps it and exposes the original via `.cause`.
if (
error instanceof Error &&
error.cause instanceof UnsupportedStickyEventsEndpointError
) {
setExternalError(new StickyEventsRequiredError());
} else {
setExternalError(new ConnectionLostError());
}
},
);
useEffect(() => {
// Sanity check the room object
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
@@ -318,12 +338,26 @@ export const GroupCallView: FC<Props> = ({
(
reason: "timeout" | "user" | "allOthersLeft" | "decline" | "error",
): void => {
let playSound: CallEventSounds = "left";
if (reason === "timeout" || reason === "decline") playSound = reason;
let audioPromise: Promise<void> | undefined = undefined;
switch (reason) {
case "allOthersLeft":
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
// already plays the "left" sound when the remote participant's media
// disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise.
// (used to destory the widget)
audioPromise = leaveSoundContext.current?.playSound("left", 0);
break;
case "timeout":
case "decline":
audioPromise = leaveSoundContext.current?.playSound(reason);
break;
default:
audioPromise = leaveSoundContext.current?.playSound("left");
}
setJoined(false);
setLeft(true);
const audioPromise = leaveSoundContext.current?.playSound(playSound);
// We need to wait until the callEnded event is tracked on PostHog,
// otherwise the iframe may get killed first.
const posthogRequest = new Promise((resolve) => {

View File

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
*/
import {
afterEach,
beforeEach,
describe,
expect,
@@ -14,10 +15,15 @@ import {
type MockedFunction,
vi,
} from "vitest";
import { render, type RenderResult } from "@testing-library/react";
import {
render,
type RenderResult,
getByRole,
screen,
} from "@testing-library/react";
import { type LocalParticipant } from "livekit-client";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter, MemoryRouter } from "react-router-dom";
import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import userEvent from "@testing-library/user-event";
@@ -34,7 +40,10 @@ import {
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
import {
type CallViewModel,
type CallViewModelOptions,
} from "../state/CallViewModel/CallViewModel";
import { alice, local } from "../utils/test-fixtures";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
@@ -48,7 +57,10 @@ import { initializeWidget } from "../widget";
initializeWidget();
vi.hoisted(
() =>
(global.ImageData = class MockImageData {
// Use globalThis rather than global because vite-plugin-node-polyfills seems
// to rewrite global into an import which then interferes with vitest's hoisting
// which runs before imports.
(globalThis.ImageData = class MockImageData {
public data: number[] = [];
} as unknown as typeof ImageData),
);
@@ -100,13 +112,12 @@ beforeEach(() => {
interface CreateInCallViewArgs {
mediaDevices?: ECMediaDevices;
callViewModelOptions?: Partial<CallViewModelOptions>;
/** If set, uses a MemoryRouter with this as the initial entry instead of BrowserRouter */
initialRoute?: string;
/** If true, wraps the rendered tree in an AppBar provider */
withAppBar?: boolean;
}
function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
rtcSession: MockRTCSession;
vm: CallViewModel;
} {
const mediaDevices = args.mediaDevices ?? mockMediaDevices({});
const muteState = mockMuteStates();
@@ -118,7 +129,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
remoteParticipants$: of([remoteParticipant]),
},
);
const { vm, rtcSession } = getBasicCallViewModelEnvironment(
const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment(
[local, alice],
undefined,
mediaDevices,
@@ -129,20 +140,13 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
const room = rtcSession.room;
const client = room.client;
const Router = args.initialRoute
? ({ children }: { children: React.ReactNode }): React.ReactNode => (
<MemoryRouter initialEntries={[args.initialRoute!]}>
{children}
</MemoryRouter>
)
: BrowserRouter;
const inCallView = (
<InCallView
client={client}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
vm={vm}
footerVm={footerVm}
matrixInfo={{
userId: "",
displayName: "",
@@ -163,7 +167,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView;
const renderResult = render(
<Router>
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<ReactionsSenderProvider
vm={vm}
@@ -174,11 +178,12 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
</TooltipProvider>
</ReactionsSenderProvider>
</MediaDevicesContext>
</Router>,
</BrowserRouter>,
);
return {
...renderResult,
rtcSession,
vm,
};
}
@@ -189,57 +194,46 @@ describe("InCallView", () => {
expect(container).toMatchSnapshot();
});
});
describe("settings button with AppBar header", () => {
it("mobile landscape, is accessible when showHeader is false", () => {
// windowSize with height <= 600 results in "flat" windowMode,
// which means showHeader$ emits false.
const { getAllByRole } = createInCallView({
initialRoute: "/?header=app_bar",
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "flat" (height <= 600)
windowSize$: constant({ width: 1000, height: 500 }),
},
});
// When showHeader is false, hideSettingsButton is false,
// so the settings button is visible in the footer.
const settingsBtn = getAllByRole("button", { name: "Settings" });
// here we check for two settings buttons because there are two buttons in the bottom bar. One for the
// the narrow layout and another one for the wide layout.
// Their visibility uses @media css queries, which cannot be tested in JSDOM,
// but we can at least check that both buttons are rendered and have the correct classes.
expect(settingsBtn.length).toBe(2);
expect(settingsBtn[0]).toHaveAttribute(
"data-testid",
"settings-bottom-left",
);
expect(settingsBtn[0]).toBeVisible();
beforeEach(() => {
// getUrlParams() reads window.location directly rather than from the
// React Router context, so MemoryRouter alone is not enough to make
// it see "header=app_bar". Push the real URL so both paths agree.
window.history.pushState({}, "", "?header=app_bar");
});
it("mobile portrait, is accessible when showHeader is true", () => {
// windowSize with height > 600 and width > 600 results in "normal" windowMode,
// which means showHeader$ emits true.
const { getAllByRole } = createInCallView({
initialRoute: "/?header=app_bar",
afterEach(() => {
window.history.pushState({}, "", "/");
});
it("mobile portrait, is visible in the header", () => {
createInCallView({
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "normal" (height >= 600)
windowSize$: constant({ width: 1000, height: 800 }),
// Narrow like a mobile phone in portrait orientation
windowSize$: constant({ width: 400, height: 700 }),
},
});
// When showHeader is true and headerStyle is AppBar,
// hideSettingsButton is true in the footer, but the settings
// button is rendered in the AppBar via useAppBarSecondaryButton.
const settingsBtns = getAllByRole("button", { name: "Settings" });
expect(settingsBtns.length).toBe(1);
expect(settingsBtns[0]).toHaveAttribute(
"data-testid",
"settings-app-bar",
);
expect(settingsBtns[0]).toBeVisible();
getByRole(screen.getByRole("banner"), "button", {
name: "Settings",
});
});
it("mobile landscape, is not visible anywhere", () => {
const { queryByRole } = createInCallView({
withAppBar: true,
callViewModelOptions: {
// Flat like a mobile phone in landscape orientation
windowSize$: constant({ width: 700, height: 400 }),
},
});
expect(queryByRole("button", { name: "Settings" })).not.toBeVisible();
});
});
describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => {
const user = userEvent.setup();

View File

@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
import {
type FC,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
@@ -43,7 +42,6 @@ import { InviteButton } from "../button/InviteButton";
import {
type CallViewModel,
createCallViewModel$,
type GridMode,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
import { useInitial } from "../useInitial";
@@ -65,11 +63,7 @@ import {
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
debugTileLayout as debugTileLayoutSetting,
matrixRTCMode as matrixRTCModeSetting,
useSetting,
} from "../settings/settings";
import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings";
import { ReactionsReader } from "../reactions/ReactionsReader";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
@@ -87,8 +81,10 @@ import { useTrackProcessorObservable$ } from "../livekit/TrackProcessorContext.t
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { useLatest } from "../useLatest.ts";
import { CallFooter } from "../components/CallFooter.tsx";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
import { type ViewModel } from "../state/ViewModel.ts";
declare module "react" {
interface CSSProperties {
@@ -101,7 +97,7 @@ const logger = rootLogger.getChild("[InCallView]");
export interface ActiveCallProps extends Omit<
InCallViewProps,
"vm" | "livekitRoom" | "connState"
"vm" | "livekitRoom" | "connState" | "footerVm"
> {
e2eeSystem: EncryptionSystem;
// TODO refactor those reasons into an enum
@@ -112,7 +108,9 @@ export interface ActiveCallProps extends Omit<
export const ActiveCall: FC<ActiveCallProps> = (props) => {
const [vm, setVm] = useState<CallViewModel | null>(null);
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
null,
);
const urlParams = useUrlParams();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
@@ -122,6 +120,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const vm = createCallViewModel$(
scope,
props.rtcSession,
@@ -145,7 +144,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
vm.leave$.pipe(scope.bind()).subscribe(props.onLeft);
return (): void => {
logger.info("END CALL VIEW SCOPE");
scope.end();
};
}, [
@@ -157,13 +155,44 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
urlParams,
mediaDevices,
trackProcessorState$,
props.client,
]);
useEffect(() => {
if (vm === null) return;
const scope = new ObservableScope();
const footerVm = createCallFooterViewModel(
scope,
vm,
props.muteStates,
mediaDevices,
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
);
setFooterVm(footerVm);
return (): void => {
scope.end();
};
}, [
props.rtcSession,
props.matrixRoom,
props.muteStates,
props.e2eeSystem,
props.onLeft,
urlParams,
mediaDevices,
trackProcessorState$,
props.client,
vm,
]);
if (vm === null) return null;
if (footerVm === null) return null;
return (
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView {...props} vm={vm} />
<InCallView {...props} vm={vm} footerVm={footerVm} />
</ReactionsSenderProvider>
);
};
@@ -171,6 +200,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
export interface InCallViewProps {
client: MatrixClient;
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
@@ -181,14 +211,14 @@ export interface InCallViewProps {
export const InCallView: FC<InCallViewProps> = ({
client,
vm,
footerVm,
matrixInfo,
matrixRoom,
muteStates,
onShareClick,
}) => {
const { t } = useTranslation();
const { supportsReactions, sendReaction, toggleRaisedHand } =
useReactionsSender();
const { sendReaction, toggleRaisedHand } = useReactionsSender();
useWakeLock();
// TODO-MULTI-SFU This is unused now??
@@ -224,9 +254,6 @@ export const InCallView: FC<InCallViewProps> = ({
muted: muteAllAudio,
});
const latestPickupPhaseAudio = useLatest(pickupPhaseAudio);
const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$);
const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
@@ -239,21 +266,18 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);
const ringing = useBehavior(vm.ringing$);
const ringingIntent = useBehavior(vm.ringingIntent$);
const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const showNameTags = useBehavior(vm.showNameTags$);
const gridMode = useBehavior(vm.gridMode$);
const showHeader = useBehavior(vm.showHeader$);
const showFooter = useBehavior(vm.showFooter$);
const settingsOpen = useBehavior(vm.settingsOpen$);
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const sharingScreen = useBehavior(vm.sharingScreen$);
const fatalCallError = useBehavior(vm.fatalError$);
// Stop the rendering and throw for the error boundary
@@ -265,7 +289,7 @@ export const InCallView: FC<InCallViewProps> = ({
// While ringing, loop the ringtone
useEffect((): void | (() => void) => {
const audio = latestPickupPhaseAudio.current;
if (ringing && audio) {
if (ringingIntent !== null && audio) {
const endSound = audio.playSoundLooping(
"waiting",
audio.soundDuration["waiting"] ?? 1,
@@ -276,12 +300,15 @@ export const InCallView: FC<InCallViewProps> = ({
});
};
}
}, [ringing, latestPickupPhaseAudio]);
}, [ringingIntent, latestPickupPhaseAudio]);
const onViewClick = useCallback(
(e: ReactMouseEvent) => {
// iOS Safari doesn't reliably fire `click` on plain <div>s, so we listen
// for `pointerup` instead. Scrolls end in `pointercancel`, not `pointerup`,
// so this still only fires for taps.
const onViewPointerUp = useCallback(
(e: ReactPointerEvent) => {
if (
(e.nativeEvent as PointerEvent).pointerType === "touch" &&
e.pointerType === "touch" &&
// If an interactive element was tapped, don't count this as a tap on the screen
(e.target as Element).closest?.("button, input") === null
)
@@ -298,28 +325,18 @@ export const InCallView: FC<InCallViewProps> = ({
);
const onPointerOut = useCallback(() => vm.unhoverScreen(), [vm]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
const openSettings = useCallback(
() => setSettingsModalOpen(true),
[setSettingsModalOpen],
);
const closeSettings = useCallback(
() => setSettingsModalOpen(false),
[setSettingsModalOpen],
);
const openProfile = useMemo(
() =>
// Profile settings are unavailable in widget mode
widget === null
? (): void => {
setSettingsTab("profile");
setSettingsModalOpen(true);
setSettingsOpen(true);
}
: null,
[setSettingsTab, setSettingsModalOpen],
[setSettingsTab, setSettingsOpen],
);
const [headerRef, headerBounds] = useMeasure();
@@ -345,11 +362,6 @@ export const InCallView: FC<InCallViewProps> = ({
[gridBounds],
);
const setGridMode = useCallback(
(mode: GridMode) => vm.setGridMode(mode),
[vm],
);
useAppBarHidden(!showHeader);
let header: ReactNode = null;
@@ -490,6 +502,7 @@ export const InCallView: FC<InCallViewProps> = ({
};
}, [gridBoundsObservable$]);
const showFooter = useBehavior(footerVm.showFooter$);
const renderContent = (): JSX.Element => {
if (layout.type === "pip") {
return (
@@ -514,18 +527,25 @@ export const InCallView: FC<InCallViewProps> = ({
key="fixed"
className={styles.fixedGrid}
style={{
insetBlockStart:
edgeToEdge || headerBounds.height === 0 ? 0 : headerBounds.bottom,
// If not edge-to-edge, consume the header insets right here.
insetBlockStart: edgeToEdge ? 0 : bounds.top + headerBounds.height,
height: edgeToEdge ? "100%" : gridBounds.height,
// If edge-to-edge, compute new safe area insets that account for the
// header and footer.
// header and footer, passing them down to the tiles.
"--call-view-safe-area-inset-top":
edgeToEdge && header && showHeader
? `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
edgeToEdge && headerStyle !== HeaderStyle.None && showHeader
? // Header has two relevant cases: if it's an app bar, it lives
// outside the InCallView and consumes the safe area insets
// itself. Otherwise account for the safe area and header size
// as part of the InCallView.
headerStyle === HeaderStyle.AppBar
? `${bounds.top}px`
: `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
: undefined,
"--call-view-safe-area-inset-bottom":
edgeToEdge && showFooter
? `calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)`
? // Footer always lives inside the InCallView.
`calc(env(safe-area-inset-bottom) + ${footerBounds.height}px)`
: undefined,
}}
model={layout}
@@ -568,56 +588,28 @@ export const InCallView: FC<InCallViewProps> = ({
matrixRoom.roomId,
);
const settingsButtonInAppBar =
headerStyle === HeaderStyle.AppBar && showHeader;
useAppBarSecondaryButton(
<SettingsIconButton
key="settings"
onClick={openSettings}
onClick={() => setSettingsOpen(true)}
data-testid="settings-app-bar"
/>,
);
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = (
<CallFooter
ref={footerRef}
hidden={!showFooter}
hideControls={!showControls}
asOverlay={edgeToEdge}
asPip={layout.type === "pip"}
// Hide the logo for both embedded solutions. mobile: HeaderStyle.AppBar and desktop: HeaderStyle.None.
hideLogo={headerStyle !== HeaderStyle.Standard}
layoutMode={gridMode}
setLayoutMode={setGridMode}
audioEnabled={audioEnabled}
toggleAudio={toggleAudio ?? undefined}
videoEnabled={videoEnabled}
toggleVideo={toggleVideo ?? undefined}
sharingScreen={sharingScreen}
toggleScreenSharing={vm.toggleScreenSharing ?? undefined}
reactionIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
reactionData={supportsReactions ? vm : undefined}
audioOutputSwitcher={audioOutputSwitcher ?? undefined}
// Only pass the openSettings function if the settings button is not in the app bar.
// If there is no fn the button will be hidden in the footer.
openSettings={settingsButtonInAppBar ? undefined : openSettings}
hangup={vm.hangup}
//Debug props
debugTileLayout={debugTileLayout}
tileStoreGeneration={tileStoreGeneration}
/>
const footer = footerVm !== null && (
<CallFooter ref={footerRef} vm={footerVm} />
);
const allConnections = useBehavior(vm.allConnections$);
return (
// The onClick handler here exists to control the visibility of the footer,
// The pointer handler here exists to control the visibility of the footer,
// and the footer is also viewable by moving focus into it, so this is fine.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className={styles.inRoom}
ref={containerRef}
onClick={onViewClick}
onPointerUp={onViewPointerUp}
onPointerMove={onPointerMove}
onPointerOut={onPointerOut}
>
@@ -644,8 +636,8 @@ export const InCallView: FC<InCallViewProps> = ({
<SettingsModal
client={client}
roomId={matrixRoom.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
open={settingsOpen}
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
onTabChange={setSettingsTab}
livekitRooms={allConnections

111
src/room/LobbyView.test.tsx Normal file
View File

@@ -0,0 +1,111 @@
/*
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 { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { axe } from "vitest-axe";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { mockMediaDevices, mockMuteStates } from "../utils/test";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../livekit/TrackProcessorContext";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import lobbyStyles from "./LobbyView.module.css";
import headerStyles from "../Header.module.css";
vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [],
}));
vi.mock("../livekit/TrackProcessorContext", () => ({
useTrackProcessor: (): ProcessorState => ({
supported: false,
processor: undefined,
}),
useTrackProcessorSync: (): void => {},
}));
vi.mock("react-use-measure", () => ({
default: (): [() => void, object] => [(): void => {}, {}],
}));
vi.mock("../settings/SettingsModal", () => ({
SettingsModal: (): null => null,
defaultSettingsTab: "general",
}));
const mockClient = {
getUserId: () => "@user:example.org",
getDeviceId: () => "DEVICE",
} as Partial<MatrixClient> as MatrixClient;
const matrixInfo = {
userId: "@user:example.org",
displayName: "Test User",
avatarUrl: "",
roomId: "!room:example.org",
roomName: "Test Room",
roomAlias: null,
roomAvatar: null,
e2eeSystem: { kind: E2eeType.NONE } satisfies EncryptionSystem,
};
function renderLobbyView(
props: Partial<Parameters<typeof LobbyView>[0]> = {},
): ReturnType<typeof render> {
const mediaDevices = mockMediaDevices({});
const muteStates = mockMuteStates();
return render(
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<TooltipProvider>
<LobbyView
client={mockClient}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => {}}
confineToRoom={false}
hideHeader={false}
participantCount={3}
onShareClick={null}
{...props}
/>
</TooltipProvider>
</MediaDevicesContext>
</BrowserRouter>,
);
}
describe("LobbyView", () => {
it("renders with header and participant count", async () => {
const { container } = renderLobbyView();
expect(container).toMatchSnapshot();
expect(
container.getElementsByClassName(headerStyles.header).length,
).toBeTruthy();
expect(await axe(container)).toHaveNoViolations();
});
it("renders without header", () => {
const { container } = renderLobbyView({ hideHeader: true });
expect(
container.getElementsByClassName(headerStyles.header).length,
).toBeFalsy();
});
it("renders with waiting for invite state", () => {
const { getByTestId } = renderLobbyView({
waitingForInvite: true,
});
expect(getByTestId("lobby_joinCall")).toHaveClass(lobbyStyles.wait);
});
});

View File

@@ -38,6 +38,7 @@ import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useMediaDevices } from "../MediaDevicesContext";
import { ObservableScope } from "../state/ObservableScope";
import { useInitial } from "../useInitial";
import {
useTrackProcessor,
@@ -46,8 +47,10 @@ import {
import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
import { type ViewModel } from "../state/ViewModel";
interface Props {
client: MatrixClient;
@@ -112,6 +115,7 @@ export const LobbyView: FC<Props> = ({
logger.error("Failed to navigate to /", error);
});
}, [navigate]);
const hangup = confineToRoom ? undefined : onLeaveClick;
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
const recentsButton = !confineToRoom && (
@@ -184,6 +188,27 @@ export const LobbyView: FC<Props> = ({
useTrackProcessorSync(videoTrack);
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
null,
);
useEffect(() => {
const footerScope = new ObservableScope();
setFooterVm(
createLobbyFooterViewModel(
footerScope,
muteStates,
devices,
openSettings,
hangup,
// Logo and header are connected: only show the logo in SPA with header.
!hideHeader,
),
);
return (): void => {
footerScope.end();
};
}, [devices, hangup, hideHeader, muteStates, onLeaveClick, openSettings]);
// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
return (
@@ -227,18 +252,11 @@ export const LobbyView: FC<Props> = ({
</VideoPreview>
{!recentsButtonInFooter && recentsButton}
</div>
<CallFooter
audioEnabled={audioEnabled}
videoEnabled={videoEnabled}
toggleAudio={toggleAudio ?? undefined}
toggleVideo={toggleVideo ?? undefined}
openSettings={openSettings}
hangup={!confineToRoom ? onLeaveClick : undefined}
// Logo and header are connected. We will only show the logo in SPA with header.
hideLogo={hideHeader}
>
{recentsButtonInFooter && recentsButton}
</CallFooter>
{footerVm !== null && (
<CallFooter vm={footerVm}>
{recentsButtonInFooter && recentsButton}
</CallFooter>
)}
</div>
{client && (
<SettingsModal

File diff suppressed because it is too large Load Diff

View File

@@ -104,6 +104,7 @@ exports[`InCallView > rendering > renders 1`] = `
</div>
<div
class="fixedGrid grid"
style="inset-block-start: NaNpx;"
>
<div />
</div>
@@ -164,6 +165,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
<div
class="footer"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
@@ -326,6 +328,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_i_"
@@ -350,6 +353,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_n_"
@@ -374,7 +378,33 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_s_"
class="_button_1nw83_8 raiseHand _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<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 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>
</button>
<button
aria-labelledby="_r_14_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
@@ -402,7 +432,7 @@ exports[`InCallView > rendering > renders 1`] = `
data-size="lg"
>
<input
aria-labelledby="_r_11_"
aria-labelledby="_r_19_"
name="layoutMode"
type="radio"
value="spotlight"
@@ -420,7 +450,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby="_r_16_"
aria-labelledby="_r_1e_"
checked=""
name="layoutMode"
type="radio"

View File

@@ -0,0 +1,382 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LobbyView > renders with header and participant count 1`] = `
<div>
<div
class="inRoom"
>
<header
class="header"
>
<div
class="nav leftNav"
>
<div
class="roomHeaderInfo"
data-size="lg"
>
<span
aria-label="!room:example.org"
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 56px;"
>
T
</span>
<div
class="nameLine"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
data-testid="roomHeader_roomName"
>
Test Room
</h1>
<span
tabindex="0"
>
<svg
aria-labelledby="_r_0_"
class="lock"
data-encrypted="false"
fill="currentColor"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.825 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412a2 2 0 0 1 .702-.463L1.333 4.167a1 1 0 0 1 1.414-1.414L7 7.006v-.012l13 13v.012l1.247 1.247a1 1 0 1 1-1.414 1.414l-.896-.896A1.94 1.94 0 0 1 18 22zm14-4.834V10q0-.825-.587-1.412A1.93 1.93 0 0 0 18 8h-1V6q0-2.075-1.463-3.537Q14.075 1 12 1T8.463 2.463a4.9 4.9 0 0 0-1.22 1.946L9 6.166V6q0-1.25.875-2.125A2.9 2.9 0 0 1 12 3q1.25 0 2.125.875T15 6v2h-4.166z"
/>
</svg>
</span>
</div>
<div
class="participantsLine"
>
<svg
aria-label="Participants"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.175 13.825Q10.35 15 12 15t2.825-1.175T16 11t-1.175-2.825T12 7 9.175 8.175 8 11t1.175 2.825m4.237-1.412A1.93 1.93 0 0 1 12 13q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 11q0-.825.588-1.412A1.93 1.93 0 0 1 12 9q.825 0 1.412.588Q14 10.175 14 11t-.588 1.412"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
/>
<path
d="M16.23 18.792a13 13 0 0 0-1.455-.455 11.6 11.6 0 0 0-5.55 0q-.73.18-1.455.455a8 8 0 0 1-1.729-1.454q1.336-.618 2.709-.95A13.8 13.8 0 0 1 12 16q1.65 0 3.25.387 1.373.333 2.709.95a8 8 0 0 1-1.73 1.455"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
data-testid="roomHeader_participants_count"
>
3
</span>
</div>
</div>
</div>
<div
class="nav rightNav"
/>
</header>
<div
class="content"
>
<div
class="preview"
>
<video
disablepictureinpicture=""
playsinline=""
tabindex="-1"
/>
<div
class="avatarContainer"
>
<div>
<span
aria-label="@user:example.org"
class="_avatar_va14e_8 _avatar-imageless_va14e_55"
data-color="6"
data-type="round"
role="img"
style="--cpd-avatar-size: NaNpx;"
>
T
</span>
</div>
</div>
<div
class="buttonBar"
>
<button
class="_button_1nw83_8 join"
data-kind="primary"
data-size="lg"
data-testid="lobby_joinCall"
role="button"
tabindex="0"
>
Join call
</button>
</div>
</div>
<a
class="_link_k9ljz_8"
data-kind="primary"
data-size="md"
href="/"
rel="noreferrer noopener"
>
Back to recents
</a>
</div>
<div
class="footer"
data-testid="footer-container"
>
<div
class="settingsLogoContainer"
>
<button
aria-labelledby="_r_6_"
class="_icon-button_1215g_8 settingsOnlyShowWide"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</div>
</button>
<div
class="logo"
>
<svg
aria-hidden="true"
fill="none"
height="24"
viewBox="0 0 48 48"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Logo Mark"
>
<rect
fill="#0DBD8B"
height="48"
rx="23.93"
width="47.86"
/>
<g
id="Union"
>
<path
d="M21.3075 9.42871C20.3396 9.42871 19.5549 10.214 19.5549 11.1828C19.5549 12.1516 20.3396 12.9369 21.3075 12.9369C25.9321 12.9369 29.6811 16.689 29.6811 21.3175C29.6811 22.2863 30.4657 23.0716 31.4337 23.0716C32.4016 23.0716 33.1863 22.2863 33.1863 21.3175C33.1863 14.7515 27.868 9.42871 21.3075 9.42871Z"
fill="white"
/>
<path
d="M38.4591 21.3174C38.4591 20.3486 37.6745 19.5633 36.7065 19.5633C35.7386 19.5633 34.9539 20.3486 34.9539 21.3174C34.9539 25.9459 31.2049 29.698 26.5804 29.698C25.6124 29.698 24.8277 30.4833 24.8277 31.4521C24.8277 32.4209 25.6124 33.2062 26.5804 33.2062C33.1408 33.2062 38.4591 27.8834 38.4591 21.3174Z"
fill="white"
/>
<path
d="M28.3329 36.8173C28.3329 37.786 27.5482 38.5714 26.5803 38.5714C20.0198 38.5714 14.7015 33.2486 14.7015 26.6826C14.7015 25.7138 15.4862 24.9285 16.4541 24.9285C17.4221 24.9285 18.2067 25.7138 18.2067 26.6826C18.2067 31.3111 21.9557 35.0632 26.5803 35.0632C27.5482 35.0632 28.3329 35.8485 28.3329 36.8173Z"
fill="white"
/>
<path
d="M9.40112 26.6827C9.40112 27.6514 10.1858 28.4368 11.1537 28.4368C12.1217 28.4368 12.9064 27.6514 12.9064 26.6827C12.9064 22.0542 16.6553 18.3021 21.2799 18.3021C22.2478 18.3021 23.0325 17.5167 23.0325 16.548C23.0325 15.5792 22.2478 14.7939 21.2799 14.7939C14.7194 14.7939 9.40112 20.1167 9.40112 26.6827Z"
fill="white"
/>
</g>
</g>
</svg>
<svg
aria-label="Element Call"
fill="none"
height="11"
viewBox="0 0 160 22"
width="80"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Logo Type"
>
<g
id="Vector"
>
<path
d="M14.8673 15.1575H3.39742C3.53293 16.3508 3.96849 17.3036 4.70411 18.0157C5.43974 18.7087 6.40766 19.0551 7.60789 19.0551C8.40159 19.0551 9.11785 18.8626 9.75668 18.4777C10.3955 18.0927 10.8504 17.5731 11.1215 16.9186H14.606C14.1414 18.4392 13.2702 19.671 11.9926 20.6142C10.7343 21.5381 9.24368 22 7.52078 22C5.27519 22 3.45549 21.259 2.06168 19.7769C0.687227 18.2948 0 16.4182 0 14.147C0 11.9335 0.696906 10.0761 2.09072 8.5748C3.48453 7.07349 5.28487 6.32283 7.49174 6.32283C9.69861 6.32283 11.4796 7.06387 12.8347 8.54593C14.2091 10.0087 14.8964 11.8565 14.8964 14.0892L14.8673 15.1575ZM7.49174 9.12336C6.40766 9.12336 5.50749 9.44095 4.79123 10.0761C4.07496 10.7113 3.62972 11.5582 3.45549 12.6168H11.4699C11.315 11.5582 10.8892 10.7113 10.1922 10.0761C9.49534 9.44095 8.59517 9.12336 7.49174 9.12336Z"
fill="currentColor"
/>
<path
d="M17.2743 17.1785V0H20.7298V17.2362C20.7298 18.0061 21.1557 18.3911 22.0074 18.3911L22.6172 18.3622V21.6247C22.2881 21.6824 21.9397 21.7113 21.5719 21.7113C20.0813 21.7113 18.9875 21.336 18.2906 20.5853C17.6131 19.8346 17.2743 18.699 17.2743 17.1785Z"
fill="currentColor"
/>
<path
d="M38.71 15.1575H27.2401C27.3756 16.3508 27.8112 17.3036 28.5468 18.0157C29.2824 18.7087 30.2504 19.0551 31.4506 19.0551C32.2443 19.0551 32.9606 18.8626 33.5994 18.4777C34.2382 18.0927 34.6931 17.5731 34.9642 16.9186H38.4487C37.9841 18.4392 37.113 19.671 35.8353 20.6142C34.577 21.5381 33.0864 22 31.3635 22C29.1179 22 27.2982 21.259 25.9044 19.7769C24.5299 18.2948 23.8427 16.4182 23.8427 14.147C23.8427 11.9335 24.5396 10.0761 25.9334 8.5748C27.3272 7.07349 29.1276 6.32283 31.3344 6.32283C33.5413 6.32283 35.3223 7.06387 36.6774 8.54593C38.0518 10.0087 38.7391 11.8565 38.7391 14.0892L38.71 15.1575ZM31.3344 9.12336C30.2504 9.12336 29.3502 9.44095 28.6339 10.0761C27.9177 10.7113 27.4724 11.5582 27.2982 12.6168H35.3126C35.1577 11.5582 34.7319 10.7113 34.035 10.0761C33.3381 9.44095 32.4379 9.12336 31.3344 9.12336Z"
fill="currentColor"
/>
<path
d="M54.3001 13.0499V21.6535H50.8446V12.6745C50.8446 10.4033 49.8961 9.26772 47.9989 9.26772C46.9729 9.26772 46.1502 9.59493 45.5307 10.2493C44.9306 10.9038 44.6306 11.7988 44.6306 12.9344V21.6535H41.1751V6.66929H44.3692V8.66142C44.737 7.98775 45.2984 7.42957 46.0534 6.98688C46.8084 6.54418 47.7473 6.32283 48.8701 6.32283C50.9608 6.32283 52.4707 7.11199 53.4 8.69029C54.6776 7.11199 56.3812 6.32283 58.5106 6.32283C60.2722 6.32283 61.6273 6.87139 62.5759 7.9685C63.5244 9.04637 63.9987 10.4707 63.9987 12.2415V21.6535H60.5432V12.6745C60.5432 10.4033 59.5947 9.26772 57.6975 9.26772C56.6522 9.26772 55.8198 9.60455 55.2003 10.2782C54.6002 10.9326 54.3001 11.8565 54.3001 13.0499Z"
fill="currentColor"
/>
<path
d="M81.1834 15.1575H69.7135C69.849 16.3508 70.2846 17.3036 71.0202 18.0157C71.7558 18.7087 72.7237 19.0551 73.924 19.0551C74.7177 19.0551 75.4339 18.8626 76.0728 18.4777C76.7116 18.0927 77.1665 17.5731 77.4375 16.9186H80.9221C80.4575 18.4392 79.5863 19.671 78.3087 20.6142C77.0504 21.5381 75.5598 22 73.8369 22C71.5913 22 69.7716 21.259 68.3778 19.7769C67.0033 18.2948 66.3161 16.4182 66.3161 14.147C66.3161 11.9335 67.013 10.0761 68.4068 8.5748C69.8006 7.07349 71.601 6.32283 73.8078 6.32283C76.0147 6.32283 77.7957 7.06387 79.1508 8.54593C80.5252 10.0087 81.2124 11.8565 81.2124 14.0892L81.1834 15.1575ZM73.8078 9.12336C72.7237 9.12336 71.8236 9.44095 71.1073 10.0761C70.391 10.7113 69.9458 11.5582 69.7716 12.6168H77.786C77.6311 11.5582 77.2052 10.7113 76.5083 10.0761C75.8114 9.44095 74.9113 9.12336 73.8078 9.12336Z"
fill="currentColor"
/>
<path
d="M86.8426 6.66929V8.66142C87.191 8.007 87.7621 7.45844 88.5558 7.01575C89.3689 6.55381 90.3465 6.32283 91.4886 6.32283C93.2696 6.32283 94.6441 6.86177 95.612 7.93963C96.5993 9.0175 97.0929 10.4514 97.0929 12.2415V21.6535H93.6374V12.6745C93.6374 11.6159 93.3858 10.7883 92.8824 10.1916C92.3985 9.57568 91.6532 9.26772 90.6465 9.26772C89.5431 9.26772 88.672 9.59493 88.0331 10.2493C87.4137 10.9038 87.1039 11.8084 87.1039 12.9633V21.6535H83.6484V6.66929H86.8426Z"
fill="currentColor"
/>
<path
d="M107.185 18.5932V21.5669C106.759 21.6824 106.159 21.7402 105.384 21.7402C102.442 21.7402 100.971 20.2677 100.971 17.3228V9.41208H98.6766V6.66929H100.971V2.77165H104.426V6.66929H107.243V9.41208H104.426V16.9764C104.426 18.1505 104.987 18.7375 106.11 18.7375L107.185 18.5932Z"
fill="currentColor"
/>
<path
d="M116.115 18.9881C114.474 17.2035 113.653 14.9429 113.653 12.2064C113.653 9.4699 114.474 7.21782 116.115 5.45015C117.773 3.66548 119.953 2.77314 122.654 2.77314C124.876 2.77314 126.756 3.38503 128.295 4.6088C129.833 5.83258 130.816 7.47277 131.244 9.52939H129.269C128.91 7.99967 128.132 6.7844 126.936 5.88357C125.739 4.98273 124.312 4.53232 122.654 4.53232C120.534 4.53232 118.824 5.23769 117.525 6.64842C116.243 8.05916 115.602 9.91182 115.602 12.2064C115.602 14.501 116.243 16.3536 117.525 17.7644C118.824 19.1751 120.534 19.8805 122.654 19.8805C124.312 19.8805 125.739 19.4301 126.936 18.5292C128.132 17.6284 128.91 16.4131 129.269 14.8834H131.244C130.816 16.94 129.833 18.5802 128.295 19.804C126.756 21.0278 124.876 21.6397 122.654 21.6397C119.953 21.6397 117.773 20.7558 116.115 18.9881Z"
fill="currentColor"
/>
<path
d="M143.174 15.0874C140.832 15.0874 139.233 15.1384 138.379 15.2403C137.541 15.3253 136.926 15.4698 136.532 15.6738C135.831 16.0647 135.481 16.6936 135.481 17.5604C135.481 19.2261 136.473 20.0589 138.456 20.0589C139.977 20.0589 141.139 19.719 141.943 19.0391C142.763 18.3593 143.174 17.4499 143.174 16.3111V15.0874ZM138.25 21.5632C136.763 21.5632 135.626 21.2062 134.84 20.4924C134.071 19.7615 133.686 18.8012 133.686 17.6114C133.686 16.8295 133.891 16.1327 134.301 15.5208C134.729 14.9089 135.31 14.4585 136.045 14.1695C136.661 13.9316 137.455 13.7786 138.43 13.7106C139.404 13.6256 140.986 13.5831 143.174 13.5831V12.7418C143.174 10.6002 141.943 9.52939 139.481 9.52939C137.361 9.52939 136.131 10.3877 135.789 12.1044H134.019C134.207 10.8466 134.746 9.84383 135.635 9.09597C136.541 8.34811 137.849 7.97418 139.558 7.97418C141.387 7.97418 142.746 8.3991 143.635 9.24894C144.541 10.0988 144.994 11.2716 144.994 12.7673V21.2572H143.251V19.3706C142.345 20.8323 140.678 21.5632 138.25 21.5632Z"
fill="currentColor"
/>
<path
d="M149.358 18.4018V2.13576H151.178V18.1978C151.178 18.7247 151.264 19.0901 151.435 19.2941C151.623 19.498 151.956 19.6 152.435 19.6L152.948 19.549V21.2062C152.657 21.2572 152.341 21.2827 151.999 21.2827C150.238 21.2827 149.358 20.3224 149.358 18.4018Z"
fill="currentColor"
/>
<path
d="M155.944 18.4018V2.13576H157.764V18.1978C157.764 18.7247 157.85 19.0901 158.021 19.2941C158.209 19.498 158.542 19.6 159.021 19.6L159.534 19.549V21.2062C159.243 21.2572 158.927 21.2827 158.585 21.2827C156.824 21.2827 155.944 20.3224 155.944 18.4018Z"
fill="currentColor"
/>
</g>
</g>
</svg>
</div>
</div>
<div
class="buttons"
>
<button
aria-labelledby="_r_b_"
class="_button_1nw83_8 settingsOnlyShowNarrow _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
data-testid="settings-bottom-center"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_g_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_mute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 8v-.006l6.831 6.832-.002.002 1.414 1.415.003-.003 1.414 1.414-.003.003L20.5 20.5a1 1 0 0 1-1.414 1.414l-3.022-3.022A7.95 7.95 0 0 1 13 19.938V21a1 1 0 0 1-2 0v-1.062A8 8 0 0 1 4 12a1 1 0 1 1 2 0 6 6 0 0 0 8.587 5.415l-1.55-1.55A4.005 4.005 0 0 1 8 12v-1.172L2.086 4.914A1 1 0 0 1 3.5 3.5zm9.417 6.583 1.478 1.477A7.96 7.96 0 0 0 20 12a1 1 0 0 0-2 0c0 .925-.21 1.8-.583 2.583M8.073 5.238l7.793 7.793q.132-.495.134-1.031V6a4 4 0 0 0-7.927-.762"
/>
</svg>
</button>
<button
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_l_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
data-testid="incall_videomute"
role="switch"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.747 2.753 4.35 4.355l.007-.003L18 17.994v.012l3.247 3.247a1 1 0 0 1-1.414 1.414l-2.898-2.898A2 2 0 0 1 16 20H6a4 4 0 0 1-4-4V8c0-.892.292-1.715.785-2.38L1.333 4.166a1 1 0 0 1 1.414-1.414M18 15.166 6.834 4H16a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715z"
/>
</svg>
</button>
<button
aria-labelledby="_r_q_"
class="_button_1nw83_8 endCall _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
data-testid="incall_leave"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m2.765 16.02-2.47-2.416A1.02 1.02 0 0 1 0 12.852q0-.456.295-.751a15.6 15.6 0 0 1 5.316-3.786A15.9 15.9 0 0 1 12 7q3.355 0 6.39 1.329a16 16 0 0 1 5.315 3.772q.295.294.295.751t-.295.752l-2.47 2.416a1.047 1.047 0 0 1-1.396.108l-3.114-2.363a1.1 1.1 0 0 1-.322-.376 1.1 1.1 0 0 1-.108-.483v-2.27a13.6 13.6 0 0 0-2.12-.524C13.459 9.996 12 9.937 12 9.937s-1.459.059-2.174.175q-1.074.174-2.121.523v2.271q0 .268-.108.483a1.1 1.1 0 0 1-.322.376l-3.114 2.363a1.047 1.047 0 0 1-1.396-.107"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;

View File

@@ -14,7 +14,14 @@ import type { MatrixClient } from "matrix-js-sdk";
import type { Room as LivekitRoom } from "livekit-client";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
import { customLivekitUrl as customLivekitUrlSetting } from "./settings";
import {
customLivekitUrl as customLivekitUrlSetting,
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
matrixRTCMode as matrixRTCModeSetting,
} from "./settings";
import { MatrixRTCMode } from "../config/ConfigOptions";
import { mockConfig } from "../utils/test";
// Mock url params hook to avoid environment-dependent snapshot churn.
vi.mock("../UrlParams", () => ({
useUrlParams: (): { mocked: boolean; answer: number } => ({
@@ -248,4 +255,161 @@ describe("DeveloperSettingsTab", () => {
expect(customLivekitUrlSetting.getValue()).toBe(null);
});
});
// Add this test inside the describe("DeveloperSettingsTab", () => { block,
// after the custom livekit url tests:
describe("enable extended livekit logs", () => {
afterEach(() => {
enableExtendedLivekitLogsSetting.setValue(false);
});
it("toggles extended livekit logs setting", async () => {
const user = userEvent.setup();
const client = createMockMatrixClient();
render(
<TooltipProvider>
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
/>
</TooltipProvider>,
);
const checkbox = screen.getByLabelText("Enable extended livekit logs");
// Initial state should be unchecked (default false)
expect(checkbox).not.toBeChecked();
expect(enableExtendedLivekitLogsSetting.getValue()).toBe(false);
// Click to enable
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(enableExtendedLivekitLogsSetting.getValue()).toBe(true);
// Click to disable
await user.click(checkbox);
expect(checkbox).not.toBeChecked();
expect(enableExtendedLivekitLogsSetting.getValue()).toBe(false);
});
it("Use the current setting value on render", () => {
const client = createMockMatrixClient();
// Set the value to true before rendering
enableExtendedLivekitLogsSetting.setValue(true);
render(
<TooltipProvider>
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
/>
</TooltipProvider>,
);
const checkbox = screen.getByLabelText("Enable extended livekit logs");
expect(checkbox).toBeChecked();
expect(enableExtendedLivekitLogsSetting.getValue()).toBe(true);
});
});
describe("matrix rtc mode", () => {
afterEach(() => {
matrixRTCModeSetting.setValue(MatrixRTCMode.Legacy);
vi.restoreAllMocks();
});
function getModeRadios(): {
legacy: HTMLInputElement;
compatibility: HTMLInputElement;
matrix20: HTMLInputElement;
} {
return {
legacy: screen.getByDisplayValue(
MatrixRTCMode.Legacy,
) as HTMLInputElement,
compatibility: screen.getByDisplayValue(
MatrixRTCMode.Compatibility,
) as HTMLInputElement,
matrix20: screen.getByDisplayValue(
MatrixRTCMode.Matrix_2_0,
) as HTMLInputElement,
};
}
it("radios reflect the localStorage setting when config does not force the mode", async () => {
mockConfig({});
matrixRTCModeSetting.setValue(MatrixRTCMode.Compatibility);
const client = createMockMatrixClient();
render(
<TooltipProvider>
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
/>
</TooltipProvider>,
);
await waitFor(() =>
expect(client.doesServerSupportUnstableFeature).toHaveBeenCalled(),
);
const radios = getModeRadios();
expect(radios.compatibility).toBeChecked();
expect(radios.legacy).not.toBeChecked();
expect(radios.matrix20).not.toBeChecked();
// None are disabled by config; only Matrix_2_0 may be disabled by sticky-events support.
expect(radios.legacy).not.toBeDisabled();
expect(radios.compatibility).not.toBeDisabled();
});
it.each([
MatrixRTCMode.Legacy,
MatrixRTCMode.Compatibility,
MatrixRTCMode.Matrix_2_0,
])(
"disables all radios and shows the config value (%s) as checked when matrix_rtc_mode is set",
async (configMode) => {
mockConfig({ matrix_rtc_mode: configMode });
// Local setting is intentionally different from the config value to
// prove config wins.
matrixRTCModeSetting.setValue(
configMode === MatrixRTCMode.Legacy
? MatrixRTCMode.Compatibility
: MatrixRTCMode.Legacy,
);
const client = createMockMatrixClient();
render(
<TooltipProvider>
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
/>
</TooltipProvider>,
);
await waitFor(() =>
expect(client.doesServerSupportUnstableFeature).toHaveBeenCalled(),
);
const radios = getModeRadios();
expect(radios.legacy).toBeDisabled();
expect(radios.compatibility).toBeDisabled();
expect(radios.matrix20).toBeDisabled();
const checkedValue = (
{
[MatrixRTCMode.Legacy]: radios.legacy,
[MatrixRTCMode.Compatibility]: radios.compatibility,
[MatrixRTCMode.Matrix_2_0]: radios.matrix20,
} as const
)[configMode];
expect(checkedValue).toBeChecked();
},
);
});
});

View File

@@ -33,6 +33,7 @@ import {
import { type Room as LivekitRoom } from "livekit-client";
import { FieldRow, InputField } from "../input/Input";
import { Config } from "../config/Config";
import {
useSetting,
duplicateTiles as duplicateTilesSetting,
@@ -42,8 +43,9 @@ import {
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting,
customLivekitUrl as customLivekitUrlSetting,
MatrixRTCMode,
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
} from "./settings";
import { MatrixRTCMode } from "../config/ConfigOptions";
import styles from "./DeveloperSettingsTab.module.css";
import { useUrlParams } from "../UrlParams";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
@@ -92,6 +94,11 @@ export const DeveloperSettingsTab: FC<Props> = ({
},
[setMatrixRTCMode],
);
const configMatrixRTCMode = Config.get().matrix_rtc_mode as
| MatrixRTCMode
| undefined;
const matrixRTCModeForced = configMatrixRTCMode !== undefined;
const effectiveMatrixRTCMode = configMatrixRTCMode ?? matrixRTCMode;
const [showConnectionStats, setShowConnectionStats] = useSetting(
showConnectionStatsSetting,
@@ -101,6 +108,10 @@ export const DeveloperSettingsTab: FC<Props> = ({
alwaysShowIphoneEarpieceSetting,
);
const [enableExtendedLivekitLogs, setEnableExtendedLivekitLogs] = useSetting(
enableExtendedLivekitLogsSetting,
);
const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] =
useState<string | null>(null);
const [customLivekitUrl, setCustomLivekitUrl] = useSetting(
@@ -225,7 +236,21 @@ export const DeveloperSettingsTab: FC<Props> = ({
},
[setAlwaysShowIphoneEarpiece],
)}
/>{" "}
/>
</FieldRow>
<FieldRow>
<InputField
id="enableLivekitExtendedLogs"
type="checkbox"
label="Enable extended livekit logs"
checked={enableExtendedLivekitLogs}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setEnableExtendedLivekitLogs(event.target.checked);
},
[setEnableExtendedLivekitLogs],
)}
/>
</FieldRow>
<EditInPlace
onSubmit={(e) => e.preventDefault()}
@@ -293,13 +318,15 @@ export const DeveloperSettingsTab: FC<Props> = ({
<Heading as="h3" type="body" weight="semibold" size="lg">
{t("developer_mode.matrixRTCMode.title")}
</Heading>
{matrixRTCModeForced && <p>Your deployment overrides the mode.</p>}
<Form>
<InlineField
name={matrixRTCModeRadioGroup}
control={
<RadioControl
checked={matrixRTCMode === MatrixRTCMode.Legacy}
checked={effectiveMatrixRTCMode === MatrixRTCMode.Legacy}
value={MatrixRTCMode.Legacy}
disabled={matrixRTCModeForced}
onChange={onMatrixRTCModeChange}
/>
}
@@ -313,8 +340,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
name={matrixRTCModeRadioGroup}
control={
<RadioControl
checked={matrixRTCMode === MatrixRTCMode.Compatibility}
checked={effectiveMatrixRTCMode === MatrixRTCMode.Compatibility}
value={MatrixRTCMode.Compatibility}
disabled={matrixRTCModeForced}
onChange={onMatrixRTCModeChange}
/>
}
@@ -328,9 +356,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
name={matrixRTCModeRadioGroup}
control={
<RadioControl
checked={matrixRTCMode === MatrixRTCMode.Matrix_2_0}
checked={effectiveMatrixRTCMode === MatrixRTCMode.Matrix_2_0}
value={MatrixRTCMode.Matrix_2_0}
disabled={!stickyEventsSupported}
disabled={matrixRTCModeForced || !stickyEventsSupported}
onChange={onMatrixRTCModeChange}
/>
}

View File

@@ -100,7 +100,7 @@ export const SettingsModal: FC<Props> = ({
const devices = useMediaDevices();
useEffect(() => {
if (open) devices.requestDeviceNames();
if (open) devices.requestDeviceNames(); // No-op after the first call
}, [open, devices]);
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);

View File

@@ -185,17 +185,53 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
Show iPhone earpiece option on all platforms
</label>
</div>
</div>
<form
class="_root_19upo_16"
<div
class="fieldRow"
>
<div
class="_field_19upo_26"
class="field checkboxField"
>
<input
aria-describedby="_r_6_"
id="enableLivekitExtendedLogs"
type="checkbox"
/>
<label
for="enableLivekitExtendedLogs"
>
<div
class="checkbox"
>
<svg
fill="none"
height="24"
stroke="#000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20 6-11 11-5-5"
/>
</svg>
</div>
Enable extended livekit logs
</label>
</div>
</div>
<form
class="_root_1o4d9_17"
>
<div
class="_field_1o4d9_27"
>
<label
class="_label_19upo_59"
for="radix-_r_6_"
class="_label_1o4d9_60"
for="radix-_r_7_"
>
Custom Livekit-url
</label>
@@ -203,17 +239,17 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_controls_17lij_8"
>
<input
aria-describedby="radix-_r_7_"
aria-describedby="radix-_r_8_"
class="_control_d83jn_10"
id="radix-_r_6_"
id="radix-_r_7_"
name="input"
title=""
value=""
/>
</div>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_7_"
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_8_"
>
Currently, no overwrite is set. Url from well-known or config is used.
</span>
@@ -225,22 +261,22 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
MatrixRTC mode
</h3>
<form
class="_root_19upo_16"
class="_root_1o4d9_17"
>
<div
class="_inline-field_19upo_32"
class="_inline-field_1o4d9_33"
>
<div
class="_inline-field-control_19upo_44"
class="_inline-field-control_1o4d9_45"
>
<div
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_9_ radix-_r_b_ radix-_r_d_"
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
checked=""
class="_input_1ug7n_18"
id="radix-_r_8_"
id="radix-_r_9_"
name="_r_0_"
title=""
type="radio"
@@ -252,35 +288,35 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
</div>
<div
class="_inline-field-body_19upo_38"
class="_inline-field-body_1o4d9_39"
>
<label
class="_label_19upo_59"
for="radix-_r_8_"
class="_label_1o4d9_60"
for="radix-_r_9_"
>
Legacy: state events & oldest membership SFU
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_9_"
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_a_"
>
Compatible with old versions of EC that do not support multi SFU
</span>
</div>
</div>
<div
class="_inline-field_19upo_32"
class="_inline-field_1o4d9_33"
>
<div
class="_inline-field-control_19upo_44"
class="_inline-field-control_1o4d9_45"
>
<div
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_9_ radix-_r_b_ radix-_r_d_"
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
class="_input_1ug7n_18"
id="radix-_r_a_"
id="radix-_r_b_"
name="_r_0_"
title=""
type="radio"
@@ -292,35 +328,35 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
</div>
<div
class="_inline-field-body_19upo_38"
class="_inline-field-body_1o4d9_39"
>
<label
class="_label_19upo_59"
for="radix-_r_a_"
class="_label_1o4d9_60"
for="radix-_r_b_"
>
Compatibility: state events & multi SFU
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_b_"
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_c_"
>
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
</span>
</div>
</div>
<div
class="_inline-field_19upo_32"
class="_inline-field_1o4d9_33"
>
<div
class="_inline-field-control_19upo_44"
class="_inline-field-control_1o4d9_45"
>
<div
class="_container_1ug7n_10"
>
<input
aria-describedby="radix-_r_9_ radix-_r_b_ radix-_r_d_"
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
class="_input_1ug7n_18"
id="radix-_r_c_"
id="radix-_r_d_"
name="_r_0_"
title=""
type="radio"
@@ -332,17 +368,17 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</div>
</div>
<div
class="_inline-field-body_19upo_38"
class="_inline-field-body_1o4d9_39"
>
<label
class="_label_19upo_59"
for="radix-_r_c_"
class="_label_1o4d9_60"
for="radix-_r_d_"
>
Matrix 2.0: sticky events & multi SFU
</label>
<span
class="_message_19upo_85 _help-message_19upo_91"
id="radix-_r_d_"
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_e_"
>
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
</span>
@@ -372,9 +408,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
local
)
</p>
<pre
class="pre"
>
<pre>
{
"region": "local",
"version": "1.2.3"
@@ -384,9 +418,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
<p>
Local Participant
</p>
<pre
class="pre"
>
<pre>
localParticipantIdentity
</pre>
<p>
@@ -413,9 +445,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
remote
)
</p>
<pre
class="pre"
>
<pre>
{
"region": "remote",
"version": "4.5.6"
@@ -425,9 +455,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
<p>
Local Participant
</p>
<pre
class="pre"
>
<pre>
localParticipantIdentity
</pre>
<p>

View File

@@ -149,7 +149,7 @@ class IndexedDBLogStore {
* @return Resolves when the store is ready.
*/
public async connect(): Promise<void> {
const req = this.indexedDB.open("logs");
const req = this.indexedDB.open("logs-element-call");
return new Promise((resolve, reject) => {
req.onsuccess = (): void => {
this.db = req.result;
@@ -502,6 +502,13 @@ export async function init(): Promise<void> {
};
});
window.addEventListener("unhandledrejection", (event) => {
global.mx_rage_logger.log(
LogLevel.error,
`Unhandled promise rejection: ${event.reason}`,
);
});
return tryInitStorage();
}

View File

@@ -11,6 +11,7 @@ import { BehaviorSubject } from "rxjs";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior";
import { MatrixRTCMode } from "../config/ConfigOptions";
export class Setting<T> {
public constructor(
@@ -129,17 +130,10 @@ export const alwaysShowIphoneEarpiece = new Setting<boolean>(
false,
);
export enum MatrixRTCMode {
Legacy = "legacy",
Compatibility = "compatibility",
/** This implies using
* - sticky events
* - hashed RTC backend identity
* - the new endpoint for the jwt token on the local membership (remote memberships will always try the new jwt endpoint first -> then the legacy one)
* - use the hashed identity for the local membership
*/
Matrix_2_0 = "matrix_2_0",
}
export const enableExtendedLivekitLogs = new Setting<boolean>(
"extended-livekit-logs",
false,
);
export const matrixRTCMode = new Setting<MatrixRTCMode>(
"matrix-rtc-mode",

View File

@@ -5,17 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, it } from "vitest";
import { test } from "vitest";
import {
EventType,
type IEvent,
type IRoomTimelineData,
MatrixEvent,
type Room,
} from "matrix-js-sdk";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import { map, mergeMap, NEVER, type Observable, startWith } from "rxjs";
import { withTestScheduler } from "../../utils/test";
import {
alice,
aliceRtcMember,
local,
localRtcMember,
@@ -23,9 +25,10 @@ import {
import {
type CallNotificationWrapper,
createCallNotificationLifecycle$,
type Props as CallNotificationLifecycleProps,
type RingAttempt,
} from "./CallNotificationLifecycle";
import { trackEpoch } from "../ObservableScope";
import { Epoch, trackEpoch } from "../ObservableScope";
import { constant } from "../Behavior";
function mockRingEvent(
eventId: string,
@@ -40,311 +43,272 @@ function mockRingEvent(
} as unknown as CallNotificationWrapper;
}
describe("waitForCallPickup$", () => {
it("unknown -> ringing -> timeout when notified and nobody joins", () => {
withTestScheduler(({ scope, expectObservable, behavior, hot }) => {
// No one ever joins (only local user)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", { a: [] }).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const defaultProps = {
memberships$: constant(new Epoch([])),
matrixRoomMembers$: constant(new Map([[alice.userId, alice]])),
receivedDecline$: NEVER,
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
function summarizeRingAttempts$(
ringAttempts$: Observable<RingAttempt>,
): Observable<
| { intent: RTCCallIntent; recipient: string }
| { outcome: "accept" | "decline" | "timeout" }
> {
return ringAttempts$.pipe(
mergeMap(({ intent, recipient, outcome$ }) =>
outcome$.pipe(
map((outcome) => ({ outcome })),
startWith({ intent, recipient }),
),
),
);
}
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms c", {
a: "unknown",
b: "ringing",
c: "timeout",
});
test("no ring attempt when waitForCallPickup=false", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
options: { ...defaultProps.options, waitForCallPickup: false },
});
});
it("ringing -> success if someone joins before timeout is reached", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a 19ms b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("5ms a", {
a: mockRingEvent("$notif2", 100),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 4ms b 14ms c", {
a: "unknown",
b: "ringing",
c: "success",
});
});
});
it("success when someone joins before we notify", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a 9ms b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("20ms a", {
a: mockRingEvent("$notif2", 50),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
a: "unknown",
b: "success",
});
});
});
it("notify without lifetime -> immediate timeout", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$notif2", undefined),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b", {
a: "unknown",
b: "timeout",
});
});
});
it("stays null when waitForCallPickup=false", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const validProps: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a--b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$notif5", 30),
}),
receivedDecline$: hot(""),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const propsDeactivated = {
...validProps,
options: {
...validProps.options,
waitForCallPickup: false,
},
};
const lifecycle = createCallNotificationLifecycle$(propsDeactivated);
expectObservable(lifecycle.callPickupState$).toBe("n", {
n: null,
});
const lifecycleReference = createCallNotificationLifecycle$(validProps);
expectObservable(lifecycleReference.callPickupState$).toBe("u--s", {
u: "unknown",
s: "success",
});
});
});
it("decline before timeout window ends -> decline", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$decl1", 50),
}),
receivedDecline$: hot("40ms d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$decl1",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$).toBe("a 9ms b 29ms e", {
a: "unknown",
b: "ringing",
e: "decline",
});
});
});
it("decline after timeout window ends -> stays timeout", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$decl", 20),
}),
receivedDecline$: hot("40ms d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$decl",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
expectObservable(lifecycle.callPickupState$, "50ms !").toBe(
"a 9ms b 19ms e",
{
a: "unknown",
b: "ringing",
e: "timeout",
},
);
});
});
//
function testStaysRinging(
declineEvent: Partial<IEvent>,
expectDecline: boolean,
): void {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
const props: CallNotificationLifecycleProps = {
scope,
memberships$: scope.behavior(
behavior("a", {
a: [localRtcMember],
}).pipe(trackEpoch()),
),
sentCallNotification$: hot("10ms a", {
a: mockRingEvent("$right", 50),
}),
receivedDecline$: hot("20ms d", {
d: [
new MatrixEvent(declineEvent),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
options: {
waitForCallPickup: true,
autoLeaveWhenOthersLeft: false,
},
localUser: localRtcMember,
};
const lifecycle = createCallNotificationLifecycle$(props);
const marbles = expectDecline ? "a 9ms b 9ms d" : "a 9ms b";
expectObservable(lifecycle.callPickupState$, "21ms !").toBe(marbles, {
a: "unknown",
b: "ringing",
d: "decline",
});
});
}
const reference = (refId?: string, sender?: string): Partial<IEvent> => ({
event_id: "$decline",
type: EventType.RTCDecline,
sender: sender ?? "@other:example.org",
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: refId ?? "$right",
},
},
});
it("decline reference works", () => {
testStaysRinging(reference(), true);
});
it("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging(reference("$wrong"), false);
});
it("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging(reference(undefined, local.userId), false);
expectObservable(ringAttempts$).toBe("");
});
});
test("no ring attempt when notification type is not ring", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: {
...mockRingEvent("$notif1", 30),
notification_type: "notification",
},
}),
});
expectObservable(ringAttempts$).toBe("");
});
});
test("no ring attempt if lifetime is missing", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", undefined),
}),
});
expectObservable(ringAttempts$).toBe("");
});
});
test("ring attempt times out after nobody joins", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
// No one ever joins (only local user)
memberships$: constant(new Epoch([])),
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
});
});
});
test("ring attempt is accepted once recipient joins", () => {
withTestScheduler(({ scope, expectObservable, hot, behavior }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
memberships$: scope.behavior(
behavior("a-b", { a: [], b: [aliceRtcMember] }).pipe(trackEpoch()),
),
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "accept" },
});
});
});
test("ring attempt is immediately accepted if recipient is already joined", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
memberships$: constant(new Epoch([aliceRtcMember])),
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-(aA)", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "accept" },
});
});
});
test("ring attempt can be declined", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$notif1",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-aA", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "decline" },
});
});
});
test("ring attempt times out if recipient declines too late", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("100ms d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$notif1",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
});
});
});
test("decline event relating to wrong event is ignored (times out)", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: alice.userId,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$other", // <---- WRONG
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
});
});
});
test("decline event from wrong sender is ignored (times out)", () => {
withTestScheduler(({ scope, expectObservable, hot }) => {
const { ringAttempts$ } = createCallNotificationLifecycle$({
scope,
...defaultProps,
sentCallNotification$: hot("-a", {
a: mockRingEvent("$notif1", 30),
}),
receivedDecline$: hot("--d", {
d: [
new MatrixEvent({
type: EventType.RTCDecline,
sender: local.userId, // <---- WRONG
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$notif1",
},
},
}),
{} as Room,
undefined,
false,
{} as IRoomTimelineData,
],
}),
});
expectObservable(summarizeRingAttempts$(ringAttempts$)).toBe("-a 29ms A", {
a: { intent: "audio", recipient: alice.userId },
A: { outcome: "timeout" },
});
});
});

View File

@@ -10,24 +10,22 @@ import {
type IRTCNotificationContent,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type RTCCallIntent,
} from "matrix-js-sdk/lib/matrixrtc";
import {
combineLatest,
concat,
endWith,
filter,
fromEvent,
ignoreElements,
map,
merge,
NEVER,
type Observable,
of,
pairwise,
startWith,
switchMap,
takeUntil,
timer,
EMPTY,
race,
take,
} from "rxjs";
import {
type EventTimelineSetHandlerMap,
@@ -35,18 +33,28 @@ import {
type Room as MatrixRoom,
RoomEvent,
} from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior";
import { type Epoch, mapEpoch, type ObservableScope } from "../ObservableScope";
import { type Epoch, type ObservableScope } from "../ObservableScope";
import { type RoomMemberMap } from "./remoteMembers/MatrixMemberMetadata";
const logger = rootLogger.getChild("[CallNotificationLifecycle]");
export type AutoLeaveReason = "allOthersLeft" | "timeout" | "decline";
export type CallPickupState =
| "unknown"
| "ringing"
| "timeout"
| "decline"
| "success"
| null;
export interface RingAttempt {
intent: RTCCallIntent;
/**
* The user ID of the recipient being rung.
*/
recipient: string;
/**
* The eventual outcome of the ringing attempt. (Emits a single value.)
*/
// TODO: Include a callback for attempting ringing again in case of a timeout
outcome$: Observable<"accept" | "decline" | "timeout">;
}
export type CallNotificationWrapper = {
event_id: string;
@@ -76,6 +84,7 @@ export function createReceivedDecline$(
export interface Props {
scope: ObservableScope;
memberships$: Behavior<Epoch<CallMembership[]>>;
matrixRoomMembers$: Behavior<RoomMemberMap>;
sentCallNotification$: Observable<CallNotificationWrapper | null>;
receivedDecline$: Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
@@ -84,34 +93,81 @@ export interface Props {
localUser: { deviceId: string; userId: string };
}
/**
* @returns two observables:
* `callPickupState$` The current call pickup state of the call.
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
* Then we can conclude if we were the first one to join or not.
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
* The call failed. If desired this can be used as a trigger to exit the call.
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
* - null: EC is configured to never show any waiting for answer state.
*
* `autoLeave$` An observable that emits (null) when the call should be automatically left.
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
*
*/
export function createCallNotificationLifecycle$({
scope,
memberships$,
matrixRoomMembers$,
sentCallNotification$,
receivedDecline$,
options,
localUser,
}: Props): {
callPickupState$: Behavior<CallPickupState>;
/**
* An observable of attempts to ring the remote participant's devices.
*/
ringAttempts$: Observable<RingAttempt>;
/**
* An observable that emits when the call should be automatically left.
* - if options.autoLeaveWhenOthersLeft is set to true it emits when all others left.
* - if options.waitForCallPickup is set to true it emits if noone picked up the ring or if the ring got declined.
* - if options.autoLeaveWhenOthersLeft && options.waitForCallPickup is false it will never emit.
*/
autoLeave$: Observable<AutoLeaveReason>;
} {
let ringAttempts$: Observable<RingAttempt> = NEVER;
if (options.waitForCallPickup)
ringAttempts$ = sentCallNotification$.pipe(
filter(
(
notificationEvent: CallNotificationWrapper | null,
): notificationEvent is CallNotificationWrapper =>
// only care about new events (legacy do not have decline pattern)
notificationEvent?.notification_type === "ring" &&
notificationEvent.lifetime > 0,
),
switchMap((notificationEvent) => {
// We assume that there is only one other user in the room when ringing
// TODO: Respect io.element.functional_members
const recipient = [...matrixRoomMembers$.value.keys()].find(
(userId) => userId !== localUser.userId,
);
if (recipient === undefined) {
logger.warn("No recipient for notification event; not ringing.");
return EMPTY;
}
// Ringing times out after lifetime ms have passed
const timeout$ = timer(notificationEvent.lifetime).pipe(
map(() => "timeout" as const),
);
// Call is accepted when the recipient joins
const accept$ = memberships$.pipe(
filter((ms) => ms.value.some((m) => m.userId === recipient)),
map(() => "accept" as const),
);
// Call is declined when we receive a decline event
const decline$ = receivedDecline$.pipe(
filter(
([event]) =>
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id === notificationEvent.event_id &&
event.getSender() === recipient,
),
map(() => "decline" as const),
);
return of({
intent: notificationEvent["m.call.intent"] ?? "audio",
recipient,
outcome$: race(timeout$, accept$, decline$).pipe(
take(1),
scope.share,
),
});
}),
scope.share,
);
const allOthersLeft$ = memberships$.pipe(
pairwise(),
filter(
@@ -122,87 +178,18 @@ export function createCallNotificationLifecycle$({
map(() => {}),
);
/**
* Whether some Matrix user other than ourself is joined to the call.
*/
const someoneElseJoined$ = memberships$.pipe(
mapEpoch((ms) => ms.some((m) => m.userId !== localUser.userId)),
) as Behavior<Epoch<boolean>>;
/**
* The state of the current ringing attempt, if the RTC session is indeed
* ringing the remote participant's devices. Otherwise `null`.
*/
const remoteRingState$: Behavior<"ringing" | "timeout" | "decline" | null> =
scope.behavior(
sentCallNotification$.pipe(
filter(
(notificationEventArgs: CallNotificationWrapper | null) =>
// only care about new events (legacy do not have decline pattern)
notificationEventArgs?.notification_type === "ring",
),
map((e) => e as CallNotificationWrapper),
switchMap((notificationEvent) => {
const lifetimeMs = notificationEvent?.lifetime ?? 0;
return concat(
lifetimeMs === 0
? // If no lifetime, skip the ring state
of(null)
: // Ring until lifetime ms have passed
timer(lifetimeMs).pipe(
ignoreElements(),
startWith("ringing" as const),
),
// The notification lifetime has timed out, meaning ringing has likely
// stopped on all receiving clients.
of("timeout" as const),
// This makes sure we will not drop into the `endWith("decline" as const)` state
NEVER,
).pipe(
takeUntil(
receivedDecline$.pipe(
filter(
([event]) =>
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id ===
notificationEvent.event_id &&
event.getSender() !== localUser.userId &&
callPickupState$.value !== "timeout",
),
),
),
endWith("decline" as const),
);
}),
),
null,
);
const callPickupState$ = scope.behavior(
options.waitForCallPickup === true
? combineLatest(
[someoneElseJoined$, remoteRingState$],
(someoneElseJoined, ring) => {
if (someoneElseJoined.value === true) {
return "success" as const;
}
// Show the ringing state of the most recent ringing attempt.
// as long as we have not yet sent an RTC notification event or noone else joined,
// ring will be null -> callPickupState$ = unknown.
return ring ?? ("unknown" as const);
},
)
: NEVER,
null,
);
const autoLeave$ = merge(
options.autoLeaveWhenOthersLeft === true
? allOthersLeft$.pipe(map(() => "allOthersLeft" as const))
: NEVER,
callPickupState$.pipe(
filter((state) => state === "timeout" || state === "decline"),
ringAttempts$.pipe(
switchMap(({ outcome$ }) =>
outcome$.pipe(
filter((outcome) => outcome === "timeout" || outcome === "decline"),
),
),
),
);
return { autoLeave$, callPickupState$ };
return { ringAttempts$, autoLeave$ };
}

View File

@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test, vi, onTestFinished, it, describe } from "vitest";
import { test, vi, onTestFinished, it, describe, expect } from "vitest";
import {
BehaviorSubject,
combineLatest,
@@ -65,7 +65,7 @@ import {
localParticipant,
withCallViewModel as withCallViewModelInMode,
} from "./CallViewModelTestUtils.ts";
import { MatrixRTCMode } from "../../settings/settings.ts";
import { MatrixRTCMode } from "../../config/ConfigOptions.ts";
import { initializeWidget } from "../../widget.ts";
initializeWidget();
@@ -85,6 +85,14 @@ vi.mock("../e2ee/matrixKeyProvider");
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../UrlParams", () => ({ getUrlParams }));
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../../Platform", () => ({
get platform(): string {
return getPlatform();
},
isFirefox: (): boolean => false,
}));
vi.mock(
"../state/CallViewModel/localMember/localTransport",
async (importOriginal) => ({
@@ -838,6 +846,94 @@ describe.each([
});
});
// Test cases for footer visibility in PIP mode across different platforms
const PIP_FOOTER_VISIBILITY_TEST_CASES: Array<{
platform: "ios" | "android" | "desktop";
expectedMarbles: string;
description: string;
}> = [
{
platform: "ios",
expectedMarbles: "tf",
description: "hidden on iOS",
},
{
platform: "android",
expectedMarbles: "tf",
description: "hidden on Android",
},
{
platform: "desktop",
expectedMarbles: "t",
description: "visible on desktop",
},
];
it.each(PIP_FOOTER_VISIBILITY_TEST_CASES)(
"footer is $description in PIP mode",
({ platform: testPlatform, expectedMarbles }) => {
withTestScheduler(({ schedule, expectObservable }) => {
// Set platform for this test case
getPlatform.mockReturnValue(testPlatform);
// Enable PIP mode after initial render
const pipControlInputMarbles = "-e";
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant]),
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
},
(vm) => {
schedule(pipControlInputMarbles, {
e: () => window.controls.enablePip(),
});
expectObservable(vm.showFooter$).toBe(expectedMarbles, {
t: true,
f: false,
});
},
);
});
},
);
// TODO add media to lk mocks
test("onPipMediaOrientationUpdate is called with the spotlight media orientation", () => {
// Set the spy before creating the view model so the initial call is captured
const onPipMediaOrientationUpdate = vi.fn();
window.controls.onPipMediaOrientationUpdate = onPipMediaOrientationUpdate;
onTestFinished(() => {
window.controls.onPipMediaOrientationUpdate = undefined;
});
withTestScheduler(({ behavior }) => {
// Alice starts as a regular participant, then shares her screen, then stops
const aliceSharingInputMarbles = "nyn";
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant]),
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
sharingScreen: new Map([
[aliceParticipant, behavior(aliceSharingInputMarbles, yesNo)],
]),
},
() => {},
);
});
// Should be called exactly 3 times:
// 1. Initially with "portrait" (Alice is in spotlight as a user, default portrait orientation)
// 2. With "landscape" when Alice starts screen sharing (screen shares always use landscape)
// 3. With "portrait" again when Alice stops screen sharing and returns to user tile
expect(onPipMediaOrientationUpdate).toHaveBeenCalledTimes(3);
expect(onPipMediaOrientationUpdate).toHaveBeenNthCalledWith(1, "portrait");
expect(onPipMediaOrientationUpdate).toHaveBeenNthCalledWith(2, "landscape");
expect(onPipMediaOrientationUpdate).toHaveBeenNthCalledWith(3, "portrait");
});
test("PiP tile in expanded spotlight layout switches speakers without layout shifts", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Switch to spotlight immediately
@@ -1325,7 +1421,10 @@ describe.each([
});
// Should ring for 30ms and then time out
expectObservable(vm.ringing$).toBe("(ny) 26ms n", yesNo);
expectObservable(vm.ringingIntent$).toBe("(ab) 26ms a", {
a: null,
b: "audio",
});
// Layout should show placeholder media for the participant we're
// ringing the entire time (even once timed out)
expectObservable(summarizeLayout$(vm.layout$)).toBe("a", {
@@ -1364,7 +1463,10 @@ describe.each([
});
// Should ring until Alice joins
expectObservable(vm.ringing$).toBe("(ny) 17ms n", yesNo);
expectObservable(vm.ringingIntent$).toBe("(ab) 17ms a", {
a: null,
b: "audio",
});
// Layout should show placeholder media for the participant we're
// ringing the entire time
expectObservable(summarizeLayout$(vm.layout$)).toBe("a 20ms b", {

View File

@@ -15,6 +15,7 @@ import {
} from "livekit-client";
import { type Room as MatrixRoom } from "matrix-js-sdk";
import {
BehaviorSubject,
catchError,
combineLatest,
distinctUntilChanged,
@@ -28,7 +29,6 @@ import {
pairwise,
race,
scan,
skipWhile,
startWith,
Subject,
switchAll,
@@ -38,11 +38,13 @@ import {
tap,
throttleTime,
timer,
BehaviorSubject,
takeUntil,
concat,
} from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
MembershipManagerEvent,
type RTCCallIntent,
type LivekitTransportConfig,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -60,10 +62,11 @@ import {
} from "../../utils/observable";
import {
duplicateTiles,
MatrixRTCMode,
playReactionsSound,
showReactions,
} from "../../settings/settings";
import { Config } from "../../config/Config";
import { MatrixRTCMode } from "../../config/ConfigOptions";
import { isFirefox, platform } from "../../Platform";
import { setPipEnabled$ } from "../../controls";
import { TileStore } from "../TileStore";
@@ -229,9 +232,9 @@ export interface CallViewModel {
// lifecycle
autoLeave$: Observable<AutoLeaveReason>;
/**
* Whether we are ringing a call recipient.
* Whether we are ringing a call recipient. Contains the ringing intent if so.
*/
ringing$: Behavior<boolean>;
ringingIntent$: Behavior<RTCCallIntent | null>;
/** Observable that emits when the user should leave the call (hangup pressed, widget action, error).
* THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is
* - by ending the scope
@@ -356,6 +359,9 @@ export interface CallViewModel {
*/
edgeToEdge$: Behavior<boolean>;
settingsOpen$: Behavior<boolean>;
setSettingsOpen$: Behavior<(open: boolean) => void>;
// audio routing
/**
* Whether audio is currently being output through the earpiece.
@@ -413,8 +419,15 @@ export function createCallViewModel$(
options.encryptionSystem,
matrixRTCSession,
);
// matrix_rtc_mode in config.json overrides the user's Developer Settings choice.
// It is validated at config load (src/config/Config.ts) so the cast is safe.
const configMatrixRTCMode = Config.get().matrix_rtc_mode as
| MatrixRTCMode
| undefined;
const matrixRTCMode$ =
options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy);
configMatrixRTCMode !== undefined
? constant(configMatrixRTCMode)
: (options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy));
// Each hbar seperates a block of input variables required for the CallViewModel to function.
// The outputs of this block is written under the hbar.
@@ -599,20 +612,6 @@ export function createCallViewModel$(
),
);
// ------------------------------------------------------------------------
// callLifecycle
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
const { callPickupState$, autoLeave$ } = createCallNotificationLifecycle$({
scope: scope,
memberships$: memberships$,
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
receivedDecline$: createReceivedDecline$(matrixRoom),
options: options,
localUser: { userId: userId, deviceId: deviceId },
});
// ------------------------------------------------------------------------
// matrixMemberMetadataStore
@@ -623,6 +622,21 @@ export function createCallViewModel$(
matrixRoomMembers$,
);
// ------------------------------------------------------------------------
// callLifecycle
// TODO if we are in "unknown" state we need a loading rendering (or empty screen)
// Otherwise it looks like we already connected and only than the ringing starts which is weird.
const { ringAttempts$, autoLeave$ } = createCallNotificationLifecycle$({
scope,
memberships$,
matrixRoomMembers$,
sentCallNotification$: createSentCallNotification$(scope, matrixRTCSession),
receivedDecline$: createReceivedDecline$(matrixRoom),
options,
localUser: { userId, deviceId },
});
const allConnections$ = scope.behavior(
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
);
@@ -755,11 +769,13 @@ export function createCallViewModel$(
pretendToBeDisconnected$: localMembership.reconnecting$,
displayName$: scope.behavior(
matrixMemberMetadataStore
.createDisplayNameBehavior$(userId)
.createDisplayNameBehavior$(scope, userId)
.pipe(map((name) => name ?? userId)),
),
mxcAvatarUrl$:
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
mxcAvatarUrl$: matrixMemberMetadataStore.createAvatarUrlBehavior$(
scope,
userId,
),
handRaised$: scope.behavior(
handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)),
),
@@ -771,49 +787,42 @@ export function createCallViewModel$(
),
);
const ringingMedia$ = scope.behavior<RingingMediaViewModel[]>(
combineLatest([userMedia$, matrixRoomMembers$, callPickupState$]).pipe(
generateItems(
"CallViewModel ringingMedia$",
function* ([userMedia, roomMembers, callPickupState]) {
if (
callPickupState === "ringing" ||
callPickupState === "timeout" ||
callPickupState === "decline"
) {
// TODO: Respect io.element.functional_members
for (const member of roomMembers.values()) {
if (!userMedia.some((vm) => vm.userId === member.userId))
yield {
keys: [member.userId],
data: callPickupState,
};
}
}
},
(scope, pickupState$, userId) =>
createRingingMedia({
id: `ringing:${userId}`,
userId,
displayName$: scope.behavior(
matrixRoomMembers$.pipe(
map((members) => members.get(userId)?.rawDisplayName || userId),
),
),
mxcAvatarUrl$:
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
pickupState$,
muteStates,
}),
const ringingMedia$ = scope.behavior<RingingMediaViewModel | null>(
ringAttempts$.pipe(
switchMap(({ intent, recipient, outcome$ }) =>
outcome$.pipe(
startWith("ringing" as const),
generateItems(
"CallViewModel ringingMedia$",
function* (pickupState) {
if (pickupState !== "accept")
yield { keys: [intent, recipient], data: pickupState };
},
(scope, pickupState$, intent, userId) =>
createRingingMedia({
id: `ringing:${userId}`,
userId,
displayName$: scope.behavior(
matrixRoomMembers$.pipe(
map(
(members) =>
members.get(userId)?.rawDisplayName || userId,
),
),
),
mxcAvatarUrl$:
matrixMemberMetadataStore.createAvatarUrlBehavior$(
scope,
userId,
),
pickupState$,
intent,
}),
),
map(([media]) => media ?? null),
),
),
distinctUntilChanged(shallowEquals),
tap((ringingMedia) => {
if (ringingMedia.length > 1)
// Warn that UI may do something unexpected in this case
logger.warn(
`Ringing more than one participant is not supported (ringing ${ringingMedia.map((vm) => vm.userId).join(", ")})`,
);
}),
startWith(null),
),
);
@@ -855,11 +864,7 @@ export function createCallViewModel$(
matrixLivekitMembers$.pipe(map((ms) => ms.value.length)),
);
const leaveSoundEffect$ = combineLatest([callPickupState$, userMedia$]).pipe(
// Until the call is successful, do not play a leave sound.
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
skipWhile(([c]) => c !== null && c !== "success"),
map(([, userMedia]) => userMedia),
const leaveSoundEffect$ = userMedia$.pipe(
pairwise(),
filter(
([prev, current]) =>
@@ -868,6 +873,9 @@ export function createCallViewModel$(
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
// Avoid doubling up on any auto-leave sounds (e.g. the decline sound),
// which are handled elsewhere
takeUntil(autoLeave$),
);
const userHangup$ = new Subject<void>();
@@ -972,8 +980,8 @@ export function createCallViewModel$(
}>(
ringingMedia$.pipe(
switchMap((ringingMedia) => {
if (ringingMedia.length > 0)
return of({ spotlight: ringingMedia, pip$: localUserMediaForPip$ });
if (ringingMedia !== null)
return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
return screenShares$.pipe(
switchMap((screenShares) => {
@@ -1070,10 +1078,12 @@ export function createCallViewModel$(
}),
);
const spotlightLandscapeLayoutMedia$: Observable<SpotlightLandscapeLayoutMedia> =
const spotlightLandscapeLayoutMedia$ = (
edgeToEdge: boolean,
): Observable<SpotlightLandscapeLayoutMedia> =>
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-landscape",
edgeToEdge: false,
edgeToEdge,
spotlight,
grid,
}));
@@ -1127,14 +1137,10 @@ export function createCallViewModel$(
// show ringing media instead
if (userMedia.length === 1)
return ringingMedia$.pipe(
map((ringingMedia) => {
return ringingMedia.length === 1
? {
local,
remote: ringingMedia[0],
}
: null;
}),
map(
(ringingMedia) =>
ringingMedia && { local, remote: ringingMedia },
),
);
}
}
@@ -1186,6 +1192,33 @@ export function createCallViewModel$(
})),
);
spotlight$
.pipe(
switchMap((media) => {
let layout;
const pipMedia = media[0];
if (pipMedia === undefined) return of(undefined);
switch (pipMedia.type) {
case "user":
layout = pipMedia.videoOrientation$;
break;
case "ringing":
layout = of("landscape" as const);
break;
case "screen share":
layout = of("landscape" as const);
break;
}
return layout;
}),
scope.bind(),
)
.subscribe((orientation) => {
if (orientation === undefined) return;
logger.info("controls api pip orientation updated:", orientation);
window.controls.onPipMediaOrientationUpdate?.(orientation);
});
/**
* The media to be used to produce a layout.
*/
@@ -1208,7 +1241,7 @@ export function createCallViewModel$(
switchMap((expanded) =>
expanded
? spotlightExpandedLayoutMedia$(false)
: spotlightLandscapeLayoutMedia$,
: spotlightLandscapeLayoutMedia$(false),
),
);
}
@@ -1234,7 +1267,7 @@ export function createCallViewModel$(
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return spotlightLandscapeLayoutMedia$;
return spotlightLandscapeLayoutMedia$(true);
case "spotlight":
return spotlightExpandedLayoutMedia$(true);
}
@@ -1341,6 +1374,10 @@ export function createCallViewModel$(
// Layout is edge-to-edge; show/hide the footer in response to interactions
return windowMode$.pipe(
switchMap((mode) => {
if (mode === "pip" && platform !== "desktop") {
// No controls are shown in mobile pip as interactions are disabled
return of(false);
}
const showInitially = mode !== "flat";
const timeout$ = mode === "flat" ? timer(showFooterMs) : NEVER;
@@ -1391,6 +1428,10 @@ export function createCallViewModel$(
map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams),
),
);
const settingsOpen$ = new BehaviorSubject(false);
const setSettingsOpen$ = constant((open: boolean) => {
settingsOpen$.next(open);
});
const showHeader$ = scope.behavior<boolean>(
windowMode$.pipe(
@@ -1676,8 +1717,14 @@ export function createCallViewModel$(
return {
autoLeave$: autoLeave$,
ringing$: scope.behavior(
callPickupState$.pipe(map((state) => state === "ringing")),
ringingIntent$: scope.behavior(
ringAttempts$.pipe(
switchMap(({ intent, outcome$ }) =>
// Hold the intent as the value until the ring attempt completes
concat(of(intent), NEVER.pipe(takeUntil(outcome$)), of(null)),
),
startWith<RTCCallIntent | null>(null),
),
),
leave$: leave$,
hangup: (): void => userHangup$.next(),
@@ -1745,6 +1792,8 @@ export function createCallViewModel$(
showNameTags$,
showHeader$: showHeader$,
showFooter$: showFooter$,
settingsOpen$: settingsOpen$,
setSettingsOpen$: setSettingsOpen$,
edgeToEdge$,
earpieceMode$: earpieceMode$,
audioOutputSwitcher$: audioOutputSwitcher$,

View File

@@ -56,7 +56,7 @@ import {
import { type Behavior, constant } from "../Behavior";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type MediaDevices } from "../MediaDevices";
import { type MatrixRTCMode } from "../../settings/settings";
import { type MatrixRTCMode } from "../../config/ConfigOptions";
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },

View File

@@ -26,7 +26,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type LocalTrack } from "livekit-client";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics";
import { MatrixRTCMode } from "../../../settings/settings";
import { MatrixRTCMode } from "../../../config/ConfigOptions";
import { type HomeserverDisconnectReason } from "./HomeserverConnected";
import {
flushPromises,

View File

@@ -53,7 +53,7 @@ import {
import { ElementWidgetActions, widget } from "../../../widget.ts";
import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import { MatrixRTCMode } from "../../../settings/settings.ts";
import { MatrixRTCMode } from "../../../config/ConfigOptions.ts";
import { Config } from "../../../config/Config.ts";
import {
ConnectionState,

View File

@@ -63,6 +63,7 @@ function createMockLocalTrack(source: Track.Source): LocalTrack {
function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
enabled$: BehaviorSubject<boolean>;
syncing$: BehaviorSubject<boolean>;
setHandler: (h: (enabled: boolean) => void) => void;
unsetHandler: () => void;
} {
@@ -70,6 +71,7 @@ function createMockMuteState(enabled$: BehaviorSubject<boolean>): {
const ms = {
enabled$,
syncing$: new BehaviorSubject(false),
setHandler: vi.fn().mockImplementation((h: (enabled: boolean) => void) => {
currentHandler = h;
}),
@@ -132,6 +134,7 @@ beforeEach(() => {
new Map(),
{},
{},
{},
);
vi.mocked(localParticipant).createTracks = vi

View File

@@ -112,27 +112,49 @@ export class Publisher {
this.logger.info("Local track published", localTrackPublication);
const lkRoom = this.connection.livekitRoom;
if (!this.shouldPublish) {
this.logger.debug("Not publishing, pausing upstream");
this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => {
this.logger.error(`Failed to pause upstreams`, e);
});
}
// also check the mute state and apply it
if (localTrackPublication.source === Track.Source.Microphone) {
const enabled = this.muteStates.audio.enabled$.value;
lkRoom.localParticipant.setMicrophoneEnabled(enabled).catch((e) => {
this.logger.error(
`Failed to enable microphone track, enabled:${enabled}`,
e,
);
});
const muteState = this.muteStates.audio;
// skip this if a sync is in progress: enabled$ still reflects the old
// state while the handler is mid-flight, so the handler itself will apply
// the correct mute state once it completes.
if (!muteState.syncing$.value) {
const enabled = muteState.enabled$.value;
if (!enabled) {
this.logger.info(
"Local audio track just published but muted meanwhile, setting enabled to false",
);
lkRoom.localParticipant.setMicrophoneEnabled(false).catch((e) => {
this.logger.error(
`Failed to enable microphone track, enabled:${enabled}`,
e,
);
});
}
}
} else if (localTrackPublication.source === Track.Source.Camera) {
const enabled = this.muteStates.video.enabled$.value;
lkRoom.localParticipant.setCameraEnabled(enabled).catch((e) => {
this.logger.error(
`Failed to enable camera track, enabled:${enabled}`,
e,
);
});
const muteState = this.muteStates.video;
// skip this if a sync is in progress: enabled$ still reflects the old
// state while the handler is mid-flight, so the handler itself will apply
// the correct mute state once it completes.
if (!muteState.syncing$.value) {
const enabled = muteState.enabled$.value;
if (!enabled) {
this.logger.info(
"Local video track just published but muted meanwhile, setting enabled to false",
);
lkRoom.localParticipant.setCameraEnabled(false).catch((e) => {
this.logger.error(
`Failed to enable camera track, enabled:${enabled}`,
e,
);
});
}
}
}
}
/**

View File

@@ -260,7 +260,10 @@ describe("Start connection states", () => {
await deferredSFU.promise;
return {
status: 500,
body: "Internal Server Error",
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
@@ -282,7 +285,7 @@ describe("Start connection states", () => {
capturedState.cause instanceof Error
) {
expect(capturedState.cause.message).toContain(
"SFU Config fetch failed with status code 500",
"Failed to look up user info from homeserver",
);
expect(connection.transport.livekit_alias).toEqual(
livekitFocus.livekit_alias,

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