Compare commits

..

172 Commits

Author SHA1 Message Date
Timo
2828b92d91 Merge pull request #3887 from element-hq/fkwp/support_latest_changes_MSC4195
Transitions Element Call from yarn to pnpm.
2026-04-21 00:20:18 +08:00
Timo K
ff6112826c missed version bump 2026-04-20 18:02:53 +02:00
Timo K
1f443cf739 bump jwt for dev backend back to proper release
(v0.4.4)
2026-04-20 17:12:56 +02:00
Timo K
66cea929a7 Add back our custom linking plugins to pnpm 2026-04-20 16:59:34 +02:00
fkwp
4d86580542 pin lk-jwt-service to delegation PR 2026-04-17 23:03:17 +02:00
fkwp
2adb498620 prettier 2026-04-17 18:28:02 +02:00
fkwp
d75f2563c6 getting ride of shamefully-hoist=true 2026-04-17 18:23:32 +02:00
fkwp
938a03d0c1 address review comments 2026-04-17 18:21:48 +02:00
Robin
2c65a75ba8 Merge pull request #3889 from element-hq/multiple-screen-shares
Fix visual glitches with multiple screen shares
2026-04-17 13:09:41 +02:00
Robin
7e39889f78 Fix name tags appearing in the wrong place with multiple screen shares
They were not properly being contained to where the MediaView is supposed to appear, causing them to all stack up on the first screen share in the spotlight tile.
2026-04-17 12:39:40 +02:00
Robin
96b343effc Fix forward/back buttons not showing up on desktop for multiple screen shares
6667fc54c0 changed this CSS selector that shows buttons on hover to only target the buttons in the bottom right corner of the spotlight tile, causing the forward/back buttons to stay invisible.
2026-04-17 12:23:29 +02:00
fkwp
b58ad639c1 Prevents Prettier from formatting the lockfile to avoid conflicts with
pnpm's internal formatting
2026-04-17 11:14:21 +02:00
fkwp
2b42b2bdc1 fix tests after moving to pnpm 2026-04-17 11:13:41 +02:00
Jake Janicke
8eccb95fd3 Merge pull request #3859 from JakeTripplJ/screenshare-audio-filtering-removal
Remove unneeded filtering from screen share audio
2026-04-17 11:10:12 +02:00
fkwp
9c1719a22f Switching to pnpm caused module resolution errors because some dependencies rely on being hoisted to the root. This flag restores the flat node_modules structure temporarily. 2026-04-17 11:08:25 +02:00
fkwp
17ad20cd09 switching from yarn to pnpm 2026-04-17 11:07:31 +02:00
fkwp
5657036f2f Bump matrix js-sdk to support latest MSC4195 update 2026-04-16 17:02:07 +02:00
Timo
565a5ab73f Merge pull request #3885 from element-hq/toger5/bottom-bar-storybook
[Corrected merge target] Footer component -> Storybook
2026-04-16 20:26:32 +08:00
Timo K
0dcd761080 only warn about app bar provider missing on mobile 2026-04-16 11:20:24 +02:00
Valere Fedronic
60fae70a60 Merge pull request #3882 from element-hq/local-screen-share
Fix local screen share not appearing in one-on-one calls
2026-04-16 09:42:33 +02:00
renovate[bot]
d31a2a4fdd Update dependency livekit-client to v2.18.3 (#3883)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-16 07:29:49 +00:00
Robin
e8963effe2 Test that the local user can see their own screen share
To make this test work I had to extend the mocking of the CallViewModel tests to make a local connection object exist.
2026-04-15 20:03:49 +02:00
Robin
b03524e25f Make screen sharing work more like speaking marbles in tests
This approach is more flexible in that it allows even the local participant to share their screen in CallViewModel tests, and more rigorous in that it ensures that application code is reacting specifically to track publications.
2026-04-15 19:56:04 +02:00
Robin
832c5784d4 Fix local screen share not appearing in one-on-one calls
This was regressed by 9dfade68ee.
2026-04-15 19:55:32 +02:00
Robin
014f645bb3 Merge pull request #3880 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.18.2
2026-04-15 17:00:28 +02:00
Timo K
cea32b86a3 remove reactive url param for showFooter 2026-04-15 16:47:18 +02:00
renovate[bot]
bbda8ecdcf Update dependency livekit-client to v2.18.2 2026-04-15 14:43:02 +00:00
Timo K
9fa382ed0c fix loudspeaker confusion and icons 2026-04-15 16:42:28 +02:00
Timo K
3fc823e049 Fix unit tests 2026-04-15 15:51:21 +02:00
Timo
c922936777 Merge pull request #3856 from element-hq/toger5/move-settings-out-of-bottom-bar
Move settings button out of the button bar (space for loud-speaker button)
2026-04-15 21:14:19 +08:00
Timo K
486b3ea45d Merge branch 'toger5/move-settings-out-of-bottom-bar' into toger5/bottom-bar-storybook 2026-04-15 14:53:08 +02:00
Timo K
71e389cdef unit test snapshots 2026-04-15 14:45:53 +02:00
Valere Fedronic
d19191a2d3 Merge pull request #3878 from element-hq/valere/fix_deploy_playwright_artifact
fixup: Looks like the upload & download action used are not compatible
2026-04-15 14:42:57 +02:00
Timo K
505e408c47 Merge branch 'toger5/move-settings-out-of-bottom-bar' into toger5/bottom-bar-storybook 2026-04-15 14:42:26 +02:00
Timo K
f07bf5c520 fix lint 2026-04-15 14:37:13 +02:00
Timo K
75fb89a98a review: update styles + aria hidden 2026-04-15 14:20:19 +02:00
Valere
29ba0bdf13 make zizmore happy with exact version in comment 2026-04-15 14:13:58 +02:00
Timo K
e46be38869 Make mediaDevices less confusing 2026-04-15 14:04:00 +02:00
Valere
0350f57275 fixup: Looks like the upload & download action used are not compatible 2026-04-15 14:01:26 +02:00
Valere Fedronic
6512a9d843 Merge pull request #3666 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.17.2
2026-04-15 13:39:49 +02:00
Timo K
dbf76db590 cleanup ReactionData 2026-04-15 13:06:47 +02:00
Timo K
3cb092f541 Revert "fix race in playwright sticky test"
This reverts commit 57f12d3d1c.
2026-04-15 11:51:41 +02:00
Timo K
b59600099d fix logo in lobby 2026-04-15 11:46:08 +02:00
Valere
7871877863 bump livekit server version 2026-04-15 11:27:13 +02:00
Valere Fedronic
9056989f0e Merge pull request #3858 from element-hq/valere/devx/netlify_playwright_report
devx: Use new action to netlify the playwright report as EW is doing
2026-04-15 11:12:47 +02:00
Robin
27a6e67ce9 Merge pull request #3870 from element-hq/delete-unused-css
Delete unused CSS file
2026-04-15 11:09:33 +02:00
Valere
68518e323e Skip NotAllowed test until livekit allows to detect that again 2026-04-15 10:28:19 +02:00
Robin
eccf119afd Merge pull request #3871 from element-hq/no-app-prompt
Get rid of the 'open in app' mobile prompt
2026-04-14 18:39:06 +02:00
Timo K
242c40af14 move appBar hook to InCallView 2026-04-14 18:36:36 +02:00
Timo K
dd80679ec5 test snapshot 2026-04-14 18:17:00 +02:00
Timo K
4b62ab2083 Merge branch 'toger5/move-settings-out-of-bottom-bar' into toger5/bottom-bar-storybook 2026-04-14 18:16:19 +02:00
Timo K
32823aef92 test and lints 2026-04-14 18:15:20 +02:00
Timo K
4295ae55d4 Merge branch 'toger5/move-settings-out-of-bottom-bar' into
toger5/bottom-bar-storybook
2026-04-14 18:09:59 +02:00
Timo K
ab751e8c38 review: smaller changes
- use correct button styles
 - move overrides to options
 - add flex-wrap: nowrap
2026-04-14 17:44:47 +02:00
Timo K
f7e1fa74cd review: hide logo at 750px already 2026-04-14 17:44:47 +02:00
Timo K
a069d28f3a review: make appBar styling use IconButton (fix margins) 2026-04-14 17:44:47 +02:00
Timo
ed83672720 Update src/room/InCallView.tsx
Co-authored-by: Robin <robin@robin.town>
2026-04-14 17:43:05 +02:00
Timo K
57f12d3d1c fix race in playwright sticky test 2026-04-14 16:23:05 +02:00
Timo K
2ae1df467d knip 2026-04-14 14:30:46 +02:00
Timo K
2d5de234d2 prettier 2026-04-14 14:26:00 +02:00
Timo K
3c039be7f2 lint 2026-04-14 14:11:08 +02:00
Timo K
8c5668bf1a Breaking changes detected by snapshot tests.
Add screenshare button back to snapshot test.
Remove reaction button.
2026-04-14 14:06:20 +02:00
Timo K
400259207e Also use CallFooter for lobby 2026-04-14 13:25:33 +02:00
Valere Fedronic
c79251682d Merge pull request #3872 from element-hq/floating-tile-inset
Move local video tile in one-on-one layout to be flush with the edge
2026-04-14 09:44:14 +02:00
Timo K
be74ebbb6c convert to from so that multiple layout switcher dont collide in
storybook.
2026-04-13 19:04:44 +02:00
Timo K
02fa29c7b2 lint 2026-04-13 18:40:56 +02:00
Timo K
7e1ee2eece Merge branch 'toger5/move-settings-out-of-bottom-bar' into toger5/bottom-bar-storybook 2026-04-13 18:40:14 +02:00
Timo K
118d11ceec Merge branch 'livekit' into toger5/move-settings-out-of-bottom-bar 2026-04-13 18:36:03 +02:00
Robin
438b6b8672 Move local video tile in one-on-one layout to be flush with the edge
Per design updates.
2026-04-13 18:30:42 +02:00
Robin
364fdb5e54 Get rid of the 'open in app' mobile prompt
We've determined that calls are working fine in mobile browsers these days and we no longer need it.
2026-04-13 18:05:00 +02:00
Timo K
5f0afd3edc Add tests to make sure we always have one settings button. 2026-04-13 15:49:28 +02:00
Valere Fedronic
23eadfcc9f Merge pull request #3869 from element-hq/footer-space
Make the footer move floating tiles out of the way when shown
2026-04-13 15:46:20 +02:00
Robin
3b0dce33a2 Merge pull request #3863 from element-hq/renovate/major-compound
Update Compound (major)
2026-04-13 15:30:29 +02:00
Robin
9df60310e4 Update test snapshots 2026-04-13 15:13:19 +02:00
Robin
4d1fbf0502 Delete unused CSS file
Seems this was mistakenly committed during initial development of the Grid component. src/grid/Grid.module.css is the one that's actually used.
2026-04-13 15:08:40 +02:00
Robin
0025d4f882 Merge pull request #3868 from element-hq/reactions-placement
Fix placement of reaction and raised hand indicators
2026-04-13 15:06:44 +02:00
Robin
5daa110f7e Make the footer move floating tiles out of the way when shown
If you manage to move your floating video tile to the bottom of the screen in a small landscape window, the footer obscures the tile when shown. The designs want us to smoothly move the floating tile out of the way in this case.
2026-04-13 13:13:09 +02:00
Robin
0842b2b472 Fix placement of reaction and raised hand indicators 2026-04-13 13:02:15 +02:00
Robin
01bd1e3af7 Merge pull request #3842 from element-hq/voice-intents
Add intents for group voice calls
2026-04-13 12:34:45 +02:00
Valere
07e3dc1ca4 Update livekit to 2.18.1
Fix mocking in a test
Also add "skipLibCheck": true in tsconfig
2026-04-13 10:02:54 +02:00
Valere
dbeae383eb update livekit again 2026-04-13 09:35:08 +02:00
fkwp
79de8dffff fix test as with the recent version of livekit-client the URL format changed 2026-04-13 09:22:24 +02:00
renovate[bot]
1b67f3d887 Update dependency livekit-client to v2.17.2 2026-04-13 09:22:24 +02:00
renovate[bot]
9ac42b240e Update Compound 2026-04-13 01:27:40 +00:00
Timo K
098a8c0c41 Improve logic for settings button show/hide.
Fix button shown in appbar
2026-04-10 18:59:17 +02:00
Timo K
c59de789e5 Update AppBar.tsx 2026-04-10 18:46:44 +02:00
Timo K
9905afd2ad add appBar logging 2026-04-10 18:45:34 +02:00
Timo K
8956d55d5c dont call useAppBarSecondaryButton two times 2026-04-10 18:38:35 +02:00
Timo K
16f9c107a5 better default InCallFooter story config 2026-04-10 18:28:15 +02:00
Timo K
c02f2bf140 lint 2026-04-10 18:28:15 +02:00
Timo K
251a41e7a5 fix imports 2026-04-10 18:28:15 +02:00
Timo K
8ca1b2c749 update snapshot to include the two settings buttons corner + bottom bar. 2026-04-10 18:28:15 +02:00
Timo K
9e86d517a0 move footer related css to InCallFooter.module.css 2026-04-10 18:28:13 +02:00
Timo K
134fdeef3a Fix logic for hiding the logo.
Was shown on embedded mobile on wide screens.
2026-04-10 18:27:56 +02:00
Timo K
9de5938e79 Add back the settings button to the bottom buttons group for SPA mobile.
This is an edgecase where we need the settings button but have hidden it
before.
2026-04-10 18:27:52 +02:00
Timo K
f75e91fc2b move show footer logic to callViewModel
Also remove header prop. This is accesible via urlParams.
2026-04-10 18:27:24 +02:00
Timo K
6be06de153 fix tests 2026-04-10 18:27:24 +02:00
Timo K
9528c4f837 fix lints 2026-04-10 18:27:24 +02:00
Timo K
5da7dd6413 Move footer to storybook 2026-04-10 18:27:24 +02:00
Valere Fedronic
fae3c4195f Merge pull request #3854 from element-hq/valere/fix_race_connection_fails
Fix a race connection causing the joiner to never publish media to the call
2026-04-10 11:12:55 +02:00
Valere
208184909e fixup: prettier 2026-04-10 10:08:57 +02:00
Valere
aea5815dab Ensure we don't publish to any transport until our own transport is ok 2026-04-10 09:24:34 +02:00
Timo K
2e9c8bd3ce fix playwright tests 2026-04-09 16:47:42 +02:00
Valere
0f14da7fbb devx: Use new action to netlify the playwright report as EW is doing 2026-04-09 16:33:26 +02:00
Valere
40dacd523b review: Move the all advertised/active down to the LocalMember
And let the local member use it properly to send membership event and publish media
2026-04-09 15:22:10 +02:00
Timo K
a74c8025ac fix imports 2026-04-09 13:32:29 +02:00
Timo K
9d20ec00f1 Add test for the media switch button:
- is it available in the bottom bar
 - does pressing it trigger the expected callback
2026-04-09 13:25:23 +02:00
Timo K
4356e8cdb2 Merge branch 'livekit' into toger5/move-settings-out-of-bottom-bar 2026-04-09 10:10:28 +02:00
Timo
44958db514 Merge pull request #3851 from element-hq/toger5/shodow-for-draggable
Add a shadow to draggable items to prohibit invisible PIP outlines.
2026-04-09 15:52:33 +08:00
Robin
330584ac31 Merge pull request #3827 from element-hq/storybook
Set up Storybook again
2026-04-08 17:12:16 +02:00
Timo K
00cfb8bb10 update test snapshots 2026-04-08 16:41:54 +02:00
Timo K
2bca491b0c ignore story files for coverage 2026-04-08 16:38:52 +02:00
Timo K
9764f6f5de Update .eslintrc.cjs 2026-04-08 16:18:51 +02:00
Timo K
69bcdc1bea fixup yarn after merge 2026-04-08 16:18:06 +02:00
Timo K
27b42ca641 Merge branch 'livekit' into storybook 2026-04-08 16:17:37 +02:00
Timo K
cf642aa670 Move settings button out of the button bar.
User overflow button instead: at the top for mobile, bottom left for
web.
2026-04-08 16:05:46 +02:00
Robin
417164cc11 Merge pull request #3855 from element-hq/changelog-labels
Reconfirm that a PR has a changelog label when new commits are pushed
2026-04-08 16:00:07 +02:00
Robin
2a5acc0207 Reconfirm that a PR has a changelog label when new commits are pushed
Without re-running the changelog label workflow on new commits, PRs get stuck waiting for the check after new commits are pushed.
2026-04-08 15:08:54 +02:00
Valere
08006d640a fix: Ensure to publish on the correct transport 2026-04-08 11:10:27 +02:00
Valere
58d377c3f7 test: Add publishing bug reproduction test 2026-04-08 11:06:05 +02:00
Timo K
6c8219a26b Add a shadow to draggable items to prohibit invisible PIP outlines. 2026-04-07 13:02:46 +02:00
Robin
efd1b42da0 Merge pull request #3847 from element-hq/renovate/npm-vite-vulnerability
Update dependency vite to v8 [SECURITY]
2026-04-07 01:45:41 +02:00
Robin
31b35463fc Fix build 2026-04-07 01:31:21 +02:00
renovate[bot]
89c4485f2d Update dependency vite to v8 [SECURITY] 2026-04-06 21:16:47 +00:00
Robin
3c2c189830 Merge pull request #3838 from element-hq/valere/refactor_transport_discovery
Refactor LocalTransport discovery and validation
2026-04-06 13:24:16 +02:00
Robin
2c34e681a1 Fix tests 2026-04-06 12:58:26 +02:00
Robin
48226795de Merge pull request #3846 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-design-tokens to v9
2026-04-06 12:44:47 +02:00
renovate[bot]
3503f8177d Update dependency @vector-im/compound-design-tokens to v9 2026-04-06 01:31:03 +00:00
Valere
9fe35ba822 Add back the patch to avoid reconnect glitch 2026-04-03 19:08:52 +02:00
Robin
a84419c8a4 Merge pull request #3824 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-design-tokens to v8
2026-04-03 19:07:59 +02:00
Valere
a89d231858 review change comment to proper doc 2026-04-03 18:51:14 +02:00
Robin
f0ec4b9add Add intents for group voice calls
This adds two new intents: start_call_voice and join_existing_voice. I need the latter in order to implement Element Web's new incoming call toasts, in which you can turn off your video before joining a group call. The other one, start_call_voice, exists more for completeness than anything; we don't currently want to allow users to start voice calls in group chats in our messenger clients, but maybe Cinny would, for instance.
2026-04-03 18:47:13 +02:00
Valere
6d99450d81 review: Remove un-needed initial value for behavior 2026-04-03 18:46:49 +02:00
Robin
47e14ff574 Merge pull request #3832 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-04-03 18:09:58 +02:00
Robin
55eea7a968 Fix type error 2026-04-03 17:48:42 +02:00
Robin
121160e835 Merge pull request #3837 from element-hq/renovate/npm-lodash-es-vulnerability
Update dependency lodash-es to v4.18.1 [SECURITY]
2026-04-03 14:57:35 +02:00
Valere Fedronic
47cb01ab6f Merge pull request #3839 from element-hq/valere/revert_widget_cap_workaround
revert workaround for widget permission dialog, not needed anymore
2026-04-03 11:17:46 +02:00
Valere
3d286e8ab4 revert workaround for widget permission dialog, not needed anymore 2026-04-03 09:45:59 +02:00
Valere
b1f84a34a8 remove dead commented code 2026-04-02 20:42:09 +02:00
Valere
95eb8943af fixup test: Errors are now wrapped in user readable error
and proper fetchmock for doNetworkOperationWithRetry
2026-04-02 19:05:24 +02:00
Valere
b5be123953 fix uncaught errors in tests 2026-04-02 16:53:51 +02:00
Valere
90bfaecd13 refact: step 2 - break down old membership local transport logic 2026-04-02 16:53:51 +02:00
Valere
4cecdda955 refact: step 1 - extract oldest membership transport into new function 2026-04-02 16:53:51 +02:00
Valere
869bab8253 fixup test since usage of doNetworkOperationWithRetry
doNetworkOperationWithRetry requires to properly mock the get_token endpoint, whereas retryNetworkOperation didn't require it.
2026-04-02 16:53:51 +02:00
Valere
23f846a308 fixup: test prettier 2026-04-02 16:53:51 +02:00
Valere
6dcb470162 Refactor local transport
- use the new domain logic to discover the transport
- then try to authenticate
- Also fix the bug in multi sfu where active$ not updated on delayId change
2026-04-02 14:38:49 +02:00
Valere
c5c154c99b add self contained domain logic to discover transports 2026-04-02 14:37:25 +02:00
Valere
fd08489afb add failing test showing delayId not properly used for delegation 2026-04-02 14:32:26 +02:00
renovate[bot]
67d7ec6703 Update dependency lodash-es to v4.18.1 [SECURITY] 2026-04-02 08:02:14 +00:00
Valere Fedronic
a2005febd5 Merge pull request #3835 from element-hq/redundant-pip
Avoid redundantly showing the local user in the PiP tile
2026-04-02 10:01:32 +02:00
renovate[bot]
5e337a5399 Update dependency @vector-im/compound-design-tokens to v8 2026-04-01 20:36:05 +00:00
renovate[bot]
e0a8e84df4 Update all non-major dependencies 2026-04-01 20:35:33 +00:00
renovate[bot]
99ba23bc96 Update GitHub Actions (#3831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 17:33:25 +02:00
Robin
f7488a0474 Avoid redundantly showing the local user in the PiP tile
If you are the only participant in the call, the expanded spotlight layout would redundantly show your media in both the spotlight and PiP tiles. This is a regression; in versions 0.16.1 and earlier we would avoid showing the same user twice.
2026-04-01 13:16:39 +02:00
Valere Fedronic
bb7b2d1060 Merge pull request #3818 from element-hq/valere/msc_4039
msc4039 support b64 in addition to blob file download
2026-03-31 19:15:13 +02:00
Valere
b5870c9e78 Merge branch 'livekit' into valere/msc_4039 2026-03-31 18:48:13 +02:00
Valere Fedronic
1fce220965 Merge pull request #3828 from element-hq/valere/fix/stop_calling_msc4143_in_widget
Stop calling rtc/transport in widget mode
2026-03-31 18:47:20 +02:00
Valere
cd3c8f001f Merge branch 'livekit' into valere/msc_4039 2026-03-31 17:24:52 +02:00
Valere
f316ece404 Merge branch 'livekit' into valere/fix/stop_calling_msc4143_in_widget 2026-03-31 17:15:08 +02:00
Valere Fedronic
ce1cd9aefb Merge pull request #3830 from element-hq/valere/tmp_fix_download_file_permission
Workaround: EW has not yet merged the download file permission for EC
2026-03-31 17:14:15 +02:00
Valere
d5ec3c342c reuse exisiting helpers to join call 2026-03-31 16:06:56 +02:00
Valere
c9242148f9 add missing dismiss dialog test 2026-03-31 15:24:12 +02:00
Valere
0dabb43e0f Workaround: EW has not yet merged the download file permission for EC 2026-03-31 14:56:25 +02:00
Valere
8cee4df46e fix test: use fetchMock to avoid test interference 2026-03-31 12:03:02 +02:00
Valere
6b7467ce6d Stop calling rtc/transport in widget mode 2026-03-31 11:38:21 +02:00
Robin
c9eab59161 Create a StarRatingInput story 2026-03-30 18:26:38 +02:00
Robin
4cf75c8eeb Create a LayoutToggle story 2026-03-30 18:26:38 +02:00
Robin
2de151d94d Set up Storybook again 2026-03-30 18:26:38 +02:00
Valere
3389fb5a34 fixup linter 2026-03-26 19:39:02 +01:00
Valere
421c49e7ba fixup prettier 2026-03-26 19:10:10 +01:00
Valere
1eabb0f0aa add tests 2026-03-26 18:14:51 +01:00
Valere
8f38ecac2e msc4039 support b64 in addition to blob file download 2026-03-26 15:29:51 +01:00
117 changed files with 16973 additions and 17530 deletions

View File

@@ -15,6 +15,7 @@ module.exports = {
"plugin:matrix-org/typescript",
"prettier",
"plugin:rxjs/recommended",
"plugin:storybook/recommended",
],
parserOptions: {
ecmaVersion: "latest",

View File

@@ -1,11 +1,12 @@
#!/usr/bin/sh
FILE=.links.temp-disabled.yaml
if test -f "$FILE"; then
FILE=.links.cjs
FILE_DIS=.links.temp-disabled.cjs
if test -f "$FILE_DIS"; then
# Only do the post-commit hook if the file was temp-disabled by the pre-commit hook.
# Otherwise linking was actively (`yarn links:disable`) disabled and this hook should noop.
mv .links.temp-disabled.yaml .links.yaml
mv $FILE_DIS $FILE
yarnLog=$(yarn)
echo "[yarn-linker] The post-commit hook has re-enabled .links.yaml."
echo "[yarn-linker] The post-commit hook has re-enabled $FILE"
exit 1
fi

View File

@@ -1,11 +1,12 @@
#!/usr/bin/sh
FILE=".links.yaml"
FILE=.links.cjs
FILE_DIS=.links.temp-disabled.cjs
if test -f "$FILE"; then
mv .links.yaml .links.temp-disabled.yaml
mv $FILE .links.temp-disabled.cjs
# echo "running yarn"
x=$(yarn)
y=$(git add yarn.lock)
echo "[yarn-linker] The pre-commit hook has disabled .links.yaml and MODIFIED the yarn.lock file. Review the staged changes (the hook added yarn.lock, was this desired?) and run \`git commit \` again if they look okay. The post-commit hook will re-enable your links."
x=$(pnpm install)
y=$(git add pnpm-lock.yaml)
echo "[yarn-linker] The pre-commit hook has disabled $FILE and MODIFIED the pnpm-lock.yaml file. Review the staged changes (the hook added pnpm-lock.yaml, was this desired?) and run \`git commit \` again if they look okay. The post-commit hook will re-enable your links."
exit 1
fi

View File

@@ -37,15 +37,15 @@ jobs:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
- name: pnpm cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: "yarn install --immutable"
run: "pnpm install --frozen-lockfile"
- name: Build Element Call
run: yarn run build:"$PACKAGE":"$BUILD_MODE"
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
env:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}

View File

@@ -5,7 +5,7 @@ on:
# This is safe because we do not use actions/checkout or execute untrusted code.
# Using pull_request_target is necessary to allow status writes for PRs from forks.
pull_request_target:
types: [labeled, unlabeled, opened]
types: [labeled, unlabeled, opened, synchronize]
permissions:
pull-requests: read

View File

@@ -12,20 +12,20 @@ jobs:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
- name: pnpm cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: "yarn install --immutable"
run: "pnpm install --frozen-lockfile"
- name: Prettier
run: "yarn run prettier:check"
run: "pnpm run prettier:check"
- name: i18n
run: "yarn run i18n:check"
run: "pnpm run i18n:check"
- name: ESLint
run: "yarn run lint:eslint"
run: "pnpm run lint:eslint"
- name: Type check
run: "yarn run lint:types"
run: "pnpm run lint:types"
- name: Dead code analysis
run: "yarn run lint:knip"
run: "pnpm run lint:knip"

View File

@@ -97,7 +97,7 @@ jobs:
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
- name: Upload
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -297,7 +297,7 @@ jobs:
NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}
- name: Add release notes
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
append_body: true
body: |

View File

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

48
.github/workflows/test-netlify.yaml vendored Normal file
View File

@@ -0,0 +1,48 @@
# Triggers after the playwright tests have finished,
# taking the artifact and uploading it to Netlify for easier viewing
name: Upload End to End Test report to Netlify
on:
# Privilege escalation necessary to publish to Netlify
# 🚨 We must not execute any checked out code here.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["Test"]
types:
- completed
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
permissions: {}
jobs:
report:
if: github.event.workflow_run.conclusion != 'cancelled'
name: Report results
runs-on: ubuntu-24.04
environment: Netlify
permissions:
statuses: write
deployments: write
actions: read
steps:
- name: Download HTML report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: html-report
path: playwright-report
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
with:
path: playwright-report
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
site_id: ${{ vars.NETLIFY_SITE_ID }}
desc: Playwright Report
deployment_env: EndToEndTests
prefix: "e2e-"

View File

@@ -14,17 +14,17 @@ jobs:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
- name: pnpm cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: "yarn install --immutable"
run: "pnpm install --frozen-lockfile"
- name: Vitest
run: "yarn run test:coverage"
run: "pnpm run test:coverage"
- name: Upload to codecov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
@@ -42,12 +42,12 @@ jobs:
run: corepack enable
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: yarn install --immutable
run: pnpm install --frozen-lockfile
- name: Install Playwright Browsers
run: yarn playwright install --with-deps
run: pnpm exec playwright install --with-deps
- name: Run backend components
run: |
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
@@ -56,10 +56,11 @@ jobs:
- name: Run Playwright tests
env:
USE_DOCKER: 1
run: yarn playwright test
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
run: pnpm exec playwright test
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 3
name: html-report
path: playwright-report
if-no-files-found: error
retention-days: 4

View File

@@ -22,11 +22,11 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
cache: "pnpm"
node-version-file: ".node-version"
- name: Install Deps
run: "yarn install --immutable"
run: "pnpm install --frozen-lockfile"
- name: Prune i18n
run: "rm -R locales"
@@ -40,7 +40,7 @@ jobs:
run: "sudo chown runner:docker -R locales"
- name: Prettier
run: yarn prettier:format
run: pnpm prettier:format
- name: Create Pull Request
id: cpr

8
.gitignore vendored
View File

@@ -21,12 +21,20 @@ yarn-error.log
!/.yarn/releases
!/.yarn/sdks
!/.yarn/versions
# old yarn based linking
/.links.yaml
/.links.disabled.yaml
/.links.temp-disabled.yaml
# pnpm based linking
/.links.cjs
/.links.disabled.cjs
/.links.temp-disabled.cjs
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
*storybook.log
storybook-static

58
.pnpmfile.cjs Normal file
View File

@@ -0,0 +1,58 @@
/*
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.
*/
// Created based on https://github.com/element-hq/element-call/blob/60fae70a60e3697eb41210ccf1e400cab37df7c8/.yarn/plugins/linker.cjs
// and the following prompt history:
// - Can you convert this yarn plugin into a pnpm plugin.
// - The goal is to not have modifications to the package.json and lock files so that we do not track links on gh.
// This seems to modify the package.json file.
// What can we do with pnpm to have the link inforamtion in a seperate file
// - why do you cache the loaded links. When does this file get executed?
// Do we need this optimization.
// How do we guarantee, that we aleays use the most recent content from the links file?
//
// Manual transition to cjs. Claude proposed manual yaml parsing.
const fs = require("fs");
const path = require("path");
function loadLinks() {
try {
return require(path.join(__dirname, ".links.cjs"));
} catch (e) {
return null;
}
}
function readPackage(pkg, context) {
const links = loadLinks();
if (!links) return pkg;
const manifest = JSON.parse(
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
);
if (pkg.name !== manifest.name) return pkg;
for (const [name, linkPath] of Object.entries(links)) {
const resolved = `link:${path.resolve(__dirname, linkPath)}`;
if (pkg.dependencies && pkg.dependencies[name]) {
context.log(`Linking ${name} -> ${resolved}`);
pkg.dependencies[name] = resolved;
} else if (pkg.devDependencies && pkg.devDependencies[name]) {
context.log(`Linking ${name} -> ${resolved}`);
pkg.devDependencies[name] = resolved;
}
}
return pkg;
}
module.exports = {
hooks: {
readPackage,
},
};

View File

@@ -1,2 +1,3 @@
pnpm-lock.yaml
node_modules
dist

15
.storybook/main.ts Normal file
View File

@@ -0,0 +1,15 @@
/*
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 type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
};
export default config;

31
.storybook/manager.ts Normal file
View File

@@ -0,0 +1,31 @@
/*
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 { create } from "storybook/theming";
import { addons } from "storybook/manager-api";
addons.setConfig({
theme: create({
base: "light",
colorPrimary: "#1b1d22",
colorSecondary: "#0467dd",
// Typography
fontBase: '"Inter", sans-serif',
fontCode: '"Inconsolata", monospace',
// Text colors
textColor: "#1b1d22",
appBg: "#ffffff",
barBg: "#ffffff",
brandTitle: "Element Call",
brandUrl: "https://element.io/",
brandImage: "/src/icons/Logo.svg",
brandTarget: "_self",
}),
});

56
.storybook/preview.tsx Normal file
View File

@@ -0,0 +1,56 @@
/*
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 type { Preview } from "@storybook/react-vite";
import { TooltipProvider } from "@vector-im/compound-web";
import i18n from "i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import EN from "../locales/en/app.json";
import { initReactI18next } from "react-i18next";
import "../src/index.css";
// Bare-minimum i18n config
i18n
.use(initReactI18next)
.init({
lng: "en",
fallbackLng: "en",
supportedLngs: ["en"],
// We embed the translations, so that it never needs to fetch
resources: {
en: {
translation: EN,
},
},
interpolation: {
escapeValue: false, // React has built-in XSS protections
},
})
.catch((e) => logger.warn("Failed to init i18n for stories", e));
const preview: Preview = {
parameters: {
layout: "centered",
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
},
tags: ["autodocs"],
decorators: [
(Story) => (
<TooltipProvider>
<Story />
</TooltipProvider>
),
],
};
export default preview;

View File

@@ -1,91 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
module.exports = {
name: "linker",
factory: (require) => ({
hooks: {
// Yarn's plugin system is very light on documentation. The best we have
// for this hook is simply the type definition in
// https://github.com/yarnpkg/berry/blob/master/packages/yarnpkg-core/sources/Plugin.ts
registerPackageExtensions: async (config, registerPackageExtension) => {
const { structUtils } = require("@yarnpkg/core");
const { parseSyml } = require("@yarnpkg/parsers");
const path = require("path");
const fs = require("fs");
const process = require("process");
// Create a descriptor that we can use to target our direct dependencies
const projectPath = config.projectCwd
.replace(/\\/g, "/")
.replace("/C:/", "C:/");
const manifestPath = path.join(projectPath, "package.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const selfDescriptor = structUtils.parseDescriptor(
`${manifest.name}@*`,
true,
);
// Load the list of linked packages
const linksPath = path.join(projectPath, ".links.yaml");
let linksFile;
try {
linksFile = fs.readFileSync(linksPath, "utf8");
} catch (e) {
return; // File doesn't exist, there's nothing to link
}
let links;
try {
links = parseSyml(linksFile);
} catch (e) {
console.error(".links.yaml has invalid syntax", e);
process.exit(1);
}
// Resolve paths and turn them into a Yarn package extension
const overrides = Object.fromEntries(
Object.entries(links).map(([name, link]) => [
name,
`portal:${path.resolve(config.projectCwd, link)}`,
]),
);
const overrideIdentHashes = new Set();
for (const name of Object.keys(overrides))
overrideIdentHashes.add(
structUtils.parseDescriptor(`${name}@*`, true).identHash,
);
// Extend our own package's dependencies with these local overrides
registerPackageExtension(selfDescriptor, { dependencies: overrides });
// Filter out the original dependencies from the package spec so Yarn
// actually respects the overrides
const filterDependencies = (original) => {
const pkg = structUtils.copyPackage(original);
pkg.dependencies = new Map(
Array.from(pkg.dependencies.entries()).filter(
([, value]) => !overrideIdentHashes.has(value.identHash),
),
);
return pkg;
};
// Patch Yarn's own normalizePackage method to use the above filter
const originalNormalizePackage = config.normalizePackage;
config.normalizePackage = function (pkg, extensions) {
return originalNormalizePackage.call(
this,
pkg.identHash === selfDescriptor.identHash
? filterDependencies(pkg)
: pkg,
extensions,
);
};
},
},
}),
};

View File

@@ -1,3 +0,0 @@
nodeLinker: node-modules
plugins:
- .yarn/plugins/linker.cjs

View File

@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
pull_policy: always
hostname: auth-server
environment:
@@ -25,7 +25,7 @@ services:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
pull_policy: always
hostname: auth-server-1
environment:
@@ -47,7 +47,7 @@ services:
- ecbackend
livekit:
image: livekit/livekit-server:v1.9.11
image: livekit/livekit-server:v1.10.1
pull_policy: always
hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml
@@ -67,7 +67,7 @@ services:
- ecbackend
livekit-1:
image: livekit/livekit-server:v1.9.11
image: livekit/livekit-server:v1.10.1
pull_policy: always
hostname: livekit-sfu-1
command: --dev --config /etc/livekit.yaml

View File

@@ -1,30 +1,34 @@
# Developing with linked packages
If you want to make changes to a package that Element Call depends on and see those changes applied in real time, you can create a link to a local copy of the package. Yarn has a command for this (`yarn link`), but it's not recommended to use it as it ends up modifying package.json with details specific to your development environment.
If you want to make changes to a package that Element Call depends on and see those changes applied in real time, you can create a link to a local copy of the package. Pnpm has a command for this (`pnpm link`), but it's not recommended to use it as it ends up modifying package.json with details specific to your development environment.
Instead, you can use our little 'linker' plugin. Create a file named `.links.yaml` in the Element Call project directory, listing the names and paths of any dependencies you want to link. For example:
Instead, you can use our little 'linker' plugin. Create a file named `.links.cjs` in the Element Call project directory, listing the names and paths of any dependencies you want to link. For example:
```yaml
matrix-js-sdk: ../path/to/matrix-js-sdk
"@vector-im/compound-web": /home/alice/path/to/compound-web
```cjs
// Packages to link to local checkouts
module.exports = {
"matrix-js-sdk": "../your/path/matrix-js-sdk",
"matrix-widget-api": "../your/path/matrix-widget-api",
};
```
Then run `yarn install`.
Then run `pnpm install`.
## Hooks
Changes in `.links.yaml` will also update `yarn.lock` when `yarn` is executed. The lockfile will then contain the local
Changes in `.links.yaml` will also update `pnpm-lock.yaml` when `pnpm` is executed. The lockfile will then contain the local
version of the package which would not work on others dev setups or the github CI.
One always needs to run:
```bash
mv .links.yaml .links.disabled.yaml
mv .links.cjs .links.disabled.cjs
yarn
```
before committing a change.
To make it more convenient to work with this linking system we added git hooks for your conviniece.
To make it more convenient to work with this linking system we added git hooks.
A `pre-commit` hook will run `mv .links.yaml .links.disabled.yaml`, `yarn` and `git add yarn.lock` if it detects
a `.links.yaml` file and abort the commit.
You will than need to check if the resulting changes are appropriate and commit again.
@@ -35,5 +39,5 @@ before if a `.links.disabled.yaml` is present. It runs `mv .links.disabled.yaml
To activate the hooks automatically configure git with
```bash
git config --local core.hooksPath .githooks/
git config --local core.hooksPath .githooks
```

View File

@@ -46,32 +46,31 @@ possible to support encryption.
These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes:
| Name | Values | Required for widget | Required for SPA | Description |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesnt provide any. |
| `posthogUserId` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
| `displayName` | | No | No | Display name used for auto-registration. |
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
| Name | Values | Required for widget | Required for SPA | Description |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `intent` | `start_call`, `join_existing`, `start_call_voice`, `join_existing_voice`, `start_call_dm`, `join_existing_dm`, `start_call_dm_voice`, or `join_existing_dm_voice`. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesnt provide any. |
| `posthogUserId` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
| `displayName` | | No | No | Display name used for auto-registration. |
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
### Widget-only parameters

View File

@@ -22,12 +22,6 @@
"upload_file": "Nahrát soubor"
},
"analytics_notice": "Účastí v této beta verzi souhlasíte se shromažďováním anonymních údajů, které používáme ke zlepšování produktu. Více informací o tom, které údaje sledujeme, najdete v našich <2>Zásadách ochrany osobních údajů</2> a <6>Zásadách používání souborů cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Pokračovat v prohlížeči",
"open_in_app": "Otevřít v aplikaci",
"text": "Jste připraveni se připojit?",
"title": "Vybrat aplikaci"
},
"call_ended_view": {
"create_account_button": "Vytvořit účet",
"create_account_prompt": "<0>Proč neskončit nastavením hesla, abyste mohli účet použít znovu?</0><1>Budete si moci nechat své jméno a nastavit si avatar pro budoucí hovory </1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Upload fil"
},
"analytics_notice": "Ved at deltage i denne beta giver du samtykke til indsamling af anonyme data, som vi bruger til at forbedre produktet. Du kan finde flere oplysninger om, hvilke data vi sporer, i vores <2>fortrolighedspolitik</2> og vores <6>cookiepolitik</6>.",
"app_selection_modal": {
"continue_in_browser": "Fortsæt i browseren",
"open_in_app": "Åbn i appen",
"text": "Klar til at deltage?",
"title": "Vælg app"
},
"call_ended_view": {
"create_account_button": "Opret konto",
"create_account_prompt": "<0>Hvorfor ikke afslutte med at oprette en adgangskode for at beholde din konto? </0><1>Du kan beholde dit navn og indstille en avatar til brug ved fremtidige opkald </1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Datei hochladen"
},
"analytics_notice": "Mit der Teilnahme an der Beta akzeptierst du die Sammlung von anonymen Daten, die wir zur Verbesserung des Produkts verwenden. Weitere Informationen zu den von uns erhobenen Daten findest du in unserer <2>Datenschutzerklärung</2> und unseren <6>Cookie-Richtlinien</6>.",
"app_selection_modal": {
"continue_in_browser": "Weiter im Browser",
"open_in_app": "In der App öffnen",
"text": "Bereit, beizutreten?",
"title": "App auswählen"
},
"call_ended_view": {
"create_account_button": "Konto erstellen",
"create_account_prompt": "<0>Warum vergibst du nicht abschließend ein Passwort, um dein Konto zu erhalten?</0><1>Du kannst deinen Namen behalten und ein Profilbild für zukünftige Anrufe festlegen.</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Μεταφόρτωση αρχείου"
},
"analytics_notice": "Συμμετέχοντας σε αυτή τη δοκιμαστική έκδοση, συναινείτε στη συλλογή ανώνυμων δεδομένων, τα οποία χρησιμοποιούμε για τη βελτίωση του προϊόντος. Μπορείτε να βρείτε περισσότερες πληροφορίες σχετικά με το ποια δεδομένα καταγράφουμε στην <2>Πολιτική απορρήτου</2> και στην <6>Πολιτική cookies</6>.",
"app_selection_modal": {
"continue_in_browser": "Συνέχεια στο πρόγραμμα περιήγησης",
"open_in_app": "Ανοίξτε στην εφαρμογή",
"text": "Έτοιμοι να συμμετάσχετε?",
"title": "Επιλέξτε εφαρμογή"
},
"call_ended_view": {
"create_account_button": "Δημιουργία λογαριασμού",
"create_account_prompt": "<0>Γιατί να μην ολοκληρώσετε με τη δημιουργία ενός κωδικού πρόσβασης για τη διατήρηση του λογαριασμού σας;</0><1>Θα μπορείτε να διατηρήσετε το όνομά σας και να ορίσετε ένα avatar για χρήση σε μελλοντικές κλήσεις.</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Upload file"
},
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
"app_selection_modal": {
"continue_in_browser": "Continue in browser",
"open_in_app": "Open in the app",
"text": "Ready to join?",
"title": "Select app"
},
"call_ended_view": {
"create_account_button": "Create account",
"create_account_prompt": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",

View File

@@ -15,12 +15,6 @@
"submit": "Enviar"
},
"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>.",
"app_selection_modal": {
"continue_in_browser": "Continuar en el navegador",
"open_in_app": "Abrir en la aplicación",
"text": "¿Listo para unirte?",
"title": "Selecciona aplicación"
},
"call_ended_view": {
"create_account_button": "Crear cuenta",
"create_account_prompt": "<0>¿Por qué no mantienes tu cuenta estableciendo una contraseña?</0><1>Podrás mantener tu nombre y establecer un avatar para usarlo en futuras llamadas</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Laadi fail üles"
},
"analytics_notice": "Nõustudes selle beetaversiooni kasutamisega, sa nõustud ka toote arendamiseks kasutatavate anonüümsete andmete kogumisega. Täpsemat teavet kogutavate andmete kohta leiad meie <2>Privaatsuspoliitikast</2> ja meie <6>Küpsiste kasutamise reeglitest</6>.",
"app_selection_modal": {
"continue_in_browser": "Jätka veebibrauseris",
"open_in_app": "Ava rakenduses",
"text": "Oled valmis liituma?",
"title": "Vali rakendus"
},
"call_ended_view": {
"create_account_button": "Loo konto",
"create_account_prompt": "<0>Kas soovid salasõna seadistada ja sellega oma kasutajakonto alles jätta?</0><1>Nii saad säilitada oma nime ja määrata profiilipildi, mida saad kasutada tulevastes kõnedes</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Lähetä tiedosto"
},
"analytics_notice": "Osallistumalla tähän betaan hyväksyt nimettömien tietojen keräämisen, joita käytämme tuotteen parantamiseen. Löydät lisätietoa siitä, mitä tietoja seuraamme meidän <2> Tietosuojakäytännöstä</2> ja <6>Evästekäytännöstä</6> .",
"app_selection_modal": {
"continue_in_browser": "Jatka selaimessa",
"open_in_app": "Avaa sovelluksessa",
"text": "Oletko valmis liittymään?",
"title": "Valitse sovellus"
},
"call_ended_view": {
"create_account_button": "Luo tili",
"create_account_prompt": "<0>Miksi et viimeistelisi määrittämällä salasanaa tilisi säilyttämiseksi?</0><1>Voit säilyttää nimesi ja asettaa avatarin käytettäväksi tulevissa puheluissa</1>",

View File

@@ -15,12 +15,6 @@
"submit": "Envoyer"
},
"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>.",
"app_selection_modal": {
"continue_in_browser": "Continuer dans le navigateur",
"open_in_app": "Ouvrir dans lapplication",
"text": "Prêt à rejoindre ?",
"title": "Choisissez lapplication"
},
"call_ended_view": {
"create_account_button": "Créer un compte",
"create_account_prompt": "<0>Pourquoi ne pas créer un mot de passe pour conserver votre compte ?</0><1>Vous pourrez garder votre nom et définir un avatar pour vos futurs appels</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Unggah berkas"
},
"analytics_notice": "Dengan bergabung dalam beta ini, Anda mengizinkan kami untuk mengumpulkan data anonim, yang kami gunakan untuk meningkatkan produk ini. Anda dapat mempelajari lebih lanjut tentang data apa yang kami lacak dalam <2>Kebijakan Privasi</2> dan <5>Kebijakan Kuki</5> kami.",
"app_selection_modal": {
"continue_in_browser": "Lanjutkan dalam peramban",
"open_in_app": "Buka dalam aplikasi",
"text": "Siap untuk bergabung?",
"title": "Pilih plikasi"
},
"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>",

View File

@@ -22,12 +22,6 @@
"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>.",
"app_selection_modal": {
"continue_in_browser": "Continua nel browser",
"open_in_app": "Apri nell'app",
"text": "Tutto pronto per entrare?",
"title": "Seleziona app"
},
"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>",

View File

@@ -15,12 +15,6 @@
"submit": "送信"
},
"analytics_notice": "ベータ版への参加と同時に、製品の改善のために匿名データを収集することに同意したことになります。追跡するデータの詳細については、<2>プライバシーポリシー</2>と<6>クッキーポリシー</6>をご確認下さい。",
"app_selection_modal": {
"continue_in_browser": "ブラウザで続行",
"open_in_app": "アプリで開く",
"text": "準備完了?",
"title": "アプリを選択"
},
"call_ended_view": {
"create_account_button": "アカウントを作成",
"create_account_prompt": "<0>パスワードを設定してアカウント設定を保持してみませんか?</0><1>名前とアバターの設定を次の通話に利用する事ができます。</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Augšupielādēt failu"
},
"analytics_notice": "Piedaloties šajā beta versijā, jūs piekrītat anonīmu datu vākšanai, ko mēs izmantojam produkta uzlabošanai. Plašāku informāciju par to, kādus datus mēs izsekojam, varat atrast mūsu <2>konfidencialitātes politikā</2> un mūsu <6>sīkfailu politikā</6>.",
"app_selection_modal": {
"continue_in_browser": "Turpināt pārlūkprogrammā",
"open_in_app": "Atvērt lietotnē",
"text": "Gatavs pievienoties?",
"title": "Izvēlies lietotni"
},
"call_ended_view": {
"create_account_button": "Izveidot kontu",
"create_account_prompt": "<0>Kādēļ nepabeigt ar paroles iestatīšanu, lai paturētu savu kontu?</0><1>Būs iespējams paturēt savu vārdu un iestatīt attēlu izmantošanai turpmākajos zvanos</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Prześlij plik"
},
"analytics_notice": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <5>Polityce ciasteczek</5>.",
"app_selection_modal": {
"continue_in_browser": "Kontynuuj w przeglądarce",
"open_in_app": "Otwórz w aplikacji",
"text": "Gotowy, by dołączyć?",
"title": "Wybierz aplikację"
},
"call_ended_view": {
"create_account_button": "Utwórz konto",
"create_account_prompt": "<0>Może zechcesz ustawić hasło, aby zachować swoje konto?</0><1>Będziesz w stanie utrzymać swoją nazwę i ustawić awatar do wyświetlania podczas połączeń w przyszłości</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Încărcați fișierul"
},
"analytics_notice": "Prin participarea la această versiune beta, sunteți de acord cu colectarea de date anonime, pe care le folosim pentru a îmbunătăți produsul. Puteți găsi mai multe informații despre datele pe care le urmărim în Politica noastră de <2> confidențialitate </2> și Politica noastră <6> privind cookie-urile</6>.",
"app_selection_modal": {
"continue_in_browser": "Continuați în browser",
"open_in_app": "Deschideți în aplicație",
"text": "Sunteți gata să vă alăturați?",
"title": "Selectați o aplicație"
},
"call_ended_view": {
"create_account_button": "Creaţi un cont",
"create_account_prompt": "<0>De ce să nu terminați prin configurarea unei parole pentru a vă păstra contul? </0><1>Veți putea să vă păstrați numele și să setați un avatar pentru a fi utilizat la apelurile viitoare </1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Загрузить файл"
},
"analytics_notice": "Участвуя в этой бета-версии, вы соглашаетесь на сбор анонимных данных, которые мы используем для улучшения продукта. Дополнительную информацию о том, какие данные мы отслеживаем, можно найти в нашей <2> Политике конфиденциальности </2> и Политике <6> использования файлов cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Продолжить в браузере",
"open_in_app": "Открыть в приложении",
"text": "Готовы присоединиться?",
"title": "Выбрать приложение"
},
"call_ended_view": {
"create_account_button": "Создать аккаунт",
"create_account_prompt": "<0>Почему бы не задать пароль, тем самым сохранив аккаунт?</0><1>Так вы можете оставить своё имя и задать аватар для будущих звонков.</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Nahrať súbor"
},
"analytics_notice": "Účasťou v tejto beta verzii súhlasíte so zhromažďovaním anonymných údajov, ktoré použijeme na zlepšenie produktu. Viac informácií o tom, ktoré údaje sledujeme, nájdete v našich <2>Zásadách ochrany osobných údajov</2> a <6>Zásadách používania súborov cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Pokračovať v prehliadači",
"open_in_app": "Otvoriť v aplikácii",
"text": "Ste pripravení sa pridať?",
"title": "Vybrať aplikáciu"
},
"call_ended_view": {
"create_account_button": "Vytvoriť účet",
"create_account_prompt": "<0>Prečo neskončiť nastavením hesla, aby ste si zachovali svoj účet? </0><1>Budete si môcť ponechať svoje meno a nastaviť obrázok, ktorý sa bude používať pri budúcich hovoroch</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Ladda upp fil"
},
"analytics_notice": "Genom att delta i denna beta samtycker du till insamling av anonyma uppgifter, som vi använder för att förbättra produkten. Du kan hitta mer information om vilka data vi spårar i vår <2>integritetspolicy</2> och vår <5>cookiepolicy</5>.",
"app_selection_modal": {
"continue_in_browser": "Fortsätt i webbläsaren",
"open_in_app": "Öppna i appen",
"text": "Är du redo att gå med?",
"title": "Välj app"
},
"call_ended_view": {
"create_account_button": "Skapa konto",
"create_account_prompt": "<0>Varför inte avsluta genom att skapa ett lösenord för att behålla ditt konto?</0><1>Du kommer att kunna behålla ditt namn och ställa in en avatar för användning vid framtida samtal</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Dosya Yükle"
},
"analytics_notice": "Bu beta sürümüne katılarak, ürünü geliştirmek için kullandığımız anonim verilerin toplanmasına izin vermiş olursunuz. Hangi verileri izlediğimiz hakkında daha fazla bilgiyi <2>Gizlilik Politikamızda</2> ve <6>Çerez Politikamızda bulabilirsiniz</6>..",
"app_selection_modal": {
"continue_in_browser": "Tarayıcıda devam et",
"open_in_app": "Uygulamada aç",
"text": "Katılmaya hazır mısınız?",
"title": "Uygulama seçin"
},
"call_ended_view": {
"create_account_button": "Hesap aç",
"create_account_prompt": "<0>Hesabınızı tutmak için niye bir parola açmıyorsunuz?</0><1>Böylece ileriki aramalarda adınızı ve avatarınızı kullanabileceksiniz</1>",

View File

@@ -22,12 +22,6 @@
"upload_file": "Завантажити файл"
},
"analytics_notice": "Користуючись дочасним доступом, ви даєте згоду на збір анонімних даних, які ми використовуємо для вдосконалення продукту. Ви можете знайти більше інформації про те, які дані ми відстежуємо в нашій <2>Політиці Приватності</2> і нашій <6>Політиці про файли cookie</6>.",
"app_selection_modal": {
"continue_in_browser": "Продовжити у браузері",
"open_in_app": "Відкрити у застосунку",
"text": "Готові приєднатися?",
"title": "Вибрати застосунок"
},
"call_ended_view": {
"create_account_button": "Створити обліковий запис",
"create_account_prompt": "<0>Чому б не завершити, налаштувавши пароль для збереження свого облікового запису?</0><1>Ви зможете зберегти своє ім'я та встановити аватарку для подальшого користування під час майбутніх викликів</1>",

View File

@@ -15,12 +15,6 @@
"submit": "提交"
},
"analytics_notice": "参与测试即表示您同意我们收集匿名数据,用于改进产品。您可以在我们的<2>隐私政策</2>和<5>Cookie政策</5>中找到有关我们跟踪哪些数据以及更多信息。",
"app_selection_modal": {
"continue_in_browser": "在浏览器中继续",
"open_in_app": "在应用中打开",
"text": "准备好加入了吗?",
"title": "选择应用程序"
},
"call_ended_view": {
"create_account_button": "创建账户",
"create_account_prompt": "<0>为何不设置密码来保留你的账户?</0><1>保留昵称并设置头像,以便在未来的通话中使用。</1>",

View File

@@ -15,12 +15,6 @@
"submit": "遞交"
},
"analytics_notice": "參與此測試版即表示您同意蒐集匿名資料,我們使用這些資料來改進產品。您可以在我們的<2>隱私政策</2>與我們的 <5>Cookie 政策</5> 中找到關於我們追蹤哪些資料的更多資訊。",
"app_selection_modal": {
"continue_in_browser": "在瀏覽器中繼續",
"open_in_app": "在應用程式中開啟",
"text": "準備好加入了?",
"title": "選取應用程式"
},
"call_ended_view": {
"create_account_button": "建立帳號",
"create_account_prompt": "<0>何不設定密碼以保留此帳號?</0><1>您可以保留暱稱並設定頭像,以便未來通話時使用</1>",

View File

@@ -3,23 +3,23 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "yarn dev:full",
"dev": "pnpm dev:full",
"dev:full": "vite",
"dev:embedded": "vite --config vite-embedded.config.js",
"build": "yarn build:full",
"build": "pnpm build:full",
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
"build:full:production": "yarn build:full",
"build:full:development": "yarn build:full --mode development",
"build:embedded": "yarn build:full --config vite-embedded.config.js",
"build:embedded:production": "yarn build:embedded",
"build:embedded:development": "yarn build:embedded --mode development",
"build:sdk:development": "yarn build:sdk --mode development",
"build:sdk": "yarn build:full --config vite-sdk.config.js",
"build:sdk:production": "yarn build:sdk",
"build:full:production": "pnpm build:full",
"build:full:development": "pnpm build:full --mode development",
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
"build:embedded:production": "pnpm build:embedded",
"build:embedded:development": "pnpm build:embedded --mode development",
"build:sdk:development": "pnpm build:sdk --mode development",
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
"build:sdk:production": "pnpm build:sdk",
"serve": "vite preview",
"prettier:check": "prettier -c .",
"prettier:format": "prettier -w .",
"lint": "yarn lint:types && yarn lint:eslint && yarn lint:knip",
"lint": "pnpm lint:types && pnpm lint:eslint && pnpm lint:knip",
"lint:eslint": "eslint --max-warnings 0 src playwright",
"lint:eslint-fix": "eslint --max-warnings 0 src playwright --fix",
"lint:knip": "knip",
@@ -31,9 +31,11 @@
"backend": "docker-compose -f dev-backend-docker-compose.yml up",
"backend-playwright": "docker-compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up",
"test:playwright": "playwright test",
"test:playwright:open": "yarn test:playwright --ui",
"links:enable": "mv .links.disabled.yaml .links.yaml & touch .links.yaml",
"links:disable": "mv .links.yaml .links.disabled.yaml"
"test:playwright:open": "pnpm test:playwright --ui",
"links:enable": "mv .links.disabled.cjs .links.cjs & touch .links.cjs",
"links:disable": "mv .links.cjs .links.disabled.cjs",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@babel/core": "^7.16.5",
@@ -57,6 +59,8 @@
"@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",
"@stylistic/eslint-plugin": "^3.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.6.3",
@@ -76,8 +80,8 @@
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-web": "^8.0.0",
"@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^9.0.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
@@ -86,7 +90,7 @@
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "^0.8.2",
"eslint-plugin-deprecate": "^0.9.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
@@ -94,6 +98,7 @@
"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-unicorn": "^56.0.0",
"fetch-mock": "11.1.5",
"global-jsdom": "^26.0.0",
@@ -102,10 +107,10 @@
"i18next-parser": "^9.1.0",
"jsdom": "^26.0.0",
"knip": "^5.86.0",
"livekit-client": "^2.13.0",
"livekit-client": "^2.18.1",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^1.16.1",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
@@ -118,32 +123,38 @@
"qrcode": "^1.5.4",
"react": "19",
"react-dom": "19",
"react-i18next": "^16.0.0 <16.6.0",
"react-i18next": "^16.0.0 <16.7.0",
"react-router-dom": "^7.0.0",
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sass": "^1.42.1",
"storybook": "^10.3.3",
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"uuid": "^13.0.0",
"vaul": "^1.0.0",
"vite": "^7.3.0",
"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-stdlib-browser": "^0.2.1",
"vite-plugin-svgr": "^4.0.0",
"vite-plugin-wasm": "^3.6.0",
"vitest": "^4.0.18",
"vitest-axe": "^1.0.0-pre.3"
},
"resolutions": {
"@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"
"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.27.7"
}
},
"packageManager": "yarn@4.7.0"
"packageManager": "pnpm@9.0.0"
}

View File

@@ -75,7 +75,9 @@ test("Should automatically retry non fatal JWT errors", async ({
await expect(page.getByTestId("video").first()).toBeVisible();
});
test("Should show error screen if call creation is restricted", async ({
// We skip this test for now as it appears the livekit does not let us
// detect and handle NotAllowed errors anymore. https://github.com/livekit/client-sdk-js/issues/1883
test.skip("Should show error screen if call creation is restricted", async ({
page,
browserName,
}) => {
@@ -102,8 +104,10 @@ test("Should show error screen if call creation is restricted", async ({
// Then if the socket connection fails, livekit will try to validate the token!
// Livekit will not auto_create anymore and will return a 404 error.
// Note the regex is required as livekit-client is nowasays trying two
// differnt APIs
await page.route(
"**/badurltotricktest/livekit/sfu/rtc/validate?**",
/.*\/badurltotricktest\/livekit\/sfu\/rtc(\/v1)?\/validate?.*/,
async (route) =>
await route.fulfill({
status: 404,

View File

@@ -45,9 +45,6 @@ async function createCallAndInvite(
await creatorPage.getByTestId("home_go").click();
await expect(creatorPage.locator("video")).toBeVisible();
await creatorPage
.getByRole("button", { name: "Continue in browser" })
.click();
// join
await creatorPage.getByTestId("lobby_joinCall").click();

View File

@@ -23,8 +23,6 @@ test("@mobile Start a new call then leave and show the feedback screen", async (
// await page.pause();
await expect(page.locator("video")).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Continue in browser" }).click();
// Join the call
await page.getByTestId("lobby_joinCall").click();
@@ -67,10 +65,6 @@ mobileTest(
const guestPage = await guestInviteeContext.newPage();
await guestPage.goto(inviteLink + "&controlledAudioDevices=true");
await guestPage
.getByRole("button", { name: "Continue in browser" })
.click();
await guestPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible();
await guestPage.getByTestId("joincall_joincall").click();
@@ -104,10 +98,12 @@ mobileTest(
// Open settings to select earpiece
await guestPage.getByRole("button", { name: "Settings" }).click();
await guestPage.getByText("Handset", { exact: true }).click();
await guestPage
.getByRole("radio", { name: "Handset", exact: true })
.click();
// dismiss settings
await guestPage.locator("#root").getByLabel("Settings").press("Escape");
await guestPage.locator("#root").press("Escape");
await guestPage.pause();
await expect(

View File

@@ -0,0 +1,92 @@
/*
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 { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user";
import { HOST1, HOST2, TestHelpers } from "./test-helpers";
widgetTest(
"Bug new joiner was not publishing on correct SFU",
async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
"This is a bug in the old widget, not a browser problem.",
);
test.slow();
// 2 users in federation
const florian = await addUser("floriant", HOST1);
const timo = await addUser("timo", HOST2);
// Florian creates a room and invites Timo to it
const roomName = "Call Room";
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
// Timo joins the room
await TestHelpers.acceptRoomInvite(roomName, timo.page);
// Ensure we are in legacy mode (should be the default)
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
"legacy",
);
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
timo.page,
"legacy",
);
// Let timo create a call
await TestHelpers.startCallInCurrentRoom(timo.page, false);
await TestHelpers.joinCallFromLobby(timo.page);
// We want to simulate that the oldest membership authentication is way slower than
// the preffered auth.
// In this setup, timo advertised$ transport will be it's own, and the active will be the one from florian
await florian.page.route(
"**/matrix-rtc.othersite.m.localhost/livekit/jwt/**",
async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000)); // 5 second delay
await route.continue();
},
);
// Florian joins the call
await expect(florian.page.getByTestId("join-call-button")).toBeVisible();
await florian.page.getByTestId("join-call-button").click();
await TestHelpers.joinCallFromLobby(florian.page);
await florian.page.waitForTimeout(3000);
await timo.page.waitForTimeout(3000);
// We should see 2 video tiles everywhere now
for (const user of [timo, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// There should be 2 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(2);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
}
},
);

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
import { TestHelpers } from "./test-helpers.ts";
// Skip test, including Fixtures
widgetTest.skip(
@@ -20,19 +21,7 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
const { brooks, whistler } = asWidget;
await expect(
brooks.page.getByRole("button", { name: "Video call" }),
).toBeVisible();
await brooks.page.getByRole("button", { name: "Video call" }).click();
await expect(
brooks.page.getByRole("menuitem", { name: "Legacy Call" }),
).toBeVisible();
await expect(
brooks.page.getByRole("menuitem", { name: "Element Call" }),
).toBeVisible();
await brooks.page.getByRole("menuitem", { name: "Element Call" }).click();
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
await expect(
brooks.page
@@ -56,11 +45,7 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
).toBeVisible();
// Join from the other side
await expect(whistler.page.getByText("Video call started")).toBeVisible();
await expect(
whistler.page.getByRole("button", { name: "Join" }),
).toBeVisible();
await whistler.page.getByRole("button", { name: "Join" }).click();
await TestHelpers.joinCallInCurrentRoom(whistler.page);
// Currently disabled due to recent Element Web is bypassing Lobby
// await expect(

View File

@@ -235,9 +235,11 @@ export class TestHelpers {
): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click();
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
await page.getByRole("button", { name: "Close lobby" }).click();
}
/**
* Goes to the settings to set the RTC mode.
* then closes the settings modal.

13760
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,5 +5,5 @@ set -ex
export VITE_APP_VERSION=$(git describe --tags --abbrev=0)
corepack enable
yarn install
yarn run build
pnpm install
pnpm run build

View File

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

12
src/@types/mdx.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/*
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 { JSX as ReactJSX } from "react";
declare module "mdx/types.js" {
export import JSX = ReactJSX;
}

View File

@@ -19,6 +19,7 @@ import {
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import { Header, LeftNav, RightNav } from "./Header";
import { platform } from "./Platform";
@@ -49,7 +50,9 @@ export const AppBar: FC<Props> = ({ children }) => {
const [title, setTitle] = useState<string>("");
const [hidden, setHidden] = useState<boolean>(false);
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
const [secondaryButton, setSecondaryButton] = useState<ReactNode | null>(
null,
);
const context = useMemo(
() => ({ setTitle, setSecondaryButton, setHidden }),
[setTitle, setHidden, setSecondaryButton],
@@ -68,8 +71,8 @@ export const AppBar: FC<Props> = ({ children }) => {
>
<LeftNav>
<Tooltip label={t("common.back")}>
<IconButton onClick={onBackClick}>
<CollapseIcon />
<IconButton size="24px" onClick={onBackClick}>
<CollapseIcon aria-hidden />
</IconButton>
</Tooltip>
</LeftNav>
@@ -114,6 +117,10 @@ export function useAppBarHidden(hidden: boolean): void {
if (setHidden !== undefined) {
setHidden(hidden);
return (): void => setHidden(false);
} else if (platform !== "desktop") {
logger.warn(
"[AppBar] useAppBarHidden called without AppBarContext provider, this will have no effect",
);
}
}, [setHidden, hidden]);
}
@@ -129,6 +136,10 @@ export function useAppBarSecondaryButton(button: ReactNode): void {
if (setSecondaryButton !== undefined) {
setSecondaryButton(button);
return (): void => setSecondaryButton("");
} else if (platform !== "desktop") {
logger.warn(
"[AppBar] useAppBarSecondaryButton called without AppBarContext provider, this will have no effect",
);
}
}, [button, setSecondaryButton]);
}

View File

@@ -12,7 +12,7 @@ import { type FC, type PropsWithChildren } from "react";
import { type WidgetApi } from "matrix-widget-api";
import { ClientContextProvider } from "./ClientContext";
import { Avatar } from "./Avatar";
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
import { widget } from "./widget";
@@ -178,3 +178,36 @@ test("should attempt to use widget API if running as a widget", async () => {
expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl);
});
test("Supports download files as base64", async () => {
const expectedMXCUrl = "mxc://example.org/alice-avatar";
const expectedBase64 =
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" +
"+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" +
"ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" +
"UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" +
"Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" +
"oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" +
"A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" +
"nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" +
"ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" +
"Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" +
"2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" +
"M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" +
"kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" +
"uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" +
"XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" +
"YII=";
const mockWidgetAPI = {
downloadFile: vi.fn().mockImplementation(async (contentUri) => {
if (contentUri !== expectedMXCUrl) {
return Promise.reject(new Error("Unexpected content URI"));
}
return { file: expectedBase64 };
}),
} as unknown as WidgetApi;
const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl);
expect(blob).toBeInstanceOf(Blob);
});

View File

@@ -173,7 +173,8 @@ async function getAvatarFromServer(
return blob;
}
async function getAvatarFromWidgetAPI(
// export for testing
export async function getAvatarFromWidgetAPI(
api: WidgetApi,
src: string,
): Promise<Blob> {
@@ -181,9 +182,14 @@ async function getAvatarFromWidgetAPI(
const file = response.file;
// element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that
if (!(file instanceof Blob)) {
throw new Error("Downloaded file is not a Blob");
if (file instanceof Blob) {
return file;
} else if (typeof file === "string") {
// it is a base64 string
const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0));
return new Blob([bytes]);
}
return file;
throw new Error(
"Downloaded file format is not supported: " + typeof file + "",
);
}

View File

@@ -25,7 +25,7 @@ export abstract class TranslatedError extends Error {
messageKey: ParseKeys<DefaultNamespace, TOptions>,
translationFn: TFunction<DefaultNamespace>,
) {
super(translationFn(messageKey, { lng: "en" } as TOptions));
super(translationFn(messageKey, { lng: "en" }));
this.translatedMessage = translationFn(messageKey);
}
}

View File

@@ -218,7 +218,6 @@ describe("UrlParams", () => {
describe("intent", () => {
const noIntentDefaults = {
confineToRoom: false,
appPrompt: true,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
@@ -232,7 +231,6 @@ describe("UrlParams", () => {
};
const startNewCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
@@ -246,7 +244,6 @@ describe("UrlParams", () => {
});
const joinExistingCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
@@ -271,7 +268,11 @@ describe("UrlParams", () => {
computeUrlParams(
"?intent=start_call&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false });
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: "video",
});
});
it("accepts start_call_dm mobile", () => {
@@ -308,6 +309,29 @@ describe("UrlParams", () => {
),
).toMatchObject(joinExistingCallDefaults("desktop"));
});
it("accepts start_call_voice", () => {
expect(
computeUrlParams(
"?intent=start_call_voice&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: "audio",
});
});
it("accepts join_existing_voice", () => {
expect(
computeUrlParams(
"?intent=join_existing_voice&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({
...joinExistingCallDefaults("desktop"),
callIntent: "audio",
});
});
});
describe("skipLobby", () => {

View File

@@ -29,6 +29,8 @@ interface RoomIdentifier {
export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallVoice = "start_call_voice",
JoinExistingCallVoice = "join_existing_voice",
StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm",
@@ -157,13 +159,6 @@ export interface UrlConfiguration {
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* The app prompt must also be enabled in the config for this to take effect.
*/
appPrompt: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
@@ -255,26 +250,6 @@ export interface UrlConfiguration {
// behavior to the needs of specific consumers.
export interface UrlParams extends UrlProperties, UrlConfiguration {}
// This is here as a stopgap, but what would be far nicer is a function that
// takes a UrlParams and returns a query string. That would enable us to
// consolidate all the data about URL parameters and their meanings to this one
// file.
export function editFragmentQuery(
hash: string,
edit: (params: URLSearchParams) => URLSearchParams,
): string {
const fragmentQueryStart = hash.indexOf("?");
const fragmentParams = edit(
new URLSearchParams(
fragmentQueryStart === -1 ? "" : hash.substring(fragmentQueryStart),
),
);
return `${hash.substring(
0,
fragmentQueryStart,
)}?${fragmentParams.toString()}`;
}
class ParamParser {
private fragmentParams: URLSearchParams;
private queryParams: URLSearchParams;
@@ -390,7 +365,6 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
// Here we only use constants and `platform` to determine the intent preset.
let intentPreset: UrlConfiguration = {
confineToRoom: true,
appPrompt: false,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
@@ -414,6 +388,15 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.StartNewCallVoice:
intentPreset.skipLobby = false;
intentPreset.callIntent = "audio";
break;
case UserIntent.JoinExistingCallVoice:
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = false;
intentPreset.callIntent = "audio";
break;
case UserIntent.StartNewCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
@@ -437,7 +420,6 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
default:
intentPreset = {
confineToRoom: false,
appPrompt: true,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
@@ -482,7 +464,6 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
const configuration: Partial<UrlConfiguration> = {
confineToRoom: parser.getFlag("confineToRoom"),
appPrompt: parser.getFlag("appPrompt"),
preload: isWidget ? parser.getFlag("preload") : undefined,
// Check hideHeader for backwards compatibility. If header is set, hideHeader
// is ignored.

View File

@@ -17,7 +17,7 @@ exports[`AppBar > renders 1`] = `
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
>
<div
@@ -25,6 +25,7 @@ exports[`AppBar > renders 1`] = `
style="--cpd-icon-button-size: 100%;"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"

View File

@@ -8,7 +8,11 @@ Please see LICENSE in the repository root for full details.
import { type ComponentPropsWithoutRef, type FC } from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { Button as CpdButton, Tooltip } from "@vector-im/compound-web";
import {
Button as CpdButton,
IconButton,
Tooltip,
} from "@vector-im/compound-web";
import {
MicOnSolidIcon,
MicOffSolidIcon,
@@ -16,10 +20,15 @@ import {
VideoCallOffSolidIcon,
EndCallIcon,
ShareScreenSolidIcon,
SettingsSolidIcon,
OverflowHorizontalIcon,
OverflowVerticalIcon,
VolumeOnSolidIcon,
VolumeOffSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Button.module.css";
import callFooterStyles from "../components/CallFooter.module.css";
import { platform } from "../Platform";
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
@@ -124,18 +133,87 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
);
};
interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "sm" | "lg";
loudspeakerModeEnabled: boolean;
}
export const LoudspeakerButton: FC<LoudspeakerButtonProps> = ({
loudspeakerModeEnabled,
...props
}) => {
const { t } = useTranslation();
// if the target is the earpice, we are currently in loudspeaker mode.
const label = loudspeakerModeEnabled
? t("settings.devices.loudspeaker")
: t("settings.devices.handset");
return (
<Tooltip label={label}>
<CpdButton
iconOnly
Icon={loudspeakerModeEnabled ? VolumeOnSolidIcon : VolumeOffSolidIcon}
{...props}
kind={loudspeakerModeEnabled ? "primary" : "secondary"}
aria-checked={loudspeakerModeEnabled}
/>
</Tooltip>
);
};
function classNamesForScreenWidth(
className?: string,
forScreenWidth?: "wide" | "narrow",
): string {
return classNames(className, {
[callFooterStyles.settingsOnlyShowWide]: forScreenWidth === "wide",
[callFooterStyles.settingsOnlyShowNarrow]: forScreenWidth === "narrow",
});
}
interface SettingsIconButtonProps extends ComponentPropsWithoutRef<"button"> {
/** If this buttons should be setup to be used in the app bar */
showForScreenWidth?: "wide" | "narrow";
kind?: "secondary" | "primary";
}
export const SettingsIconButton: FC<SettingsIconButtonProps> = ({
showForScreenWidth,
className,
...props
}) => {
const { t } = useTranslation();
const Icon =
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
return (
<Tooltip label={t("common.settings")}>
<IconButton
className={classNamesForScreenWidth(className, showForScreenWidth)}
{...props}
>
<Icon aria-hidden />
</IconButton>
</Tooltip>
);
};
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "sm" | "lg";
/** If this buttons should be setup to be used in the app bar */
showForScreenWidth?: "wide" | "narrow";
}
export const SettingsButton: FC<SettingsButtonProps> = (props) => {
export const SettingsButton: FC<SettingsButtonProps> = ({
showForScreenWidth,
className,
...props
}) => {
const { t } = useTranslation();
return (
<Tooltip label={t("common.settings")}>
<CpdButton
className={classNamesForScreenWidth(className, showForScreenWidth)}
iconOnly
Icon={SettingsSolidIcon}
kind="secondary"
Icon={
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon
}
kind={"secondary"}
{...props}
/>
</Tooltip>

View File

@@ -37,7 +37,13 @@ function TestComponent({
vm={vm}
rtcSession={rtcSession.asMockedSession()}
>
<ReactionToggleButton vm={vm} identifier={localIdent} />
<ReactionToggleButton
reactionData={{
reactions$: vm.reactions$,
handsRaised$: vm.handsRaised$,
}}
identifier={localIdent}
/>
</ReactionsSenderProvider>
</TooltipProvider>
);

View File

@@ -28,13 +28,14 @@ import classNames from "classnames";
import { useReactionsSender } from "../reactions/useReactionsSender";
import styles from "./ReactionToggleButton.module.css";
import {
type RaisedHandInfo,
type ReactionOption,
ReactionSet,
ReactionsRowSize,
} from "../reactions";
import { Modal } from "../Modal";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
import { type Behavior } from "../state/Behavior";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
@@ -163,15 +164,22 @@ export function ReactionPopupMenu({
);
}
export interface ReactionData {
handsRaised$: Behavior<Record<string, RaisedHandInfo>>;
/** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/
reactions$: Behavior<Record<string, ReactionOption>>;
}
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
reactionData: ReactionData;
identifier: string;
vm: CallViewModel;
size?: "sm" | "lg";
/** List of participants raising their hand */
}
export function ReactionToggleButton({
identifier,
vm,
reactionData: { handsRaised$, reactions$ },
...props
}: ReactionToggleButtonProps): ReactNode {
const { t } = useTranslation();
@@ -180,8 +188,8 @@ export function ReactionToggleButton({
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>();
const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
const canReact = !useBehavior(vm.reactions$)[identifier];
const isHandRaised = !!useBehavior(handsRaised$)[identifier];
const canReact = !useBehavior(reactions$)[identifier];
useEffect(() => {
// Clear whenever the reactions menu state changes.

View File

@@ -0,0 +1,37 @@
{/**
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.
**/}
{/**
This is a custom doc page overwriting the default autodocs tag.
This can be done by using the same filename as the component
With the help of Primary, Controls,Stories the overhead is minimal
**/}
import {
Meta,
Primary,
Controls,
Stories,
Title,
Subtitle,
} from "@storybook/addon-docs/blocks";
import * as CallFooterStories from "./CallFooter.stories";
<Meta of={CallFooterStories} />
<Title> Call Footer </Title>
The footer compoentn contains all main interactions needed for a call.
<Subtitle> Mobile layouts </Subtitle>
This component is reactive. To properly check the mobile layout, you will need to click on the stories in the left sidebar to see the
component on a mobile screen.
The story summary here does not render the mobile layouts correctly.
<Primary />
<Controls of={CallFooterStories.Primary} />
<Stories />

View File

@@ -0,0 +1,158 @@
/*
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.
*/
.footer {
position: sticky;
inset-block-end: 0;
z-index: var(--call-view-header-footer-layer);
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-areas: ". buttons layout";
align-items: center;
gap: var(--cpd-space-3x);
padding: var(--cpd-space-10x) var(--cpd-space-6x);
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.footer.hidden {
display: none;
}
.footer.overlay {
/* Note that the footer is still position: sticky in this case so that certain
tiles can move up out of the way of the footer when visible. */
opacity: 1;
transition: opacity 0.15s;
}
.footer.overlay.hidden {
display: grid;
opacity: 0;
pointer-events: none;
/* Switch to position: absolute so the footer takes up no space in the layout
when hidden. */
position: absolute;
inset-block-end: 0;
inset-inline: 0;
}
.footer.overlay:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.settingsLogoContainer {
display: flex;
align-items: center;
gap: var(--cpd-space-4x);
flex-direction: row;
flex-wrap: nowrap;
}
.logo {
justify-self: start;
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
padding-inline-start: var(--cpd-space-1x);
}
.buttons {
grid-area: buttons;
justify-self: center;
display: flex;
gap: var(--cpd-space-3x);
}
.layout {
grid-area: layout;
justify-self: end;
}
/*First hide the logo*/
@media (max-width: 750px) {
.logo {
display: none;
}
}
.settingsOnlyShowNarrow {
display: none;
}
.settingsOnlyShowWide {
display: inherit;
}
/*
With the logo hidden >500px is enough space to show overflow, buttons, layout.
Once we exceed 500 we hide everything except the buttons.
*/
@media (max-width: 500px) {
.footer {
grid-template-areas: "buttons buttons buttons";
}
.settingsOnlyShowNarrow {
display: inherit;
}
.settingsOnlyShowWide {
display: none;
}
.settingsLogoContainer {
display: none;
}
.layout {
display: none !important;
}
}
@media (max-height: 800px) {
.footer {
padding-block: var(--cpd-space-8x);
}
}
@media (max-height: 400px) {
.footer {
padding-block: var(--cpd-space-4x);
}
}
@media (max-width: 370px) {
.shareScreen {
display: none;
}
/* PIP custom css */
@media (max-height: 400px) {
.shareScreen {
display: flex;
}
.footer {
padding-block-start: var(--cpd-space-3x);
padding-block-end: var(--cpd-space-2x);
}
}
}
@media (max-width: 320px) {
.raiseHand {
display: none;
}
}
@media (min-width: 800px) {
.buttons {
gap: var(--cpd-space-4x);
}
}

View File

@@ -0,0 +1,242 @@
/*
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 { fn } from "storybook/test";
import { BehaviorSubject } from "rxjs";
import { 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 inCallViewStyles from "../room/InCallView.module.css";
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>;
const reactionIdentifier = "@user:example.com:DEVICE";
const reactionData = {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
};
const fnArgType = {
control: { type: "select" as const },
options: ["MockedCallback", "undefined"],
mapping: { MockedCallback: fn(), undefined: undefined },
};
export const Default: Story = {
args: {
hideLogo: true,
layoutMode: "grid",
audioEnabled: true,
videoEnabled: true,
setLayoutMode: fn(),
openSettings: fn(),
toggleAudio: fn(),
toggleVideo: fn(),
toggleScreenSharing: fn(),
hangup: fn(),
},
parameters: {
layout: "fullscreen",
},
argTypes: {
layoutMode: { control: "radio", options: ["grid", "spotlight"] },
audioOutputSwitcher: {
control: "select",
options: ["NoOutputCallback", "speaker", "earpiece"],
table: { defaultValue: { summary: "NoOutputCallback" } },
mapping: {
NoOutputCallback: undefined,
// This is inverersed (speaker<->earpice) because the switcher object stores the target output, not the current one.
speaker: { targetOutput: "earpiece", switch: fn() },
earpiece: { targetOutput: "speaker", switch: fn() },
},
},
toggleScreenSharing: fnArgType,
setLayoutMode: fnArgType,
openSettings: fnArgType,
toggleAudio: fnArgType,
toggleVideo: fnArgType,
hangup: fnArgType,
},
};
export const WithLogo: Story = {
...Default,
args: {
...Default.args,
hideLogo: false,
},
};
export const AudioVideoEnabled: Story = {
...Default,
args: {
...Default.args,
audioEnabled: true,
videoEnabled: true,
},
};
export const WithAudioOutputSpeaker: Story = {
...Default,
args: {
...Default.args,
audioOutputSwitcher: { targetOutput: "earpiece", switch: fn() },
},
};
export const WithAudioOutputEarpiece: Story = {
...Default,
args: {
...Default.args,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
};
export const WithReactions: Story = {
...Default,
args: {
...Default.args,
reactionIdentifier,
reactionData,
},
};
export const Pip: Story = {
...Default,
args: {
...Default.args,
asPip: true,
},
};
export const NoControlsWithLogo: Story = {
...Default,
args: {
...Default.args,
hideControls: true,
hideLogo: false,
},
};
export const DebugData: Story = {
...Default,
args: {
...Default.args,
debugTileLayout: true,
tileStoreGeneration: 74,
},
};
export const UnavailableMediaDevices: Story = {
...Default,
args: {
...Default.args,
toggleAudio: undefined,
toggleVideo: undefined,
audioOutputSwitcher: undefined,
},
};
export const MobileLayout: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
audioOutputSwitcher: { targetOutput: "speaker", switch: fn() },
},
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
...Default.parameters,
},
};
export const Lobby: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
openSettings: undefined,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
parameters: {
...Default.parameters,
},
};
export const LobbyMobile: Story = {
...Default,
args: {
...Default.args,
hideLogo: true,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
...Default.parameters,
},
};
export const LobbyRecentButton: Story = {
...Default,
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
parameters: {
...Default.parameters,
},
};
export const LobbyRecentButtonMobile: Story = {
...Default,
args: {
...Default.args,
children: <Link>Back To Recents</Link>,
hideLogo: true,
setLayoutMode: undefined,
toggleScreenSharing: undefined,
},
globals: {
viewport: { value: "mobile2", isRotated: false },
},
parameters: {
...Default.parameters,
},
};

View File

@@ -0,0 +1,243 @@
/*
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 { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
import { BehaviorSubject } from "rxjs";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
import {
EndCallButton,
MicButton,
VideoButton,
ShareScreenButton,
SettingsButton,
ReactionToggleButton,
LoudspeakerButton,
SettingsIconButton,
type ReactionData,
} from "../button";
import styles from "./CallFooter.module.css";
import { LayoutToggle } from "../room/LayoutToggle";
import { type GridMode } from "../state/CallViewModel/CallViewModel";
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;
/** Also controls if the audioMute button is disabled */
toggleAudio: (() => void) | undefined;
videoEnabled: boolean;
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
/* 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;
/** The footer should be used as an overlay.
* (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;
sharingScreen?: boolean;
toggleScreenSharing?: () => void;
/** 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;
reactionIdentifier?: string;
reactionData?: ReactionData;
hideLogo?: boolean;
// debug stuff
debugTileLayout?: boolean;
tileStoreGeneration?: number;
}
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,
}) => {
const buttons: JSX.Element[] = [];
const buttonSize = asPip ? "sm" : "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.
buttons.push(
<SettingsButton
key="settings"
showForScreenWidth="narrow"
onClick={openSettings}
data-testid="settings-bottom-center"
/>,
);
}
buttons.push(
<MicButton
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
onClick={toggleAudio}
disabled={toggleAudio === undefined}
data-testid="incall_mute"
/>,
<VideoButton
size={buttonSize}
key="video"
enabled={videoEnabled ?? false}
onClick={toggleVideo}
disabled={toggleVideo === undefined}
data-testid="incall_videomute"
/>,
);
if (toggleScreenSharing !== undefined) {
buttons.push(
<ShareScreenButton
size={buttonSize}
key="share_screen"
className={styles.shareScreen}
enabled={sharingScreen ?? false}
onClick={toggleScreenSharing}
data-testid="incall_screenshare"
/>,
);
}
if (reactionIdentifier && reactionData) {
buttons.push(
<ReactionToggleButton
size={buttonSize}
reactionData={
reactionData ?? {
handsRaised$: new BehaviorSubject({}),
reactions$: new BehaviorSubject({}),
}
}
key="raise_hand"
className={styles.raiseHand}
identifier={reactionIdentifier}
/>,
);
}
// In this PR we just move the button to the bottom bar. We do not yet update its appearance
const audioOutputButton = useMemo(() => {
if (audioOutputSwitcher === undefined) return null;
return (
<LoudspeakerButton
size={buttonSize}
onClick={() => audioOutputSwitcher.switch()}
loudspeakerModeEnabled={audioOutputSwitcher.targetOutput === "earpiece"}
/>
);
}, [audioOutputSwitcher, buttonSize]);
if (audioOutputButton) buttons.push(audioOutputButton);
if (hangup)
buttons.push(
<EndCallButton
size={buttonSize}
key="end_call"
onClick={hangup}
data-testid="incall_leave"
/>,
);
const logoDebugContainer = (
<div className={styles.logo}>
{showLogo && (
<>
<LogoMark width={24} height={24} aria-hidden />
<LogoType
width={80}
height={11}
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
/>
</>
)}
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
</div>
);
return (
<div
ref={ref}
className={classNames(styles.footer, {
[styles.overlay]: asOverlay,
[styles.hidden]: hidden,
})}
>
<div className={styles.settingsLogoContainer}>
{showSettingsButton && (
<SettingsIconButton
key="settings"
kind="secondary"
showForScreenWidth="wide"
onClick={openSettings}
data-testid="settings-bottom-left"
/>
)}
{children}
{showLogoDebugContainer && logoDebugContainer}
</div>
{!hideControls && <div className={styles.buttons}>{buttons}</div>}
{setLayoutMode && layoutMode && showLayoutSwitcher && (
<LayoutToggle
className={styles.layout}
layout={layoutMode}
setLayout={setLayoutMode}
/>
)}
</div>
);
};

View File

@@ -97,14 +97,6 @@ export interface ConfigOptions {
enable_video?: boolean;
};
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* Note that this can additionally be disabled by the app's URL parameters.
*/
app_prompt?: boolean;
/**
* These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options.
@@ -164,7 +156,6 @@ export interface ResolvedConfigOptions extends ConfigOptions {
};
};
ssla: string;
app_prompt: boolean;
}
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
@@ -178,5 +169,4 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
feature_use_device_session_member_events: true,
},
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
app_prompt: true,
};

View File

@@ -1,22 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.grid {
contain: layout style;
position: relative;
flex-grow: 1;
margin-inline: var(--inline-content-inset);
margin-block: var(--cpd-space-4x);
}
.slots {
position: relative;
}
.slot {
contain: strict;
}

View File

@@ -19,7 +19,7 @@ Please see LICENSE in the repository root for full details.
position: absolute;
inline-size: 180px;
block-size: 135px;
inset: var(--cpd-space-4x);
inset: 0;
}
.spotlight {

View File

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

View File

@@ -1,3 +1,3 @@
<svg width="28" height="26" viewBox="0 0 28 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267Z" fill="white"/>
</svg>
<path d="M14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 298 B

View File

@@ -1,4 +1,4 @@
<svg width="28" height="26" viewBox="0 0 28 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M14 7.50675L15.2933 10.5601L15.92 12.0401L17.52 12.1734L20.8133 12.4534L18.3066 14.6267L17.0933 15.6801L17.4533 17.2534L18.2 20.4667L15.3733 18.7601L14 17.9067L12.6266 18.7334L9.79996 20.4401L10.5466 17.2267L10.9066 15.6534L9.69329 14.6001L7.18663 12.4267L10.48 12.1467L12.08 12.0134L12.7066 10.5334L14 7.50675M14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748Z" fill="white"/>
<path id="Vector" d="M14 7.50675L15.2933 10.5601L15.92 12.0401L17.52 12.1734L20.8133 12.4534L18.3066 14.6267L17.0933 15.6801L17.4533 17.2534L18.2 20.4667L15.3733 18.7601L14 17.9067L12.6266 18.7334L9.79996 20.4401L10.5466 17.2267L10.9066 15.6534L9.69329 14.6001L7.18663 12.4267L10.48 12.1467L12.08 12.0134L12.7066 10.5334L14 7.50675M14 0.666748L10.2533 9.50675L0.666626 10.3201L7.94663 16.6267L5.75996 26.0001L14 21.0267L22.24 26.0001L20.0533 16.6267L27.3333 10.3201L17.7466 9.50675L14 0.666748Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 620 B

After

Width:  |  Height:  |  Size: 628 B

View File

@@ -43,6 +43,7 @@ layer(compound);
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
);
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
--subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
--background-gradient: url("graphics/backgroundGradient.svg");

View File

@@ -18,7 +18,7 @@ import {
import { mockConfig } from "./utils/test";
const sentryInitSpy = vi.fn();
const sentryInitSpy = vi.hoisted(() => vi.fn());
// Place the mock after the spy is defined
vi.mock("@sentry/react", () => ({

View File

@@ -0,0 +1,25 @@
/*
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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { StarRatingInput } from "./StarRatingInput";
const meta = {
component: StarRatingInput,
} satisfies Meta<typeof StarRatingInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
starCount: 5,
onChange: fn(),
},
};

View File

@@ -19,12 +19,14 @@ import fetchMock from "fetch-mock";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../utils/test-fixtures";
import { ownMemberMock } from "../utils/test";
import { FailToGetOpenIdToken } from "../utils/errors";
const sfuUrl = "https://sfu.example.org";
describe("getSFUConfigWithOpenID", () => {
let matrixClient: MockedObject<OpenIDClientParts>;
beforeEach(() => {
fetchMock.catch(404);
matrixClient = {
getOpenIdToken: vitest.fn(),
getDeviceId: vitest.fn(),
@@ -71,9 +73,10 @@ describe("getSFUConfigWithOpenID", () => {
"https://sfu.example.org",
"!example_room_id",
);
} catch (ex) {
expect((ex as Error).message).toEqual(
"SFU Config fetch failed with status code 500",
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
);
void (await fetchMock.flush());
return;
@@ -106,8 +109,9 @@ describe("getSFUConfigWithOpenID", () => {
},
);
} catch (ex) {
expect((ex as Error).message).toEqual(
"SFU Config fetch failed with status code 500",
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
);
void (await fetchMock.flush());
}
@@ -160,8 +164,9 @@ describe("getSFUConfigWithOpenID", () => {
},
);
} catch (ex) {
expect((ex as Error).message).toEqual(
"SFU Config fetch failed with status code 500",
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
);
void (await fetchMock.flush());
}

View File

@@ -5,11 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
retryNetworkOperation,
type IOpenIDToken,
type MatrixClient,
} from "matrix-js-sdk";
import { type IOpenIDToken, type MatrixClient } from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
@@ -70,6 +66,7 @@ export type OpenIDClientParts = Pick<
MatrixClient,
"getOpenIdToken" | "getDeviceId"
>;
/**
* Gets a bearer token from the homeserver and then use it to authenticate
* to the matrix RTC backend in order to get acces to the SFU.
@@ -113,9 +110,6 @@ export async function getSFUConfigWithOpenID(
);
}
logger?.debug("Got openID token", openIdToken);
logger?.info(`Trying to get JWT for focus ${serviceUrl}...`);
let sfuConfig: { url: string; jwt: string } | undefined;
const tryBothJwtEndpoints = opts?.forceJwtEndpoint === undefined; // This is for SFUs where we do not publish.
@@ -127,7 +121,10 @@ export async function getSFUConfigWithOpenID(
// if we can use both or if we are forced to use the new one.
if (tryBothJwtEndpoints || forceMatrix2Jwt) {
try {
sfuConfig = await getLiveKitJWTWithDelayDelegation(
logger?.info(
`Trying to get JWT with delegation for focus ${serviceUrl}...`,
);
const sfuConfig = await getLiveKitJWTWithDelayDelegation(
membership,
serviceUrl,
roomId,
@@ -135,32 +132,24 @@ export async function getSFUConfigWithOpenID(
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} catch (e) {
if (e instanceof NotSupportedError) {
logger?.warn(
`Failed fetching jwt with matrix 2.0 endpoint (retry with legacy) Not supported`,
e,
);
sfuConfig = undefined;
} else {
logger?.warn(
`Failed fetching jwt with matrix 2.0 endpoint other issues ->`,
`(not going to try with legacy endpoint: forceOldJwtEndpoint is set to false, we did not get a not supported error from the sfu)`,
e,
);
// Make this throw a hard error in case we force the matrix2.0 endpoint.
if (forceMatrix2Jwt)
throw new NoMatrix2AuthorizationService(e as Error);
// NEVER get bejond this point if we forceMatrix2 and it failed!
logger?.debug(`Failed fetching jwt with matrix 2.0 endpoint:`, e);
// Make this throw a hard error in case we force the matrix2.0 endpoint.
if (forceMatrix2Jwt) {
throw new NoMatrix2AuthorizationService(e as Error);
}
}
}
// DEPRECATED
// here we either have a sfuConfig or we alredy exited because of `if (forceMatrix2) throw ...`
// here we either have a sfuConfig or we already exited because of `if (forceMatrix2) throw ...`
// The only case we can get into this condition is, if `forceMatrix2` is `false`
if (sfuConfig === undefined) {
try {
logger?.info(
`Trying to get JWT with legacy endpoint for focus ${serviceUrl}...`,
);
sfuConfig = await getLiveKitJWT(
membership.deviceId,
serviceUrl,
@@ -168,15 +157,19 @@ export async function getSFUConfigWithOpenID(
openIdToken,
);
logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} catch (ex) {
throw new FailToGetOpenIdToken(
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
);
}
}
if (!sfuConfig) {
throw new Error("No `sfuConfig` after trying with old and new endpoints");
}
// Pull the details from the JWT
function extractFullConfigFromToken(sfuConfig: {
url: string;
jwt: string;
}): SFUConfig {
const [, payloadStr] = sfuConfig.jwt.split(".");
// TODO: Prefer Uint8Array.fromBase64 when widely available
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
@@ -188,16 +181,15 @@ export async function getSFUConfigWithOpenID(
livekitIdentity: payload.sub,
};
}
const RETRIES = 4;
async function getLiveKitJWT(
deviceId: string,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
): Promise<{ url: string; jwt: string }> {
let res: Response | undefined;
await retryNetworkOperation(RETRIES, async () => {
res = await fetch(livekitServiceURL + "/sfu/get", {
const res = await doNetworkOperationWithRetry(async () => {
return await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -210,11 +202,7 @@ async function getLiveKitJWT(
}),
});
});
if (!res) {
throw new Error(
`Network error while connecting to jwt service after ${RETRIES} retries`,
);
}
if (!res.ok) {
throw new Error("SFU Config fetch failed with status code " + res.status);
}
@@ -261,10 +249,8 @@ export async function getLiveKitJWTWithDelayDelegation(
};
}
let res: Response | undefined;
await retryNetworkOperation(RETRIES, async () => {
res = await fetch(livekitServiceURL + "/get_token", {
const res = await doNetworkOperationWithRetry(async () => {
return await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -273,11 +259,6 @@ export async function getLiveKitJWTWithDelayDelegation(
});
});
if (!res) {
throw new Error(
`Network error while connecting to jwt service after ${RETRIES} retries`,
);
}
if (!res.ok) {
const msg = "SFU Config fetch failed with status code " + res.status;
if (res.status === 404) {

View File

@@ -29,7 +29,7 @@ interface ReactionsSenderContextType {
sendReaction: (reaction: ReactionOption) => Promise<void>;
}
const ReactionsSenderContext = createContext<
export const ReactionsSenderContext = createContext<
ReactionsSenderContextType | undefined
>(undefined);

View File

@@ -1,24 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.modal p {
text-align: center;
margin-block-end: var(--cpd-space-8x);
}
.modal button,
.modal a {
width: 100%;
}
.modal button {
margin-block-end: var(--cpd-space-6x);
}
.modal a {
box-sizing: border-box;
}

View File

@@ -1,92 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type FC,
type MouseEvent,
useCallback,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Button, Text } from "@vector-im/compound-web";
import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { logger } from "matrix-js-sdk/lib/logger";
import { Modal } from "../Modal";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { getAbsoluteRoomUrl } from "../utils/matrix";
import styles from "./AppSelectionModal.module.css";
import { editFragmentQuery } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
interface Props {
roomId: string;
}
export const AppSelectionModal: FC<Props> = ({ roomId }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(true);
const onBrowserClick = useCallback(
(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setOpen(false);
},
[setOpen],
);
const e2eeSystem = useRoomEncryptionSystem(roomId);
if (e2eeSystem.kind === E2eeType.NONE) {
logger.error(
"Generating app redirect URL for encrypted room but don't have key available!",
);
}
const appUrl = useMemo(() => {
// If the room ID is not known, fall back to the URL of the current page
// Also, we don't really know the room name at this stage as we haven't
// started a client and synced to get the room details. We could take the one
// we got in our own URL and use that, but it's not a string that a human
// ever sees so it's somewhat redundant. We just don't pass a name.
const url = new URL(
roomId === null
? window.location.href
: getAbsoluteRoomUrl(roomId, e2eeSystem),
);
// Edit the URL to prevent the app selection prompt from appearing a second
// time within the app, and to keep the user confined to the current room
url.hash = editFragmentQuery(url.hash, (params) => {
params.set("appPrompt", "false");
params.set("confineToRoom", "true");
return params;
});
const result = new URL("io.element.call:/");
result.searchParams.set("url", url.toString());
return result.toString();
}, [e2eeSystem, roomId]);
return (
<Modal
className={styles.modal}
title={t("app_selection_modal.title")}
open={open}
>
<Text size="md" weight="semibold">
{t("app_selection_modal.text")}
</Text>
<Button kind="secondary" onClick={onBrowserClick}>
{t("app_selection_modal.continue_in_browser")}
</Button>
<Button as="a" href={appUrl} Icon={PopOutIcon}>
{t("app_selection_modal.open_in_app")}
</Button>
</Modal>
);
};

View File

@@ -123,6 +123,7 @@ test("does not play a sound before the call is successful", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
[local, alice],
[localRtcMember],
undefined,
{ waitForCallPickup: true },
);
render(<CallEventAudioRenderer vm={vm} />);

View File

@@ -49,7 +49,6 @@ import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCTransportMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";
import { constant } from "../state/Behavior";
import { type MuteStates } from "../state/MuteStates.ts";
@@ -173,7 +172,6 @@ function createGroupCallView(
confineToRoom={false}
preload={false}
skipLobby={false}
header={HeaderStyle.Standard}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
widget={widget}

View File

@@ -93,7 +93,6 @@ interface Props {
confineToRoom: boolean;
preload: UrlParams["preload"];
skipLobby: UrlParams["skipLobby"];
header: HeaderStyle;
rtcSession: MatrixRTCSession;
joined: boolean;
setJoined: (value: boolean) => void;
@@ -107,7 +106,6 @@ export const GroupCallView: FC<Props> = ({
confineToRoom,
preload,
skipLobby,
header,
rtcSession,
joined,
setJoined,
@@ -182,6 +180,7 @@ export const GroupCallView: FC<Props> = ({
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
header,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
@@ -437,7 +436,7 @@ export const GroupCallView: FC<Props> = ({
muteStates={muteStates}
onEnter={() => setJoined(true)}
confineToRoom={confineToRoom}
hideHeader={header === HeaderStyle.None}
hideHeader={header !== HeaderStyle.Standard}
participantCount={participantCount}
onShareClick={onShareClick}
/>
@@ -463,7 +462,6 @@ export const GroupCallView: FC<Props> = ({
rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room}
onLeft={onLeft}
header={header}
muteStates={muteStates}
e2eeSystem={e2eeSystem}
//otelGroupCallMembership={otelGroupCallMembership}

View File

@@ -31,125 +31,12 @@ Please see LICENSE in the repository root for full details.
background: none;
}
.footer {
position: sticky;
inset-block-end: 0;
z-index: var(--call-view-header-footer-layer);
display: grid;
grid-template-columns:
minmax(0, var(--inline-content-inset))
1fr auto 1fr minmax(0, var(--inline-content-inset));
grid-template-areas: ". logo buttons layout .";
align-items: center;
gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-10x);
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.footer.hidden {
display: none;
}
.footer.overlay {
position: absolute;
inset-block-end: 0;
inset-inline: 0;
opacity: 1;
transition: opacity 0.15s;
}
.footer.overlay.hidden {
display: grid;
opacity: 0;
pointer-events: none;
}
.footer.overlay:has(:focus-visible) {
opacity: 1;
pointer-events: initial;
}
.logo {
grid-area: logo;
justify-self: start;
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
padding-inline-start: var(--cpd-space-1x);
}
.buttons {
grid-area: buttons;
justify-self: center;
display: flex;
gap: var(--cpd-space-3x);
}
.layout {
grid-area: layout;
justify-self: end;
}
@media (max-width: 660px) {
.footer {
grid-template-areas: ". buttons buttons buttons .";
}
.logo {
display: none;
}
.layout {
display: none !important;
}
}
@media (max-height: 800px) {
.footer {
padding-block: var(--cpd-space-8x);
}
}
@media (max-height: 400px) {
.footer {
padding-block: var(--cpd-space-4x);
}
}
@media (max-width: 370px) {
.shareScreen {
display: none;
}
/* PIP custom css */
@media (max-height: 400px) {
.shareScreen {
display: flex;
}
.footer {
padding-block-start: var(--cpd-space-3x);
padding-block-end: var(--cpd-space-2x);
}
}
}
@media (max-width: 320px) {
.invite,
.raiseHand {
.invite {
display: none;
}
}
@media (min-width: 800px) {
.buttons {
gap: var(--cpd-space-4x);
}
}
.fixedGrid {
position: absolute;
inline-size: 100%;

View File

@@ -15,20 +15,17 @@ import {
vi,
} from "vitest";
import { render, type RenderResult } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { type LocalParticipant } from "livekit-client";
import { of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
import { BehaviorSubject, of } from "rxjs";
import { BrowserRouter, MemoryRouter } 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";
import { InCallView } from "./InCallView";
import {
mockLivekitRoom,
mockLocalParticipant,
mockMatrixRoom,
mockMatrixRoomMember,
mockMediaDevices,
mockMuteStates,
mockRemoteParticipant,
@@ -37,13 +34,17 @@ import {
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { type CallViewModelOptions } from "../state/CallViewModel/CallViewModel";
import { alice, local } from "../utils/test-fixtures";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { constant } from "../state/Behavior";
import { AppBar } from "../AppBar";
import { initializeWidget } from "../widget";
initializeWidget();
vi.hoisted(
() =>
@@ -71,10 +72,7 @@ const localParticipant = mockLocalParticipant({
const remoteParticipant = mockRemoteParticipant({
identity: "@alice:example.org:AAAAAA",
});
const carol = mockMatrixRoomMember(localRtcMember);
const roomMembers = new Map([carol].map((p) => [p.userId, p]));
const roomId = "!foo:bar";
let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>;
beforeEach(() => {
@@ -99,36 +97,18 @@ beforeEach(() => {
useRoomEncryptionSystem as typeof useRoomEncryptionSystemMock;
useRoomEncryptionSystemMock.mockReturnValue({ kind: E2eeType.NONE });
});
function createInCallView(): RenderResult & {
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;
} {
const client = {
getUser: () => null,
getUserId: () => localRtcMember.userId,
getDeviceId: () => localRtcMember.deviceId,
getRoom: (rId) => (rId === roomId ? room : null),
getDomain: () => "example.com",
} as Partial<MatrixClient> as MatrixClient;
const room = mockMatrixRoom({
relations: {
getChildEventsForEvent: () =>
vi.mocked({
getRelations: () => [],
}),
} as unknown as RelationsContainer,
client,
roomId,
// getMember: (userId) => roomMembers.get(userId) ?? null,
getMembers: () => Array.from(roomMembers.values()),
getMxcAvatarUrl: () => null,
hasEncryptionStateEvent: vi.fn().mockReturnValue(true),
getCanonicalAlias: () => null,
currentState: {
getJoinRule: () => JoinRule.Invite,
} as Partial<RoomState> as RoomState,
});
const mediaDevices = args.mediaDevices ?? mockMediaDevices({});
const muteState = mockMuteStates();
const livekitRoom = mockLivekitRoom(
{
@@ -138,44 +118,63 @@ function createInCallView(): RenderResult & {
remoteParticipants$: of([remoteParticipant]),
},
);
const { vm, rtcSession } = getBasicCallViewModelEnvironment([local, alice]);
const { vm, rtcSession } = getBasicCallViewModelEnvironment(
[local, alice],
undefined,
mediaDevices,
args.callViewModelOptions,
);
rtcSession.joined = true;
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}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
matrixRoom={room}
onShareClick={null}
/>
);
const content = args.withAppBar ? <AppBar>{inCallView}</AppBar> : inCallView;
const renderResult = render(
<BrowserRouter>
<MediaDevicesContext value={mockMediaDevices({})}>
<Router>
<MediaDevicesContext value={mediaDevices}>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession.asMockedSession()}
>
<TooltipProvider>
<RoomContext value={livekitRoom}>
<InCallView
client={client}
header={HeaderStyle.Standard}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
vm={vm}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
matrixRoom={room}
onShareClick={null}
/>
</RoomContext>
<RoomContext value={livekitRoom}>{content}</RoomContext>
</TooltipProvider>
</ReactionsSenderProvider>
</MediaDevicesContext>
</BrowserRouter>,
</Router>,
);
return {
...renderResult,
@@ -190,4 +189,92 @@ 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();
});
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",
withAppBar: true,
callViewModelOptions: {
// Set windowMode$ to "normal" (height >= 600)
windowSize$: constant({ width: 1000, height: 800 }),
},
});
// 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();
});
});
describe("audioOutputSwitcher", () => {
it("is visible and can be clicked", async () => {
const user = userEvent.setup();
const switchFn = vi.fn();
// Create mediaDevices with a speaker and an earpiece available,
// with the speaker currently selected.
// This is needed so that the audio switcher button is visible
const available$ = new BehaviorSubject(
new Map<string, { type: "speaker" } | { type: "earpiece" }>([
["speaker-id", { type: "speaker" }],
["earpiece-id", { type: "earpiece" }],
]),
);
const selected$ = new BehaviorSubject({
id: "speaker-id",
virtualEarpiece: false,
});
const mediaDevices = mockMediaDevices({
audioOutput: {
available$,
selected$,
select: switchFn,
},
});
const { getByRole } = createInCallView({ mediaDevices });
// The button should be visible. When current output is "speaker",
const audioOutputBtn = getByRole("button", { name: "Loudspeaker" });
expect(audioOutputBtn).toBeVisible();
await user.click(audioOutputBtn);
// Clicking the button should call select -> switchFn with the earpiece device id
expect(switchFn).toHaveBeenCalledWith("earpiece-id");
});
});
});

View File

@@ -5,7 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { IconButton, Tooltip } from "@vector-im/compound-web";
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
import {
type FC,
@@ -25,24 +24,10 @@ import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { useObservable } from "observable-hooks";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
VoiceCallSolidIcon,
VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
import {
EndCallButton,
MicButton,
VideoButton,
ShareScreenButton,
SettingsButton,
ReactionToggleButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type HeaderStyle, useUrlParams } from "../UrlParams";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { widget } from "../widget";
import styles from "./InCallView.module.css";
@@ -55,7 +40,6 @@ import { useMergedRefs } from "../useMergedRefs";
import { type MuteStates } from "../state/MuteStates";
import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle";
import {
type CallViewModel,
createCallViewModel$,
@@ -106,6 +90,8 @@ 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 { SettingsIconButton } from "../button/Button.tsx";
const logger = rootLogger.getChild("[InCallView]");
@@ -185,7 +171,6 @@ export interface InCallViewProps {
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
muteStates: MuteStates;
header: HeaderStyle;
onShareClick: (() => void) | null;
}
@@ -195,8 +180,6 @@ export const InCallView: FC<InCallViewProps> = ({
matrixInfo,
matrixRoom,
muteStates,
header: headerStyle,
onShareClick,
}) => {
const { t } = useTranslation();
@@ -220,7 +203,7 @@ export const InCallView: FC<InCallViewProps> = ({
// Merge the refs so they can attach to the same element
const containerRef = useMergedRefs(containerRef1, containerRef2);
const { showControls } = useUrlParams();
const { showControls, header: headerStyle } = useUrlParams();
const muteAllAudio = useBehavior(muteAllAudio$);
@@ -373,36 +356,16 @@ export const InCallView: FC<InCallViewProps> = ({
[vm],
);
useAppBarSecondaryButton(
useMemo(() => {
if (audioOutputSwitcher === null) return null;
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
const label = isEarpieceTarget
? t("settings.devices.handset")
: t("settings.devices.loudspeaker");
return (
<Tooltip label={label}>
<IconButton
onClick={(e) => {
e.preventDefault();
audioOutputSwitcher.switch();
}}
>
<Icon />
</IconButton>
</Tooltip>
);
}, [t, audioOutputSwitcher]),
);
useAppBarHidden(!showHeader);
let header: ReactNode = null;
if (showHeader) {
switch (headerStyle) {
case "none":
case HeaderStyle.AppBar: {
// dont build a header here. The AppBar will take care of it.
break;
}
case HeaderStyle.None:
// Cosmetic header to fill out space while still affecting the bounds of
// the grid
header = (
@@ -412,7 +375,7 @@ export const InCallView: FC<InCallViewProps> = ({
/>
);
break;
case "standard":
case HeaderStyle.Standard:
header = (
<Header
className={styles.header}
@@ -599,103 +562,46 @@ export const InCallView: FC<InCallViewProps> = ({
matrixRoom.roomId,
);
const buttons: JSX.Element[] = [];
const buttonSize = layout.type === "pip" ? "sm" : "lg";
buttons.push(
<MicButton
size={buttonSize}
key="audio"
enabled={audioEnabled}
onClick={toggleAudio ?? undefined}
disabled={toggleAudio === null}
data-testid="incall_mute"
/>,
<VideoButton
size={buttonSize}
key="video"
enabled={videoEnabled}
onClick={toggleVideo ?? undefined}
disabled={toggleVideo === null}
data-testid="incall_videomute"
const settingsButtonInAppBar =
headerStyle === HeaderStyle.AppBar && showHeader;
useAppBarSecondaryButton(
<SettingsIconButton
key="settings"
onClick={openSettings}
data-testid="settings-app-bar"
/>,
);
if (vm.toggleScreenSharing !== null) {
buttons.push(
<ShareScreenButton
size={buttonSize}
key="share_screen"
className={styles.shareScreen}
enabled={sharingScreen}
onClick={vm.toggleScreenSharing}
data-testid="incall_screenshare"
/>,
);
}
if (supportsReactions) {
buttons.push(
<ReactionToggleButton
size={buttonSize}
vm={vm}
key="raise_hand"
className={styles.raiseHand}
identifier={`${client.getUserId()}:${client.getDeviceId()}`}
/>,
);
}
if (layout.type !== "pip")
buttons.push(
<SettingsButton
size={buttonSize}
key="settings"
onClick={openSettings}
/>,
);
buttons.push(
<EndCallButton
size={buttonSize}
key="end_call"
onClick={function (): void {
vm.hangup();
}}
data-testid="incall_leave"
/>,
);
// Only hide the settings button if we have an AppBar header and we are showing the header
const footer = (
<div
<CallFooter
ref={footerRef}
className={classNames(styles.footer, {
[styles.overlay]: windowMode === "flat",
[styles.hidden]:
!showFooter || (!showControls && headerStyle === "none"),
})}
>
{headerStyle !== "none" && (
<div className={styles.logo}>
<LogoMark width={24} height={24} aria-hidden />
<LogoType
width={80}
height={11}
aria-label={import.meta.env.VITE_PRODUCT_NAME || "Element Call"}
/>
{/* Don't mind this odd placement, it's just a little debug label */}
{debugTileLayout
? `Tiles generation: ${tileStoreGeneration}`
: undefined}
</div>
)}
{showControls && <div className={styles.buttons}>{buttons}</div>}
{showControls && (
<LayoutToggle
className={styles.layout}
layout={gridMode}
setLayout={setGridMode}
/>
)}
</div>
hidden={!showFooter}
hideControls={!showControls}
asOverlay={windowMode === "flat"}
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 allConnections = useBehavior(vm.allConnections$);
return (

View File

@@ -0,0 +1,25 @@
/*
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 { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LayoutToggle } from "./LayoutToggle";
const meta = {
component: LayoutToggle,
} satisfies Meta<typeof LayoutToggle>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
layout: "grid",
setLayout: fn(),
},
};

View File

@@ -18,11 +18,11 @@ import styles from "./LayoutToggle.module.css";
export type Layout = "spotlight" | "grid";
interface Props {
type Props = {
layout: Layout;
setLayout: (layout: Layout) => void;
className?: string;
}
};
export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
const { t } = useTranslation();
@@ -33,7 +33,7 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
);
return (
<div className={classNames(styles.toggle, className)}>
<form className={classNames(styles.toggle, className)}>
<Tooltip label={t("layout_spotlight_label")}>
<input
type="radio"
@@ -54,6 +54,6 @@ export const LayoutToggle: FC<Props> = ({ layout, setLayout, className }) => {
/>
</Tooltip>
<GridIcon aria-hidden width={24} height={24} />
</div>
</form>
);
};

View File

@@ -33,12 +33,6 @@ import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
import { type MuteStates } from "../state/MuteStates";
import { InviteButton } from "../button/InviteButton";
import {
EndCallButton,
MicButton,
SettingsButton,
VideoButton,
} from "../button/Button";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType";
@@ -52,6 +46,7 @@ import {
import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter";
interface Props {
client: MatrixClient;
@@ -226,23 +221,18 @@ export const LobbyView: FC<Props> = ({
</VideoPreview>
{!recentsButtonInFooter && recentsButton}
</div>
<div className={inCallStyles.footer}>
<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}
<div className={inCallStyles.buttons}>
<MicButton
enabled={audioEnabled}
onClick={toggleAudio ?? undefined}
disabled={toggleAudio === null}
/>
<VideoButton
enabled={videoEnabled}
onClick={toggleVideo ?? undefined}
disabled={toggleVideo === null}
/>
<SettingsButton onClick={openSettings} />
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
</div>
</div>
</CallFooter>
</div>
{client && (
<SettingsModal

View File

@@ -29,15 +29,12 @@ import { GroupCallView } from "./GroupCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { HomePage } from "../home/HomePage";
import { platform } from "../Platform";
import { AppSelectionModal } from "./AppSelectionModal";
import { widget } from "../widget";
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { useProfile } from "../profile/useProfile";
import { useOptInAnalytics } from "../settings/settings";
import { Config } from "../config/Config";
import { Link } from "../button/Link";
import { ErrorView } from "../ErrorView";
import { useMediaDevices } from "../MediaDevicesContext";
@@ -45,10 +42,9 @@ import { MuteStates } from "../state/MuteStates";
import { ObservableScope } from "../state/ObservableScope";
import { calculateInitialMuteState } from "../state/initialMuteState.ts";
export const RoomPage: FC = () => {
export const RoomPage: FC = (): ReactNode => {
const urlParams = useUrlParams();
const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } =
urlParams;
const { confineToRoom, preload, header, displayName, skipLobby } = urlParams;
const { t } = useTranslation();
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
@@ -136,7 +132,6 @@ export const RoomPage: FC = () => {
confineToRoom={confineToRoom}
preload={preload}
skipLobby={skipLobby || wasInWaitForInviteState.current}
header={header}
muteStates={muteStates}
/>
)
@@ -242,28 +237,10 @@ export const RoomPage: FC = () => {
}
};
let content: ReactNode;
if (loading || isRegistering) {
content = <LoadingPage />;
} else if (error) {
content = <ErrorPage widget={widget} error={error} />;
} else if (!client) {
content = <RoomAuthView />;
} else if (!roomIdOrAlias) {
// TODO: This doesn't belong here, the app routes need to be reworked
content = <HomePage />;
} else {
content = groupCallView();
}
return (
<>
{content}
{/* On Android and iOS, show a prompt to launch the mobile app. */}
{appPrompt &&
Config.get().app_prompt &&
(platform === "android" || platform === "ios") &&
roomId && <AppSelectionModal roomId={roomId} />}
</>
);
if (loading || isRegistering) return <LoadingPage />;
if (error) return <ErrorPage widget={widget} error={error} />;
if (!client) return <RoomAuthView />;
// TODO: This doesn't belong here, the app routes need to be reworked
if (!roomIdOrAlias) return <HomePage />;
return groupCallView();
};

View File

@@ -108,8 +108,9 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -265,8 +266,9 @@ exports[`should have a close button in widget mode 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -418,8 +420,9 @@ exports[`should render the error page with link back to home 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -571,8 +574,9 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -724,8 +728,9 @@ exports[`should report correct error for 'Connection lost' 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -881,8 +886,9 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -1029,8 +1035,9 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
class="error"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"

View File

@@ -17,7 +17,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<span
aria-label=""
class="_avatar_zysgz_8 roomAvatar _avatar-imageless_zysgz_55"
class="_avatar_va14e_8 roomAvatar _avatar-imageless_va14e_55"
data-color="1"
data-type="round"
role="img"
@@ -117,8 +117,9 @@ exports[`InCallView > rendering > renders 1`] = `
data-show="false"
>
<div
class="_content_1r8kr_8 icon"
data-size="large"
class="_big-icon_1ssbv_8 icon"
data-kind="primary"
data-size="lg"
>
<svg
aria-hidden="true"
@@ -165,116 +166,169 @@ exports[`InCallView > rendering > renders 1`] = `
class="footer"
>
<div
class="logo"
class="settingsLogoContainer"
>
<svg
aria-hidden="true"
fill="none"
height="24"
viewBox="0 0 48 48"
width="24"
xmlns="http://www.w3.org/2000/svg"
<button
aria-labelledby="_r_8_"
class="_icon-button_1215g_8 settingsOnlyShowWide"
data-kind="secondary"
data-testid="settings-bottom-left"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<g
id="Logo Mark"
<div
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<rect
fill="#0DBD8B"
height="48"
rx="23.93"
width="47.86"
/>
<g
id="Union"
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<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"
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"
/>
<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"
</svg>
</div>
</button>
<div
class="logo"
>
<g
id="Logo Type"
<svg
aria-hidden="true"
fill="none"
height="24"
viewBox="0 0 48 48"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Vector"
id="Logo Mark"
>
<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"
<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>
</g>
</svg>
</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_d_"
class="_button_13vu4_8 settingsOnlyShowNarrow _has-icon_13vu4_60 _icon-only_13vu4_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-checked="false"
aria-disabled="true"
aria-labelledby="_r_8_"
aria-labelledby="_r_i_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary"
data-size="lg"
@@ -298,7 +352,7 @@ exports[`InCallView > rendering > renders 1`] = `
<button
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_d_"
aria-labelledby="_r_n_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary"
data-size="lg"
@@ -320,28 +374,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="_r_i_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_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
d="M12.731 2C13.432 2 14 2.568 14 3.269c0 .578.396 1.074.935 1.286q.128.052.253.106c.531.23 1.162.16 1.572-.25a1.27 1.27 0 0 1 1.794 0l1.034 1.035a1.27 1.27 0 0 1 0 1.794c-.41.41-.48 1.04-.248 1.572l.105.253c.212.539.708.935 1.286.935.701 0 1.269.568 1.269 1.269v1.462c0 .701-.568 1.269-1.269 1.269-.578 0-1.074.396-1.287.935q-.05.128-.104.253c-.232.531-.161 1.162.248 1.572a1.27 1.27 0 0 1 0 1.794l-1.034 1.034a1.27 1.27 0 0 1-1.794 0c-.41-.41-1.04-.48-1.572-.248a8 8 0 0 1-.253.105c-.539.212-.935.708-.935 1.286 0 .701-.568 1.269-1.269 1.269H11.27c-.702 0-1.27-.568-1.27-1.269 0-.578-.396-1.074-.935-1.287a8 8 0 0 1-.253-.104c-.531-.232-1.162-.161-1.572.248a1.27 1.27 0 0 1-1.794 0l-1.034-1.034a1.27 1.27 0 0 1 0-1.794c.41-.41.48-1.04.249-1.572a8 8 0 0 1-.106-.253C4.343 14.396 3.847 14 3.27 14 2.568 14 2 13.432 2 12.731V11.27c0-.702.568-1.27 1.269-1.27.578 0 1.074-.396 1.286-.935q.052-.128.106-.253c.23-.531.16-1.162-.25-1.572a1.27 1.27 0 0 1 0-1.794l1.035-1.034a1.27 1.27 0 0 1 1.794 0c.41.41 1.04.48 1.572.249a8 8 0 0 1 .253-.106c.539-.212.935-.708.935-1.286C10 2.568 10.568 2 11.269 2zM12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8"
/>
</svg>
</button>
<button
aria-labelledby="_r_n_"
aria-labelledby="_r_s_"
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
data-kind="primary"
data-size="lg"
@@ -363,11 +396,11 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
</div>
<div
<form
class="toggle layout"
>
<input
aria-labelledby="_r_s_"
aria-labelledby="_r_11_"
name="layout"
type="radio"
value="spotlight"
@@ -385,7 +418,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby="_r_11_"
aria-labelledby="_r_16_"
checked=""
name="layout"
type="radio"
@@ -403,7 +436,7 @@ exports[`InCallView > rendering > renders 1`] = `
d="M4 11a.97.97 0 0 1-.712-.287A.97.97 0 0 1 3 10V4q0-.424.288-.712A.97.97 0 0 1 4 3h6q.424 0 .713.288Q11 3.575 11 4v6q0 .424-.287.713A.97.97 0 0 1 10 11zm5-2V5H5v4zm5 12a.97.97 0 0 1-.713-.288A.97.97 0 0 1 13 20v-6q0-.424.287-.713A.97.97 0 0 1 14 13h6q.424 0 .712.287.288.288.288.713v6q0 .424-.288.712A.97.97 0 0 1 20 21zm5-2v-4h-4v4zM4 21a.97.97 0 0 1-.712-.288A.97.97 0 0 1 3 20v-6q0-.424.288-.713A.97.97 0 0 1 4 13h6q.424 0 .713.287.287.288.287.713v6q0 .424-.287.712A.97.97 0 0 1 10 21zm5-2v-4H5v4zm5-8a.97.97 0 0 1-.713-.287A.97.97 0 0 1 13 10V4q0-.424.287-.712A.97.97 0 0 1 14 3h6q.424 0 .712.288Q21 3.575 21 4v6q0 .424-.288.713A.97.97 0 0 1 20 11zm5-2V5h-4v4z"
/>
</svg>
</div>
</form>
</div>
</div>
</div>

View File

@@ -204,7 +204,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
>
<input
aria-describedby="radix-_r_7_"
class="_control_sqdq4_10"
class="_control_d83jn_10"
id="radix-_r_6_"
name="input"
title=""

View File

@@ -36,7 +36,6 @@ import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type Layout } from "../layout-types.ts";
import {
mockLocalParticipant,
mockMatrixRoomMember,
mockRemoteParticipant,
withTestScheduler,
@@ -61,7 +60,10 @@ import {
import { MediaDevices } from "../MediaDevices.ts";
import { getValue } from "../../utils/observable.ts";
import { type Behavior, constant } from "../Behavior.ts";
import { withCallViewModel as withCallViewModelInMode } from "./CallViewModelTestUtils.ts";
import {
localParticipant,
withCallViewModel as withCallViewModelInMode,
} from "./CallViewModelTestUtils.ts";
import { MatrixRTCMode } from "../../settings/settings.ts";
import { initializeWidget } from "../../widget.ts";
@@ -104,16 +106,7 @@ const dave = mockMatrixRoomMember(daveRtcMember, { rawDisplayName: "Dave" });
const daveId = `${dave.userId}:${daveRtcMember.deviceId}`;
const localParticipant = mockLocalParticipant({ identity: "" });
const aliceSharingScreen = mockRemoteParticipant({
identity: aliceId,
isScreenShareEnabled: true,
});
const bobParticipant = mockRemoteParticipant({ identity: bobId });
const bobSharingScreen = mockRemoteParticipant({
identity: bobId,
isScreenShareEnabled: true,
});
const daveParticipant = mockRemoteParticipant({ identity: daveId });
export interface GridLayoutSummary {
@@ -277,11 +270,12 @@ describe.each([
});
});
test("screen sharing activates spotlight layout", () => {
test("remote screen sharing activates spotlight layout", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Start with no screen shares, then have Alice and Bob share their screens,
// then return to no screen shares, then have just Alice share for a bit
const participantInputMarbles = " abcda-ba";
const aliceSharingInputMarbles = " ny-n--yn";
const bobSharingInputMarbles = " n-y-n---";
// While there are no screen shares, switch to spotlight manually, and then
// switch back to grid at the end
const modeInputMarbles = " -----s--g";
@@ -292,13 +286,12 @@ describe.each([
const expectedShowSpeakingMarbles = "y----nyny";
withCallViewModel(
{
remoteParticipants$: behavior(participantInputMarbles, {
a: [aliceParticipant, bobParticipant],
b: [aliceSharingScreen, bobParticipant],
c: [aliceSharingScreen, bobSharingScreen],
d: [aliceParticipant, bobSharingScreen],
}),
remoteParticipants$: constant([aliceParticipant, bobParticipant]),
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
sharingScreen: new Map([
[aliceParticipant, behavior(aliceSharingInputMarbles, yesNo)],
[bobParticipant, behavior(bobSharingInputMarbles, yesNo)],
]),
},
(vm) => {
schedule(modeInputMarbles, {
@@ -358,6 +351,76 @@ describe.each([
});
});
test("local screen sharing stays in grid layout", () => {
withTestScheduler(({ behavior, expectObservable }) => {
// Local participant shares their screen, then stops sharing
const sharingInputMarbles = " nyn";
// Layout should show the screen share but stay in type: "grid"
const expectedLayoutMarbles = "aba";
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant, bobParticipant]),
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
sharingScreen: new Map([
[localParticipant, behavior(sharingInputMarbles, yesNo)],
]),
},
(vm) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "grid",
spotlight: undefined,
grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`],
},
b: {
type: "grid",
spotlight: [`${localId}:0:screen-share`],
grid: [`${localId}:0`, `${aliceId}:0`, `${bobId}:0`],
},
},
);
},
);
});
});
test("local screen sharing in one-on-one call activates grid layout", () => {
withTestScheduler(({ behavior, expectObservable }) => {
// Local participant shares their screen, then stops sharing
const sharingInputMarbles = " nyn";
// Layout should switch to grid layout then back to one-on-one layout
const expectedLayoutMarbles = "aba";
withCallViewModel(
{
remoteParticipants$: constant([aliceParticipant]),
rtcMembers$: constant([localRtcMember, aliceRtcMember]),
sharingScreen: new Map([
[localParticipant, behavior(sharingInputMarbles, yesNo)],
]),
},
(vm) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "one-on-one",
pip: `${localId}:0`,
spotlight: `${aliceId}:0`,
},
b: {
type: "grid",
spotlight: [`${localId}:0:screen-share`],
grid: [`${localId}:0`, `${aliceId}:0`],
},
},
);
},
);
});
});
test("participants stay in the same order unless to appear/disappear", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const visibilityInputMarbles = "a";
@@ -688,7 +751,7 @@ describe.each([
withCallViewModel(
{
remoteParticipants$: constant([
aliceSharingScreen,
aliceParticipant,
bobParticipant,
daveParticipant,
]),
@@ -702,6 +765,7 @@ describe.each([
[bobParticipant, behavior(bSpeakingInputMarbles, yesNo)],
[daveParticipant, behavior(dSpeakingInputMarbles, yesNo)],
]),
sharingScreen: new Map([[aliceParticipant, constant(true)]]),
},
(vm) => {
schedule(modeInputMarbles, {
@@ -750,6 +814,53 @@ describe.each([
});
});
test("PiP tile in expanded spotlight layout avoids redundantly showing local user", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Switch to spotlight immediately
const modeInputMarbles = " s";
// And expand the spotlight immediately
const expandInputMarbles = " a";
// First no one else is in the call, then Alice joins
const participantInputMarbles = "ab";
// First local user should be in the spotlight, then they appear in PiP
// only once Alice has joined
const expectedLayoutMarbles = " ab";
withCallViewModel(
{
rtcMembers$: behavior(participantInputMarbles, {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
},
(vm) => {
schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"),
});
schedule(expandInputMarbles, {
a: () => vm.toggleSpotlightExpanded$.value!(),
});
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "spotlight-expanded",
spotlight: [`${localId}:0`],
pip: undefined,
},
b: {
type: "spotlight-expanded",
spotlight: [`${aliceId}:0`],
pip: `${localId}:0`,
},
},
);
},
);
});
});
test("spotlight remembers whether it's expanded", () => {
withTestScheduler(({ schedule, expectObservable }) => {
// Start in spotlight mode, then switch to grid and back to spotlight a
@@ -809,26 +920,30 @@ describe.each([
withTestScheduler(({ behavior, expectObservable }) => {
// iterate through a number of combinations of participants and MatrixRTC memberships
// Bob never has an MatrixRTC membership
const scenarioInputMarbles = " abcdec";
const participantInputMarbles = "abcd-c";
// Bob even tries to share his screen at the end
const bobSharingInputMarbles = " n---yn";
// Bob should never be visible
const expectedLayoutMarbles = "a-bc-b";
const expectedLayoutMarbles = " a-bc-b";
withCallViewModel(
{
remoteParticipants$: behavior(scenarioInputMarbles, {
remoteParticipants$: behavior(participantInputMarbles, {
a: [],
b: [bobParticipant],
c: [aliceParticipant, bobParticipant],
d: [aliceParticipant, daveParticipant, bobParticipant],
e: [aliceParticipant, daveParticipant, bobSharingScreen],
}),
rtcMembers$: behavior(scenarioInputMarbles, {
rtcMembers$: behavior(participantInputMarbles, {
a: [localRtcMember],
b: [localRtcMember],
c: [localRtcMember, aliceRtcMember],
d: [localRtcMember, aliceRtcMember, daveRtcMember],
e: [localRtcMember, aliceRtcMember, daveRtcMember],
}),
sharingScreen: new Map([
[bobParticipant, behavior(bobSharingInputMarbles, yesNo)],
]),
},
(vm) => {
vm.setGridMode("grid");

View File

@@ -82,7 +82,7 @@ import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType";
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates";
import { getUrlParams } from "../../UrlParams";
import { getUrlParams, HeaderStyle } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget";
import {
@@ -105,12 +105,16 @@ import {
import {
createLocalTransport$,
JwtEndpointVersion,
type LocalTransport,
} from "./localMember/LocalTransport.ts";
import {
createMemberships$,
membershipsAndTransports$,
} from "../SessionBehaviors.ts";
import { ECConnectionFactory } from "./remoteMembers/ConnectionFactory.ts";
import {
type ConnectionFactory,
ECConnectionFactory,
} from "./remoteMembers/ConnectionFactory.ts";
import {
type ConnectionManagerData,
createConnectionManager$,
@@ -170,8 +174,14 @@ export interface CallViewModelOptions {
connectionState$?: Behavior<ConnectionState>;
/** Optional behavior overriding the computed window size, mainly for testing purposes. */
windowSize$?: Behavior<{ width: number; height: number }>;
/** Optional value overriding the local transport, for testing purposes. */
localTransport?: LocalTransport;
/** Optional value overriding the connection factory, for testing purposes. */
connectionFactory?: ConnectionFactory;
/** The version & compatibility mode of MatrixRTC that we should use. */
matrixRTCMode$?: Behavior<MatrixRTCMode>;
/** Optional behavior overriding for the screensharing, for testing */
toggleScreensharing?: () => void;
}
// Do not play any sounds if the participant count has exceeded this
@@ -441,6 +451,7 @@ export function createCallViewModel$(
// Re-create LocalTransport whenever the mode changes
(mode) => ({ keys: [mode], data: undefined }),
(scope, _data$, mode) =>
options.localTransport ??
createLocalTransport$({
scope: scope,
memberships$: memberships$,
@@ -467,17 +478,19 @@ export function createCallViewModel$(
),
);
const connectionFactory = new ECConnectionFactory(
client,
matrixRoom.roomId,
mediaDevices,
trackProcessorState$,
livekitKeyProvider,
getUrlParams().controlledAudioDevices,
options.livekitRoomFactory,
getUrlParams().echoCancellation,
getUrlParams().noiseSuppression,
);
const connectionFactory =
options.connectionFactory ??
new ECConnectionFactory(
client,
matrixRoom.roomId,
mediaDevices,
trackProcessorState$,
livekitKeyProvider,
getUrlParams().controlledAudioDevices,
options.livekitRoomFactory,
getUrlParams().echoCancellation,
getUrlParams().noiseSuppression,
);
const connectionManager = createConnectionManager$({
scope: scope,
@@ -546,9 +559,7 @@ export function createCallViewModel$(
},
connectionManager,
matrixRTCSession,
localTransport$: scope.behavior(
localTransport$.pipe(switchMap((t) => t.advertised$)),
),
localTransport$,
logger: logger.getChild(`[${Date.now()}]`),
});
@@ -951,7 +962,7 @@ export function createCallViewModel$(
const spotlightAndPip$ = scope.behavior<{
spotlight: MediaViewModel[];
pip$: Behavior<UserMediaViewModel | undefined>;
pip$: Observable<UserMediaViewModel | undefined>;
}>(
ringingMedia$.pipe(
switchMap((ringingMedia) => {
@@ -966,7 +977,10 @@ export function createCallViewModel$(
return spotlightSpeaker$.pipe(
map((speaker) => ({
spotlight: speaker ? [speaker] : [],
pip$: localUserMediaForPip$,
// Hide PiP if redundant (i.e. if local user is already in spotlight)
pip$: localUserMediaForPip$.pipe(
map((m) => (m === speaker ? undefined : m)),
),
})),
);
}),
@@ -1077,9 +1091,10 @@ export function createCallViewModel$(
);
const oneOnOneLayoutMedia$: Observable<OneOnOneLayoutMedia | null> =
userMedia$.pipe(
switchMap((userMedia) => {
if (userMedia.length <= 2) {
combineLatest([userMedia$, screenShares$]).pipe(
switchMap(([userMedia, screenShares]) => {
// One-on-one layout only supports 2 user media, no screen shares
if (userMedia.length <= 2 && screenShares.length === 0) {
const local = userMedia.find(
(vm): vm is WrappedUserMediaViewModel & LocalUserMediaViewModel =>
vm.type === "user" && vm.local,
@@ -1313,7 +1328,11 @@ export function createCallViewModel$(
windowMode$.pipe(map((mode) => mode !== "pip" && mode !== "flat")),
);
const showFooter$ = scope.behavior<boolean>(
const urlParams = getUrlParams();
const showFooterUrlParams = !(
urlParams.header === HeaderStyle.None && urlParams.showControls === false
);
const showFooterLayout$ = scope.behavior<boolean>(
windowMode$.pipe(
switchMap((mode) => {
switch (mode) {
@@ -1367,7 +1386,11 @@ export function createCallViewModel$(
}),
),
);
const showFooter$ = scope.behavior(
showFooterLayout$.pipe(
map((showFooter) => showFooter && showFooterUrlParams),
),
);
/**
* Whether audio is currently being output through the earpiece.
*/
@@ -1494,7 +1517,8 @@ export function createCallViewModel$(
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
// reassigned here to make it publicly accessible
const toggleScreenSharing = localMembership.toggleScreenSharing;
const toggleScreenSharing =
options.toggleScreensharing ?? localMembership.toggleScreenSharing;
const errors$ = scope.behavior<{
transportError?: ElementCallError;

View File

@@ -15,7 +15,7 @@ import {
type Room as LivekitRoom,
} from "livekit-client";
import { SyncState } from "matrix-js-sdk/lib/sync";
import { BehaviorSubject, type Observable, map, of } from "rxjs";
import { BehaviorSubject, combineLatest, map, of } from "rxjs";
import { onTestFinished, vi } from "vitest";
import { ClientEvent, type RoomMember, type MatrixClient } from "matrix-js-sdk";
import EventEmitter from "events";
@@ -30,7 +30,10 @@ import {
type CallViewModelOptions,
} from "./CallViewModel";
import {
exampleSfuConfig,
exampleTransport,
mockConfig,
MockConnection,
mockLivekitRoom,
mockLocalParticipant,
mockMatrixRoom,
@@ -68,13 +71,14 @@ export interface CallViewModelInputs {
rtcMembers$: Behavior<Partial<CallMembership>[]>;
roomMembers: RoomMember[];
livekitConnectionState$: Behavior<ConnectionState>;
speaking: Map<Participant, Observable<boolean>>;
speaking: Map<Participant, Behavior<boolean>>;
sharingScreen: Map<Participant, Behavior<boolean>>;
mediaDevices: MediaDevices;
initialSyncState: SyncState;
windowSize$: Behavior<{ width: number; height: number }>;
}
const localParticipant = mockLocalParticipant({ identity: "" });
export const localParticipant = mockLocalParticipant({ identity: "" });
export function withCallViewModel(mode: MatrixRTCMode) {
return (
@@ -94,6 +98,7 @@ export function withCallViewModel(mode: MatrixRTCMode) {
ConnectionState.Connected,
),
speaking = new Map(),
sharingScreen = new Map(),
mediaDevices = mockMediaDevices({}),
initialSyncState = SyncState.Syncing,
windowSize$ = constant({ width: 1000, height: 800 }),
@@ -131,6 +136,9 @@ export function withCallViewModel(mode: MatrixRTCMode) {
public getSyncState(): SyncState {
return syncState;
}
public getAccessToken(): string | null {
return "a-token";
}
})() as Partial<MatrixClient> as MatrixClient,
getMembers: () => roomMembers,
getMembersWithMembership: () => roomMembers,
@@ -151,13 +159,19 @@ export function withCallViewModel(mode: MatrixRTCMode) {
const eventsSpy = vi
.spyOn(ComponentsCore, "observeParticipantEvents")
.mockImplementation((p, ...eventTypes) => {
if (eventTypes.includes(ParticipantEvent.IsSpeakingChanged)) {
return (speaking.get(p) ?? of(false)).pipe(
map((s): Participant => ({ ...p, isSpeaking: s }) as Participant),
);
} else {
return of(p);
}
return combineLatest([
(eventTypes.includes(ParticipantEvent.IsSpeakingChanged) &&
speaking.get(p)) ||
constant(false),
(eventTypes.includes(ParticipantEvent.TrackPublished) &&
sharingScreen.get(p)) ||
constant(false),
]).pipe(
map(
([isSpeaking, isScreenShareEnabled]) =>
({ ...p, isSpeaking, isScreenShareEnabled }) as Participant,
),
);
});
const roomEventSelectorSpy = vi
@@ -169,6 +183,13 @@ export function withCallViewModel(mode: MatrixRTCMode) {
);
const reactions$ = new BehaviorSubject<Record<string, ReactionInfo>>({});
const livekitRoomFactory = (): LivekitRoom =>
mockLivekitRoom({
localParticipant,
disconnect: async () => Promise.resolve(),
setE2EEEnabled: async () => Promise.resolve(),
});
const vm = createCallViewModel$(
testScope(),
rtcSession.asMockedSession(),
@@ -178,14 +199,38 @@ export function withCallViewModel(mode: MatrixRTCMode) {
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
autoLeaveWhenOthersLeft: false,
livekitRoomFactory: (): LivekitRoom =>
mockLivekitRoom({
localParticipant,
disconnect: async () => Promise.resolve(),
setE2EEEnabled: async () => Promise.resolve(),
}),
livekitRoomFactory,
connectionState$,
windowSize$,
localTransport: {
active$: constant({
transport: exampleTransport,
sfuConfig: exampleSfuConfig,
}),
advertised$: constant(exampleTransport),
},
connectionFactory: {
createConnection(
scope,
transport,
ownMembershipIdentity,
logger,
sfuConfig,
) {
return new MockConnection(
{
scope,
transport,
ownMembershipIdentity,
existingSFUConfig: sfuConfig,
client: room.client,
roomId: room.roomId,
livekitRoomFactory,
},
logger,
);
},
},
matrixRTCMode$: constant(mode),
...options,
},

View File

@@ -33,13 +33,20 @@ import {
PublishState,
TrackState,
} from "./LocalMember";
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import {
FailToGetOpenIdToken,
MatrixRTCTransportMissingError,
} from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget";
import {
type LocalTransport,
type LocalTransportWithSFUConfig,
} from "./LocalTransport";
initializeWidget();
@@ -214,7 +221,7 @@ describe("LocalMembership", () => {
};
it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
null,
@@ -230,11 +237,16 @@ describe("LocalMembership", () => {
),
};
const aLocalTransport: LocalTransport = {
advertised$: localTransport$,
active$: behavior("a", { a: null }),
};
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport$,
localTransport$: behavior("a", { a: aLocalTransport }),
});
localMembership.requestJoinAndPublish();
@@ -245,10 +257,62 @@ describe("LocalMembership", () => {
});
});
it("Should not publish to active transport if advertised has errors", () => {
withTestScheduler(({ scope, hot, behavior, expectObservable }) => {
const advertised$ = scope.behavior<null | LivekitTransportConfig>(
hot("--#", {}, new FailToGetOpenIdToken(new Error("foo"))),
null,
);
// Populate a connection for active
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportBConnected, []);
const mockConnectionManager = {
transports$: constant(new Epoch([bTransport])),
connectionManagerData$: constant(new Epoch(connectionManagerData)),
};
const aLocalTransport: LocalTransport = {
advertised$,
active$: behavior("a", { n: null, a: bTransportWithSFUConfig }),
};
defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation(
() => {
return {} as unknown as Publisher;
},
);
const publisherFactory =
defaultCreateLocalMemberValues.createPublisherFactory as ReturnType<
typeof vi.fn
>;
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: mockConnectionManager,
localTransport$: behavior("a", { a: aLocalTransport }),
});
localMembership.requestJoinAndPublish();
expectObservable(localMembership.localMemberState$).toBe("n-e", {
n: TransportState.Waiting,
e: expect.toSatisfy((e) => e instanceof FailToGetOpenIdToken),
});
// Should not have created any publisher
expect(publisherFactory).toHaveBeenCalledTimes(0);
});
});
it("logs if callIntent cannot be updated", async () => {
const scope = new ObservableScope();
const localTransport$ = new BehaviorSubject(aTransport);
const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig),
};
const mockConnectionManager = {
transports$: constant(new Epoch([])),
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
@@ -264,7 +328,7 @@ describe("LocalMembership", () => {
leaveRoomSession: vi.fn(),
},
connectionManager: mockConnectionManager,
localTransport$,
localTransport$: new BehaviorSubject(aLocalTransport),
});
const expextedLog =
"'not connected yet' while updating the call intent (this is expected on startup)";
@@ -279,10 +343,31 @@ describe("LocalMembership", () => {
const aTransport = {
livekit_service_url: "a",
} as LivekitTransportConfig;
const aTransportWithSFUConfig = {
transport: aTransport,
sfuConfig: {
jwt: "foo",
livekitAlias: "bar",
livekitIdentity: "baz",
url: "bro",
},
} as LocalTransportWithSFUConfig;
const bTransport = {
livekit_service_url: "b",
} as LivekitTransportConfig;
const bTransportWithSFUConfig = {
transport: bTransport,
sfuConfig: {
jwt: "foo2",
livekitAlias: "bar2",
livekitIdentity: "baz2",
url: "bro2",
},
} as LocalTransportWithSFUConfig;
const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({
localParticipant: {
@@ -307,7 +392,11 @@ describe("LocalMembership", () => {
it("recreates publisher if new connection is used, always unpublish and end tracks", async () => {
const scope = new ObservableScope();
const localTransport$ = new BehaviorSubject(aTransport);
const activeTransport$ = new BehaviorSubject(aTransportWithSFUConfig);
const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport),
active$: activeTransport$,
};
const publishers: Publisher[] = [];
let seed = 0;
@@ -343,10 +432,13 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$,
localTransport$: new BehaviorSubject(aLocalTransport),
});
await flushPromises();
localTransport$.next(bTransport);
activeTransport$.next({
...aTransportWithSFUConfig,
transport: bTransport,
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledTimes(2);
@@ -368,8 +460,6 @@ describe("LocalMembership", () => {
it("only start tracks if requested", async () => {
const scope = new ObservableScope();
const localTransport$ = new BehaviorSubject(aTransport);
const publishers: Publisher[] = [];
const tracks$ = new BehaviorSubject<LocalTrack[]>([]);
@@ -396,6 +486,11 @@ describe("LocalMembership", () => {
typeof vi.fn
>;
const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig),
};
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []);
@@ -405,7 +500,7 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$,
localTransport$: new BehaviorSubject(aLocalTransport),
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce();
@@ -428,9 +523,15 @@ describe("LocalMembership", () => {
const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new BehaviorSubject<null | LivekitTransportConfig>(
null,
);
const activeTransport$ =
new BehaviorSubject<null | LocalTransportWithSFUConfig>(null);
const aLocalTransport: LocalTransport = {
advertised$: new BehaviorSubject(aTransport),
active$: activeTransport$,
};
const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData),
);
@@ -470,14 +571,14 @@ describe("LocalMembership", () => {
connectionManager: {
connectionManagerData$,
},
localTransport$,
localTransport$: new BehaviorSubject(aLocalTransport),
});
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual(
TransportState.Waiting,
);
localTransport$.next(aTransport);
activeTransport$.next(aTransportWithSFUConfig);
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected,

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