Compare commits

...

220 Commits

Author SHA1 Message Date
Robin
94ce207581 Allow PostHog dependency updates again 2026-05-20 11:50:01 +02:00
Robin
f3c0b9ed8d Merge pull request #3969 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.18.10
2026-05-18 13:30:48 +02:00
Robin
ac1297d02c Merge pull request #3916 from element-hq/one-on-one-portrait
Create a proper one-on-one call layout for portrait screens
2026-05-18 13:28:59 +02:00
Robin
d66afd734d Merge pull request #3971 from Johennes/johannes/minor-docs-fixes
Minor documentation fixes
2026-05-18 13:17:34 +02:00
Robin
cfbdbca5bb Fix media foreground elements being obscured by header/footer 2026-05-18 13:08:52 +02:00
Johannes Marbach
49f6c74818 Minor documentation fixes
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 11:12:26 +02:00
Robin
d7acce49a3 Merge pull request #3970 from Johennes/johannes/typo
Minor README fixes
2026-05-18 10:12:59 +02:00
Johannes Marbach
18a128b865 Fix linking link
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:56:37 +02:00
Johannes Marbach
80f573e899 Add missing apostrophe
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:50:42 +02:00
Johannes Marbach
0ca2b6d4ea Remove extra space and add colon
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:49:58 +02:00
Johannes Marbach
198d961646 Add missing apostrophe
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:48:46 +02:00
Johannes Marbach
9d522913f9 Add missing colon
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:46:41 +02:00
Johannes Marbach
69b5209071 Fix typo
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:40:08 +02:00
Johannes Marbach
0160f60c6d Reorder sentence fragments for better readability
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:33:36 +02:00
Johannes Marbach
abce4d16fc Add missing article
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:29:47 +02:00
Johannes Marbach
b0c1c48ad4 Add missing article
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:26:42 +02:00
Johannes Marbach
a2c6b8102a Link to self-hosting guide
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:21:17 +02:00
Johannes Marbach
959a55a9f8 Use correct preposition
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:21:00 +02:00
renovate[bot]
4452f3c0c6 Update dependency livekit-client to v2.18.10 2026-05-18 00:39:52 +00:00
Robin
7e932d27da Merge pull request #3939 from element-hq/renovate/github-actions
Update GitHub Actions
2026-05-15 11:42:37 +02:00
Robin
a1e03ea389 Merge pull request #3942 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v10.1.1
2026-05-15 11:36:20 +02:00
Robin
de52ea6590 Fix tests 2026-05-15 11:29:22 +02:00
Robin
9ad5d02e31 Merge branch 'livekit' into one-on-one-portrait 2026-05-15 11:28:47 +02:00
Robin
f3234b1c3e Merge branch 'livekit' into renovate/github-actions 2026-05-15 11:26:25 +02:00
Robin
53acd2596c Merge branch 'livekit' into renovate/compound 2026-05-15 11:20:55 +02:00
fkwp
cec3a799af Posthog add reconnect event (#3953)
* Add Posthog events for Call reconnect including the reason

* Expose single trackCallReconnecting() entry point on PosthogAnalytics

* Track reconnect duration and align with existing analytics pattern

* Refactor combined$ to return [connected, reason] tuple

* Update firefoxUserPrefs to allow getUserMedia and enumerateDevices on CI
---------

Co-authored-by: Valere <bill.carson@valrsoft.com>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Timo K <toger5@hotmail.de>
2026-05-14 23:07:02 +02:00
Robin
b562a0f721 Create a proper one-on-one call layout for portrait screens 2026-05-13 09:54:58 +02:00
renovate[bot]
77f4704b20 Update dependency @vector-im/compound-design-tokens to v10.1.1 2026-05-12 15:58:30 +00:00
renovate[bot]
53e9d05587 Update GitHub Actions 2026-05-12 15:57:48 +00:00
Valere Fedronic
b9f73e3e9a Merge pull request #3962 from element-hq/valere/fix_double_unmute_bug
bugfix: When starting muted, have to click twice to unmute in call
2026-05-12 17:28:27 +02:00
Valere
408a38a7d6 bugfix: When starting muted, have to click twice to unmute in call 2026-05-12 15:46:38 +02:00
Timo
f4ff790d2c Add MediaMuteAndSwitchButton component (storybook only) (#3938)
* add MeidaMuteAndSwitchButton

* User button in footer

* Add tests

* update styling (dark bg on menu open + chevron white + chevron up)

* fix tests

* add storybook to CI

only add storybook with storybook label

test names

another env name test

TestName

new default name

remove label condition

Update pr-deploy.yaml

* Update pr-deploy.yaml

* add toggle example to default component

* hook up footer select actions

* fix video audio button (swapped) and lable in story
2026-05-11 11:50:33 +02:00
Robin
bd2de29470 Merge pull request #3922 from element-hq/hs/compound-switch
Use Compound Switch component
2026-05-11 09:51:56 +02:00
Robin
069074d241 Merge branch 'livekit' into hs/compound-switch 2026-05-11 09:36:08 +02:00
renovate[bot]
78417ff2cb Update dependency @livekit/components-react to v2.9.21 (#3957)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 09:05:59 +02:00
renovate[bot]
1025d23ac7 Update dependency livekit-client to v2.18.9 (#3958)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 08:35:17 +02:00
Valere Fedronic
7010d546bc Merge pull request #3954 from element-hq/valere/playwright_toast_fixture
playwrigth: make dismiss toast more reliable
2026-05-07 14:16:21 +02:00
Valere Fedronic
85bdb007fc Merge pull request #3952 from element-hq/decline-call
Update Playwright test to look for 'Decline' button
2026-05-07 12:25:17 +02:00
Valere
e06f717156 playwrigth: make dismiss toast more reliable 2026-05-07 11:23:59 +02:00
Robin
d50c8e0ca9 Update Playwright test to look for 'Decline' button
Since the label of this button is changing in Element Web.
2026-05-06 12:37:31 +02:00
fkwp
147d0f96e0 [Feature] Support delayed event delegation for legacy JWT request (#3946)
* make `delayed_leave_event_delay_ms` and `network_error_retry_ms` mandatory

* Support delegation for legacy  jwt request

* Calculate `maximumNetworkErrorRetryCount` based on timeouts
2026-05-05 17:33:39 +02:00
Timo
95f772df2b Add storybook to CI (#3948)
* add storybook to CI

only add storybook with storybook label

test names

another env name test

TestName

new default name

remove label condition

Update pr-deploy.yaml

* rename things to check if we actually run the latests ci

* cleanup things used for testing

* Update deploy-to-netlify.yaml

* use package instead of custom environment_name

* final cleanup
2026-05-05 15:46:11 +02:00
fkwp
667723aa92 fix nginx config as the missing 50x.html escalates 502 errors to 404 errors (#3947) 2026-05-05 14:43:03 +02:00
Timo
b276eeee75 Merge pull request #3927 from element-hq/toger5/back-button-press-control-on-esc
Refactor of Escape keyboard event bahvior (allow mute/emoji shortcuts after closing a modal with escape)
2026-05-05 00:39:53 +08:00
Timo K
58f430eb32 review 2026-05-04 18:25:04 +02:00
Robin
5ca50fdb63 Merge pull request #3913 from element-hq/fkwp/feature_relax_homeserver_offline
[Feature] add grace period before reporting the sync loop as disconnected
2026-05-04 18:18:27 +02:00
Robin
7916360b52 Convert all grace period tests to marble tests 2026-05-04 18:01:41 +02:00
Robin
83a9a2a86c Merge pull request #3903 from element-hq/edge-to-edge
Allow calls to display edge-to-edge on mobile
2026-05-04 15:40:08 +02:00
fkwp
0dc8fab256 linting 2026-05-04 13:36:26 +02:00
fkwp
69757b06ce prettier 2026-05-04 13:27:57 +02:00
fkwp
a081b40fb2 add marble test 2026-05-04 13:24:19 +02:00
fkwp
88b5db6675 fix rtsSession initial state 2026-05-04 10:41:48 +02:00
fkwp
22f16174ef addressing review comments 2026-05-04 09:16:55 +02:00
Robin
0470dabff2 Merge pull request #3940 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-05-01 16:10:27 +02:00
renovate[bot]
5a30666939 Update all non-major dependencies 2026-05-01 13:53:45 +00:00
Robin
f093946a29 Allow calls to display edge-to-edge on mobile
By adding viewport-fit=cover to the <meta name="viewport"> header, the page now requests to be displayed edge-to-edge across the entire screen. This gives us control over what we display around camera cut-outs and system navigation UI, if the user agent supports it. I then adjusted the styles of various UI elements to ensure that they still lie within the screen's safe area.
2026-05-01 12:14:40 +02:00
fkwp
6c25048b92 Merge branch 'livekit' into fkwp/feature_relax_homeserver_offline 2026-04-30 21:02:24 +02:00
fkwp
2e4ca9b761 use 10 Seconds of grace period in config.sample.json 2026-04-30 20:54:46 +02:00
fkwp
42aae307f9 use latest stable version of jwt service 2026-04-30 20:53:18 +02:00
fkwp
24c2845958 move starWith to corresponding branch in switchMap 2026-04-30 20:50:45 +02:00
renovate[bot]
51209aa825 Update dependency livekit-client to v2.18.8 (#3934)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-30 20:12:17 +02:00
Timo K
e68e62bf41 Merge branch 'livekit' into toger5/back-button-press-control-on-esc 2026-04-30 18:13:03 +02:00
Timo
d1dc7cd7bb Merge pull request #3936 from element-hq/valere/fix_playwight_test_web_update
Playwright Test: Update test to react to the new call notification changes on web
2026-05-01 00:06:25 +08:00
Valere
09fc38930f fixup 2026-04-30 17:07:13 +02:00
Valere
e79255e23b fix accept call with latest web 2026-04-30 16:47:52 +02:00
Timo K
3caae52ac5 Merge branch 'livekit' into toger5/back-button-press-control-on-esc 2026-04-30 15:18:32 +02:00
Timo K
b4dffa598e fix lint 2026-04-30 13:32:27 +02:00
Timo K
df78ff6bd2 Fix drawer option 2026-04-28 15:39:17 +02:00
Timo K
b5c1b36850 more logging 2026-04-28 13:53:08 +02:00
Half-Shot
5db1a20f9f update snap 2026-04-28 09:29:12 +01:00
Timo K
6b1b316ce4 fix tests 2026-04-27 18:22:06 +02:00
Half-Shot
c196e9ea10 Add name 2026-04-27 14:01:04 +01:00
Half-Shot
ef1e8b5d3f Merge remote-tracking branch 'origin/livekit' into hs/compound-switch 2026-04-27 13:54:46 +01:00
Will Hunt
d335a307bb Update compound-web to 9.3.0 and update Buttons from "sm" to "md". (#3926)
* Update compound-web to 9.3.0 and update Buttons from "sm" to "md".

* Clean up the liast bits

* Update snaps too

* fix test, need to dismiss a new dialog

---------

Co-authored-by: Valere <bill.carson@valrsoft.com>
2026-04-27 13:54:00 +01:00
Timo K
89281c6d70 Refactor leveraging the fact, things blocking shortcuts are using react
portals.
2026-04-27 14:21:02 +02:00
Half-Shot
e3edf3a696 Merge remote-tracking branch 'origin/livekit' into hs/compound-switch 2026-04-27 10:03:41 +01:00
Half-Shot
5aa45714bf Snap updates 2026-04-27 09:34:46 +01:00
Half-Shot
62751787ca Use actual package 2026-04-24 13:02:47 +01:00
Half-Shot
9b71070ef8 publish lock changes 2026-04-24 11:57:13 +01:00
Half-Shot
d8be069747 Fix type 2026-04-24 11:54:24 +01:00
Valere Fedronic
2ce6098085 Merge pull request #3841 from element-hq/valere/devx/use_synapse_api_for_playwright
Use synapse API to register instead doing via UI
2026-04-24 12:50:39 +02:00
Half-Shot
44b6db6ff1 Replace LayoutToggle with compound-web Switch component 2026-04-24 11:47:24 +01:00
Valere
2f2bdfce5b fixup: wrong skip 2026-04-24 12:32:41 +02:00
Valere
82b7e243eb This test does not need to run on both FF and chrome 2026-04-24 12:10:24 +02:00
Valere
0d5c71bb4e fixup 2026-04-24 11:29:34 +02:00
Valere
0864b7d828 Merge branch 'livekit' into valere/devx/use_synapse_api_for_playwright 2026-04-24 11:25:10 +02:00
Valere
ca305b9da1 fix: wrong usage of poll 2026-04-24 11:24:00 +02:00
Valere
6317a06472 review: quick cleaning 2026-04-24 11:21:14 +02:00
renovate[bot]
93657ee3c4 Update dependency livekit-client to v2.18.6 (#3919)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 09:52:04 +02:00
renovate[bot]
f98e6d1c08 Update dependency uuid to v14 [SECURITY] (#3918)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 09:20:42 +02:00
renovate[bot]
8bd6b04451 Update dependency livekit-client to v2.18.5 (#3912)
* Update dependency livekit-client to v2.18.5

* reenable "Should show error screen if call creation is restricted"

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2026-04-23 19:27:07 +02:00
fkwp
39ef815d8b Merge branch 'livekit' into fkwp/feature_relax_homeserver_offline 2026-04-23 17:29:14 +02:00
Robin
72d922468e Merge pull request #3914 from element-hq/valere/playwright/update_test_for_latest_web
Dismiss the new invite confirmation modal
2026-04-23 17:06:34 +02:00
fkwp
0abc32b80e prettier 2026-04-23 15:47:40 +02:00
Valere
3699138eaa Dismiss the new invite confirmation modal 2026-04-23 15:36:46 +02:00
fkwp
c1f821ca0f Update default sync disconnect grace period to 10000ms in configuration and related functions 2026-04-23 15:33:00 +02:00
Valere
a7788a2bfa Dismiss the new invite confirmation modal 2026-04-23 11:33:52 +02:00
fkwp
90ba38d2bc setup SFU webhooks for local dev environment to be compatible with delegation of delayed events (#3717)
* setup webhooks for SFUs

* add ca certs to SFU containers such that webhooks are accepted
2026-04-23 10:06:05 +02:00
Valere
d9dc61a49d mark test as slow 2026-04-23 09:50:01 +02:00
fkwp
283c606b9d Add tests for grace period handling in createHomeserverConnected$ 2026-04-22 21:46:25 +02:00
fkwp
ee343935a4 Merge branch 'fkwp/delegation_of_delayed_events' into fkwp/feature_relax_homeserver_offline 2026-04-22 21:11:23 +02:00
fkwp
032ec662e9 bump lk-jwt-service 2026-04-22 21:08:49 +02:00
fkwp
9fccd49d7d Improve the handling of the grace period for the home server connection to support both immediate and delayed emissions. 2026-04-22 21:08:31 +02:00
Valere
84ac0cc47d mark test as slow 2026-04-22 20:56:42 +02:00
fkwp
9f2db7cfc3 Merge branch 'livekit' into fkwp/delegation_of_delayed_events 2026-04-22 20:42:00 +02:00
Valere
e5c11eee13 fix bad assert for reconnect 2026-04-22 20:27:30 +02:00
Valere
6edc4084c6 adjust timeouts 2026-04-22 20:16:59 +02:00
Valere
6d70247acb this test should be slow (taking more than 30s) 2026-04-22 20:13:00 +02:00
Valere
21b78d6dad test something in the reconnect bug 2026-04-22 19:30:15 +02:00
Valere
4d8e4c66eb adjust time out 2026-04-22 19:30:02 +02:00
Valere
78e7adc4c6 remove redundant toBeVisible before click 2026-04-22 18:49:51 +02:00
Valere
5c4d6d0317 turn an assertion as retryable with expect.poll 2026-04-22 18:06:24 +02:00
Valere
1b9682869a adjust timeouts 2026-04-22 17:25:14 +02:00
Valere
854cfdfe1b adjust timeouts 2026-04-22 16:51:04 +02:00
Valere
f355aa1ed7 adjust timeout 2026-04-22 15:40:06 +02:00
fkwp
9e2eef09d4 Add support for a grace period for /sync (aka homeserver disconnected) interruptions 2026-04-22 15:18:50 +02:00
Valere
879526ce5e adjust flaky timeouts 2026-04-22 15:17:40 +02:00
Timo
9525136c91 Merge pull request #3908 from element-hq/toger5/back-button-press-control-on-esc
Allow Android native back gesture/button to close settings in EC (only if settings are closed EC will go into pip)
2026-04-22 21:15:33 +08:00
Valere
5dd1c76d1c increase timeout of flaky test 2026-04-22 14:42:35 +02:00
Timo
2e149ad08c Merge pull request #3895 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.18.4
2026-04-22 20:30:20 +08:00
Valere
f9dc4d9b82 Update a tohaveCount() timeout that made a test flak 2 times 2026-04-22 14:14:54 +02:00
Timo K
56bab70534 add tests 2026-04-22 12:26:03 +02:00
Valere
2d9802b6f5 Merge branch 'livekit' into valere/devx/use_synapse_api_for_playwright 2026-04-22 12:10:28 +02:00
Timo K
2b46925e77 Call window.controls.onBackButtonPressed if esc is pressed without any
focus.
2026-04-22 11:51:33 +02:00
renovate[bot]
33172837e7 Update dependency livekit-client to v2.18.4 2026-04-22 07:23:47 +00:00
Valere
55de3fd216 tests optimizations 2026-04-22 09:03:57 +02:00
Timo
50ad678a42 Merge pull request #3877 from element-hq/toger5/playwright-race-await
Fix race in playwright sticky test
2026-04-22 01:07:07 +08:00
Timo
4155d4c9b3 Merge pull request #3896 from element-hq/toger5/bump-pnpm-to-v10
post pnpm transition work
2026-04-22 01:01:58 +08:00
Timo
a1d0a742b2 Merge pull request #3852 from element-hq/toger5/posthog-call-ended-duration-fix
Only send posthog callEnded Event if the user has joined the call.
2026-04-22 01:01:46 +08:00
Valere
0e2d2c6f72 try paralelize registrations? 2026-04-21 18:36:40 +02:00
Valere
754a42ffd1 increase default timeout on huddle test 2026-04-21 18:14:51 +02:00
Valere
701edd9522 fixup lint 2026-04-21 17:54:10 +02:00
Valere
0127040a8c Remove slow? no registration anymore 2026-04-21 17:51:42 +02:00
Valere
24e721f572 fix web stuck problem! go to / and not to #/login 2026-04-21 17:51:42 +02:00
Valere
4211405e7b Use synapse API to register instead doing via UI 2026-04-21 17:51:42 +02:00
Timo K
ea73e5881a pevara 2026-04-21 17:29:15 +02:00
Timo K
bbb73b27cb fix knip 2026-04-21 17:20:58 +02:00
Timo K
ecb2a6846f prettier 2026-04-21 17:13:13 +02:00
Timo K
2d16487ca1 fix sticky send race 2026-04-21 17:11:40 +02:00
Timo K
d28cad3f12 fix matrix-js-sdk branch reference 2026-04-21 16:45:00 +02:00
Timo K
92338a2e7c review 2026-04-21 16:12:14 +02:00
Timo K
6ae88531ec pnpm v10.33.0 + lock file 2026-04-21 16:10:57 +02:00
Timo K
8728676eb6 update CI to ignore pnpmfile
- This is save since we never run pnpmfiles in ci
 - We can still run it locally
 - It helps us detect if we accidently commit it anyways.
2026-04-21 16:10:45 +02:00
Timo K
741b82b026 update linking realted logic
- remove post-commit hook
 - remove .links.cjs enable/disable (instead just add/rm .pnpmfile.cjs)
 - rename pnpm links:enable -> pnpm links:on
 - rename pnpm links:disable -> pnpm links:off
 - unify doc filenames `-` -> `_`
 - add linking_concept_reasoning.md to provide background why the
   linking is how it is.
2026-04-21 16:06:51 +02:00
Timo K
b58076f1b9 remove pnpmfileChecksum and .pnpmfile.cjs 2026-04-21 15:06:06 +02:00
Robin
ab05e8d250 Merge pull request #3900 from element-hq/invert-buttons-again
Invert the microphone and video button colors again
2026-04-21 14:09:29 +02:00
Valere Fedronic
0894659cea Merge pull request #3901 from element-hq/valere/devx/fix_playwright_netlify
fix: using secrets. instead of vars. for NETLIFY_SITE_ID
2026-04-21 13:50:51 +02:00
Valere
84ed5943ea fix: using vars. instead of secrets. for NETLIFY_SITE_ID 2026-04-21 11:55:03 +02:00
Valere Fedronic
d6f6542945 Merge pull request #3891 from element-hq/valere/playwrigth/screen_share_test
test: add a screenshare test
2026-04-21 11:22:12 +02:00
Robin
e9fd84816e Invert the colors of the camera and microphone buttons again
Turns out design prefers the button colors how they were prior to fa844446b6.
2026-04-21 11:21:48 +02:00
Timo K
00d6e034a0 fixup some missing yarn -> pnpm transitions 2026-04-21 10:53:56 +02:00
Timo K
4d09651fe1 Bump pnpm to v10 2026-04-21 10:00:36 +02:00
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
Valere
8b4762fb29 review: use role locator instead of testID 2026-04-17 17:29:35 +02:00
Valere
eeafd81d31 test: add a screenshare test 2026-04-17 14:58:10 +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
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 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
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
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
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
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
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
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
5f0afd3edc Add tests to make sure we always have one settings button. 2026-04-13 15:49:28 +02:00
Timo K
270726d446 Add tests 2026-04-13 13:38:35 +02: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
Timo K
1c5b483a7e Only send callEndedEvent if the user has joined the call. 2026-04-07 14:11:55 +02:00
fkwp
cbb9e7a289 Merge branch 'livekit' into fkwp/delegation_of_delayed_events 2026-04-01 11:46:29 +02:00
fkwp
4006a8d9f9 Merge branch 'livekit' into fkwp/delegation_of_delayed_events 2026-03-27 15:29:16 +01:00
fkwp
7b1eb03e2a add ca certs to SFU containers such that webhooks are accepted 2026-02-06 11:54:39 +01:00
fkwp
d3a4234f4a setup webhooks for SFUs 2026-02-06 11:54:22 +01:00
158 changed files with 19169 additions and 18314 deletions

View File

@@ -1,11 +0,0 @@
#!/usr/bin/sh
FILE=.links.temp-disabled.yaml
if test -f "$FILE"; 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
yarnLog=$(yarn)
echo "[yarn-linker] The post-commit hook has re-enabled .links.yaml."
exit 1
fi

View File

@@ -1,11 +1,9 @@
#!/usr/bin/sh
#!/usr/bin/env bash
FILE=".links.yaml"
if test -f "$FILE"; then
mv .links.yaml .links.temp-disabled.yaml
# 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."
# Checks if there currently is linking configured. Informs the user to disable linking before committing.
PNPMFILE=.pnpmfile.cjs
if test -f "$PNPMFILE"; then
echo "[pnpm-linker] The pre-commit hook detected $PNPMFILE which implies you have linked packages in your pnpm-lock.yaml. Run pnpm links:off and commit again. See also linking.md."
exit 1
fi

View File

@@ -37,15 +37,16 @@ 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"
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- 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

@@ -85,3 +85,31 @@ jobs:
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build_storybook:
name: Build Storybook
if: contains(github.event.pull_request.labels.*.name, 'storybook build')
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: pnpm cache
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Install dependencies
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Build Storybook
run: pnpm run build-storybook
- name: Upload Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: build-output-storybook
path: storybook-static
# We'll only use this in a triggered job, then we're done with it
retention-days: 1

View File

@@ -17,7 +17,7 @@ on:
package:
required: true
type: string
description: Which package to deploy - 'full', 'embedded', or 'sdk'
description: Which package to deploy - 'full', 'embedded', 'sdk', or 'storybook'
artifact_run_id:
required: false
type: string
@@ -43,7 +43,7 @@ jobs:
with:
step: start
token: ${{ secrets.GITHUB_TOKEN }}
env: Netlify
env: ${{ inputs.package}}
ref: ${{ inputs.deployment_ref }}
desc: |
Do you trust the author of this PR? Maybe this build will steal your keys or give you malware.
@@ -59,9 +59,13 @@ jobs:
- name: Add redirects file
# We fetch from github directly as we don't bother checking out the repo
# Not needed for storybook deployments
if: inputs.package != 'storybook'
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
- name: Add config file
# Not needed for storybook deployments
if: inputs.package != 'storybook'
run: |
if [ "${INPUTS_PACKAGE}" = "full" ]; then
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview.json" > webapp/config.json
@@ -78,7 +82,7 @@ jobs:
with:
publish-dir: webapp
deploy-message: "Deploy from GitHub Actions"
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', inputs.pr_number) || inputs.package == 'storybook' && format('pr{0}-storybook', inputs.pr_number) || format('pr{0}', inputs.pr_number) }}
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}

View File

@@ -12,20 +12,21 @@ 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"
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- 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

@@ -14,7 +14,7 @@ jobs:
# 2. Event must be a pull_request
# 3. Head repository must be the SAME as the base repository (No Forks!)
if: >
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
@@ -63,6 +63,24 @@ jobs:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
netlify-storybook:
needs: prdetails
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'storybook build') }}
permissions:
deployments: write
uses: ./.github/workflows/deploy-to-netlify.yaml
with:
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
pr_number: ${{ needs.prdetails.outputs.pr_number }}
pr_head_full_name: ${{ github.event.workflow_run.head_repository.full_name }}
pr_head_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.ref }}
deployment_ref: ${{ needs.prdetails.outputs.pr_data_json && fromJSON(needs.prdetails.outputs.pr_data_json).head.sha || github.ref || github.head_ref }}
package: storybook
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
docker:
if: ${{ needs.prdetails.outputs.pr_data_json && contains(fromJSON(needs.prdetails.outputs.pr_data_json).labels.*.name, 'docker build') }}
needs: prdetails

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@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # 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@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # 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@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # 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@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
append_body: true
body: |

View File

@@ -42,7 +42,7 @@ jobs:
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 }}
site_id: ${{ secrets.NETLIFY_SITE_ID }}
desc: Playwright Report
deployment_env: EndToEndTests
prefix: "e2e-"

View File

@@ -14,15 +14,16 @@ 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"
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Vitest
run: "yarn run test:coverage"
run: "pnpm run test:coverage"
- name: Upload to codecov
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
env:
@@ -42,12 +43,13 @@ 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
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: pnpm install --frozen-lockfile --ignore-pnpmfile
- 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,7 +58,7 @@ jobs:
- name: Run Playwright tests
env:
USE_DOCKER: 1
run: yarn playwright test
run: pnpm exec playwright test
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:

View File

@@ -22,11 +22,12 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
cache: "pnpm"
node-version-file: ".node-version"
- name: Install Deps
run: "yarn install --immutable"
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Prune i18n
run: "rm -R locales"
@@ -40,7 +41,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

View File

@@ -20,4 +20,4 @@ jobs:
persist-credentials: false
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3

5
.gitignore vendored
View File

@@ -21,9 +21,14 @@ 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/

View File

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

View File

@@ -1,7 +1,14 @@
/*
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/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-docs"],
framework: "@storybook/react-vite",
};

View File

@@ -1,3 +1,10 @@
/*
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";

View File

@@ -1,3 +1,10 @@
/*
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";

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

@@ -66,7 +66,7 @@ requiring a separate Matrix client.
### 📲 In-App Calling (Widget Mode in Messenger Apps)
When used as a widget 🧩, Element Call is solely responsible on the core calling
When used as a widget 🧩, Element Call is solely responsible for the core calling
functionality (MatrixRTC). Authentication, event handling, and room state
updates (via the Client-Server API) are handled by the hosting client.
Communication between Element Call and the client is managed through the widget
@@ -108,18 +108,18 @@ recommended method for embedding Element Call.
</p>
For more details on the packages, see the
[Embedded vs. Standalone Guide](./docs/embedded-standalone.md).
[Embedded vs. Standalone Guide](./docs/embedded_standalone.md).
## 🛠️ Self-Hosting
For operating and deploying Element Call on your own server, refer to the
[**Self-Hosting Guide**](./docs/self-hosting.md).
[**Self-Hosting Guide**](./docs/self_hosting.md).
## 🧭 MatrixRTC Backend Discovery and Selection
For proper Element Call operation each site deployment needs a MatrixRTC backend
setup as outlined in the [Self-Hosting](#self-hosting). A typical federated site
deployment for three different sites A, B and C is depicted below.
setup as outlined in the [Self-Hosting Guide](./docs/self_hosting.md). A typical
federated site deployment for three different sites A, B and C is depicted below.
<p align="center">
<img src="./docs/Federated_Setup.drawio.png" alt="Element Call federated setup">
@@ -127,7 +127,7 @@ deployment for three different sites A, B and C is depicted below.
### Backend Discovery
MatrixRTC backend (according to
The MatrixRTC backend (according to
[MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)) is
announced by the Matrix site's `.well-known/matrix/client` file and discovered
via the `org.matrix.msc4143.rtc_foci` key, e.g.:
@@ -151,11 +151,10 @@ via `livekit_service_url`.
- Each call participant proposes their discovered MatrixRTC backend from
`org.matrix.msc4143.rtc_foci` in their `org.matrix.msc3401.call.member` state event.
- For **LiveKit** MatrixRTC backend
- For the **LiveKit** MatrixRTC backend
([MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)),
the **first participant who joined the call** defines via the `foci_preferred`
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
will be used for this call.
the **first participant who joined the call** defines which backend will be used for this call via
the `foci_preferred` key in their `org.matrix.msc3401.call.member` state event.
- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)**
provides the client with the **LiveKit SFU WebSocket URL** and an
**access JWT token** in order to exchange media via WebRTC.
@@ -186,7 +185,7 @@ To get started clone and set up this project:
git clone https://github.com/element-hq/element-call.git
cd element-call
corepack enable
yarn
pnpm install
```
To use it, create a local config by, e.g.,
@@ -197,12 +196,12 @@ environment as outlined in the next section out of box.
You're now ready to launch the development server:
```sh
yarn dev
pnpm dev
```
See also:
- [Developing with linked packages](./linking.md)
- [Developing with linked packages](./docs/linking.md)
### Backend
@@ -210,12 +209,12 @@ A docker compose file `dev-backend-docker-compose.yml` is provided to start the
whole stack of components which is required for a local development environment
including federation:
- Minimum Synapse Setup (servernameis: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Minimum Synapse Setup (servernames: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
- MatrixRTC Authorization Service (Note: requires Federation API and hence a TLS reverse proxy)
- Minimum LiveKit SFU setup using dev defaults for config
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
- Hostnames: `m.localhost`, `*.m.localhost`, `*.othersite.m.localhost`
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browser's trusted
certificates
- Minimum TLS reverse proxy for
- Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
@@ -230,8 +229,8 @@ only for local development and **_never be exposed to the public Internet._**
Run backend components:
```sh
yarn backend
# or for podman-compose
pnpm backend
# or for podman-compose:
# podman-compose -f dev-backend-docker-compose.yml up
```
@@ -242,7 +241,7 @@ yarn backend
> `https://synapse.m.localhost/.well-known/matrix/client`. This can be either
> done by adding the minimum localhost CA
> ([./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt)) to your web
> browsers trusted certificates or by simply copying and pasting each URL into
> browser's trusted certificates or by simply copying and pasting each URL into
> your browsers address bar and follow the prompts to add the exception.
### Playwright tests
@@ -260,13 +259,13 @@ on https://localhost:3000 (this is configured in `playwright.config.ts`) - this
is what will be tested.
The local backend environment should be running for the test to work:
`yarn backend`
`pnpm backend`
There are a few different ways to run the tests yourself. The simplest is to
run:
```shell
yarn run test:playwright
pnpm run test:playwright
```
This will run the Playwright tests once, non-interactively.
@@ -274,7 +273,7 @@ This will run the Playwright tests once, non-interactively.
There is a more user-friendly way to run the tests in interactive mode:
```shell
yarn run test:playwright:open
pnpm run test:playwright:open
```
The easiest way to develop new test is to use the codegen feature of Playwright:
@@ -316,7 +315,7 @@ To add a new translation key you can do these steps:
1. Add the new key entry to the code where the new key is used:
`t("some_new_key")`
1. Run `yarn i18n` to extract the new key and update the translation files. This
1. Run `pnpm i18n` to extract the new key and update the translation files. This
will add a skeleton entry to the `locales/en/app.json` file:
```jsonc

View File

@@ -1,6 +1,6 @@
# Testing Element-Call in widget mode
When running `yarn backend` the latest element-web develop will be deployed and served on `http://localhost:8081`.
When running `pnpm backend` the latest element-web develop will be deployed and served on `http://localhost:8081`.
In a development environment, you might prefer to just use the `element-web` repo directly, but this setup is useful for CI/CD testing.
## Setup
@@ -18,7 +18,7 @@ that uses
It is part of the existing backend setup. To start the backend, run:
```sh
yarn backend
pnpm backend
```
Then open `http://localhost:8081` in your browser.

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true
enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true
enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true

View File

@@ -18,3 +18,7 @@ keys:
devkey: secret
room:
auto_create: false
webhook:
api_key: devkey
urls:
- https://matrix-rtc.othersite.m.localhost/livekit/jwt/sfu_webhook

View File

@@ -18,3 +18,7 @@ keys:
devkey: secret
room:
auto_create: false
webhook:
api_key: devkey
urls:
- https://matrix-rtc.m.localhost/livekit/jwt/sfu_webhook

View File

@@ -28,14 +28,19 @@ server {
# Reason: the lk-jwt-service uses the federation API for the openid token
# verification, which requires TLS
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass "http://homeserver:8008";
proxy_pass "http://homeserver:8008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Host $host;
}
location ~ ^(/_matrix|/_synapse/admin) {
proxy_pass "http://homeserver:8008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
}
@@ -73,10 +78,16 @@ server {
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
location ~ ^(/_matrix|/_synapse/admin) {
proxy_pass "http://homeserver-1:18008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
}
@@ -108,7 +119,7 @@ server {
# JWT Service running at port 6080
proxy_pass http://jwt-auth-services/;
}
location ^~ /livekit/sfu/ {
@@ -128,8 +139,6 @@ server {
# LiveKit SFU websocket connection running at port 7880
proxy_pass http://livekit-sfu:7880/;
}
error_page 500 502 503 504 /50x.html;
}
@@ -156,7 +165,7 @@ server {
# JWT Service running at port 16080
proxy_pass http://auth-service-1:16080/;
}
location ^~ /livekit/sfu/ {
@@ -176,14 +185,12 @@ server {
# LiveKit SFU websocket connection running at port 17880
proxy_pass http://livekit-sfu-1:17880/;
}
error_page 500 502 503 504 /50x.html;
}
# Convenience reverse proxy for the call.m.localhost domain to element call
# running on the host either via
# - yarn dev --host or
# - pnpm dev --host or
# - falling back to http (the element call docker container)
server {
listen 80;
@@ -228,7 +235,6 @@ server {
proxy_pass http://host.docker.internal:8080;
}
error_page 500 502 503 504 /50x.html;
}
@@ -260,8 +266,6 @@ server {
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}
@@ -293,7 +297,5 @@ server {
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true
enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true

View File

@@ -50,6 +50,9 @@ max_event_delay_duration: 24h
enable_registration: true
enable_registration_without_verification: true
# Shared secret for admin user registration via API (for testing only!)
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true

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:
@@ -62,7 +62,10 @@ services:
- 7882:7882/tcp
- 50100-50200:50100-50200/udp
volumes:
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
- ./backend/dev_livekit.yaml:/etc/livekit.yaml:Z
environment:
- SSL_CERT_FILE=/local_cert.pem
networks:
- ecbackend
@@ -82,7 +85,10 @@ services:
- 17882:17882/tcp
- 50300-50400:50300-50400/udp
volumes:
- ./backend/dev_tls_m.localhost.crt:/local_cert.pem:Z
- ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
environment:
- SSL_CERT_FILE=/local_cert.pem
networks:
- ecbackend
@@ -164,6 +170,8 @@ services:
- "8448:8448"
extra_hosts:
- "host.docker.internal:host-gateway"
- "auth-server:127.0.0.1"
- "auth-server-1:127.0.0.1"
depends_on:
- synapse
networks:

View File

@@ -2,8 +2,8 @@
This folder contains documentation for setup, usage, and development of Element Call.
- [Embedded vs standalone mode](./embedded-standalone.md)
- [Url format and parameters](./url-params.md)
- [Embedded vs standalone mode](./embedded_standalone.md)
- [Url format and parameters](./url_params.md)
- [Global JS controls](./controls.md)
- [Self-Hosting](./self-hosting.md)
- [Self-Hosting](./self_hosting.md)
- [Developing with linked packages](./linking.md)

View File

@@ -12,7 +12,7 @@ A few aspects of Element Call's interface can be controlled through a global API
On mobile platforms (iOS, Android), web views do not reliably support selecting audio output devices such as the main speaker, earpiece, or headset. To address this limitation, the following functions allow the hosting application (e.g., Element Web, Element X) to manage audio devices via exposed JavaScript interfaces. These functions must be enabled using the URL parameter `controlledAudioDevices` to take effect.
- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?, boolean; }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
- `controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?: boolean }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
It flags the device that should be used if the user selects earpiece mode. This should be the main stereo loudspeaker of the device.
- `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
- `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example.

View File

@@ -14,7 +14,7 @@ The table below provides a comparison of the two packages:
| **Release artifacts** | Docker Image, Tarball | Tarball, NPM for Web, Android AAR, SwiftPM for iOS |
| **Recommended for** | Standalone/guest access usage | Embedding within messenger apps |
| **Responsibility for regulatory compliance** | The administrator that is deploying the app is responsible for compliance with any applicable regulations (e.g. privacy) | The developer of the messenger app is responsible for compliance |
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url-params.md#embedded-only-parameters) if consent has been granted. |
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url_params.md#embedded-only-parameters) if consent has been granted. |
| **Analytics data** | Element Call will send data to the Posthog, Sentry and Open Telemetry targets specified by the administrator in the `config.json` | Element Call will send data to the Posthog and Sentry targets specified in the URL parameters by the messenger app |
### Using the embedded package within a messenger app
@@ -25,8 +25,8 @@ The basics are:
1. Add the appropriate platform dependency as given for a [release](https://github.com/element-hq/element-call/releases), or use the embedded tarball. e.g. `npm install @element-hq/element-call-embedded@0.9.0`
2. Include the assets from the platform dependency in the build process. e.g. copy the assets during a [Webpack](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/webpack.config.js#L677-L682) build.
3. Use the `index.html` entrypointof the imported assets when you are constructing the WebView or iframe. e.g. using a [relative path in a webapp](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/src/models/Call.ts#L680), or on the the Android [WebViewAssetLoader](https://github.com/element-hq/element-x-android/blob/fe5aab6588ecdcf9354a3bfbd9e97c1b31175a8f/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt#L20)
4. Set any of the [embedded-only URL parameters](./url-params.md#embedded-only-parameters) that you need.
3. Use the `index.html` entrypoint of the imported assets when you are constructing the WebView or iframe. e.g. using a [relative path in a webapp](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/src/models/Call.ts#L680), or on the the Android [WebViewAssetLoader](https://github.com/element-hq/element-x-android/blob/fe5aab6588ecdcf9354a3bfbd9e97c1b31175a8f/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt#L20)
4. Set any of the [embedded-only URL parameters](./url_params.md#embedded-only-parameters) that you need.
## Widget vs standalone mode
@@ -35,5 +35,5 @@ Element Call is developed using the [js-sdk](https://github.com/matrix-org/matri
As a widget, the app only uses the core calling (MatrixRTC) parts. The rest (authentication, sending events, getting room state updates about calls) is done by the hosting client.
Element Call and the hosting client are connected via the widget API.
Element Call detects that it is run as a widget if a widgetId is defined in the url parameters. If `widgetId` is present then Element Call will try to connect to the client via the widget postMessage API using the parameters provided in [Url Format and parameters
](./url-params.md).
Element Call detects that it is run as a widget if `widgetId` is defined in the url parameters. If `widgetId` is present then Element Call will try to connect to the client via the widget postMessage API using the parameters provided in [Url Format and parameters
](./url_params.md).

View File

@@ -1,39 +1,65 @@
# Developing with linked packages
## Quickstart guide
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.
Run:
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:
```yaml
matrix-js-sdk: ../path/to/matrix-js-sdk
"@vector-im/compound-web": /home/alice/path/to/compound-web
```bash
./scripts/setup-linking.sh
```
Then run `yarn install`.
Read the script output:
```
Setup complete.
Update: .links.cjs to your liking
Run: 'pnpm links:on' to test your .links.cjs
Run: 'git commit' with links enabled to test the git pre-commit hook.
Run: 'pnpm links:off' to be able to commit again
Run: 'git config --local core.hooksPath ""' to allow committing with linking (not recommended)
Run: 'rm links.cjs' & 'git config --local core.hooksPath ""' to fully revert what this script did
```
# 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. `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, create a file named `.links.cjs` in the Element Call project directory (or run `./scripts/setup-linking.sh` to create a template), listing the names and paths of any dependencies you want to link. For example:
```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 `pnpm links:on`. (this will activate the pnpm file + run `pnpm install` to setup the linking)
## Hooks
Changes in `.links.yaml` will also update `yarn.lock` when `yarn` is executed. The lockfile will then contain the local
Changes in `.links.cjs` will also update `pnpm-lock.yaml` when `pnpm install` 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:
One always needs to remove the pnpm `readPackage` script (the `.pnpmfile.cjs`) and run:
```bash
mv .links.yaml .links.disabled.yaml
yarn
pnpm install
```
before committing a change.
To make it more convenient to work with this linking system we added git hooks for your conviniece.
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.
To make this less of a foot gun we added a git hook.
A `pre-commit` hook will check if linking is currently used. If it detects
a `.pnpmfile.cjs` file it will abort the commit with an explanatory message.
You will then need to run `pnpm links:off` and commit again.
A `post-commit` hook will setup the linking as it was
before if a `.links.disabled.yaml` is present. It runs `mv .links.disabled.yaml .links.yaml` and `yarn`.
To activate the hooks automatically configure git with
To activate the hooks configure git with (when using the setup script (`./scripts/setup-linking.sh`) this is already done):
```bash
git config --local core.hooksPath .githooks/
git config --local core.hooksPath .githooks
```
This will add the hook path for this repository only to .gihooks. which is a tracked (by git) folder containing the pre-commit hook.
## Background
Information, why this approach is used can be found in the [linking concept reasoning](./linking_concept_reasoning.md) document.

View File

@@ -0,0 +1,30 @@
### Why do we not enable .pnpmfile.cjs by default
Background: The presence of the `.pnpmfile.cjs` adds a field to the `pnpm-lock.yaml` called: `pnpmfileChecksum`. This field is a checksum of the content of the `.pnpmfile.cjs` file.
`pnpm install --frozen-lockfile` **fails** if there is a `.pnpmfile.cjs` but no `pnpmfileChecksum` or vice versa (or on mismatch).
_TLDR: running with `--ignore-pnpmfile` will fail if `pnpmfileChecksum` is present._
#### `pnpmfileChecksum` + renovate bot
When the renovate bot creates a PR it runs `pnpm install --ignore-pnpmfile`. This means that the `pnpmfileChecksum` in the lockfile will be **empty**.
This breaks builds that **don't** ignore the `.pnpmfile.cjs`-file. (CI that runs on the renovate PR)
From here we have two possible paths:
- ignore `.pnpmfile.cjs` in all CI builds (CI will also fail if we accidently add it locally).
- fixup the `pnpm-lock.yaml` in the renovate PR to contain the correct `pnpmfileChecksum`.
Ignoring in all CI builds means that CI will always fail if we enable the linking system.
This is annoying but can be worked around with the git hook we provide that at least lets us know that we are
commiting with enabled linking.
Only if we remember setting it back/disbale linking (or let ourselves remember by the git hook) the CI will work.
#### Summary
- We will always run into conflicts with the `pnpmfileChecksum` because in renovate prs it will be empty (`--ignore-pnpmfile`)
- To keep it simple we set `--ignore-pnpmfile` in all of our CI builds to see issues immediately.
- The only solution is to never have a `.pnpmfile.cjs` in the repository when pushing.
- This way there will never be a commit with `pnpmfileChecksum` in the lockfile.
- renovate (which uses `--ignore-pnpmfile` which we cannot disable) and other CI will work.
- We are able to use the linking system locally if we `cp` this file from the scripts folder into `./` on demand.
- `pnpm links:on` and `pnpm links:off` + `./scripts/setup-linking.sh` will help us with this.

View File

@@ -58,7 +58,7 @@ rc_message:
rc_delayed_event_mgmt:
# This needs to match at least the heart-beat frequency plus a bit of headroom
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2Hz
per_second: 1
burst_count: 20
```
@@ -70,7 +70,7 @@ make sure that your Synapse server has either a `federation` or `openid`
### MatrixRTC Backend
In order to **guarantee smooth operation** of Element Call MatrixRTC backend is
In order to **guarantee smooth operation** of Element Call, a MatrixRTC backend is
required for each site deployment.
![MSC4195 compatible setup](MSC4195_setup.drawio.png)
@@ -190,8 +190,8 @@ backend mxrtc_auth_backend
> [!IMPORTANT]
> As defined in
> [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)
> MatrixRTC backend must be announced to the client via your **Matrix site's
> [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143),
> the MatrixRTC backend(s) must be announced to the client via your **Matrix site's
> `.well-known/matrix/client`** file (e.g.
> `example.com/.well-known/matrix/client` matching the site deployment example
> from above). The configuration is a list of Foci configs:
@@ -222,7 +222,7 @@ Access-Control-Allow-Headers: X-Requested-With, Content-Type, Authorization
> [!NOTE]
> Most `org.matrix.msc4143.rtc_foci` configurations will only have one entry in
> the array
> the array.
## Building Element Call
@@ -237,8 +237,8 @@ source. First, clone and install the package:
git clone https://github.com/element-hq/element-call.git
cd element-call
corepack enable
yarn
yarn build
pnpm install
pnpm build
```
If all went well, you can now find the build output under `dist` as a series of
@@ -291,7 +291,7 @@ be able to handle those yet and it may behave unreliably.
Therefore, to use a self-hosted homeserver, this is recommended to be a new
server where any user account created has not joined any normal rooms anywhere
in the Matrix federated network. The homeserver used can be setup to disable
in the Matrix federated network. The homeserver used can be set up to disable
federation, so as to prevent spam registrations (if you keep registrations open)
and to ensure Element Call continues to work in case any user decides to log in
to their Element Call account using the standard Element app and joins normal

View File

@@ -4,7 +4,7 @@ There are two formats for Element Call URLs.
## Link for sharing
Requires Element Call to be deployed in [standalone](./embedded-standalone.md) mode.
Requires Element Call to be deployed in [standalone](./embedded_standalone.md) mode.
```text
https://element_call.domain/room/#
@@ -12,7 +12,7 @@ https://element_call.domain/room/#
```
The URL is split into two sections. The `https://element_call.domain/room/#`
contains the app and the intend that the link brings you into a specific room
contains the app and the intent that the link brings you into a specific room
(`https://call.element.io/#` would be the homepage). The fragment is used for
query parameters to make sure they never get sent to the element_call.domain
server. Here we have the actual Matrix room ID and the password which are used
@@ -36,15 +36,15 @@ possible to support encryption.
| Package | Deployment | URL |
| ------------------------------------ | ----------------------------- | ----------------------------------------------------------------------------- |
| [Full](./embedded-standalone.md) | All | `https://element_call.domain/room` |
| [Embedded](./embedded-standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
| [Embedded](./embedded-standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
| [Full](./embedded_standalone.md) | All | `https://element_call.domain/room` |
| [Embedded](./embedded_standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
| [Embedded](./embedded_standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
## Parameters
### Common Parameters
These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes:
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 |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -74,22 +74,22 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
### Widget-only parameters
These parameters are only supported in [widget](./embedded-standalone.md) mode.
These parameters are only supported in [widget](./embedded_standalone.md) mode.
| Name | Values | Required | Description |
| --------------- | ----------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | | Yes | The base URL of the homeserver to use for media lookups. |
| `deviceId` | Matrix device ID | Yes | The Matrix device ID for the widget host. |
| `parentUrl` | | Yes | The url used to send widget action postMessages. This should be the domain of the client or the webview the widget is hosted in. (in case the widget is not in an Iframe but in a dedicated webview we send the postMessages same WebView the widget lives in. Filtering is done in the widget so it ignores the messages it receives from itself) |
| `posthogUserId` | Posthog user identifier | No | This replaces the `analyticsID` parameter |
| `preload` | `true` or `false` | No, defaults to `false` | Pauses app before joining a call until an `io.element.join` widget action is seen, allowing preloading. |
| `returnToLobby` | `true` or `false` | No, defaults to `false` | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. |
| `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | The Matrix user ID. |
| `widgetId` | [MSC2774](https://github.com/matrix-org/matrix-spec-proposals/pull/2774) format widget ID | Yes | The id used by the widget. The presence of this parameter implies that element call will not connect to a homeserver directly and instead tries to establish postMessage communication via the `parentUrl`. |
| Name | Values | Required | Description |
| --------------- | ----------------------------------------------------------------------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `baseUrl` | | Yes | The base URL of the homeserver to use for media lookups. |
| `deviceId` | Matrix device ID | Yes | The Matrix device ID for the widget host. |
| `parentUrl` | | Yes | The url used to send widget action postMessages. This should be the domain of the client or the webview the widget is hosted in. (In case the widget is not in an Iframe but in a dedicated webview, we send the postMessages in the same WebView the widget lives in. Filtering is done in the widget so it ignores the messages it receives from itself.) |
| `posthogUserId` | Posthog user identifier | No | This replaces the `analyticsID` parameter |
| `preload` | `true` or `false` | No, defaults to `false` | Pauses app before joining a call until an `io.element.join` widget action is seen, allowing preloading. |
| `returnToLobby` | `true` or `false` | No, defaults to `false` | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. |
| `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | The Matrix user ID. |
| `widgetId` | [MSC2774](https://github.com/matrix-org/matrix-spec-proposals/pull/2774) format widget ID | Yes | The id used by the widget. The presence of this parameter implies that element call will not connect to a homeserver directly and instead tries to establish postMessage communication via the `parentUrl`. |
### Embedded-only parameters
These parameters are only supported in the [embedded](./embedded-standalone.md) package of Element Call and will be ignored in the [full](./embedded-standalone.md) package.
These parameters are only supported in the [embedded](./embedded_standalone.md) package of Element Call and will be ignored in the [full](./embedded_standalone.md) package.
| Name | Values | Required | Description |
| -------------------- | -------------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- |

View File

@@ -11,7 +11,7 @@ pushd $CURRENT_DIR > /dev/null
function build_assets() {
echo "Generating Element Call assets..."
pushd ../.. > /dev/null
yarn build
pnpm build
popd > /dev/null
}
@@ -26,7 +26,7 @@ function copy_assets() {
}
getopts :sh opt
case $opt in
case $opt in
s)
SKIP=1
;;
@@ -41,7 +41,7 @@ if [ ! $SKIP ]; then
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
build_assets
else
else
echo "Using existing assets from ../../dist"
fi
copy_assets
@@ -56,4 +56,4 @@ echo "Publishing the Android project"
./gradlew publishAndReleaseToMavenCentral --no-daemon
popd > /dev/null
popd > /dev/null

View File

@@ -10,7 +10,7 @@
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0"
content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0"
/>
<title><%- brand %></title>
<script>

View File

@@ -18,6 +18,7 @@ export default {
// https://docs.docker.com/compose/migrate/
"docker-compose",
],
ignoreFiles: ["scripts/.pnpmfile.cjs"],
ignoreDependencies: [
// Used in CSS
"normalize.css",
@@ -30,16 +31,10 @@ export default {
"@types/content-type",
"@types/sdp-transform",
"@types/uuid",
// We obviously use this, but if the package has been linked with yarn link,
// We obviously use this, but if the package has been linked with pnpm link,
// then Knip will flag it as a false positive
// https://github.com/webpro-nl/knip/issues/766
"@vector-im/compound-web",
// Yarn plugins are allowed to depend on packages provided by the Yarn
// runtime. These shouldn't be listed in package.json, because plugins
// should work before Yarn even installs dependencies for the first time.
// https://yarnpkg.com/advanced/plugin-tutorial#what-does-a-plugin-look-like
"@yarnpkg/core",
"@yarnpkg/parsers",
"matrix-widget-api",
],
ignoreExportsUsedInFile: true,

View File

@@ -145,6 +145,7 @@
},
"layout_grid_label": "Grid",
"layout_spotlight_label": "Spotlight",
"layout_switch_label": "Layout",
"lobby": {
"ask_to_join": "Request to join call",
"join_as_guest": "Join as guest",

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,9 @@
"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:on": "cp scripts/.pnpmfile.cjs .pnpmfile.cjs & pnpm install",
"links:off": "rm .pnpmfile.cjs & pnpm install",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
@@ -81,7 +81,7 @@
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^9.0.0",
"@vector-im/compound-web": "^9.3.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
@@ -110,7 +110,7 @@
"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",
@@ -132,24 +132,29 @@
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"uuid": "^13.0.0",
"uuid": "^14.0.0",
"vaul": "^1.0.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.28.0"
}
},
"packageManager": "yarn@4.7.0"
"packageManager": "pnpm@10.33.0"
}

View File

@@ -7,11 +7,22 @@ Please see LICENSE in the repository root for full details.
*/
import { defineConfig, devices } from "@playwright/test";
import { join } from "path";
import path from "node:path";
import { fileURLToPath } from "node:url";
const baseURL = process.env.USE_DOCKER
? "http://localhost:8080"
: "https://localhost:3000";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Needed by the synapse admin API called in fixtures
process.env.NODE_EXTRA_CA_CERTS = join(
__dirname,
"backend/dev_tls_local-ca.crt",
);
/**
* See https://playwright.dev/docs/test-configuration.
*/
@@ -68,6 +79,11 @@ export default defineConfig({
firefoxUserPrefs: {
"permissions.default.microphone": 1,
"permissions.default.camera": 1,
// Equivalent to Chromium's --use-fake-device-for-media-stream:
// feeds a synthetic media stream so getUserMedia and
// enumerateDevices work on CI runners without real hardware.
"media.navigator.streams.fake": true,
"media.navigator.permission.disabled": true,
},
},
},

View File

@@ -58,3 +58,41 @@ test("Start a new call then leave and show the feedback screen", async ({
page.getByRole("link", { name: "Not now, return to home screen" }),
).toBeVisible();
});
test("BugFix: When unmuting in lobby, you had to click twice to unmute in call", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("DoubleUnMute");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("me");
await page.getByTestId("home_go").click();
const microphoneButton = page.getByTestId("incall_mute");
const cameraButton = page.getByTestId("incall_videomute");
// Wait for devices to enumerate before the button enables.
await expect(microphoneButton).toBeEnabled({ timeout: 10_000 });
await microphoneButton.click();
await cameraButton.click();
// Should be muted now
await expect(microphoneButton).toHaveAccessibleName("Unmute microphone");
await expect(cameraButton).toHaveAccessibleName("Start video");
// Create the call and join
await page.getByTestId("lobby_joinCall").click();
// Give sometime for the all to be connected
// Check the number of participants
await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible();
// Click again on the mute button. it should unmute
await microphoneButton.click();
await expect(microphoneButton).toHaveAccessibleName("Mute microphone");
await cameraButton.click();
await expect(cameraButton).toHaveAccessibleName("Stop video");
});

View File

@@ -75,9 +75,7 @@ test("Should automatically retry non fatal JWT errors", async ({
await expect(page.getByTestId("video").first()).toBeVisible();
});
// 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 ({
test("Should show error screen if call creation is restricted", async ({
page,
browserName,
}) => {

View File

@@ -91,7 +91,9 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1
.getByRole("button", { name: "Invite to this room", exact: true })
.click();
.click({
timeout: 10000,
});
await expect(
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
).toBeVisible();
@@ -104,6 +106,7 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId);
await ewPage1.getByRole("dialog").getByRole("textbox").click();
await ewPage1.getByRole("button", { name: "Invite" }).click();
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
// Accept the invite
await expect(
@@ -126,6 +129,7 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1.getByRole("textbox", { name: "Search" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
await ewPage1.getByRole("button", { name: "Go" }).click();
await TestHelpers.dismissInviteUnknownUserModalDM(ewPage1);
// Wait and send the first message to create the DM
await expect(

View File

@@ -53,7 +53,6 @@ test("@mobile Start a new call then leave and show the feedback screen", async (
mobileTest(
"Test earpiece overlay in controlledAudioDevices mode",
async ({ asMobile, browser }) => {
test.slow(); // Triples the timeout
const { creatorPage, inviteLink } = asMobile;
// ========
@@ -103,7 +102,7 @@ mobileTest(
.click();
// dismiss settings
await guestPage.locator("#root").getByLabel("Settings").press("Escape");
await guestPage.locator("#root").press("Escape");
await guestPage.pause();
await expect(

View File

@@ -9,7 +9,9 @@ import { expect, test } from "@playwright/test";
test("When creator left, avoid reconnect to the same SFU", async ({
browser,
browserName,
}) => {
test.skip(browserName === "firefox", "Browser independent");
// Use reduce motion to disable animations that are making the tests a bit flaky
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
const creatorPage = await creatorContext.newPage();
@@ -91,8 +93,10 @@ test("When creator left, avoid reconnect to the same SFU", async ({
// the creator leaves the call
await creatorPage.getByTestId("incall_leave").click();
await guestCPage.waitForTimeout(2000);
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1);
// Wait a bit to be sure that if there was a reconnect, it would have happened by now
await guestCPage.waitForTimeout(6000);
expect(wsConnectionCount).toBe(1);
});

View File

@@ -28,7 +28,7 @@ async function setupTwoUserSpaCall(
await page.goto("/");
let androlHasSentStickyEvent = false;
const androlResolver = Promise.withResolvers<void>();
await interceptEventSend(
page,
// This room is not encrypted, so the event is sent in clear
@@ -36,6 +36,7 @@ async function setupTwoUserSpaCall(
(req) => {
androlHasSentStickyEvent =
androlHasSentStickyEvent || isStickySend(req.url());
androlResolver.resolve();
},
);
@@ -53,6 +54,7 @@ async function setupTwoUserSpaCall(
let pevaraHasSentStickyEvent = false;
const pevaraResolver = Promise.withResolvers<void>();
await interceptEventSend(
guestPage,
// This room is not encrypted, so the event is sent in clear
@@ -60,6 +62,7 @@ async function setupTwoUserSpaCall(
(req) => {
pevaraHasSentStickyEvent =
pevaraHasSentStickyEvent || isStickySend(req.url());
pevaraResolver.resolve();
},
);
@@ -70,7 +73,9 @@ async function setupTwoUserSpaCall(
"2_0",
);
// Assert both sides have sent sticky membership events
await androlResolver.promise;
expect(androlHasSentStickyEvent).toEqual(true);
await pevaraResolver.promise;
expect(pevaraHasSentStickyEvent).toEqual(true);
return { guestPage };
@@ -110,8 +115,12 @@ test("One to One rejoin after improper leave does not crash EC", async ({
await guestPage.getByTestId("lobby_joinCall").click();
// We cannot use the `expectVideoTilesCount` helper here since one of them is expected to show waiting for media
await expect(page.getByTestId("videoTile")).toHaveCount(3);
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2);
await expect(page.getByTestId("videoTile")).toHaveCount(3, {
timeout: 10000,
});
await expect(guestPage.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
});
function isStickySend(url: string): boolean {

View File

@@ -119,25 +119,27 @@ async function setRtcModeFromSettings(
async function expectVideoTilesCount(page: Page, count: number): Promise<void> {
await expect(page.getByTestId("videoTile")).toHaveCount(2);
// There are no other options than to wait for all media to be ready?
// Or it is too flaky :/
await page.waitForTimeout(3000);
// No one should be waiting for media
await expect(page.getByText("Waiting for media...")).not.toBeVisible();
await expect(page.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000,
});
// There should be 5 video elements, visible and autoplaying
const videoElements = await page.locator("video").all();
expect(videoElements.length).toBe(count);
// There should be `count` video elements, visible and autoplaying
await expect(page.locator("video")).toHaveCount(count);
const blockDisplayCount = await page
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(count);
await expect(async () => {
const videoBlockCount = await page
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(videoBlockCount).toBe(count);
}).toPass({
timeout: 10000,
});
}
export const SpaHelpers = {

View File

@@ -0,0 +1,142 @@
/*
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 { createHmac } from "crypto";
/**
* Response from Synapse registration API
*/
export interface SynapseRegistrationResponse {
access_token: string;
user_id: string;
home_server: string;
device_id: string;
}
/**
* Utility class for interacting with Synapse Admin API
* This provides fast user registration without going through the UI
*
* @see https://matrix-org.github.io/synapse/latest/admin_api/register_api.html
*/
export class SynapseAdmin {
public constructor(
private baseUrl: string = "https://synapse.m.localhost",
private sharedSecret: string = "test_shared_secret_for_local_dev_only",
) {}
/**
* Register a user using the Synapse Admin API
* This is much faster than going through the UI registration flow
*
* @param username - The username (localpart) for the new user
* @param password - The password for the new user
* @param displayName - Optional display name (defaults to username)
* @param admin - Whether the user should be an admin (defaults to false)
* @returns Registration response containing access token and user ID
*/
public async registerUser(
username: string,
password: string,
displayName?: string,
admin: boolean = false,
): Promise<SynapseRegistrationResponse> {
// Get a nonce first
const nonce = await this.getNonce();
// Generate the HMAC
const mac = this.generateMac(username, password, admin, nonce);
// Make the registration request
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
nonce,
username,
password,
displayname: displayName || username,
admin,
mac,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(
`Failed to register user ${username}: ${response.status} ${error}`,
);
}
return response.json();
}
/**
* Get a nonce for registration
* The nonce is required for the HMAC calculation
*
* @returns A nonce string
*/
private async getNonce(): Promise<string> {
const response = await fetch(`${this.baseUrl}/_synapse/admin/v1/register`, {
method: "GET",
});
if (!response.ok) {
throw new Error(
`Failed to get nonce: ${response.status} ${await response.text()}`,
);
}
const data = await response.json();
return data.nonce;
}
/**
* Generate HMAC for shared secret registration
* This is the authentication mechanism for the admin API
*
* @param username - The username
* @param password - The password
* @param admin - Whether the user is an admin
* @param nonce - The nonce from the server
* @returns The HMAC hex string
*/
private generateMac(
username: string,
password: string,
admin: boolean,
nonce: string,
): string {
const mac = createHmac("sha1", this.sharedSecret);
mac.update(nonce);
mac.update("\x00");
mac.update(username);
mac.update("\x00");
mac.update(password);
mac.update("\x00");
mac.update(admin ? "admin" : "notadmin");
return mac.digest("hex");
}
/**
* Create a new SynapseAdmin instance for a different homeserver
*
* @param baseUrl - The base URL of the homeserver
* @param sharedSecret - The shared secret (defaults to test secret)
* @returns A new SynapseAdmin instance
*/
public static forHomeserver(
baseUrl: string,
sharedSecret: string = "test_shared_secret_for_local_dev_only",
): SynapseAdmin {
return new SynapseAdmin(baseUrl, sharedSecret);
}
}

View File

@@ -26,8 +26,12 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
const florian = await addUser("floriant", HOST1);
const timo = await addUser("timo", HOST2);
test.slow();
const [florian, timo] = await Promise.all([
addUser("florian", HOST1),
addUser("timo", HOST2),
]);
const roomName = "Call Room";
@@ -57,27 +61,20 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
timeout: 10000,
});
// There are no other options than to wait for all media to be ready?
// Or it is too flaky :/
await user.page.waitForTimeout(3000);
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000,
});
// 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);
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
// await florian.page.pause();

View File

@@ -75,18 +75,11 @@ widgetTest(
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);
await expect(frame.locator("video")).toHaveCount(2, {
timeout: 10000,
});
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
},
);

View File

@@ -27,6 +27,7 @@ import { HOST1, HOST2, TestHelpers } from "./test-helpers";
widgetTest(
`Test swapping publisher from ${HOST1} to ${HOST2}`,
async ({ addUser, browserName }) => {
test.slow();
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
@@ -65,40 +66,26 @@ widgetTest(
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
// There are no other options than to wait for all media to be ready?
// Or it is too flaky :/
await user.page.waitForTimeout(3000);
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// Wait for "Waiting for media..." to disappear (with timeout)
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
timeout: 10000, // Maximum time to wait
});
// There should be 2 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(2);
await expect(frame.locator("video")).toHaveCount(2, {
timeout: 10000,
});
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
// now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1)
await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat");
await timo.page.waitForTimeout(3000);
const blockDisplayCount = await timo.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(2);
await TestHelpers.expectVisibleVideoCount(
timo.page.locator('iframe[title="Element Call"]').contentFrame(),
2,
);
},
);

View File

@@ -11,18 +11,21 @@ import { widgetTest } from "../fixtures/widget-user.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts";
widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
// increase the timeouts, it is a long test and it is annoying to retry from the beginning for a single timeout.
test.slow();
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // We are registering multiple users here, give it more time
const valere = await addUser("Valere", HOST1);
const timo = await addUser("Timo", HOST1);
const robin = await addUser("Robin", HOST1);
const halfshot = await addUser("Halfshot", HOST1);
const florian = await addUser("florian", HOST1);
const [valere, timo, robin, halfshot, florian] = await Promise.all([
addUser("Valere", HOST1),
addUser("Timo", HOST1),
addUser("Robin", HOST1),
addUser("Halfshot", HOST1),
addUser("florian", HOST1),
]);
const roomName = "Group Call Room";
await TestHelpers.createRoom(roomName, valere.page, [
@@ -47,52 +50,55 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
await TestHelpers.joinCallFromLobby(valere.page);
for (const user of [timo, robin, halfshot, florian]) {
await TestHelpers.joinCallInCurrentRoom(user.page);
}
await Promise.all(
[timo, robin, halfshot, florian].map(async (user) => {
await TestHelpers.joinCallInCurrentRoom(user.page);
}),
);
for (const user of [timo, robin, halfshot, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// No lobby, should start with video on
await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible();
}
await Promise.all(
[timo, robin, halfshot, florian].map(async (user) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible({
timeout: 10000,
});
}),
);
// We should see 5 video tiles everywhere now
for (const user of [valere, timo, robin, halfshot, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(5);
for (const participant of [valere, timo, robin, halfshot, florian]) {
// Check the names are correct
await expect(frame.getByText(participant.displayName)).toBeVisible();
}
await Promise.all(
[valere, timo, robin, halfshot, florian].map(async (user) => {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(5, {
timeout: 15000,
});
// There is no other options than to wait for all media to be ready?
// Or it is too flaky :/
await user.page.waitForTimeout(5000);
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// There should be 5 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(5);
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
await Promise.all(
[valere, timo, robin, halfshot, florian].map(async (user) => {
// Check the names are correct
await expect(frame.getByText(user.displayName)).toBeVisible();
}),
);
expect(blockDisplayCount).toBe(5);
}
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
// Use a bigger timeout here
timeout: 10000,
});
// There should be 5 video elements, visible and autoplaying
await expect(frame.locator("video")).toHaveCount(5);
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
await TestHelpers.expectVisibleVideoCount(frame, 5);
}),
);
// Quickly test muting one participant to see it reflects and that our asserts works
const florianFrame = florian.page
@@ -108,28 +114,16 @@ widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
await expect(florianVideoButton).toHaveAccessibleName("Start video");
await expect(florianVideoButton).not.toBeChecked();
// wait a bit for the state to propagate
await valere.page.waitForTimeout(3000);
{
const frame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(5);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
await expect(frame.locator("video")).toHaveCount(5, {
timeout: 10000,
});
// out of 5 ONLY 4 are visible (display:block) !!
// XXX we need to be better at our HTML markup and accessibility, it would make
// this kind of stuff way easier to test if we could look out for aria attributes.
expect(blockDisplayCount).toBe(4);
await TestHelpers.expectVisibleVideoCount(frame, 4);
}
});

View File

@@ -16,8 +16,6 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow();
const valere = await addUser("Valere", HOST1);
const callRoom = "CallRoom";

View File

@@ -47,7 +47,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
// check that the video is on
await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible();
).toBeVisible({
// Increase timeout, as this expect was flaky
timeout: 15000,
});
// Switch to the other room, the call should go to PIP
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
@@ -63,8 +66,10 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
const frame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.locator("video")).toHaveCount(1, { timeout: 10000 });
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(1);
const pipVideo = videoElements[0];
await expect(pipVideo).toHaveCSS("object-fit", "cover");

View File

@@ -0,0 +1,152 @@
/*
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.ts";
import { HOST1, TestHelpers } from "./test-helpers.ts";
widgetTest("Sharing screen in group call", async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // We are registering multiple users here, give it more time
const [alice, bob, carol] = await Promise.all([
addUser("Alice", HOST1),
addUser("Bob", HOST1),
addUser("Carol", HOST1),
]);
const roomName = "Meeting Room";
await TestHelpers.createRoom(roomName, alice.page, [bob.mxId, carol.mxId]);
for (const user of [bob, carol]) {
// Accept the invite
// This isn't super stable to get this as this super generic locator,
// but it works for now.
await TestHelpers.acceptRoomInvite(roomName, user.page);
}
await TestHelpers.startCallInCurrentRoom(alice.page, false);
await expect(
alice.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
await TestHelpers.joinCallFromLobby(alice.page);
for (const user of [bob, carol]) {
await TestHelpers.joinCallInCurrentRoom(user.page);
}
for (const user of [alice, bob, carol]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// Expect 3 video tiles
await expect(frame.locator("video")).toHaveCount(3, {
timeout: 10000,
});
}
// await alice.page.pause();
await alice.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByRole("switch", { name: "Share screen" })
.click();
// await alice.page.pause();
for (const user of [alice, bob, carol]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// Expect 4 (3 + screen share) video tiles
await expect(frame.locator("video")).toHaveCount(4, {
timeout: 5000,
});
await expect(
frame.locator('video[data-lk-source="screen_share"]'),
).toHaveCount(1);
}
// Alice should be in grid mode as she is local sharing
{
const frame = alice.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByRole("radio", { name: "Grid" })).toBeChecked();
}
// Others should have switched to spotlight
for (const user of [bob, carol]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByRole("radio", { name: "Spotlight" })).toBeChecked();
}
// await alice.page.pause();
// await bob.page.pause();
// Let's start another screen share from bob
await bob.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByRole("switch", { name: "Share screen" })
.click();
{
const frame = carol.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// Expect 5 (2 + screen share) video tiles
await expect(frame.locator("video")).toHaveCount(5, {
timeout: 5000,
});
await expect(
frame.locator('video[data-lk-source="screen_share"]'),
).toHaveCount(2);
// Expect 2 indicators at the bottom
await expect(frame.getByTestId("screenshare-indicator")).toHaveCount(2);
// Check the first indicator is visible
await expect(
frame.getByTestId("screenshare-indicator").first(),
).toHaveAttribute("data-visible", "true");
await carol.page.pause();
// now click on next
await expect(frame.getByRole("button", { name: "Next" })).toBeVisible();
await frame.getByRole("button", { name: "Next" }).click();
// Check the second indicator is visible
await expect(
frame.getByTestId("screenshare-indicator").nth(1),
).toHaveAttribute("data-visible", "true");
// the first one should be grayed out
await expect(
frame.getByTestId("screenshare-indicator").first(),
).toHaveAttribute("data-visible", "false");
// There should be a prev button now
await expect(frame.getByRole("button", { name: "Back" })).toBeVisible();
// await carol.page.pause();
}
});

View File

@@ -17,7 +17,7 @@ widgetTest.skip(
);
widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
test.slow(); // Triples the timeout
test.slow();
const { brooks, whistler } = asWidget;
@@ -83,8 +83,12 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("incall_leave")
.click();
.click({ timeout: 15000 });
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible();
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible({
timeout: 10000,
});
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible({
timeout: 10000,
});
});

View File

@@ -10,9 +10,12 @@ import {
expect,
type JSHandle,
type Page,
type FrameLocator,
} from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk";
import { SynapseAdmin } from "../utils/synapse-admin.ts";
const PASSWORD = "foobarbaz1!";
export const HOST1 = "https://app.m.localhost/#/welcome";
@@ -26,14 +29,14 @@ export class TestHelpers {
voice: boolean = false,
): Promise<void> {
const buttonName = voice ? "Voice call" : "Video call";
await expect(page.getByRole("button", { name: buttonName })).toBeVisible();
await page.getByRole("button", { name: buttonName }).click();
await expect(
page.getByRole("menuitem", { name: "Element Call" }),
).toBeVisible();
await page.getByRole("button", { name: buttonName }).click({
timeout: 5000,
});
await page.getByRole("menuitem", { name: "Element Call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click({
timeout: 10000,
});
}
public static async joinCallFromLobby(page: Page): Promise<void> {
@@ -51,15 +54,40 @@ export class TestHelpers {
.click();
}
public static async joinCallInCurrentDM(
page: Page,
audioOnly: boolean = false,
): Promise<void> {
await this.joinCallInRoom(page, audioOnly, true);
}
public static async joinCallInCurrentRoom(
page: Page,
audioOnly: boolean = false,
): Promise<void> {
// This is the header button that notifies about an ongoing call
const label = audioOnly ? "Voice call started" : "Video call started";
await expect(page.getByText(label)).toBeVisible();
await expect(page.getByRole("button", { name: "Join" })).toBeVisible();
await page.getByRole("button", { name: "Join" }).click();
await this.joinCallInRoom(page, audioOnly, false);
}
public static async joinCallInRoom(
page: Page,
audioOnly: boolean = false,
isDM: boolean = false,
): Promise<void> {
// XXX This using the notification toast to join the room.
// Not the button in the header
await page.waitForTimeout(3000);
const label = isDM
? audioOnly
? "Incoming voice call"
: "Incoming video call"
: "Group call started";
await expect(page.getByText(label)).toBeVisible({
timeout: 10000,
});
await page.getByRole("button", { name: "Join" }).click({
timeout: 5000,
});
}
/**
@@ -74,97 +102,90 @@ export class TestHelpers {
clientHandle: JSHandle<MatrixClient>;
mxId: string;
}> {
// Determine which homeserver to use based on the host
const synapseBaseUrl =
host === HOST2
? "https://synapse.othersite.m.localhost"
: "https://synapse.m.localhost";
// Register user via Synapse Admin API to speed things up
const synapseAdmin = SynapseAdmin.forHomeserver(synapseBaseUrl);
const credentials = await synapseAdmin.registerUser(
username,
PASSWORD,
username,
);
// STEP 2: Open browser and login
const userContext = await browser.newContext({
reducedMotion: "reduce",
});
const page = await userContext.newPage();
await page.goto(host);
await page.getByRole("link", { name: "Create Account" }).click();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).click();
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await page.getByRole("textbox", { name: "Confirm password" }).click();
await page
.getByRole("textbox", { name: "Confirm password" })
.fill(PASSWORD);
await page.getByRole("button", { name: "Register" }).click();
await page.getByRole("link", { name: "Sign in" }).click({
timeout: 10000,
});
await page.getByRole("textbox", { name: "Username" }).fill(username, {
timeout: 10000,
});
await page.getByRole("textbox", { name: "Password" }).fill(PASSWORD, {
timeout: 10000,
});
await page.getByRole("button", { name: "Sign in" }).click();
await expect(
page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible({
// Increase timeout as registration can be slow :/
timeout: 15_000,
// Increase timeout here :/ flaky
timeout: 15000,
});
await this.maybeDismissBrowserNotSupportedToast(page);
await this.maybeDismissServiceWorkerWarningToast(page);
await this.dismissStartupToasts(page);
await TestHelpers.setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const mxId = (await clientHandle.evaluate(
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
const mxId = credentials.user_id;
return { page, clientHandle, mxId };
}
private static async maybeDismissBrowserNotSupportedToast(
page: Page,
): Promise<void> {
const browserUnsupportedToast = page
.getByText("Element does not support this browser")
.locator("..")
.locator("..");
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
// dismiss (if part of expected toats)
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Back up your chats", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
// Dismiss incompatible browser toast
const dismissButton = browserUnsupportedToast.getByRole("button", {
name: "Dismiss",
});
try {
await expect(dismissButton).toBeVisible({ timeout: 700 });
await dismissButton.click();
} catch {
// dismissButton not visible, continue as normal
}
}
const toast = page.locator(".mx_Toast_toast");
private static async maybeDismissServiceWorkerWarningToast(
page: Page,
): Promise<void> {
const toast = page
.locator(".mx_Toast_toast")
.getByText("Failed to load service worker");
// eslint-disable-next-line no-constant-condition
while (true) {
try {
await toast.waitFor({ state: "visible", timeout: 700 });
const title = await toast.locator(".mx_Toast_title h2").textContent();
try {
await expect(toast).toBeVisible({ timeout: 700 });
await page
.locator(".mx_Toast_toast")
.getByRole("button", { name: "OK" })
.click();
} catch {
// toast not visible, continue as normal
}
}
// Find the matching toast config
const toastConfig = expectedToasts.find((t) =>
title?.includes(t.title),
);
public static async maybeDismissKeyBackupToast(page: Page): Promise<void> {
const toast = page
.locator(".mx_Toast_toast")
.getByText("Back up your chats");
try {
await expect(toast).toBeVisible({ timeout: 700 });
await page
.locator(".mx_Toast_toast")
.getByRole("button", { name: "Dismiss" })
.click();
} catch {
// toast not visible, continue as normal
if (toastConfig) {
await toast.getByRole("button", { name: toastConfig.button }).click();
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
}
} catch {
// No toast visible, exit loop
break;
}
}
}
@@ -178,12 +199,16 @@ export class TestHelpers {
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New Room" }).click();
await page.getByRole("menuitem", { name: "New Room" }).click({
timeout: 5000,
});
await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByText("You created this room.")).toBeVisible();
await expect(page.getByText("You created this room.")).toBeVisible({
timeout: 10000,
});
await expect(page.getByText("Encryption enabled")).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page);
await TestHelpers.dismissStartupToasts(page);
// Invite users if any
if (andInvite.length > 0) {
@@ -199,6 +224,7 @@ export class TestHelpers {
}
await page.getByRole("button", { name: "Invite" }).click();
await TestHelpers.dismissInviteUnknownUserModal(page);
}
}
@@ -211,14 +237,17 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await expect(page.getByRole("option", { name: roomName })).toBeVisible();
await page.getByRole("option", { name: roomName }).click();
await page.getByRole("button", { name: "Accept" }).click();
await page.getByRole("option", { name: roomName }).click({
timeout: 10000,
});
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
await expect(
page.getByRole("main").getByRole("heading", { name: roomName }),
).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page);
await TestHelpers.dismissStartupToasts(page);
}
/**
@@ -233,8 +262,12 @@ export class TestHelpers {
page: Page,
mode: RtcMode,
): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click();
await page.getByRole("button", { name: "Video call" }).click({
timeout: 5000,
});
await page.getByRole("menuitem", { name: "Element Call" }).click({
timeout: 10000,
});
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
await page.getByRole("button", { name: "Close lobby" }).click();
@@ -308,4 +341,52 @@ export class TestHelpers {
): Promise<void> {
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
}
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
await expect(
page.getByRole("heading", { name: "Invite new contacts to this" }),
).toBeVisible();
await page.getByRole("button", { name: "Invite" }).click({
timeout: 5000,
});
}
public static async dismissInviteUnknownUserModalDM(
page: Page,
): Promise<void> {
await expect(
page.getByRole("heading", {
name: "Start a chat with this new contact?",
}),
).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click({
timeout: 5000,
});
}
public static async expectVisibleVideoCount(
frame: FrameLocator,
count: number,
): Promise<void> {
// XXX we need to be better at our HTML markup and accessibility, it would make
// this kind of stuff way easier to test if we could look out for aria attributes.
await expect
.poll(
async () => {
return await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) =>
window.getComputedStyle(v).display === "block",
).length,
);
},
{
timeout: 10000,
},
)
.toBe(count);
}
}

View File

@@ -20,8 +20,6 @@ widgetTest(
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, true);
@@ -43,7 +41,7 @@ widgetTest(
).toBeVisible();
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Accept" }).click();
await whistler.page.getByRole("button", { name: "Join" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
@@ -113,8 +111,6 @@ widgetTest(
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
@@ -136,7 +132,7 @@ widgetTest(
).toBeVisible();
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Accept" }).click();
await whistler.page.getByRole("button", { name: "Join" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
@@ -200,8 +196,6 @@ widgetTest(
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);

14000
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
# dependencies where we use branches and hashes in the package.json. But that also use a pre/post install script.
onlyBuiltDependencies:
- "matrix-js-sdk"

View File

@@ -49,17 +49,22 @@
"matchDepNames": ["vaul"],
"prHeader": "Please review modals on mobile for visual regressions."
},
{
"groupName": "PostHog",
"matchDepNames": ["posthog-js"],
"prHeader": "Please ensure that all analytics data is still appropriately sanitized."
},
{
"groupName": "embedded package dependencies",
"matchFileNames": ["embedded/**/*"]
},
{
"groupName": "Yarn",
"matchDepNames": ["yarn"]
"groupName": "Pnpm",
"matchDepNames": ["pnpm"]
}
],
"semanticCommits": "disabled",
"ignoreDeps": ["posthog-js", "eslint-plugin-matrix-org"],
"ignoreDeps": ["eslint-plugin-matrix-org"],
"vulnerabilityAlerts": {
"schedule": ["at any time"],
"prHourlyLimit": 0,

62
scripts/.pnpmfile.cjs Normal file
View File

@@ -0,0 +1,62 @@
/*
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.
*/
// DONT RUN THIS FILE MANUALLY
// This file is intended to be used with `pnpm links:on` and `pnpm links:off` which will copy this file to the project root.
// See docs/linking.md for 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

@@ -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

26
scripts/setup-linking.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Checks if there currently is linking configured. Informs the user to disable linking before committing.
LINKSFILE=.links.cjs
echo "Checking for existing linking configuration in $LINKSFILE..."
if test -f "$LINKSFILE"; then
echo "Linking configuration found in $LINKSFILE."
else
echo "No $LINKSFILE -> Creating $LINKSFILE with default values. Please edit this file to point to your local checkouts of the dependencies you want to link."
echo '''// Packages to link to local checkouts
module.exports = {
"matrix-js-sdk": "../your/path/matrix-js-sdk",
"matrix-widget-api": "../your/path/matrix-widget-api",
};''' > $LINKSFILE
fi
echo "updating local git hookPath to .githooks"
git config --local core.hooksPath .githooks
echo ""
echo "Setup complete."
echo "Update: .links.cjs to your liking"
echo "Run: 'pnpm links:on' to test your .links.cjs"
echo "Run: 'git commit' with links enabled to test the git pre-commit hook."
echo "Run: 'pnpm links:off' to be able to commit again"
echo "Run: 'git config --local core.hooksPath \"\"' to allow committing with linking on (not recommended)"
echo "Run: 'rm links.cjs' & 'git config --local core.hooksPath \"\"' to fully revert what this script did"

View File

@@ -13,8 +13,8 @@ This folder contains an example index.html file that showcases the sdk in use (h
To get started run
```
yarn
yarn build:sdk
pnpm install
pnpm build:sdk
```
in the repository root.

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
/**
* EXPERIMENTAL
*
* This file is the entrypoint for the sdk build of element call: `yarn build:sdk`
* This file is the entrypoint for the sdk build of element call: `pnpm build:sdk`
* use in widgets.
* It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that
* can be used to join a rtc session and exchange realtime data.

View File

@@ -1,10 +1,25 @@
.bar {
block-size: 64px;
flex-shrink: 0;
position: relative;
}
/* Pseudo-element for the gradient background */
.bar::before {
content: "";
position: absolute;
inset-inline: 0;
/* Extend the gradient beyond the bottom of the header for readability */
inset-block: -24px;
z-index: var(--call-view-header-footer-layer);
background: linear-gradient(
0deg,
rgba(0, 0, 0, 0) 0%,
var(--cpd-color-bg-canvas-default) 100%
);
}
.bar > header {
position: absolute;
position: sticky;
inset-inline: 0;
inset-block-start: 0;
block-size: 64px;

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],
@@ -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,9 @@ Please see LICENSE in the repository root for full details.
align-items: center;
user-select: none;
flex-shrink: 0;
padding-inline: var(--inline-content-inset);
padding-left: var(--content-inset-left);
padding-right: var(--content-inset-right);
padding-top: env(safe-area-inset-top);
}
.nav {

View File

@@ -92,6 +92,11 @@ export const Modal: FC<Props> = ({
return (
<Drawer.Root
open={open}
// This autofocus is a custom vault property and not the
// standard HTML autofocus attribute.
// It makes the Drawer.Root behave like the `DialogRoot`
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onOpenChange={onOpenChange}
dismissible={onDismiss !== undefined}
>

View File

@@ -87,7 +87,7 @@ export const RTCConnectionStats: FC<Props> = ({
<div>
<Button
onClick={() => showFullModal("audio")}
size="sm"
size="md"
kind="tertiary"
Icon={MicOnSolidIcon}
>
@@ -103,7 +103,7 @@ export const RTCConnectionStats: FC<Props> = ({
<div>
<Button
onClick={() => showFullModal("video")}
size="sm"
size="md"
kind="tertiary"
Icon={VideoCallSolidIcon}
>

View File

@@ -26,6 +26,7 @@ import {
QualitySurveyEventTracker,
CallDisconnectedEventTracker,
CallConnectDurationTracker,
CallReconnectingTracker,
} from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
@@ -421,4 +422,5 @@ export class PosthogAnalytics {
public eventQualitySurvey = new QualitySurveyEventTracker();
public eventCallDisconnected = new CallDisconnectedEventTracker();
public eventCallConnectDuration = new CallConnectDurationTracker();
public eventCallReconnecting = new CallReconnectingTracker();
}

View File

@@ -0,0 +1,237 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
expect,
describe,
it,
vi,
beforeEach,
beforeAll,
afterAll,
} from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { PosthogAnalytics } from "./PosthogAnalytics";
import {
CallEndedTracker,
CallReconnectingTracker,
type CallReconnectingReason,
} from "./PosthogEvents";
import { mockConfig } from "../utils/test";
const defaultCounters = {
roomEventEncryptionKeysSent: 10,
roomEventEncryptionKeysReceived: 5,
};
const defaultTotals = {
roomEventEncryptionKeysReceivedTotalAge: 500,
};
function createMockRtcSession(overrides?: {
counters?: Partial<typeof defaultCounters>;
totals?: Partial<typeof defaultTotals>;
}): MatrixRTCSession {
return {
statistics: {
counters: { ...defaultCounters, ...overrides?.counters },
totals: { ...defaultTotals, ...overrides?.totals },
},
} as unknown as MatrixRTCSession;
}
describe("CallEnded", () => {
beforeAll(() => {
mockConfig();
});
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation(
() => {},
);
});
afterAll(() => {
PosthogAnalytics.resetInstance();
});
it("warns if startTime is missing when track is called", () => {
const warnSpy = vi.spyOn(logger, "warn");
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.track("test-call-id", 2, false, mockSession);
expect(warnSpy).toHaveBeenCalledWith(
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
);
expect(PosthogAnalytics.instance.trackEvent).not.toHaveBeenCalled();
});
it("tracks event with correct properties when startTime is set", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date(Date.now() - 60000));
tracker.cacheParticipantCountChanged(5);
tracker.track("test-call-id", 3, true, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
{
eventName: "CallEnded",
callId: "test-call-id",
callParticipantsMax: 5,
callParticipantsOnLeave: 3,
callDuration: expect.closeTo(60, 1),
roomEventEncryptionKeysSent: 10,
roomEventEncryptionKeysReceived: 5,
roomEventEncryptionKeysReceivedAverageAge: 100,
callReconnectingCount: 0,
callReconnectingCountSync: 0,
callReconnectingCountMembership: 0,
callReconnectingCountProbablyLeft: 0,
callReconnectingCountLivekit: 0,
},
{ send_instantly: true },
);
});
it("tracks maxParticipantsCount correctly across multiple changes", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.cacheParticipantCountChanged(3);
tracker.cacheParticipantCountChanged(7);
tracker.cacheParticipantCountChanged(2);
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
callParticipantsMax: 7,
}),
expect.anything(),
);
});
it("computes roomEventEncryptionKeysReceivedAverageAge as 0 when no keys received", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession({
counters: { roomEventEncryptionKeysReceived: 0 },
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
roomEventEncryptionKeysReceivedAverageAge: 0,
}),
expect.anything(),
);
});
it("computes roomEventEncryptionKeysReceivedAverageAge correctly when keys are received", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession({
counters: { roomEventEncryptionKeysReceived: 4 },
totals: { roomEventEncryptionKeysReceivedTotalAge: 200 },
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
roomEventEncryptionKeysReceivedAverageAge: 50,
}),
expect.anything(),
);
});
it("passes send_instantly option correctly", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.anything(),
{ send_instantly: false },
);
});
it("includes per-reason reconnecting counts in CallEnded", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.cacheReconnecting("sync");
tracker.cacheReconnecting("sync");
tracker.cacheReconnecting("livekit");
tracker.cacheReconnecting("membership");
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
callReconnectingCount: 4,
callReconnectingCountSync: 2,
callReconnectingCountMembership: 1,
callReconnectingCountProbablyLeft: 0,
callReconnectingCountLivekit: 1,
}),
expect.anything(),
);
});
});
describe("CallReconnecting", () => {
beforeAll(() => {
mockConfig();
});
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(PosthogAnalytics.instance, "trackEvent").mockImplementation(
() => {},
);
});
afterAll(() => {
PosthogAnalytics.resetInstance();
});
it("tracks event with correct shape", () => {
const tracker = new CallReconnectingTracker();
tracker.track("!room:example.org", "sync", 3.5);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith({
eventName: "CallReconnecting",
callId: "!room:example.org",
reason: "sync",
reconnectDuration: 3.5,
});
});
it.each([
"sync",
"membership",
"probablyLeft",
"livekit",
] as CallReconnectingReason[])("tracks reason %s correctly", (reason) => {
const tracker = new CallReconnectingTracker();
tracker.track("!room:example.org", reason, 1.0);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({ reason, reconnectDuration: 1.0 }),
);
});
});

View File

@@ -17,6 +17,7 @@ import {
interface CallEnded extends IPosthogEvent {
eventName: "CallEnded";
// the callId posthog key is essentially a Matrix roomId
callId: string;
callParticipantsOnLeave: number;
callParticipantsMax: number;
@@ -24,16 +25,43 @@ interface CallEnded extends IPosthogEvent {
roomEventEncryptionKeysSent: number;
roomEventEncryptionKeysReceived: number;
roomEventEncryptionKeysReceivedAverageAge: number;
callReconnectingCount: number;
callReconnectingCountSync: number;
callReconnectingCountMembership: number;
callReconnectingCountProbablyLeft: number;
callReconnectingCountLivekit: number;
}
export class CallEndedTracker {
private cache: { startTime: Date; maxParticipantsCount: number } = {
startTime: new Date(0),
private cache: {
startTime?: Date;
maxParticipantsCount: number;
reconnectingCount: number;
reconnectingCountByReason: Record<CallReconnectingReason, number>;
} = {
startTime: undefined,
maxParticipantsCount: 0,
reconnectingCount: 0,
reconnectingCountByReason: {
sync: 0,
membership: 0,
probablyLeft: 0,
livekit: 0,
},
};
public cacheStartCall(time: Date): void {
this.cache.startTime = time;
this.cache = {
startTime: time,
maxParticipantsCount: 0,
reconnectingCount: 0,
reconnectingCountByReason: {
sync: 0,
membership: 0,
probablyLeft: 0,
livekit: 0,
},
};
}
public cacheParticipantCountChanged(count: number): void {
@@ -43,37 +71,57 @@ export class CallEndedTracker {
);
}
public cacheReconnecting(reason: CallReconnectingReason): void {
this.cache.reconnectingCount++;
this.cache.reconnectingCountByReason[reason]++;
}
public track(
callId: string,
callParticipantsNow: number,
sendInstantly: boolean,
rtcSession: MatrixRTCSession,
): void {
PosthogAnalytics.instance.trackEvent<CallEnded>(
{
eventName: "CallEnded",
callId: callId,
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
roomEventEncryptionKeysSent:
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
roomEventEncryptionKeysReceived:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
roomEventEncryptionKeysReceivedAverageAge:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
? rtcSession.statistics.totals
.roomEventEncryptionKeysReceivedTotalAge /
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0,
},
{ send_instantly: sendInstantly },
);
if (this.cache.startTime) {
PosthogAnalytics.instance.trackEvent<CallEnded>(
{
eventName: "CallEnded",
callId: callId,
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
roomEventEncryptionKeysSent:
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
roomEventEncryptionKeysReceived:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
roomEventEncryptionKeysReceivedAverageAge:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
? rtcSession.statistics.totals
.roomEventEncryptionKeysReceivedTotalAge /
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0,
callReconnectingCount: this.cache.reconnectingCount,
callReconnectingCountSync: this.cache.reconnectingCountByReason.sync,
callReconnectingCountMembership:
this.cache.reconnectingCountByReason.membership,
callReconnectingCountProbablyLeft:
this.cache.reconnectingCountByReason.probablyLeft,
callReconnectingCountLivekit:
this.cache.reconnectingCountByReason.livekit,
},
{ send_instantly: sendInstantly },
);
} else {
logger.warn(
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
);
}
}
}
interface CallStarted extends IPosthogEvent {
eventName: "CallStarted";
// the callId posthog key is essentially a Matrix roomId
callId: string;
}
@@ -134,6 +182,7 @@ export class LoginTracker {
interface MuteMicrophone {
eventName: "MuteMicrophone";
targetMuteState: "mute" | "unmute";
// the callId posthog key is essentially a Matrix roomId
callId: string;
}
@@ -150,6 +199,7 @@ export class MuteMicrophoneTracker {
interface MuteCamera {
eventName: "MuteCamera";
targetMuteState: "mute" | "unmute";
// the callId posthog key is essentially a Matrix roomId
callId: string;
}
@@ -165,6 +215,7 @@ export class MuteCameraTracker {
interface UndecryptableToDeviceEvent {
eventName: "UndecryptableToDeviceEvent";
// the callId posthog key is essentially a Matrix roomId
callId: string;
}
@@ -179,6 +230,7 @@ export class UndecryptableToDeviceEventTracker {
interface QualitySurveyEvent {
eventName: "QualitySurvey";
// the callId posthog key is essentially a Matrix roomId
callId: string;
feedbackText: string;
stars: number;
@@ -243,3 +295,32 @@ export class CallConnectDurationTracker {
);
}
}
export type CallReconnectingReason =
| "sync"
| "membership"
| "probablyLeft"
| "livekit";
interface CallReconnecting extends IPosthogEvent {
eventName: "CallReconnecting";
// the callId posthog key is essentially a Matrix roomId
callId: string;
reason: CallReconnectingReason;
reconnectDuration: number;
}
export class CallReconnectingTracker {
public track(
callId: string,
reason: CallReconnectingReason,
reconnectDuration: number,
): void {
PosthogAnalytics.instance.trackEvent<CallReconnecting>({
eventName: "CallReconnecting",
callId,
reason,
reconnectDuration,
});
}
}

View File

@@ -22,14 +22,17 @@ import {
ShareScreenSolidIcon,
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;
size?: "sm" | "lg";
size?: "md" | "lg";
}
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
@@ -44,7 +47,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
<CpdButton
iconOnly
Icon={Icon}
kind={enabled ? "primary" : "secondary"}
kind={enabled ? "secondary" : "primary"}
role="switch"
aria-checked={enabled}
{...props}
@@ -55,7 +58,7 @@ export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
size?: "sm" | "lg";
size?: "md" | "lg";
}
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
@@ -70,7 +73,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
<CpdButton
iconOnly
Icon={Icon}
kind={enabled ? "primary" : "secondary"}
kind={enabled ? "secondary" : "primary"}
role="switch"
aria-checked={enabled}
{...props}
@@ -81,7 +84,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
size: "sm" | "lg";
size: "md" | "lg";
}
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
@@ -108,7 +111,7 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
};
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "sm" | "lg";
size?: "md" | "lg";
}
export const EndCallButton: FC<EndCallButtonProps> = ({
@@ -130,32 +133,89 @@ export const EndCallButton: FC<EndCallButtonProps> = ({
);
};
interface LoudspeakerButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "md" | "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> = (props) => {
export const SettingsIconButton: FC<SettingsIconButtonProps> = ({
showForScreenWidth,
className,
...props
}) => {
const { t } = useTranslation();
const Icon =
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
return (
<Tooltip label={t("common.settings")}>
<IconButton {...props}>
<IconButton
className={classNamesForScreenWidth(className, showForScreenWidth)}
{...props}
>
<Icon aria-hidden />
</IconButton>
</Tooltip>
);
};
// interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
// size?: "sm" | "lg";
// }
// const SettingsButton: FC<SettingsButtonProps> = (props) => {
// const { t } = useTranslation();
// const Icon =
// platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon;
// return (
// <Tooltip label={t("common.settings")}>
// <CpdButton iconOnly Icon={Icon} kind="secondary" {...props} />
// </Tooltip>
// );
// };
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "md" | "lg";
/** If this buttons should be setup to be used in the app bar */
showForScreenWidth?: "wide" | "narrow";
}
export const SettingsButton: FC<SettingsButtonProps> = ({
showForScreenWidth,
className,
...props
}) => {
const { t } = useTranslation();
return (
<Tooltip label={t("common.settings")}>
<CpdButton
className={classNamesForScreenWidth(className, showForScreenWidth)}
iconOnly
Icon={
platform === "android" ? OverflowVerticalIcon : OverflowHorizontalIcon
}
kind={"secondary"}
{...props}
/>
</Tooltip>
);
};

View File

@@ -15,7 +15,7 @@ export const InviteButton: FC<
> = (props) => {
const { t } = useTranslation();
return (
<Button kind="secondary" size="sm" Icon={UserAddIcon} {...props}>
<Button kind="secondary" size="md" Icon={UserAddIcon} {...props}>
{t("action.invite")}
</Button>
);

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";
size?: "md" | "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

@@ -10,7 +10,7 @@ exports[`Can close reaction dialog 1`] = `
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_bb_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -44,7 +44,7 @@ exports[`Can fully expand emoji picker 1`] = `
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_7m_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -75,7 +75,7 @@ exports[`Can lower hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_36_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
role="button"
@@ -109,7 +109,7 @@ exports[`Can open menu 1`] = `
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="_r_0_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -140,7 +140,7 @@ exports[`Can raise hand 1`] = `
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_1j_"
class="_button_13vu4_8 raisedButton _has-icon_13vu4_60 _icon-only_13vu4_53"
class="_button_1nw83_8 raisedButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"

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,166 @@
/*
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);
/* Ensure that footer lies within the safe area */
padding-left: calc(env(safe-area-inset-left) + var(--cpd-space-6x));
padding-right: calc(env(safe-area-inset-right) + var(--cpd-space-6x));
padding-block: var(--cpd-space-10x)
calc(env(safe-area-inset-bottom) + 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 {
/* 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)
calc(env(safe-area-inset-bottom) + var(--cpd-space-8x));
}
}
@media (max-height: 400px) {
.footer {
padding-block: var(--cpd-space-4x)
calc(env(safe-area-inset-bottom) + 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: calc(
env(safe-area-inset-bottom) + 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,262 @@
/*
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 WithAudioAndVideoOptions: Story = {
...Default,
args: {
...Default.args,
audioEnabled: false,
videoEnabled: true,
audioOptions: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
],
videoOptions: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
],
selectedAudio: "2",
selectedVideo: "1",
},
};
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,
audioEnabled: false,
videoEnabled: false,
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,308 @@
/*
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 { Switch } from "@vector-im/compound-web";
import { t } from "i18next";
import {
SpotlightIcon,
GridIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
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 { type GridMode } from "../state/CallViewModel/CallViewModel";
import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
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;
audioOptions?: MenuOptions[];
videoOptions?: MenuOptions[];
selectedAudio?: string;
selectedVideo?: string;
selectAudioDevice?: (deviceId: string) => void;
selectVideoDevice?: (deviceId: string) => void;
}
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,
audioOptions,
videoOptions,
selectedAudio,
selectedVideo,
selectAudioDevice,
selectVideoDevice,
}) => {
const buttons: JSX.Element[] = [];
const buttonSize = asPip ? "md" : "lg";
const showSettingsButton =
openSettings !== undefined && !asPip && !hideControls;
const showLayoutSwitcher = !asPip && !hideControls;
const showLogoDebugContainer = !asPip || (!hideLogo && !debugTileLayout);
const showLogo = !hideLogo && !asPip;
if (showSettingsButton) {
// add the settings button to the center group of buttons, so it will be visible on small screens.
// On larger screens, it will be hidden SettingsIconButton the one with `showForScreenWidth = "wide"` in the `settingsLogoContainer` will be visible.
buttons.push(
<SettingsButton
key="settings"
showForScreenWidth="narrow"
onClick={openSettings}
data-testid="settings-bottom-center"
/>,
);
}
if ((audioOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
onMuteClick={toggleAudio}
data-testid="incall_mute"
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioDevice}
/>,
);
} else {
buttons.push(
<MicButton
size={buttonSize}
key="audio"
enabled={audioEnabled ?? false}
onClick={toggleAudio}
disabled={toggleAudio === undefined}
data-testid="incall_mute"
/>,
);
}
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Camera Source"}
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
onMuteClick={toggleVideo}
data-testid="incall_videomute"
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoDevice}
/>,
);
} else {
buttons.push(
<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 && (
<Switch<"spotlight", "grid">
name="layoutMode"
aria-label={t("layout_switch_label")}
leftLabel={t("layout_spotlight_label")}
leftValue="spotlight"
leftIcon={SpotlightIcon}
rightLabel={t("layout_grid_label")}
rightValue="grid"
rightIcon={GridIcon}
className={styles.layout}
value={layoutMode}
onChange={setLayoutMode}
/>
)}
</div>
);
};

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.
*/
.container {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--cpd-color-bg-subtle-secondary);
border-radius: 32px;
transition: background-color 0.2s ease-in-out;
}
.containerOpen {
background-color: var(--cpd-color-bg-action-primary-pressed);
}
.chevronIconOpen > svg {
color: var(--cpd-color-icon-on-solid-primary);
}
.menuButton {
width: 40px;
background-color: transparent !important;
}
.itemIcon {
color: var(--cpd-color-text-secondary);
}
.rotate {
animation: spinner 1.5s linear infinite;
}
@keyframes spinner {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,117 @@
/*
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 { AdvancedSettingsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { fn, userEvent, within, expect } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
const meta = {
component: MediaMuteAndSwitchButton,
} satisfies Meta<typeof MediaMuteAndSwitchButton>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "SomeMenu",
iconsAndLabels: {
IconEnabled: AdvancedSettingsIcon,
IconDisabled: AdvancedSettingsIcon,
enabledLabel: "Enabled",
disabledLabel: "Disabled",
optionsButtonLabel: "Options",
},
enabled: true,
options: [
{ label: "option 1", id: "1" },
{ label: "option 2", id: "2" },
],
selectedOption: "1",
onMuteClick: fn(),
onSelect: fn(),
},
};
export const AudioMute: Story = {
args: {
...Default.args,
title: "Microphone",
iconsAndLabels: "audio",
enabled: false,
options: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
],
toggles: [
{
label: "example toggle",
id: "t0",
enabled: true,
},
],
selectedOption: "2",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
// Both the mute button and the chevron trigger currently share the aria-label "Edit"
// (both are TODO placeholders in the component). The mute button is first in the DOM.
const muteButton = canvas.getByLabelText("Unmute microphone");
await userEvent.click(muteButton);
await expect(args.onMuteClick).toHaveBeenCalled();
},
};
export const AudioUnmute: Story = {
args: {
title: "Microphone",
iconsAndLabels: "audio",
enabled: true,
options: [
{ label: "Microphone 1", id: "1" },
{ label: "Microphone 2", id: "2" },
],
toggles: [],
selectedOption: "2",
},
};
export const VideoMute: Story = {
args: {
title: "Camera",
iconsAndLabels: "video",
enabled: false,
options: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
],
toggles: [],
selectedOption: "1",
},
};
export const VideoUnmute: Story = {
args: {
title: "Camera",
iconsAndLabels: "video",
enabled: true,
options: [
{ label: "Camera 1", id: "1" },
{ label: "Camera 2", id: "2" },
],
toggles: [
{
label: "Blur Background",
id: "background_blurring",
enabled: false,
},
],
selectedOption: "2",
},
};

View File

@@ -0,0 +1,226 @@
/*
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 { describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type JSX, useState } from "react";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
describe("MediaMuteAndSwitchButton", () => {
test("renders", () => {
const { container } = render(
<MediaMuteAndSwitchButton title={"Switcher"} />,
);
expect(container).toMatchSnapshot();
});
test("renders correct audio and video labels", () => {
const renderLabels = (
type: "video" | "audio",
enabled: boolean,
): RenderResult => {
return render(
<MediaMuteAndSwitchButton
title={"Switcher"}
iconsAndLabels={type}
enabled={enabled}
/>,
);
};
const renderAudioEndabled = renderLabels("audio", true);
const renderAudioDisabled = renderLabels("audio", false);
const renderVideoEnabled = renderLabels("video", true);
const renderVideoDisabled = renderLabels("video", false);
expect(
renderAudioEndabled.getByRole("button", { name: "Mute microphone" }),
).toBeInTheDocument();
expect(
renderAudioDisabled.getByRole("button", { name: "Unmute microphone" }),
).toBeInTheDocument();
expect(
renderVideoEnabled.getByRole("button", { name: "Start video" }),
).toBeInTheDocument();
expect(
renderVideoDisabled.getByRole("button", { name: "Stop video" }),
).toBeInTheDocument();
});
test("calls mute on mute press", async () => {
const user = userEvent.setup();
const onMute = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title={"Switcher"}
onMuteClick={onMute}
iconsAndLabels="audio"
enabled={true}
/>,
);
await user.click(getByRole("button", { name: "Mute microphone" }));
expect(onMute).toHaveBeenCalled();
});
test("calls select callback on menu click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
expect(onSelect).toHaveBeenCalledWith("mic2");
});
test("does not call select callback on already selected menu click", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 1" }));
expect(onSelect).not.toHaveBeenCalled();
});
test("renders menu spinner until selection updates for the component", async () => {
const user = userEvent.setup();
const { promise, resolve } = Promise.withResolvers<void>();
const onSelectPressed = vi.fn();
const onOptionUpdated = vi.fn();
function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
onSelectPressed();
void promise.then(() => {
setSelectedOption(id);
onOptionUpdated();
});
}}
/>
);
}
const { getByRole } = render(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitem", { name: "Microphone 2" }));
expect(onSelectPressed).toHaveBeenCalled();
expect(onOptionUpdated).not.toHaveBeenCalled();
// After clicking, plannedSelection="mic2" but selectedOption is still "mic1",
// so a spinner should appear on the mic2 item
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 2" });
expect(mic2Item.querySelector(".rotate")).toBeTruthy();
// The currently-selected mic1 item should not have a spinner
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 1" });
expect(mic1Item.querySelector(".rotate")).toBeNull();
await act(async () => {
// resolve the promise that acutally updates the select option.
resolve();
await promise;
});
expect(onOptionUpdated).toHaveBeenCalled();
// Spinner should now be gone since the selection has caught up
const mic2ItemAfter = screen.getByRole("menuitem", {
name: "Microphone 2",
});
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
});
test("renders menu with toggle control and calls toggle callback", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
toggles={[{ label: "Background blur", id: "bg_blur", enabled: false }]}
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const toggle = screen.getByRole("menuitemcheckbox", {
name: "Background blur",
});
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-checked", "false");
await user.click(toggle);
expect(onSelect).toHaveBeenCalledWith("bg_blur");
});
test("renders check icon to mark the selected menu item", async () => {
const user = userEvent.setup();
const { getByRole } = render(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: "Microphone 1", id: "mic1" },
{ label: "Microphone 2", id: "mic2" },
]}
selectedOption="mic2"
/>,
);
// open menu
await user.click(getByRole("button", { name: "Microphone" }));
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
const mic1Item = screen.getByRole("menuitem", { name: "Microphone 2" });
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
// The unselected item (mic1) only renders its IconOptions SVG
const mic2Item = screen.getByRole("menuitem", { name: "Microphone 1" });
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
});
});

View File

@@ -0,0 +1,230 @@
/*
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 ComponentType, useState, type FC } from "react";
import {
Button,
Menu,
MenuItem,
ToggleMenuItem,
} from "@vector-im/compound-web";
import { t } from "i18next";
import {
CheckIcon,
ChevronUpIcon,
ChevronDownIcon,
MicOffSolidIcon,
MicOnIcon,
MicOnSolidIcon,
SpinnerIcon,
VideoCallIcon,
VideoCallOffSolidIcon,
VideoCallSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger";
import styles from "./MediaMuteAndSwitchButton.module.css";
export interface MenuOptions {
label: string;
id: string;
}
export interface ToggleOption {
label: string;
enabled: boolean;
id: string;
}
export interface IconsAndLabels {
/** The Icon used if the mute button is enabled */
IconEnabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The Icon used if the mute button is disabled */
IconDisabled: ComponentType<React.SVGAttributes<SVGElement>>;
/** The icon used for the different options */
IconOptions?: ComponentType<React.SVGAttributes<SVGElement>>;
enabledLabel: string;
disabledLabel: string;
optionsButtonLabel: string;
}
export interface MediaMuteAndSwitchButtonProps {
/** The title used in the Switcher modal. */
title: string;
/** If the Mute button is enabled */
enabled?: boolean;
/** Callback if the mute button is clicked */
onMuteClick?: () => void;
iconsAndLabels?: "video" | "audio" | IconsAndLabels;
/** The options available for the media device selector modal */
options?: MenuOptions[];
/** The option that will currently be rendered as the selected option */
selectedOption?: string;
/**
* The available toggles (including there current state)
* The toggle state is not stored by this component.
* It is handled externally and needs to be set by listening to the `onSelect` callback and setting the right toggle item to `enabled`
*/
toggles?: ToggleOption[];
/**
* For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
*/
onSelect?: (id: string) => void;
}
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
title,
enabled,
onMuteClick,
iconsAndLabels: iconsAndLabelsWithDefaultCases,
options,
selectedOption,
toggles,
onSelect,
}) => {
const [plannedSelection, setPlannedSelection] = useState<string | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
let iconsAndLabels: IconsAndLabels | undefined;
switch (iconsAndLabelsWithDefaultCases) {
case "video":
iconsAndLabels = {
IconEnabled: VideoCallSolidIcon,
IconDisabled: VideoCallOffSolidIcon,
IconOptions: VideoCallIcon,
disabledLabel: t("stop_video_button_label"),
enabledLabel: t("start_video_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
break;
case "audio":
iconsAndLabels = {
IconEnabled: MicOnSolidIcon,
IconDisabled: MicOffSolidIcon,
IconOptions: MicOnIcon,
disabledLabel: t("mute_microphone_button_label"),
enabledLabel: t("unmute_microphone_button_label"),
optionsButtonLabel: t("settings.devices.microphone"),
};
break;
default:
iconsAndLabels = iconsAndLabelsWithDefaultCases;
break;
}
const {
IconEnabled,
IconDisabled,
IconOptions,
disabledLabel,
enabledLabel,
optionsButtonLabel,
} = iconsAndLabels ?? {
IconEnabled: undefined,
IconDisabled: undefined,
IconOptions: undefined,
disabledLabel: undefined,
enabledLabel: undefined,
optionsButtonLabel: undefined,
};
{
logger.info(
"RENDER WITH: selectedOption !== option.id && plannedSelection === option.id",
selectedOption,
" !==",
"option.id",
" && ",
plannedSelection,
" === ",
"option.id",
);
}
return (
<div
className={classNames({
[styles.container]: true,
[styles.containerOpen]: menuOpen,
})}
>
{/* The mute button lives inside */}
<Button
iconOnly
Icon={enabled ? IconEnabled : IconDisabled}
onClick={(e) => {
onMuteClick?.();
e.preventDefault();
e.stopPropagation();
}}
kind={enabled ? "secondary" : "primary"}
size="lg"
className={styles.button}
aria-label={enabled ? disabledLabel : enabledLabel}
/>
<Menu
title={title}
showTitle={true}
open={menuOpen}
onOpenChange={setMenuOpen}
side="top"
trigger={
<Button
iconOnly
className={classNames({
[styles.menuButton]: true,
[styles.chevronIconOpen]: menuOpen,
})}
Icon={menuOpen ? ChevronUpIcon : ChevronDownIcon}
kind={"tertiary"}
size="lg"
aria-label={optionsButtonLabel}
/>
}
>
{options?.map((option) => (
<MenuItem
hideChevron
label={option.label}
Icon={
IconOptions && (
<IconOptions
width={24}
height={24}
className={styles.itemIcon}
/>
)
}
onSelect={(e) => {
e.preventDefault();
if (option.id === selectedOption) return;
setPlannedSelection(option.id);
onSelect?.(option.id);
}}
key={option.id}
>
{selectedOption === option.id && (
<CheckIcon width={24} height={24} />
)}
{selectedOption !== option.id && plannedSelection === option.id && (
<SpinnerIcon width={24} height={24} className={styles.rotate} />
)}
</MenuItem>
))}
{(toggles?.length ?? 0) > 0 && <hr />}
{toggles?.map((toggle) => (
<ToggleMenuItem
label={toggle.label}
onSelect={(e) => {
onSelect?.(toggle.id);
e.preventDefault();
}}
checked={toggle.enabled}
key={toggle.id}
/>
))}
</Menu>
</div>
);
};

View File

@@ -0,0 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`MediaMuteAndSwitchButton > renders 1`] = `
<div>
<div
class="container"
>
<button
class="_button_1nw83_8 button _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
/>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
class="_button_1nw83_8 menuButton _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_0_"
role="button"
tabindex="0"
type="button"
>
<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 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</button>
</div>
</div>
`;

View File

@@ -97,6 +97,13 @@ export interface ConfigOptions {
enable_video?: boolean;
};
/**
* Grace period in milliseconds to wait before reporting the sync loop as disconnected.
* This allows brief sync interruptions without triggering a reconnection message.
* Default is 10000ms (10 seconds). Set to 0 to disable the grace period.
*/
sync_disconnect_grace_period_ms?: number;
/**
* These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options.
@@ -155,7 +162,16 @@ export interface ResolvedConfigOptions extends ConfigOptions {
server_name: string;
};
};
sync_disconnect_grace_period_ms: number;
ssla: string;
matrix_rtc_session: {
wait_for_key_rotation_ms?: number;
delayed_leave_event_delay_ms: number;
delayed_leave_event_restart_local_timeout_ms?: number;
delayed_leave_event_restart_ms?: number;
network_error_retry_ms: number;
membership_event_expiry_ms?: number;
};
}
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
@@ -168,5 +184,10 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
features: {
feature_use_device_session_member_events: true,
},
sync_disconnect_grace_period_ms: 10000,
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
matrix_rtc_session: {
delayed_leave_event_delay_ms: 10000,
network_error_retry_ms: 1000,
},
};

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject, type Observable } from "rxjs";
import { type Observable } from "rxjs";
import { type ComponentType } from "react";
import { type LayoutProps } from "./Grid";
@@ -16,37 +16,18 @@ export interface Bounds {
height: number;
}
export interface Alignment {
inline: "start" | "end";
block: "start" | "end";
}
export const defaultSpotlightAlignment: Alignment = {
inline: "end",
block: "end",
};
export const defaultPipAlignment: Alignment = { inline: "end", block: "start" };
export interface CallLayoutInputs {
/**
* The minimum bounds of the layout area.
*/
minBounds$: Observable<Bounds>;
/**
* The alignment of the floating spotlight tile, if present.
*/
spotlightAlignment$: BehaviorSubject<Alignment>;
/**
* The alignment of the small picture-in-picture tile, if present.
*/
pipAlignment$: BehaviorSubject<Alignment>;
}
export interface CallLayoutOutputs<Model> {
/**
* Whether the scrolling layer of the layout should appear on top.
* Which layer should appear in the foreground.
*/
scrollingOnTop: boolean;
foreground: "fixed" | "scrolling";
/**
* The visually fixed (non-scrolling) layer of the layout.
*/

View File

@@ -266,6 +266,20 @@ export function Grid<
}, []),
useCallback(() => window.innerHeight, []),
);
const orientation = useSyncExternalStore(
useCallback((onChange) => {
// Support for the change event is experimental
// https://developer.mozilla.org/en-US/docs/Web/API/Screen/change_event#browser_compatibility
(screen as unknown as EventTarget).addEventListener?.("change", onChange);
return (): void =>
(screen as unknown as EventTarget).removeEventListener?.(
"change",
onChange,
);
}, []),
useCallback(() => window.innerHeight, []),
);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] =
@@ -336,10 +350,10 @@ export function Grid<
}
return result;
// The rects may change due to the grid resizing or updating to a new
// generation, but eslint can't statically verify this
// The rects may change due to the grid resizing, changing orientation, or
// updating to a new generation, but eslint can't statically verify this
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gridRoot, layoutRoot, tiles, gridBounds, generation]);
}, [gridRoot, layoutRoot, tiles, gridBounds, orientation, generation]);
// The height of the portion of the grid visible at any given time
const visibleHeight = useMemo(

View File

@@ -31,8 +31,9 @@ Please see LICENSE in the repository root for full details.
position: absolute;
inline-size: 404px;
block-size: 233px;
inset-block: 0;
inset-inline: var(--cpd-space-3x);
/* Ensure that spotlight tile lies within the safe area */
inset: 0 calc(env(safe-area-inset-right) + var(--cpd-space-3x)) 0
calc(env(safe-area-inset-left) + var(--cpd-space-3x));
}
.fixed > .slot[data-block-alignment="start"] {

View File

@@ -32,9 +32,8 @@ interface GridCSSProperties extends CSSProperties {
*/
export const makeGridLayout: CallLayout<GridLayoutModel> = ({
minBounds$,
spotlightAlignment$,
}) => ({
scrollingOnTop: false,
foreground: "fixed",
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives
@@ -42,7 +41,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
useUpdateLayout();
const alignment = useObservableEagerState(
useInitial(() =>
spotlightAlignment$.pipe(
model.spotlightAlignment$.pipe(
distinctUntilChanged(
(a1, a2) => a1.block === a2.block && a1.inline === a2.inline,
),
@@ -52,11 +51,11 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) =>
spotlightAlignment$.next({
model.spotlightAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
[model.spotlightAlignment$],
);
return (

View File

@@ -22,12 +22,6 @@ Please see LICENSE in the repository root for full details.
inset: 0;
}
.spotlight {
position: absolute;
inline-size: 404px;
block-size: 233px;
}
.slot[data-block-alignment="start"] {
inset-block-end: unset;
}

View File

@@ -1,5 +1,6 @@
/*
Copyright 2024 New Vector Ltd.
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.
@@ -9,31 +10,35 @@ import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/layout-types.ts";
import { type OneOnOneLandscapeLayout as OneOnOneLandscapeLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneLayout.module.css";
import styles from "./OneOnOneLandscapeLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
* An implementation of the "one-on-one" layout, in which the remote participant
* is shown at maximum size, overlaid by a small view of the local participant.
* An implementation of the "one-on-one" layout for landscape screens, in which
* the remote participant is shown at maximum size, overlaid by a small view of
* the local participant.
*/
export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
minBounds$,
pipAlignment$,
}) => ({
scrollingOnTop: false,
export const makeOneOnOneLandscapeLayout: CallLayout<
OneOnOneLandscapeLayoutModel
> = ({ minBounds$ }) => ({
foreground: "fixed",
fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
fixed: function OneOnOneLandscapeLayoutFixed({ ref }): ReactNode {
useUpdateLayout();
return <div ref={ref} />;
},
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
scrolling: function OneOnOneLandscapeLayoutScrolling({
ref,
model,
Slot,
}): ReactNode {
useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useBehavior(pipAlignment$);
const pipAlignment = useBehavior(model.pipAlignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
@@ -41,11 +46,11 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
pipAlignment$.next({
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[],
[model.pipAlignment$],
);
return (
@@ -61,8 +66,8 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
</Slot>
</div>

View File

@@ -0,0 +1,46 @@
/*
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.
*/
.layer {
block-size: 100%;
}
.spotlight {
block-size: 100%;
inline-size: 100%;
}
.pip {
position: absolute;
inset: var(--cpd-space-4x);
}
.pip[data-size="sm"] {
inline-size: 88px;
block-size: 132px;
}
.pip[data-size="lg"] {
inline-size: 140px;
block-size: 210px;
}
.pip[data-block-alignment="start"] {
inset-block-end: unset;
}
.pip[data-block-alignment="end"] {
inset-block-start: unset;
}
.pip[data-inline-alignment="start"] {
inset-inline-end: unset;
}
.pip[data-inline-alignment="end"] {
inset-inline-start: unset;
}

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