Compare commits

..

629 Commits

Author SHA1 Message Date
Robin
4d1fbf0502 Delete unused CSS file
Seems this was mistakenly committed during initial development of the Grid component. src/grid/Grid.module.css is the one that's actually used.
2026-04-13 15:08:40 +02:00
Robin
0025d4f882 Merge pull request #3868 from element-hq/reactions-placement
Fix placement of reaction and raised hand indicators
2026-04-13 15:06:44 +02:00
Robin
0842b2b472 Fix placement of reaction and raised hand indicators 2026-04-13 13:02:15 +02:00
Robin
01bd1e3af7 Merge pull request #3842 from element-hq/voice-intents
Add intents for group voice calls
2026-04-13 12:34:45 +02:00
Valere Fedronic
fae3c4195f Merge pull request #3854 from element-hq/valere/fix_race_connection_fails
Fix a race connection causing the joiner to never publish media to the call
2026-04-10 11:12:55 +02:00
Valere
208184909e fixup: prettier 2026-04-10 10:08:57 +02:00
Valere
aea5815dab Ensure we don't publish to any transport until our own transport is ok 2026-04-10 09:24:34 +02:00
Valere
40dacd523b review: Move the all advertised/active down to the LocalMember
And let the local member use it properly to send membership event and publish media
2026-04-09 15:22:10 +02:00
Timo
44958db514 Merge pull request #3851 from element-hq/toger5/shodow-for-draggable
Add a shadow to draggable items to prohibit invisible PIP outlines.
2026-04-09 15:52:33 +08:00
Robin
330584ac31 Merge pull request #3827 from element-hq/storybook
Set up Storybook again
2026-04-08 17:12:16 +02:00
Timo K
2bca491b0c ignore story files for coverage 2026-04-08 16:38:52 +02:00
Timo K
9764f6f5de Update .eslintrc.cjs 2026-04-08 16:18:51 +02:00
Timo K
69bcdc1bea fixup yarn after merge 2026-04-08 16:18:06 +02:00
Timo K
27b42ca641 Merge branch 'livekit' into storybook 2026-04-08 16:17:37 +02:00
Robin
417164cc11 Merge pull request #3855 from element-hq/changelog-labels
Reconfirm that a PR has a changelog label when new commits are pushed
2026-04-08 16:00:07 +02:00
Robin
2a5acc0207 Reconfirm that a PR has a changelog label when new commits are pushed
Without re-running the changelog label workflow on new commits, PRs get stuck waiting for the check after new commits are pushed.
2026-04-08 15:08:54 +02:00
Valere
08006d640a fix: Ensure to publish on the correct transport 2026-04-08 11:10:27 +02:00
Valere
58d377c3f7 test: Add publishing bug reproduction test 2026-04-08 11:06:05 +02:00
Timo K
6c8219a26b Add a shadow to draggable items to prohibit invisible PIP outlines. 2026-04-07 13:02:46 +02:00
Robin
efd1b42da0 Merge pull request #3847 from element-hq/renovate/npm-vite-vulnerability
Update dependency vite to v8 [SECURITY]
2026-04-07 01:45:41 +02:00
Robin
31b35463fc Fix build 2026-04-07 01:31:21 +02:00
renovate[bot]
89c4485f2d Update dependency vite to v8 [SECURITY] 2026-04-06 21:16:47 +00:00
Robin
3c2c189830 Merge pull request #3838 from element-hq/valere/refactor_transport_discovery
Refactor LocalTransport discovery and validation
2026-04-06 13:24:16 +02:00
Robin
2c34e681a1 Fix tests 2026-04-06 12:58:26 +02:00
Robin
48226795de Merge pull request #3846 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-design-tokens to v9
2026-04-06 12:44:47 +02:00
renovate[bot]
3503f8177d Update dependency @vector-im/compound-design-tokens to v9 2026-04-06 01:31:03 +00:00
Valere
9fe35ba822 Add back the patch to avoid reconnect glitch 2026-04-03 19:08:52 +02:00
Robin
a84419c8a4 Merge pull request #3824 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-design-tokens to v8
2026-04-03 19:07:59 +02:00
Valere
a89d231858 review change comment to proper doc 2026-04-03 18:51:14 +02:00
Robin
f0ec4b9add Add intents for group voice calls
This adds two new intents: start_call_voice and join_existing_voice. I need the latter in order to implement Element Web's new incoming call toasts, in which you can turn off your video before joining a group call. The other one, start_call_voice, exists more for completeness than anything; we don't currently want to allow users to start voice calls in group chats in our messenger clients, but maybe Cinny would, for instance.
2026-04-03 18:47:13 +02:00
Valere
6d99450d81 review: Remove un-needed initial value for behavior 2026-04-03 18:46:49 +02:00
Robin
47e14ff574 Merge pull request #3832 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-04-03 18:09:58 +02:00
Robin
55eea7a968 Fix type error 2026-04-03 17:48:42 +02:00
Robin
121160e835 Merge pull request #3837 from element-hq/renovate/npm-lodash-es-vulnerability
Update dependency lodash-es to v4.18.1 [SECURITY]
2026-04-03 14:57:35 +02:00
Valere Fedronic
47cb01ab6f Merge pull request #3839 from element-hq/valere/revert_widget_cap_workaround
revert workaround for widget permission dialog, not needed anymore
2026-04-03 11:17:46 +02:00
Valere
3d286e8ab4 revert workaround for widget permission dialog, not needed anymore 2026-04-03 09:45:59 +02:00
Valere
b1f84a34a8 remove dead commented code 2026-04-02 20:42:09 +02:00
Valere
95eb8943af fixup test: Errors are now wrapped in user readable error
and proper fetchmock for doNetworkOperationWithRetry
2026-04-02 19:05:24 +02:00
Valere
b5be123953 fix uncaught errors in tests 2026-04-02 16:53:51 +02:00
Valere
90bfaecd13 refact: step 2 - break down old membership local transport logic 2026-04-02 16:53:51 +02:00
Valere
4cecdda955 refact: step 1 - extract oldest membership transport into new function 2026-04-02 16:53:51 +02:00
Valere
869bab8253 fixup test since usage of doNetworkOperationWithRetry
doNetworkOperationWithRetry requires to properly mock the get_token endpoint, whereas retryNetworkOperation didn't require it.
2026-04-02 16:53:51 +02:00
Valere
23f846a308 fixup: test prettier 2026-04-02 16:53:51 +02:00
Valere
6dcb470162 Refactor local transport
- use the new domain logic to discover the transport
- then try to authenticate
- Also fix the bug in multi sfu where active$ not updated on delayId change
2026-04-02 14:38:49 +02:00
Valere
c5c154c99b add self contained domain logic to discover transports 2026-04-02 14:37:25 +02:00
Valere
fd08489afb add failing test showing delayId not properly used for delegation 2026-04-02 14:32:26 +02:00
renovate[bot]
67d7ec6703 Update dependency lodash-es to v4.18.1 [SECURITY] 2026-04-02 08:02:14 +00:00
Valere Fedronic
a2005febd5 Merge pull request #3835 from element-hq/redundant-pip
Avoid redundantly showing the local user in the PiP tile
2026-04-02 10:01:32 +02:00
renovate[bot]
5e337a5399 Update dependency @vector-im/compound-design-tokens to v8 2026-04-01 20:36:05 +00:00
renovate[bot]
e0a8e84df4 Update all non-major dependencies 2026-04-01 20:35:33 +00:00
renovate[bot]
99ba23bc96 Update GitHub Actions (#3831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-01 17:33:25 +02:00
Robin
f7488a0474 Avoid redundantly showing the local user in the PiP tile
If you are the only participant in the call, the expanded spotlight layout would redundantly show your media in both the spotlight and PiP tiles. This is a regression; in versions 0.16.1 and earlier we would avoid showing the same user twice.
2026-04-01 13:16:39 +02:00
Valere Fedronic
bb7b2d1060 Merge pull request #3818 from element-hq/valere/msc_4039
msc4039 support b64 in addition to blob file download
2026-03-31 19:15:13 +02:00
Valere
b5870c9e78 Merge branch 'livekit' into valere/msc_4039 2026-03-31 18:48:13 +02:00
Valere Fedronic
1fce220965 Merge pull request #3828 from element-hq/valere/fix/stop_calling_msc4143_in_widget
Stop calling rtc/transport in widget mode
2026-03-31 18:47:20 +02:00
Valere
cd3c8f001f Merge branch 'livekit' into valere/msc_4039 2026-03-31 17:24:52 +02:00
Valere
f316ece404 Merge branch 'livekit' into valere/fix/stop_calling_msc4143_in_widget 2026-03-31 17:15:08 +02:00
Valere Fedronic
ce1cd9aefb Merge pull request #3830 from element-hq/valere/tmp_fix_download_file_permission
Workaround: EW has not yet merged the download file permission for EC
2026-03-31 17:14:15 +02:00
Valere
d5ec3c342c reuse exisiting helpers to join call 2026-03-31 16:06:56 +02:00
Valere
c9242148f9 add missing dismiss dialog test 2026-03-31 15:24:12 +02:00
Valere
0dabb43e0f Workaround: EW has not yet merged the download file permission for EC 2026-03-31 14:56:25 +02:00
Valere
8cee4df46e fix test: use fetchMock to avoid test interference 2026-03-31 12:03:02 +02:00
Valere
6b7467ce6d Stop calling rtc/transport in widget mode 2026-03-31 11:38:21 +02:00
Robin
c9eab59161 Create a StarRatingInput story 2026-03-30 18:26:38 +02:00
Robin
4cf75c8eeb Create a LayoutToggle story 2026-03-30 18:26:38 +02:00
Robin
2de151d94d Set up Storybook again 2026-03-30 18:26:38 +02:00
Valere
3389fb5a34 fixup linter 2026-03-26 19:39:02 +01:00
Valere
421c49e7ba fixup prettier 2026-03-26 19:10:10 +01:00
Valere
1eabb0f0aa add tests 2026-03-26 18:14:51 +01:00
Valere
8f38ecac2e msc4039 support b64 in addition to blob file download 2026-03-26 15:29:51 +01:00
Valere Fedronic
40fdef89eb Merge pull request #3780 from JephDiel/Download-Avatars-with-MC4039
Download avatars using the MC4039 Widget API
2026-03-26 09:03:15 +01:00
Valere Fedronic
2ab5e07f73 Merge pull request #3801 from element-hq/valere/default_route
Audio Control | Default to using earpiece when doing audio vs speaker for video call (depending on presence of headset or not)
2026-03-25 13:16:53 +01:00
Valere
09092e97c8 fix error in test 2026-03-25 12:15:12 +01:00
Valere
cb6bef3849 try extend timeout? 2026-03-25 10:10:29 +01:00
Valere
4d491be4a9 post merge fix 2026-03-25 08:21:32 +01:00
Valere
aad160a715 Merge branch 'livekit' into valere/default_route 2026-03-24 18:28:54 +01:00
Valere
fc61a36d4a review: improve comments 2026-03-24 18:25:09 +01:00
Valere
1bc2abb84f rename ControlledAudioOutput to iOSControlledAudioOutput 2026-03-24 18:03:33 +01:00
Valere
4f518819d3 review: extract ControlledAudioOutput in its own file 2026-03-24 18:02:27 +01:00
Robin
b40feae7da Merge pull request #3810 from element-hq/robin/button-accessibility
Improve accessibility of microphone, camera, and screen share buttons
2026-03-24 17:36:36 +01:00
Robin
e15761d8ee Merge pull request #3703 from element-hq/renovate/compound
Update Compound
2026-03-24 17:20:01 +01:00
Robin
c60ed50a9d Attempt to fix end-to-end widget tests 2026-03-24 17:04:28 +01:00
Robin
6dcd883fc3 Update test snapshots 2026-03-24 16:10:19 +01:00
Robin
c0d60b2c29 Improve accessibility of microphone, camera, and screen share buttons
Taking Valere's suggestion of giving them the 'switch' role. Also, the aria-label attributes were redundant (having tooltips already gives the buttons aria-labelledby).
2026-03-21 02:53:48 +01:00
Valere
c8b8d350d5 Merge branch 'livekit' into valere/default_route 2026-03-19 18:41:36 +01:00
Valere
9c2b2d4780 fix: playwright, new web popup interfering with test to witch room 2026-03-19 10:44:38 +01:00
Valere
396225a900 fix playwright earpiece mode 2026-03-19 10:44:38 +01:00
Valere
c4ec52ae15 add some test for normal AudiOutput 2026-03-19 10:44:38 +01:00
Valere
4be2bc7560 android: Select default output device based on callIntent
Add comments on existing code
Extracted a specific android controller for isolation and better testing

lint fixes

Fix device update logic and more tests

better typescript
2026-03-19 10:44:38 +01:00
Valere Fedronic
385ab0a0ed Merge pull request #3805 from element-hq/robin/ringing
New ringing UI
2026-03-19 10:43:16 +01:00
Robin
4d69565312 Merge pull request #3809 from element-hq/robin/invert-buttons
Invert the colors of the camera and microphone buttons
2026-03-18 14:49:55 +01:00
Robin
fa844446b6 Invert the colors of the camera and microphone buttons
So that they use primary color tokens when unmuted, and secondary color tokens when muted. This makes them work like the screen sharing button.
2026-03-18 11:29:55 +01:00
Robin
9dfade68ee New ringing UI
This implements the new ringing UI by showing a placeholder tile for the participant being dialed, rather than an overlay.
2026-03-18 11:20:43 +01:00
renovate[bot]
32ea8f522c Update Compound 2026-03-15 01:47:49 +00:00
renovate[bot]
6d14f1d06f Update GitHub Actions (#3804)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-14 14:48:44 +00:00
JephDiel
997cc9bb27 Lint fixes v2 2026-03-13 11:41:58 -05:00
JephDiel
fa74aaa81e Import order lint fixes
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2026-03-13 11:33:07 -05:00
JephDiel
af807489f9 Import order lint fixes
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2026-03-13 11:31:55 -05:00
Robin
5cf5227eaa Merge pull request #3802 from element-hq/toger5/fix-qs-glob-js-yaml-security
Update qs, js-yaml, glob for security patches
2026-03-13 17:31:40 +01:00
Timo K
78240c2ec8 update qs, js-yaml, glob for security patches 2026-03-13 08:01:52 +01:00
Timo K
bf8bf80417 Revert "update qs, js-yaml, glob for security patches"
This reverts commit 7da9bca08c.
2026-03-13 08:01:08 +01:00
Timo K
7da9bca08c update qs, js-yaml, glob for security patches 2026-03-13 07:59:49 +01:00
JephDiel
b198721969 Ignore stale downloads
If src or sizePx changes while we're downloading,
discard the now-stale fetch result so we don't
override the fresh one.
2026-03-12 22:20:19 -05:00
JephDiel
8ecb1b3dbf Simplify widget detection
Use the exists check for the widget API directly
instead of a feature flag.
2026-03-12 22:18:38 -05:00
Timo
748c8e6d0d Merge pull request #3800 from element-hq/toger5/fix-tar-minimatch-security
Bump tar, minimatch
2026-03-12 23:54:10 +08:00
Timo
40ef16a55f Merge pull request #3799 from element-hq/toger5/fix-rollup-security-alert
Update vite, vitest and rollup
2026-03-12 23:51:53 +08:00
Timo
07ad8374a9 Merge pull request #3595 from element-hq/toger5/dont-trap-in-invalid-config
Reset overwrite url if it is invalid (does fail to reach sfu)
2026-03-12 22:06:26 +08:00
Timo K
a0d5c79999 also update coverage 2026-03-12 14:52:16 +01:00
Timo K
3e171d9639 bump tar, minimatch
security alert
2026-03-12 14:48:05 +01:00
fkwp
413329cd26 Fix: zizmor findings (#3797)
* zizmor auto fixes

* add github action for security analysis with zizmor

* add access token to iOS push action

* fix zizmor findings

* add exceptions for dangerous-triggers including comments for reasoning

* improve comments

* prettier
2026-03-12 13:30:45 +01:00
Timo K
6b8f6e9405 update vite vitest and rollup
(rollup needs updating to fix a security alert)
2026-03-12 12:10:17 +01:00
fkwp
af54b39698 fix: typo pushing element registry OCI images now to the correct target (#3796)
* Push docker images to oci.element.io

* prettier

* add id-token permission as its required by tailscale login

* pass secrets to reusable workflows

* change secret path team -> voip

* Update .github/workflows/build-and-publish-docker.yaml

Co-authored-by: Gaël Goinvic <97093369+gaelgatelement@users.noreply.github.com>

* typo

---------

Co-authored-by: Gaël Goinvic <97093369+gaelgatelement@users.noreply.github.com>
2026-03-11 16:09:02 +01:00
Timo
c05d223133 Merge pull request #3775 from element-hq/toger5/new-pip-layout
Implement new Pip Layout (with control buttons)
2026-03-11 23:07:47 +08:00
Timo K
c7f25feb66 use better test condition for mute buttons 2026-03-11 15:46:19 +01:00
Timo K
a20edca9a1 fix pip container query 2026-03-11 15:36:37 +01:00
Timo K
d00ff78d65 fix pip interaction test (button presses) 2026-03-11 15:21:36 +01:00
fkwp
839c4dd738 fix: OCI image push to element registry (#3795)
* Push docker images to oci.element.io

* prettier

* add id-token permission as its required by tailscale login

* pass secrets to reusable workflows

* change secret path team -> voip

* Update .github/workflows/build-and-publish-docker.yaml

Co-authored-by: Gaël Goinvic <97093369+gaelgatelement@users.noreply.github.com>

---------

Co-authored-by: Gaël Goinvic <97093369+gaelgatelement@users.noreply.github.com>
2026-03-11 15:17:12 +01:00
fkwp
41f7b643fb Add zizmor checks on CI (#3792)
* zizmor auto fixes

* add github action for security analysis with zizmor

* add access token to iOS push action
2026-03-11 14:20:05 +01:00
Timo K
3a9d394529 activate click tests 2026-03-11 14:05:17 +01:00
fkwp
c9557e91d5 fix: add id-token permission as its required by tailscale login (part 3) (#3793)
* Push docker images to oci.element.io

* prettier

* add id-token permission as its required by tailscale login

* pass secrets to reusable workflows
2026-03-11 13:06:20 +01:00
Timo K
1e400bc550 remove unsused import 2026-03-10 18:26:12 +01:00
Timo K
6485da8fff add playwright tests for new pip layout 2026-03-10 15:17:41 +01:00
Timo K
54bef07b3b linter 2026-03-10 13:57:06 +01:00
Timo K
273eedd256 keep pip as it was before on mobile 2026-03-10 13:57:06 +01:00
Timo K
38382539ad fix lints 2026-03-10 13:57:06 +01:00
Timo K
8db1c4c370 Implement new Pip Layout (with control buttons) 2026-03-10 13:57:06 +01:00
Valere Fedronic
c98d03e2e1 Merge pull request #3787 from element-hq/valere/fix_typo_width_height
fix: typo inverting with/height in PIP spotlight tile
2026-03-10 09:38:53 +01:00
Valere
143b560b20 Add a playwright test to ensure object fit is correct 2026-03-10 09:05:05 +01:00
JephDiel
699e31f59a Download Avatar from relevent source
Instead of relying on failures directly use the
available method to download the avatar.
2026-03-09 23:16:12 -05:00
Valere
3da762ab36 fix: typo inverting with/height in PIP spotlight tile 2026-03-09 17:49:04 +01:00
Valere Fedronic
efcde9edfe Merge pull request #3756 from element-hq/valere/auto_fit_based_on_video_ratio
Auto fit video in tile depending on orientation of the tile and the source stream
2026-03-09 15:31:15 +01:00
Valere
ca3837f44e fix merge issue that added back a deprecated test 2026-03-09 15:07:42 +01:00
Robin
467675ff40 Merge pull request #3785 from robintown/unpin-deps
Unpin Knip and LiveKit track processors
2026-03-09 14:35:52 +01:00
Valere
5a612fea91 Merge branch 'livekit' into valere/auto_fit_based_on_video_ratio 2026-03-09 14:30:54 +01:00
Valere Fedronic
17ec488f0b Merge pull request #3784 from element-hq/valere/pr_template
Devx: Add a PR template
2026-03-09 14:15:14 +01:00
Valere
8f418ce9fd fix cla link 2026-03-09 13:59:39 +01:00
Robin
4c7fad30c0 Silence new Knip errors 2026-03-09 13:11:48 +01:00
Robin
4922249f41 Unpin Knip and LiveKit track processors
These dependencies were held back on old versions because of issues that are now resolved (see 2fca7e3719 and 44eb8acaee).
2026-03-09 13:00:43 +01:00
Robin
06c15ded37 Merge pull request #3747 from JakeTripplJ/screenshare-volume
Add volume control to screen shares
2026-03-09 10:59:45 +01:00
Robin
885a523e91 Fix formatting 2026-03-09 10:44:22 +01:00
Robin
313b8285d9 Make the screen share volume button accessible on mobile
In landscape orientation the button would be buried underneath the footer, which would block interaction with it. This commit changes the footer to not show in cases where a button has been pressed.
2026-03-09 10:30:42 +01:00
Valere
d00e1ca983 Devx: Add a PR template 2026-03-09 09:55:56 +01:00
Valere
513477d280 review: Use targetWidth/Height instead of listening to element bounds 2026-03-09 09:45:25 +01:00
Valere
273fff20bd review: add comment 2026-03-09 09:12:03 +01:00
JephDiel
005b965fba Download avatars using the Widget API
If we can't authenticate media because we're
running as a widget, use the MC4039 widget API
instead of a direct fetch to download the avatar.
2026-03-07 19:53:57 -06:00
Robin
3bbbac23a0 Adjust dimensions of screen share volume menu 2026-03-06 23:15:59 +01:00
Robin
c7a16e9dfd Refactor screen share volume button into a component 2026-03-06 23:14:15 +01:00
Jake Janicke
5f2d1c8a7e Comment typo
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 17:50:31 -06:00
Jake Janicke
e99e8628d6 Clean up and streamline safety checking instead of using multiple separate checks
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 17:09:07 -06:00
Jake Janicke
65045c264b Fix formatting
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 16:11:39 -06:00
Jake Janicke
3216d68470 Make screenShareVolume safer
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 16:05:56 -06:00
Jake Janicke
99401a7285 Make screenShareLocallyMuted check safer
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 15:13:20 -06:00
Jake Janicke
c74d19ad33 Add more guards against undefined
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 13:56:30 -06:00
Jake Janicke
ea074ceb2d Sort translation keys
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 13:16:51 -06:00
Jake Janicke
b88daf0198 Add tests for screen share volume UI presence logic
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 05:07:41 -06:00
Jake Janicke
a34fe7817a Add tests for screen share volume controls
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 03:52:46 -06:00
Jake Janicke
72520dbb3f Add screen share volume slider UI
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-04 03:52:38 -06:00
Jake Janicke
2cf4a38c17 Add volume logic to RemoteScreenShareViewModel
Signed-off-by: Jake Janicke <jaketripplj@gmail.com>
2026-03-03 16:47:41 -06:00
fkwp
00f880108e fix: add id-token permission as its required by tailscale login (part 2) (#3770)
* Push docker images to oci.element.io

* prettier

* add id-token permission as its required by tailscale login
2026-03-02 17:03:01 +01:00
Valere Fedronic
a2ee0163b6 Merge pull request #3752 from element-hq/valere/remove_deprecated_param
remove deprecated `analyticsID` url param
2026-03-02 16:10:27 +01:00
Valere
5165e95d82 fix: default to cover is size are 0 2026-03-02 15:38:43 +01:00
fkwp
6e0f48e99b fix: add id-token permission as its required by tailscale login (#3768)
* Push docker images to oci.element.io

* prettier

* add id-token permission as its required by tailscale login
2026-03-02 15:04:18 +01:00
Valere
adc329a7e7 post merge fix 2026-03-02 14:41:47 +01:00
Valere
c199d00300 Merge branch 'livekit' into valere/auto_fit_based_on_video_ratio 2026-03-02 14:31:47 +01:00
Robin
38e79bb7db Merge pull request #3764 from element-hq/renovate/github-actions
Update docker/login-action digest to c94ce9f
2026-03-02 13:44:15 +01:00
Robin
23050075b7 Merge pull request #3765 from element-hq/renovate/embedded-package-dependencies
Update embedded package dependencies
2026-03-02 12:31:27 +01:00
renovate[bot]
b2475e4d1d Update embedded package dependencies 2026-03-01 01:11:41 +00:00
renovate[bot]
8e7e099294 Update docker/login-action digest to c94ce9f 2026-03-01 01:11:01 +00:00
Robin
dcb7143359 Merge pull request #3755 from element-hq/robin/ringing
Convert media view model classes to interfaces
2026-02-27 17:27:58 +01:00
Robin
0ce24929dc Add TODO to clean up encryption status code 2026-02-27 17:12:24 +01:00
Robin
9930288d1f Move observeInboundRtpStreamStats$ into the appropriate file 2026-02-27 17:08:04 +01:00
Valere
ae8b1f840f add missing mocking 2026-02-26 17:02:43 +01:00
Valere
15aa67ebb9 remove unused fit to frame translation 2026-02-26 16:46:08 +01:00
Valere
1de8d93b4b feat: video auto fit based on video stream size 2026-02-26 16:28:37 +01:00
Robin
6b51b7dc58 Split MediaViewModel into multiple files 2026-02-25 22:41:28 +01:00
Robin
d16fa4531c Merge pull request #3712 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-02-25 19:52:22 +01:00
Robin
44eb8acaee Pin Knip to 5.82.1 to work around Yarn plugin issue
We can upgrade beyond 5.82.1 once https://github.com/webpro-nl/knip/pull/1574 is merged.
2026-02-25 19:36:00 +01:00
Robin
2fca7e3719 Roll back @livekit/track-processors to fix type error (again!)
While we wait for https://github.com/livekit/track-processors-js/pull/118 to be released
2026-02-25 18:03:04 +01:00
renovate[bot]
62b408a2b3 Update all non-major dependencies 2026-02-25 16:47:27 +00:00
fkwp
465d28a1cd bumping LiveKit SFU and synapse versions for ci/cd (#3757) 2026-02-25 17:46:35 +01:00
fkwp
dcf3a722a7 Push docker images to oci.element.io (#3725)
* Push docker images to oci.element.io

* prettier
2026-02-25 17:45:56 +01:00
Robin
6995388a29 Convert media view model classes to interfaces
Timo and I agreed previously that we should ditch the class pattern for view models and instead have them be interfaces which are simply created by functions. They're more straightforward to write, mock, and instantiate this way.

The code for media view models and media items is pretty much the last remaining instance of the class pattern. Since I was about to introduce a new media view model for ringing, I wanted to get this refactor out of the way first rather than add to the technical debt.

This refactor also makes things a little easier for https://github.com/element-hq/element-call/pull/3747 by extracting volume controls into their own module.
2026-02-25 14:47:43 +01:00
Robin
4039272e75 Merge pull request #3745 from element-hq/robin/ringing
Tidy up media view models a little bit
2026-02-25 14:22:03 +01:00
Valere
c8f5be7e4f remove deprecated analyticsID url param 2026-02-24 11:56:54 +01:00
Robin
4a0e89730d Merge pull request #3728 from element-hq/renovate/matrix-widget-api
Update dependency matrix-widget-api to v1.17.0
2026-02-18 19:43:32 +01:00
Robin
bc238778ad Make the type of participant$ stricter
It is, in fact, required to be a behavior.
2026-02-18 14:01:55 +01:00
Robin
9d37125677 Only expose RTC backend identity debug info where we actually use it
We only ever inspect the RTC backend identity of user media tiles. So it only needs to be exposed on the user media view model.
2026-02-18 13:46:18 +01:00
Robin
d87c3293c9 Make one-on-one layout media types stricter 2026-02-18 13:44:20 +01:00
renovate[bot]
789de1963a Update GitHub Actions (#3711)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 16:01:46 +01:00
Timo
ba95780813 Merge pull request #3734 from element-hq/robin/local-transport
Logically separate the advertised transport from the active transport
2026-02-16 15:21:28 +01:00
Robin
4508002947 Test local transport code in oldest member mode 2026-02-13 14:06:44 +01:00
Robin
2a56830426 Fix existing LocalTransport tests 2026-02-13 14:06:44 +01:00
Robin
6cf859fd9e Fix resource leak due to unsafe scope reference 2026-02-13 12:40:12 +01:00
renovate[bot]
91da088da4 Update dependency matrix-widget-api to v1.17.0 2026-02-12 12:15:03 +00:00
Valere Fedronic
076cc035d3 Merge pull request #3733 from element-hq/valere/bug_fix_stringify_crash
Fix: crash on mobile (android) with `0.17.0-rc.2` due to a log causing stringify to crash with `TypedError: circular structure`
2026-02-11 14:46:37 +01:00
Valere
92992df7df test: ensure ragelogger resist to JSON.stringify throws 2026-02-11 13:56:42 +01:00
Robin
13d131c2e9 Logically separate the advertised transport from the active transport
To correctly implement the legacy "oldest membership" mode, we need the code to be more nuanced about the local transport. Specifically, it needs to allow for the transport we advertise in our membership to be different from the transport that we connect to and publish media on. Otherwise, if these two are yoked together, members will resend their memberships whenever an SFU hop occurs, which an attacker could use to cause an amplified wave of state changes.
2026-02-11 13:47:15 +01:00
Valere Fedronic
8ea773aa24 Merge pull request #3731 from element-hq/valere/fix_netlify_deploy
fix: Netlify preview broken
2026-02-11 13:19:50 +01:00
Valere
0d24995c3e add defensive coding against JSON.stringify 2026-02-10 17:46:58 +01:00
Valere
1e9f2e6282 fix: Simplify log causing stringify TypeError on widget mode 2026-02-10 17:24:01 +01:00
Valere
73245e875d fix: Netlify preview broken 2026-02-10 15:11:43 +01:00
Timo
a329ae0ad1 Merge pull request #3720 from element-hq/toger5/sdk-improvements-only
[MatrixRTC SDK] improvements - compatible with custom rtc application
2026-02-09 09:50:16 +01:00
Timo K
652ff0060c Back to develop branck (matrix-js-skd) 2026-02-09 09:07:54 +01:00
Robin
d3476c0446 Merge pull request #3726 from element-hq/robin/generate-items-logs
Add debug logs to generateItems
2026-02-06 13:45:13 +01:00
Robin
a0209eb433 Add debug logs to generateItems
It's always worth having logs for when state holders are created or destroyed (these are often the most interesting things happening in the application), so I thought it would be nice to have generateItems always log for you when it's doing that.
2026-02-06 13:26:02 +01:00
Timo K
e1ef28fdcc use js-sdk fixing room hack for any app 2026-02-05 09:04:03 +01:00
Timo K
2e26193c4a update leave 2026-02-05 06:33:51 +01:00
Timo K
42efc05bd0 Add more granular leave/join api 2026-02-05 06:06:15 +01:00
Valere
809186a2e7 test: test requesting send message permission in initialize widget 2026-02-04 21:16:50 +01:00
Timo K
927e8e195c add experimental label 2026-02-04 20:16:31 +01:00
Timo K
33f6271d13 sdk improvements - compatible with custom rtc application
- add local member
 - add optional make sticky
 - default to video not enabled
 - allow sending matrix events
2026-02-04 20:06:26 +01:00
Timo
2c1476f151 Merge pull request #3715 from element-hq/revert-3701-toger5/sdk-improvements
Revert "[WIP] SDK target improvements"
2026-02-04 18:41:54 +01:00
Timo
97ee4630b0 Revert "[WIP] SDK target improvements" 2026-02-04 08:51:01 +01:00
Timo
02b29f98b6 Merge pull request #3701 from element-hq/toger5/sdk-improvements
[WIP] SDK target improvements
2026-02-04 08:47:28 +01:00
Valere
fdc33a223f build against js-sdk rc 40.2.0-rc.0 2026-02-03 17:37:58 +01:00
Valere
7beafcac92 Merge branch 'livekit' into toger5/sdk-improvements 2026-02-03 16:32:36 +01:00
Valere Fedronic
5563f30bf2 Merge pull request #3705 from element-hq/toger5/fix-test-no-more-legacy-ring
[Matrix2.0 compatibility] stop advertising `livekit_alias`, use `"ROOM"` slot_id (backwards compat), stop sending deprecated notify
2026-02-03 16:26:52 +01:00
Valere
9d8ebf8ef3 fix import 2026-02-03 15:37:41 +01:00
Valere
a0ad238952 fix: DidSendCallNotification is not emitting array anymore 2026-02-03 15:29:19 +01:00
Timo K
18b771e620 Allow sending room events in rtc sdk 2026-02-03 11:10:57 +01:00
Timo K
461a1f1b70 add to sending room messages 2026-01-31 12:29:56 +01:00
Timo K
a066495c5f bump js-sdk fixup custom applications 2026-01-31 12:09:29 +01:00
Timo K
506b85770b Merge branch 'livekit' into toger5/sdk-improvements 2026-01-31 11:53:15 +01:00
Timo K
bd8c4188d0 bump js-sdk 2026-01-29 17:24:21 +01:00
Timo K
c78e8c54b8 js-sdk-bump 2026-01-29 17:22:56 +01:00
Valere Fedronic
c18cc964a8 Merge pull request #3706 from element-hq/valere/fix_remove_livekit_alias_signaling
The advertised livekit_alias in membership is deprecated
2026-01-29 16:13:53 +01:00
Valere
8ee098eef1 review: quick comment 2026-01-29 15:46:41 +01:00
Valere
a7bd2d3668 review: comment and better type 2026-01-29 14:58:54 +01:00
Valere
ca7c8a2f11 Send livekit_alias in multi sfu with state 2026-01-28 16:42:07 +01:00
Valere
146375e320 Only remove livekit_alias in sticky events 2026-01-28 16:33:52 +01:00
Valere
992fb9093c fix test and use anonymised alias 2026-01-28 15:25:19 +01:00
Valere
99ccfcfbd4 fix linter 2026-01-28 15:18:29 +01:00
Valere
420304fd9d fix test mocking 2026-01-28 15:16:02 +01:00
Valere
81cb89e599 fix test mocking 2026-01-28 15:11:10 +01:00
Valere
ee8b27837a fix lint issues 2026-01-28 14:38:23 +01:00
Valere
a645bbe0ba fix bad import 2026-01-28 14:29:16 +01:00
Valere
4c7db0147e The advertised livekit_alias in membership is deprecated 2026-01-28 14:22:21 +01:00
Timo K
c1b2346e22 Update tests based on new js-sdk changes
(no more legacy notify event)
2026-01-27 16:49:25 +01:00
Timo K
92a4150d6d more/better logging 2026-01-26 15:43:07 +01:00
Timo K
829619cca7 fix build:sdk:production 2026-01-26 12:44:25 +01:00
Timo K
408495b07a add back permissions to pr-deploy.yaml 2026-01-26 12:43:15 +01:00
Timo K
02f1a0da82 add local member and rtcBackendIdentity to sdk target 2026-01-26 12:43:01 +01:00
Timo K
6d8ade073a use sessionManager with custom slot instead of only session. 2026-01-25 19:27:07 +01:00
Timo K
1c946f7bb2 fix package full 2026-01-25 14:38:45 +01:00
Timo K
5724c3a09c add sdk:production 2026-01-25 14:37:37 +01:00
Timo K
d55464f3ab build sdk 2026-01-25 14:32:30 +01:00
Timo K
73a5a10c6d add workflow to publish sdk version to netlify 2026-01-25 14:25:15 +01:00
Timo K
112420cb5b fix widget api appliaction capabilities 2026-01-25 14:24:52 +01:00
Timo K
31823dbd53 improve widget tests
(this we might want to cherry pick in its own PR)
2026-01-25 14:24:31 +01:00
Timo
15c39372f4 Merge pull request #3697 from element-hq/renovate/npm-lodash-es-vulnerability
Update dependency lodash-es to v4.17.23 [SECURITY]
2026-01-23 10:51:41 +01:00
Timo
330ad3448d Merge pull request #3686 from element-hq/robin/sticky-widget
Make Element Call widgets compatible with Matrix 2.0 mode
2026-01-22 20:17:16 +01:00
Timo K
220fc51eb5 add initializeWidget to tests. 2026-01-22 19:38:39 +01:00
Timo K
b754106feb Add widget.ts tests 2026-01-22 19:23:08 +01:00
renovate[bot]
05107a3e5c Update dependency lodash-es to v4.17.23 [SECURITY] 2026-01-22 04:37:37 +00:00
Robin
d516f0f602 Make Element Call widgets compatible with Matrix 2.0 mode
This gives Element Call widgets the ability to send and receive sticky RTC membership events.
2026-01-21 19:59:34 +01:00
Timo
3eb88ec4a8 Merge pull request #3693 from element-hq/toger5/fix-sticky-rejoin-error
Fix rejoin EC crash
2026-01-20 16:30:09 +01:00
Timo K
745772f672 Fix rejoin EC crash
Due to a duplcaited key (the key not being specific enough)
2026-01-20 13:45:07 +01:00
Valere Fedronic
486c0b884b Merge pull request #3690 from element-hq/valere/playwright/improve_sticky_test
playwright: intercept send request to ensure sticky events are sent
2026-01-20 08:26:47 +01:00
Valere
9f323d21a8 review: quick comment 2026-01-20 08:10:50 +01:00
Timo
a72aae0b8d Merge pull request #3679 from element-hq/toger5/fix-unnecassary-error-logs
Fix some error log lines that lead to confusion
2026-01-19 17:57:34 +01:00
Timo
b1ceeda9ee Merge pull request #3687 from toger5/toger5/fix-reemitted-event-params
Fix delayId delegation. Use correct event reemitting types.
2026-01-19 16:00:16 +01:00
Valere
ed8cee9f58 review: fix/clean the route interception 2026-01-19 15:28:22 +01:00
Timo K
3a5b9dd519 remove overkill log 2026-01-19 14:58:45 +01:00
Valere
a985281b46 playwright: intercept send request to ensure sticky event are sent 2026-01-19 13:58:36 +01:00
Timo K
a7008d37c6 better log msg 2026-01-19 13:54:46 +01:00
Timo K
94bcb1bb27 remove leftover layout action code 2026-01-19 13:54:36 +01:00
Timo
36a1aa3c59 Merge pull request #3689 from toger5/toger5/remove-deprecated-layout-widget-actions
Remove deprecated widget layout actions
2026-01-19 13:20:04 +01:00
Valere Fedronic
b4ca55f7df Merge pull request #3688 from element-hq/valere/bug_deny_permissions
Do not end call with error in case of MediaDeviceError
2026-01-19 12:32:01 +01:00
Timo K
a102b3f4aa Fix some error log lines that lead to confusion.
In the past those log lines often were referenced for issues but they
are no real issues.
Either they are just deprecated code running or expected.
2026-01-19 12:20:37 +01:00
Timo K
d8802aa079 linter 2026-01-19 12:18:13 +01:00
Timo K
4833d4b867 Bump js-sdk to use version that properly emits delayId updates 2026-01-19 12:15:40 +01:00
Timo K
abb80e4e87 Fix event reemitting types
The callback args for reemitting will change to an array including the
originals emitter.
2026-01-19 12:15:15 +01:00
Timo K
2c3365a401 remove deprecated widget layout actions 2026-01-19 12:11:32 +01:00
Valere
2817362e15 Do not end call with error in case of MediaDeviceError 2026-01-19 11:46:17 +01:00
Timo
95b730e93f Merge pull request #3683 from element-hq/valere/playwright/spa_sticky_test
playwright: Test call in SPA mode with matrix 2.0
2026-01-19 09:34:49 +01:00
Valere
c2eea67b35 Merge branch 'livekit' into valere/playwright/spa_sticky_test 2026-01-16 17:41:39 +01:00
Valere
202e06a16d skip FF 2026-01-16 17:41:32 +01:00
Timo
e278752942 Merge pull request #3675 from element-hq/toger5/fixing-duble-publishing
Fix Publisher cleanup procedure
2026-01-16 17:37:46 +01:00
Valere
89c878cf3f playwright: Test call in SPA mode with matrix 2.0 2026-01-16 17:11:45 +01:00
Timo K
f41175444b revert changes unrelated to this PR 2026-01-16 16:23:05 +01:00
Timo K
102664482b Activate the hotswap test that was failing before. 2026-01-16 15:41:25 +01:00
Timo K
d5df9e65dd Merge branch 'livekit' into toger5/fixing-duble-publishing 2026-01-16 15:38:56 +01:00
Timo
f7255b3697 Merge pull request #3681 from element-hq/toger5/playwright-publisher-swap
Add playwright test for hotswatpping the transport (swap from `oldest_member` (singeSFU) to `multi_sfu`)
2026-01-16 15:34:15 +01:00
Timo K
3f8b3ba3f1 add test to check if publisher is properly destroyed 2026-01-16 14:41:11 +01:00
Timo K
22c1948a82 Add playwright test for hotswatpping transport 2026-01-16 14:27:52 +01:00
Timo K
a99a413b88 temp 2026-01-16 14:07:56 +01:00
Timo K
83df152608 review 2026-01-16 13:20:03 +01:00
Timo K
c376d9e5c1 fix tests 2026-01-16 13:12:31 +01:00
Timo K
f09e2c8b55 Merge branch 'livekit' into toger5/dont-trap-in-invalid-config 2026-01-16 13:11:06 +01:00
Valere Fedronic
5c366320cb Merge pull request #3676 from element-hq/valere/playwright/compat_bug_reproduce
playwright: Federated call between legacy and compat
2026-01-16 13:09:02 +01:00
Valere
16b08994cc Try fixup mixed conten error loading widget 2026-01-16 12:53:10 +01:00
Timo K
f5c31626a6 fix unit tests 2026-01-16 12:43:13 +01:00
Timo K
5a48820927 remove widget layout widget action for good. 2026-01-16 10:22:23 +01:00
Timo K
971d16d243 revert jwt to stable version 2026-01-16 10:21:26 +01:00
Timo K
9b40d7420a refactor to use destroy method 2026-01-15 19:04:32 +01:00
Timo K
f71e483080 logging updates 2026-01-15 18:13:34 +01:00
Timo K
f7590a33d7 Fix publisher clean up! This is the acutal bug we chased 2026-01-15 18:12:00 +01:00
Timo K
c74accb906 remove "fetching" state again (we have to be able to deal with those
kind of switches!) Optimize to not always fetch the oldest member jwt
(only do so in legacy mode)
2026-01-15 18:11:08 +01:00
Valere
a00c6501c2 fix discarding pop ups 2026-01-15 15:22:04 +01:00
Valere
9e933b74ca cleanup notifications toast on start 2026-01-15 14:31:33 +01:00
Valere
d25d6d3196 Test all pairs of rtc mode in federated calls 2026-01-15 12:06:41 +01:00
Valere
243f369a24 re-use test helper in other files 2026-01-15 11:50:37 +01:00
Valere
0d1488af4c playwright: Federated call between legacy and compat 2026-01-14 18:07:12 +01:00
Timo K
b49411abfa dont set localTransport while still fetching oldest member transport 2026-01-14 17:35:35 +01:00
Timo
27e351270c Merge pull request #3669 from element-hq/toger5/livekit-alias-simple-roomid
Change the livekit alias to just be the room id (for backwards compatibility)
2026-01-14 12:43:08 +01:00
Timo K
41eb45b3c4 fix comments 2026-01-14 12:29:22 +01:00
Valere Fedronic
f5a6a05570 Merge pull request #3671 from element-hq/valere/playwright/huddle_test
playwright: End to end test for huddle calls in widget mode
2026-01-14 12:27:57 +01:00
Timo
31cc12eda4 Merge pull request #3670 from element-hq/toger5/livekitAlias-debugging-info
Add livekitAlias debugging info
2026-01-14 12:26:50 +01:00
Robin
39c24eb0f5 Merge pull request #3665 from element-hq/renovate/livekit-components
Update dependency @livekit/components-react to v2.9.17
2026-01-14 12:15:48 +01:00
Valere
7338a55e76 review: improve doc 2026-01-14 12:15:00 +01:00
Valere
68f04d46a9 playwright: End to end test for huddle calls in widget mode 2026-01-13 19:11:12 +01:00
Timo K
dbdcde2489 fix lint 2026-01-13 18:17:37 +01:00
Timo K
636f737cd3 reduce diff 2026-01-13 18:05:05 +01:00
Timo K
93c9225e8a Merge branch 'livekit' into toger5/dont-trap-in-invalid-config 2026-01-13 18:01:30 +01:00
Valere Fedronic
16ad89df47 Merge pull request #3667 from element-hq/valere/playwright/discard_call_widget_test
playwright: Test that declining a call in DM works as expected
2026-01-13 18:00:36 +01:00
Timo
3ce0afcacc Merge pull request #3664 from element-hq/toger5/userMedia-alternative
Small refactor: User media alternative
2026-01-13 17:51:36 +01:00
Timo K
4d35b77077 bump jwt docker 2026-01-13 17:41:46 +01:00
Timo K
20e63c8dc7 fix tests 2026-01-13 17:39:55 +01:00
Timo K
ff8d71380d Add livekitAlias debugging info 2026-01-13 15:14:34 +01:00
Timo K
d185b52091 more docstrings to docuemnt the migration away from livekit_alias 2026-01-13 12:44:17 +01:00
Timo K
72ca3821ab Change the livekit alias to just be the room id for backwards
compatibility.
2026-01-13 12:37:46 +01:00
Valere Fedronic
4b7dc8a290 Merge pull request #3659 from element-hq/valere/initial_mute_states
Fix: Ensure voice and audio intent are respected
2026-01-13 11:50:32 +01:00
Gordnho
f391c68055 Add Haproxy configuration for element call (#3661)
* Add Haproxy configuration for livekit SFU

---------

Co-authored-by: fkwp <fkwp@users.noreply.github.com>
2026-01-13 10:39:15 +00:00
Valere
e82a048088 playwright: Test that declining a call in DM works as expected 2026-01-13 09:46:44 +01:00
Valere
7a8c2d6ca9 Merge branch 'livekit' into valere/initial_mute_states 2026-01-12 19:13:40 +01:00
Valere
f3df8bc709 review: Use is_widget directly instead of using the packageType 2026-01-12 17:28:18 +01:00
Valere
534f934378 review: just inline intent and skipLobby from urlParam 2026-01-12 16:37:28 +01:00
Valere
56beb59346 review: Update copyright properly 2026-01-12 15:35:21 +01:00
Valere
fbdcacb344 review: remove dead comment 2026-01-12 15:23:02 +01:00
Timo K
89e5e5b10a fix uuid 2026-01-12 12:34:50 +01:00
Timo K
8d8eb09922 Merge branch 'livekit' into toger5/userMedia-alternative 2026-01-12 12:34:03 +01:00
renovate[bot]
fbdcde3058 Update dependency @livekit/components-react to v2.9.17 2026-01-12 01:33:44 +00:00
Timo
f444c9bc11 Merge pull request #3638 from element-hq/toger5/delayed-event-delegation
Pseudonomous identity and use the new jwt service endpoint (with delayed event delegation)
2026-01-09 19:58:30 +01:00
Timo K
47e389678b add uuid in our own package.json 2026-01-09 19:48:11 +01:00
Timo K
7a8f5cc859 use crypto.randomUUID() 2026-01-09 19:40:21 +01:00
Timo K
45bb233673 Merge branch 'toger5/delayed-event-delegation' into toger5/userMedia-alternative 2026-01-09 19:38:21 +01:00
Timo K
341277932c fix tests 2026-01-09 19:34:41 +01:00
Timo K
735c17d3e6 fix tile flicker on video mute/unmute
(only pass rtcBackendIdenitity instead of full rtcMember)
2026-01-09 19:31:40 +01:00
Timo K
1a9e4a1a5c bump js-sdk 2026-01-09 19:12:54 +01:00
Timo K
7387a00406 lint 2026-01-09 19:04:42 +01:00
Timo K
32694fddf6 improve rtcBackendIdentity debugging 2026-01-09 19:04:02 +01:00
Timo K
444c50d8c4 Change code style of user media 2026-01-09 18:27:56 +01:00
Timo K
9a88e3dab6 review valere (more readable force new endpoint logic) 2026-01-09 18:05:26 +01:00
Timo K
39432310e3 use actual uuid for member.id
review with some renaming and removal of unnecassary if
2026-01-09 17:56:30 +01:00
Timo
0addc7c09b Merge pull request #3662 from element-hq/toger5/simplify-sdk-example
Update sdk example
2026-01-09 17:34:57 +01:00
Timo
0b766472ed Update sdk/README.md
Co-authored-by: Robin <robin@robin.town>
2026-01-09 17:30:02 +01:00
Timo K
ed3d168ac3 fix tests 2026-01-09 17:29:48 +01:00
Timo K
70b7ebff80 prettier 2026-01-09 16:32:14 +01:00
Timo K
b7838e4353 Update README.md 2026-01-09 16:25:06 +01:00
Timo K
0eb0169ad9 update example 2026-01-09 16:01:03 +01:00
Timo K
c2e3774749 prettier 2026-01-09 15:40:39 +01:00
Valere
cf579638fd skip firefox 2026-01-09 15:39:53 +01:00
Timo K
c7c6dc18bf fix js-sdk dependency 2026-01-09 15:28:11 +01:00
Valere
8827ff2ea6 add option for firefox webRTC test to allow fake streams 2026-01-09 15:18:35 +01:00
Valere
8535494671 use config to unmute full package on localhost 2026-01-09 15:00:57 +01:00
Valere
ad66d10c69 run playwright in dev mode 2026-01-09 14:41:07 +01:00
Valere
e9ed9580db trust localhost for CI and devbuil 2026-01-09 14:18:14 +01:00
Timo K
7dbbd763b9 Refactor how we aquire the jwt token for the local user. (only fetch it
once)

The local jwt token needs to be aquired via the right endpoint. The
endpoint defines how our rtcBackendIdentity is computed. Based on us
using sticky events or state events we also need to use the right
endpoint. This cannot be done generically in the connection manager. The
jwt token now is computed in the localTransport and the resolved sfu
config is passed to the connection manager.

Add JWT endpoint version and SFU config support Pin matrix-js-sdk to a
specific commit and update dev auth image tag. Propagate SFU config and
JWT endpoint choice through local transport, ConnectionManager and
Connection; add JwtEndpointVersion enum and LocalTransportWithSFUConfig
type. Add NO_MATRIX_2 auth error and locale string, thread
rtcBackendIdentity through UI props, and include related test, CSS and
minor imports updates
2026-01-09 13:38:26 +01:00
Valere
0636678ad2 quick log for playwright problem 2026-01-09 13:25:16 +01:00
Robin
a567505ef5 Merge pull request #3478 from element-hq/renovate/compound
Update Compound
2026-01-09 13:12:48 +01:00
Robin
f89a7a8211 Fix tests 2026-01-09 12:58:06 +01:00
Valere Fedronic
29854a77b4 Merge pull request #3660 from element-hq/valere/add_codecov_shield
Add codecov shields.io badge
2026-01-09 12:57:56 +01:00
Valere
bf19b388ea Add codecov shields.io badge 2026-01-09 12:43:33 +01:00
Valere
d2907f51d3 prettier format 2026-01-09 12:35:25 +01:00
Valere
231a80d9de update snapshot, mute buttons have aria-label now 2026-01-09 12:32:36 +01:00
Valere
a9153f2781 fix: Regression on default mutestate for voicecall + end-2-end tests 2026-01-09 12:03:56 +01:00
Valere
f5f8bb549a delete outdated default mute state config 2026-01-09 12:03:45 +01:00
renovate[bot]
8fe49d681a Update Compound 2026-01-08 19:49:47 +00:00
Timo K
d4b06b0f9c fix connection recreation which breaks EC lk connection 2026-01-08 14:27:47 +01:00
Will Hunt
0439fdefef Remove duplicate IntentAndPlatformDerivedConfiguration interface (#3658) 2026-01-08 11:51:28 +00:00
Timo K
1909aef186 temp 2026-01-08 12:27:17 +01:00
Timo K
385f63e83e fix tests 2026-01-07 17:46:39 +01:00
Timo K
a5a4bb2b82 add retries inside the getLiveKitJWTWithDelayDelegation and
`getLiveKitJWT` functions.
2026-01-07 17:38:29 +01:00
Timo K
5556d363b9 add retries and be more specific when we fall back to legacy endpoint 2026-01-07 17:21:14 +01:00
Timo K
0eeed4e18e fix test snapshot 2026-01-07 17:05:00 +01:00
Timo K
d5ad2e38e2 fix up tests 2026-01-07 16:09:08 +01:00
Timo K
75fca3108a cleanup an rename compatibility mode 2026-01-07 16:08:20 +01:00
Timo K
d814f60f23 review (docstrings) and remove unused artifacts. 2026-01-07 15:47:44 +01:00
Timo K
dd562bdaf5 dont use throw for logic. 2026-01-07 15:36:32 +01:00
Timo K
d48042f522 fix lint 2026-01-07 15:18:37 +01:00
Timo K
6480df44e9 add tests for open id delay fallback 2026-01-07 15:10:32 +01:00
Timo K
83d04ac122 fix tests and remove duplicated mocks. 2026-01-07 13:26:37 +01:00
Timo K
556a87d141 fix js-doc 2026-01-05 22:20:19 +01:00
Timo K
69a4189517 self review 2026-01-05 22:00:02 +01:00
Timo
009c9e046c Merge branch 'livekit' into toger5/delayed-event-delegation 2026-01-05 21:26:15 +01:00
Timo K
1580087286 use latest js-sdk 2026-01-05 21:24:52 +01:00
Timo K
00fca6e3c7 simplify localTransport 2026-01-05 21:17:37 +01:00
Timo K
5d5d75ebdf fixup merge 2026-01-05 21:08:33 +01:00
Timo K
df2bd22005 Merge branch 'livekit' into toger5/delayed-event-delegation 2026-01-05 21:08:21 +01:00
Robin
de8fdcfa69 Merge pull request #3518 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-01-05 19:42:07 +01:00
Robin
6aaf2db626 Fix formatting 2026-01-05 19:35:09 +01:00
Robin
72b9b8231a Merge branch 'livekit' into renovate/all-minor-patch 2026-01-05 19:29:24 +01:00
Robin
7817b76a8b Downgrade react-i18next to fix type errors
Upgrade beyond 16.1.1 blocked by https://github.com/i18next/react-i18next/issues/1899
2026-01-05 19:28:07 +01:00
Robin
b585af41da Fix encryption keys type error
See commit 174439c2f0c09cf9926c28435ba4db1345df4aee in matrix-js-sdk for the other part of the fix.
2026-01-05 19:27:39 +01:00
Robin
1006d06b04 Fix WebCodecs type error 2026-01-05 17:46:04 +01:00
Robin
b9afaab556 Upgrade i18next to resolve a type error 2026-01-05 15:52:17 +01:00
Robin
a6cdb28e45 Roll back @livekit/track-processors to fix type error
While we await https://github.com/livekit/track-processors-js/pull/118 to be released
2026-01-05 15:36:43 +01:00
Timo
ce682232b8 Merge pull request #3586 from element-hq/toger5/otel-remove-experiment
Remove all `OpenTelementry` related code
2026-01-05 14:55:28 +01:00
Timo K
582d1d8495 Merge branch 'livekit' into toger5/dont-trap-in-invalid-config 2026-01-05 14:37:48 +01:00
Timo K
029c38be63 fix prettier 2026-01-05 14:30:55 +01:00
Timo K
5b1eb13d65 fix lints and add hint in the otel readme to the PR removing it. 2026-01-05 14:27:54 +01:00
Timo
d1aabb05ff Merge branch 'livekit' into toger5/otel-remove-experiment 2026-01-05 14:22:11 +01:00
Timo K
eb60473510 add tests 2026-01-05 14:20:16 +01:00
Valere Fedronic
49cd980ddf Merge pull request #3644 from element-hq/valere/devx_check_doc
Add script to check that the tsdoc is correct and up-to-date
2026-01-05 14:08:08 +01:00
Robin
b6ca0c4cd6 Fix tests 2026-01-05 13:55:58 +01:00
Robin
5fa170c9c9 Fix builds 2026-01-05 13:54:08 +01:00
Will Hunt
f260a20728 Set latest tag when publishing a docker release. (#3650)
Fixes https://github.com/element-hq/element-call/issues/3647
2026-01-05 12:41:26 +00:00
Robin
749ba103a7 Merge pull request #3641 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.16.1
2026-01-05 13:28:17 +01:00
Timo K
c35b960627 fix lints 2026-01-05 10:42:08 +01:00
Timo
fef4db69d8 Merge pull request #3628 from element-hq/renovate/livekit-components
Update LiveKit components
2026-01-05 10:37:18 +01:00
renovate[bot]
057bc4e2ad Update all non-major dependencies 2026-01-03 18:06:04 +00:00
renovate[bot]
a6aa4526cf Update LiveKit components 2025-12-31 13:52:19 +00:00
renovate[bot]
88cfd32e51 Update dependency livekit-client to v2.16.1 2025-12-31 13:52:04 +00:00
Valere
5e715765d9 fix: un-needed tsdoc script 2025-12-30 18:17:07 +01:00
Valere
da55d84bde Add script to check that the tsdoc is correct and up-to-date 2025-12-30 17:02:44 +01:00
Will Hunt
72ec1439f4 Support MSC4143 RTC Transport endpoint (#3629)
* Use rtc-focus branch of js-sdk

* Update makeTransport to fetch backend transports and validate all transports before response.

* Fix test

* Add test

* Loads more tests

* Add tests for openid errors

* improve comment

* update to develop commit

* Add JWT parsing

* Use JWT

* Cleanup

* fixup tests

* fixup tests

* lint

* lint lint

* Fix `Reconnecting`
2025-12-29 17:45:41 +00:00
Timo K
0f5c5d8be5 cleanup based on new js-sdk impl 2025-12-29 17:38:54 +01:00
Timo K
7591e2bda1 Merge branch 'livekit' into toger5/delayed-event-delegation 2025-12-28 21:04:49 +01:00
Timo K
3ba2d13e27 use the js-sdk where the hashed rtcSessionIdeintity is already part of
the event (no need to compute it in the encryption manager)
2025-12-28 21:04:46 +01:00
Timo
67d20a8f3d Merge pull request #3600 from element-hq/toger5/lib-ec-version
Element Call SDK target
2025-12-23 12:53:57 +01:00
Timo K
9bd7888fab copyright. 2025-12-23 12:48:54 +01:00
Timo K
150dda16c8 fix lint 2025-12-22 13:44:24 +01:00
Timo K
725ff31d6f reduce PR diff 2025-12-22 13:42:16 +01:00
Timo K
852d2ee375 after merge cleanup 2025-12-22 13:35:40 +01:00
Timo K
e78f37a6b3 Merge branch 'livekit' into toger5/lib-ec-version 2025-12-22 12:43:09 +01:00
Timo K
55d18f10fe temp refactored membership rtcidentity 2025-12-19 19:23:41 +01:00
Timo K
50f3bf00ae use correct js-sdk 2025-12-17 10:22:46 +01:00
Timo K
ab7e3486b3 Make use of the new jwt service endpoint (with delayed event delegation)
This also does all the compatibility work. When to use which endpoint to
authenticate agains a jwt service.
2025-12-17 09:53:49 +01:00
Valere Fedronic
6cfa95bcfa Merge pull request #3632 from element-hq/valere/double_pub_problems_highlevel
Fix: Races that could cause double publication or mute state desyncs
2025-12-17 08:50:00 +01:00
Valere
80e760ca55 review 2025-12-16 13:40:06 +01:00
Timo K
9bd51fdfc4 Merge branch 'livekit' into toger5/pseudonomous-identities 2025-12-16 12:18:00 +01:00
Timo
a061b9f91e Merge pull request #3637 from robintown/flat-layout-switch
Allow user to switch layouts while phone is in landscape
2025-12-16 11:53:25 +01:00
Timo
26f41c434e Merge pull request #3626 from robintown/non-publishing-participants
Don't show 'waiting for media' on connected participants
2025-12-16 11:53:06 +01:00
fkwp
ff3d6bd088 enable sticky events 2025-12-16 11:22:25 +01:00
fkwp
6fe6daba31 switch synapse docker image to one with sticky event support 2025-12-16 11:20:38 +01:00
Robin
ed585e1d34 Merge pull request #3624 from robintown/local-remote-confusion
Dispel confusion about whether media is remote or local
2025-12-15 16:43:57 -05:00
Robin
6149dd2c9a Make the video behavior less confusing
There's no reason to allow it to take on placeholder values. It should be defined when the media has a published video track and undefined when not.
2025-12-15 15:26:42 -05:00
Robin
ea6f934667 Don't show user as 'waiting for media' if they don't intend to publish
We don't expect them to be publishing on any transport; they might be a subscribe-only bot.
2025-12-15 15:26:42 -05:00
Robin
93ab3ba1ff Compute the 'waiting for media' state less implicitly
On second glance, the way that we determined a media tile to be 'waiting for media' was too implicit for my taste. It would appear on a surface reading to depend on whether a participant was currently publishing any video. But in reality, the 'video' object was always defined as long as a LiveKit participant existed, so in reality it depended on just the participant. We should show this relationship more explicitly by moving the computation into the view model, where it can depend on the participant directly.
2025-12-15 15:26:42 -05:00
Robin
2c54263b2f Don't show 'waiting for media' on connected participants
We would show 'waiting for media' on participants that were connected but had no published tracks, because we were filtering them out of the remote participants list on connections. I believe this was done in an attempt to limit our view to only the participants that have a matching MatrixRTC membership. But that's fully redundant to the "Matrix-LiveKit members" module, which actually has the right information to do this (the MatrixRTC memberships).
2025-12-15 15:26:42 -05:00
Robin
92bcc52e87 Remove unused method
The doc comment here was about to become stale, so let's just remove it.
2025-12-15 15:25:00 -05:00
Timo
3d8d0ae9dd Merge branch 'livekit' into toger5/pseudonomous-identities 2025-12-15 21:20:55 +01:00
Robin
87fbbb9a3f Make MatrixRTC mode a required input to CallViewModel 2025-12-15 15:16:47 -05:00
Robin
c7e9f1ce14 Explicitly pass the MatrixRTC mode to CallViewModel 2025-12-15 15:09:46 -05:00
Robin
53cc79f738 Allow user to switch layouts while phone is in landscape
This fixes a regression on the development branch: the layout switcher would not respond to input while the window mode is 'flat' (i.e. while a mobile phone is in landscape orientation). See https://github.com/element-hq/element-call/pull/3605#discussion_r2586226422 for more context.

I was having a little trouble interpreting the emergent behavior of the layout switching code, so I refactored it in the process into a form that I think is a more direct description of the behavior we want (while not making it as terse as my original implementation).
2025-12-15 14:54:51 -05:00
Robin
15a12b2d9c Make layout tests more concise 2025-12-15 14:22:20 -05:00
Timo K
5bc6ed5885 small refactor to make it testable. 2025-12-15 20:17:57 +01:00
Robin
e3b4ffb801 Merge branch 'livekit' into local-remote-confusion 2025-12-15 13:17:13 -05:00
Robin
8a18e70e20 Split MatrixLivekitMembers more verbosely into two types 2025-12-15 13:14:45 -05:00
Robin
00d4b8e985 Use a more suitable filter operator to compute local member 2025-12-15 12:52:23 -05:00
Timo K
909d980dff still with broken tests... 2025-12-15 18:23:30 +01:00
Valere
190cdfcb60 comment now dead state variant 2025-12-12 17:03:16 +01:00
Valere
8f2055b4f4 eslint fix 2025-12-12 14:46:13 +01:00
Valere
93da69983d post merge: partial mapping of tracks/publish states 2025-12-12 14:40:45 +01:00
Valere
b3b76d8b3d post merge 2025-12-12 11:54:43 +01:00
Valere
d052d55547 Merge branch 'livekit' into valere/double_pub_problems_highlevel 2025-12-12 11:54:35 +01:00
Valere
610af439a8 cleaning: just use LocalTrackPublished event to pause/unpause 2025-12-12 10:37:37 +01:00
Valere
f8310b4611 publisher: only use highlevel participant APIs 2025-12-12 10:31:08 +01:00
Timo
cf453baf5d Merge pull request #3611 from element-hq/toger5/connection-state-refactor
Unify LiveKit and Matrix connection states
2025-12-11 18:23:34 +01:00
Timo K
df2f503a05 review 2025-12-11 18:00:23 +01:00
Timo K
7edc97b917 remove continue button things 2025-12-11 17:24:35 +01:00
Timo K
8225e4f260 remove page.pause 2025-12-11 17:22:02 +01:00
Timo K
207b161b3b fix logger and dismiss button presses 2025-12-11 17:17:56 +01:00
Timo K
9a7e797af4 fix lint 2025-12-11 16:17:45 +01:00
Timo K
08306d663a remove duplicated connecting state and update Test setup 2025-12-11 16:04:12 +01:00
Timo K
c1c73b0f02 lint 2025-12-11 15:04:07 +01:00
Timo K
68a32fdff6 Merge branch 'livekit' into toger5/lib-ec-version 2025-12-11 13:17:26 +01:00
Timo K
328cc7133a update playwright so that we do not even need the dismiss anymore. 2025-12-11 11:32:28 +01:00
Timo K
170a38c0ba fix playwright incompatible browser toast 2025-12-11 11:30:14 +01:00
Robin
6c622fc6e7 Merge branch 'livekit' into local-remote-confusion 2025-12-10 17:21:54 -05:00
Timo K
aabd76044b fix lint 2025-12-10 21:25:35 +01:00
Timo K
e626698fda fix connection tests 2025-12-10 21:22:55 +01:00
Timo K
8dac0366b6 fix lints 2025-12-10 21:17:33 +01:00
Timo K
b380532d30 lots of error logging and fixing playwright 2025-12-10 21:14:13 +01:00
Timo K
667a3d0e3d fix test not checking for livekit connection state anymore. 2025-12-10 19:18:16 +01:00
Timo K
1941fc9ca1 fix tests. 2025-12-10 19:12:52 +01:00
Timo K
6efce232f8 fix playwright tests 2025-12-10 18:50:19 +01:00
Timo K
ef2f53c38a Merge branch 'livekit' into toger5/connection-state-refactor 2025-12-10 13:49:06 +01:00
Timo K
0ebc6078dd Update LocalMember.ts 2025-12-10 12:08:59 +01:00
Timo K
7af89b4216 fix lint 2025-12-09 17:36:56 +01:00
Robin
ee2b0c6a5d Merge pull request #3625 from robintown/misc
Miscellaneous clean-up
2025-12-09 10:01:11 -05:00
Timo K
bf801364a6 cleanup and tests 2025-12-09 15:23:30 +01:00
Robin
6ee3ef2795 Edit a misleading log line
The factory function is called once per item to construct the item. It is not called on future updates to the item's data.
2025-12-08 23:38:54 -05:00
Robin
2f3f9f95eb Use more compact optional chaining and coalescing notation 2025-12-08 23:38:15 -05:00
Robin
9481dc401c Remove extraneous 'scope running' check
Semantically, behaviors are only meaningful for as long as their scope is running. Setting a behavior's value to an empty array once its scope ends is not guaranteed to work (as it depends on execution order of how the scope is ended), and subscribers should be robust enough to handle clean-up of all connections at the end of the scope either way.
2025-12-08 23:34:42 -05:00
Robin
d8b9568400 Stop publisher in a less brittle way 2025-12-08 23:33:41 -05:00
Robin
a7a3d4e93c Remove unsound participant casts
By tagging participant behaviors with a type (local vs. remote) we can now tell what kind of participant it will be in a completely type-safe manner.
2025-12-08 23:06:19 -05:00
Robin
47cd343d44 Prove that the remote members modules only output remote members
They had loose types that were allowing them also output local members. They don't do this, it's just misleading.
2025-12-08 23:01:44 -05:00
Robin
cc8e250d96 Remove a brittle cast from local member code 2025-12-08 22:54:46 -05:00
Robin
5a9a62039c Test CallViewModel in all MatrixRTC modes 2025-12-08 22:42:57 -05:00
Robin
2986f90a5f Allow MatrixRTC mode to be configured in tests 2025-12-08 22:29:15 -05:00
Timo K
e4fee457cf allow to use custom applications 2025-12-08 09:41:01 +01:00
Timo K
7c40b0e177 ideas 2025-12-05 19:48:02 +01:00
Valere Fedronic
e4404e5bb1 Merge pull request #3608 from robintown/initial-layout
Fix the wrong layout being used until window size changes
2025-12-04 16:44:50 +01:00
Valere Fedronic
f5d4017981 Merge pull request #3606 from element-hq/valere/fix_transiant_earpiece_overlay
fix: The handset mode overlay is visible a split second for every call
2025-12-04 11:05:26 +01:00
Valere
71bf55f358 also test that video is muted when earpiece overlay is on 2025-12-04 10:37:14 +01:00
Valere
7a2c1af44b review: use simple transition instead of keyframe 2025-12-04 10:36:57 +01:00
Valere
940c787040 review: quick renaming 2025-12-04 10:06:45 +01:00
Valere
5d55f83e90 Merge branch 'livekit' into valere/fix_transiant_earpiece_overlay 2025-12-04 10:05:06 +01:00
Valere Fedronic
9e3e359445 Merge pull request #3605 from element-hq/valere/layout_switch
Fix: Allow to manually switch back to grid mode when there is a screenshare
2025-12-04 09:50:56 +01:00
Valere
be7407ea3d review: quick renaming 2025-12-04 09:37:07 +01:00
Valere Fedronic
0a3483729f Merge pull request #3615 from element-hq/valere/fix_lk_server_1.9.4
keep livekit 1.9.4 as the latest is breaking the dev backend
2025-12-04 09:28:36 +01:00
Robin
f599e22e94 Merge pull request #3614 from robintown/simplify-analytics-id
Simplify computation of analytics ID
2025-12-03 13:59:01 -05:00
Valere
fdc66a1d62 fix: existing screenshare switching twice 2025-12-03 18:36:51 +01:00
Valere
c7491c3e97 move fixture to correct folder 2025-12-03 17:25:32 +01:00
Valere
af08c1830e keep livekit 1.9.4 as the latest is breaking the dev backend 2025-12-03 17:18:35 +01:00
Valere
bcb2b36888 keep livekit 1.9.4 as the latest is breaking the dev backend 2025-12-03 17:17:58 +01:00
Valere Fedronic
b9e346d64c Merge pull request #3596 from element-hq/valere/bugfix_earpiece_mute_video
Fix: Camera is not muted when the earpiece mode is enabled
2025-12-03 17:12:10 +01:00
Valere
0ed7194d87 fix: earpiece overlay not showing + playwright test! 2025-12-03 17:10:57 +01:00
Robin
bbd92f666b Simplify computation of analytics ID
Since we now bundle a trusted Element Call widget with our messenger applications and this widget reports analytics to an endpoint determined by the messenger app, there is no longer any reason to compute a different analytics ID from the one used by the messenger app.
2025-12-03 10:42:04 -05:00
Timo K
b34a75d990 fix knip 2025-12-03 13:08:51 +01:00
Timo K
8110d22c8b fix lint 2025-12-03 10:27:30 +01:00
Timo K
88721be952 cleanup 2025-12-03 10:04:22 +01:00
Valere
6e7bedbaec Merge branch 'livekit' into valere/layout_switch 2025-12-03 09:17:31 +01:00
Valere
a93ceeae4b review: Keep previous behavior for now to always auto switch 2025-12-03 09:01:26 +01:00
Valere
b85f36598c fix: mistake in file name 2025-12-03 08:54:52 +01:00
Timo K
2e646bfac1 Unify LiveKit and Matrix connection states 2025-12-02 19:49:13 +01:00
Valere Fedronic
e0bf51b63b Merge pull request #3590 from element-hq/valere/noise_cancellation
Config: UrlParams to control noiseSuppression and echoCancellation
2025-12-02 18:36:32 +01:00
Valere
f6a3a371cb fix lint 2025-12-02 17:52:27 +01:00
Valere
ac9acc0158 review: refactor convert params to object for generateRoomOption 2025-12-02 17:47:00 +01:00
Valere
91f3b8b114 Merge branch 'livekit' into valere/noise_cancellation 2025-12-02 17:44:06 +01:00
Valere
be0c7eb365 review: fix mock import module 2025-12-02 17:43:58 +01:00
Valere
44980a2744 review: rename deviceConnected to canControlDevices 2025-12-02 16:39:26 +01:00
Robin
83ea154e1a Fix the wrong layout being used until window size changes
While looking into what had regressed https://github.com/element-hq/element-call/issues/3588, I found that 28047217b8 had filled in a couple of behaviors with non-reactive default values, the "natural window mode" behavior being among them. This meant that the app would no longer determine the correct window mode upon joining a call, instead always guessing "normal" as the value. This change restores its reactivity.
2025-12-02 10:36:53 -05:00
Valere
0e04fd9433 fix: The handset mode overlay is visible a split second for every call 2025-12-02 14:14:28 +01:00
Timo K
93de12d79c cleanup vite.configs and simplify exported sdk props. 2025-12-02 11:31:56 +01:00
Valere
b5d3f3c72a fix: Allow switching back to grid when there is a screenshare 2025-12-02 11:18:50 +01:00
Valere
60bc6f1e93 refactor: Extract layout mode switch + test 2025-12-02 11:00:32 +01:00
Timo K
f76a6cb599 cleanup unnecassary packages + add connected indicator 2025-12-01 20:39:40 +01:00
Timo K
ab675cf00b fix test caused by remoteParticipantsWithTracks->remoteParticipants 2025-12-01 20:26:54 +01:00
Timo K
43266e6200 Merge branch 'livekit' into toger5/lib-ec-version 2025-12-01 20:15:58 +01:00
Timo K
af47002613 Refactor local membership publisher and connectionState related logic 2025-12-01 20:14:29 +01:00
Timo K
c20b206ab2 i18n 2025-12-01 19:53:27 +01:00
Timo K
5ceb140d13 remove check in local transport 2025-12-01 19:51:48 +01:00
Timo K
ed4517703f better UX (valiate on save) 2025-12-01 19:40:44 +01:00
Timo K
bd3e91738e Reset overwrite url if it is invalid (does fail to reach sfu) 2025-12-01 19:40:44 +01:00
Timo
f05d4b158e Merge pull request #3565 from element-hq/renovate/matrix-widget-api
Update dependency matrix-widget-api to v1.15.0
2025-12-01 19:14:52 +01:00
Robin
0240087afe Unpin matrix-js-sdk so it can be upgraded 2025-12-01 13:09:10 -05:00
Timo K
4ec1b19fe2 fix js-sdk tests 2025-12-01 18:54:57 +01:00
Timo K
5f6a45f0e9 also bump js-sdk 2025-12-01 18:45:47 +01:00
renovate[bot]
e2916d9093 Update dependency matrix-widget-api to v1.14.0 2025-12-01 18:23:35 +01:00
Timo
90d4b6a6ce Merge pull request #3598 from element-hq/renovate/github-actions
Update GitHub Actions
2025-12-01 17:42:05 +01:00
Timo
e3eb2ea12b Merge pull request #3599 from element-hq/renovate/embedded-package-dependencies
Update embedded package dependencies
2025-12-01 17:40:29 +01:00
Timo
4f45825cf2 Merge pull request #3591 from element-hq/toger5/fix-local-transport-update
Fix reactivity based on localTransport updates (Cleanup for LocalMembership.ts)
2025-12-01 17:39:55 +01:00
Timo K
8cb8357398 make logs just debug. (info -> debug) 2025-12-01 17:34:37 +01:00
Timo K
63cd4f79dd fix playwright test! (It caught an actual bug!!! so the right wording
would be: fix implementation thanks to the playwright test!
2025-12-01 17:29:35 +01:00
Valere Fedronic
7fd4424e7b Merge pull request #3601 from element-hq/valere/fix_switch_spotlight_screenshare
fix: Automatically switch to spotlight if remote screenshare
2025-12-01 15:30:24 +01:00
Valere
b7fc86ee06 review: move setGridMode close to the value it sets 2025-12-01 15:25:17 +01:00
Timo K
f26aa8f970 fix tests 2025-12-01 15:10:38 +01:00
Valere
1ff479a315 fix: Automatically switch to spotlight if remote screenshare 2025-12-01 15:06:17 +01:00
Timo K
47c6a17d1e review 2025-12-01 14:58:06 +01:00
Timo K
24ed43ce13 Merge branch 'livekit' into toger5/fix-local-transport-update 2025-12-01 14:23:11 +01:00
Timo K
1490359e4c cleanup changes godot->sdk add docs 2025-12-01 14:09:09 +01:00
Timo K
0664af0f1b log cleanup and expose members$ 2025-12-01 13:49:33 +01:00
Timo K
284a52c23c mvp 2025-12-01 12:44:41 +01:00
Timo K
2d8ffc0ccd almost mvp 2025-12-01 12:43:43 +01:00
Timo K
28158bfc23 temp 2025-12-01 12:43:43 +01:00
renovate[bot]
04bbf83ac5 Update GitHub Actions 2025-12-01 11:00:52 +00:00
renovate[bot]
5aa82295fd Update embedded package dependencies 2025-12-01 01:28:55 +00:00
Timo
3331af1108 Merge pull request #3592 from element-hq/valere/fix_flaky_test
test: use `setSystemTime` for better test stability
2025-11-28 22:00:24 +01:00
Timo K
66dece98a5 add more test for publisher 2025-11-28 21:50:22 +01:00
Timo K
2011aef116 skip "Should show error screen if call creation is restricted" on ff 2025-11-28 18:00:04 +01:00
Valere Fedronic
f5c422c559 Merge pull request #3597 from element-hq/valere/test_reconcile
test: Add simple tests for scope.reconcile
2025-11-28 10:00:06 +01:00
Valere
c6bfda94cf test: additional reconcile test 2025-11-28 09:53:18 +01:00
Valere
cdd391b3a2 test: Add simple tests for scope.reconcile 2025-11-28 09:12:05 +01:00
Valere
59d21d2421 Merge branch 'livekit' into valere/bugfix_earpiece_mute_video 2025-11-27 18:47:45 +01:00
Valere
149f3d02ae fix: The force mute state was not synced to the handler 2025-11-27 18:47:33 +01:00
Valere Fedronic
5bc7b31dcd Merge pull request #3593 from element-hq/valere/quality_gate
test: Enable quality gate and touch a file
2025-11-27 18:28:30 +01:00
Timo K
c0913b6546 fix playwright test 2025-11-27 18:02:46 +01:00
Timo K
46f8fe4ec7 fix test errors 2025-11-27 16:43:28 +01:00
Valere
c00e332dd8 Revert "vitest: Add threshold on vitest to check locally"
This reverts commit e5509ba49b.
2025-11-27 16:43:12 +01:00
Valere
e5509ba49b vitest: Add threshold on vitest to check locally 2025-11-27 16:39:45 +01:00
Valere
4c2c7d51cf revert test changes on ObservableScope 2025-11-27 16:38:51 +01:00
Valere
fc39e82666 Fix: Camera is not muted when the earpiece mode is enabled 2025-11-27 15:52:25 +01:00
Timo K
4b0f6e76c4 revert complete behavior check 2025-11-27 15:38:31 +01:00
Timo K
e5117f705d More testing and cleaning up 2025-11-27 14:42:23 +01:00
Valere
794585514a Revert "add untested function"
This reverts commit c078dd87fa.
2025-11-27 10:04:09 +01:00
Valere
c078dd87fa add untested function 2025-11-26 19:28:44 +01:00
Valere
2b3e2f6188 test: Enable quality gate and touch a file 2025-11-26 19:13:07 +01:00
Valere
acd5dde6d8 test: use setSystemTime for better test stability 2025-11-26 16:27:54 +01:00
Timo K
d22d7460fe Another larger refactor to fix sfu switches and in general proper
cleanup.
2025-11-25 20:18:34 +01:00
Valere
7f3596845c fix formatting 2025-11-25 11:40:38 +01:00
Valere
e2607d6399 Config: UrlParams to control noiseSuppression and echoCancellation 2025-11-25 11:10:53 +01:00
Timo K
8bf6518371 remove span processor endpoints 2025-11-24 12:42:00 +01:00
Timo K
2f92867383 remove otel deps 2025-11-24 12:36:36 +01:00
Timo K
1fda5c7920 remove otel to see what impact it has on tests. 2025-11-24 12:33:02 +01:00
Timo
3491a68362 Merge pull request #3576 from element-hq/toger5/fix-skipped-tests
Refactor local transport testing and local membership initialization
2025-11-24 11:36:27 +01:00
Valere
294b90b6dc fix fixture following web change 2025-11-24 11:29:03 +01:00
Timo K
ddd015d696 fix dev EditInPlace 2025-11-24 10:04:01 +01:00
Timo K
7532f72c90 use edit in place (WIP) This is WIP since it will submite -> reload the
page onSave
2025-11-21 18:49:30 +01:00
Timo K
e60bc9e98f add developer tab snapshot test 2025-11-21 17:44:35 +01:00
Timo
1aa1af2ade Merge pull request #3585 from element-hq/toger5/add-log-screenshare
Add log to screenshare
2025-11-21 16:58:04 +01:00
Timo K
ccadc17406 Add log to screenshare. 2025-11-21 16:19:19 +01:00
Timo K
a731981388 Cleanup transport creation and local store hack. 2025-11-21 14:02:15 +01:00
Timo K
4099c4383d move HomeserverConnected 2025-11-21 13:04:28 +01:00
Timo K
d34775fc47 fix linter 2025-11-20 16:40:06 +01:00
Timo K
a29f0162ad remove widget log 2025-11-20 16:34:53 +01:00
Timo K
a69a50d30c add log to error boudnary 2025-11-20 16:32:40 +01:00
Timo K
89a1bfac2d remove unused test helper 2025-11-20 15:25:53 +01:00
Timo K
1fd9ac93c9 refactor local transport testing and local memberhsip initialization 2025-11-20 15:19:00 +01:00
Timo
f6ef872242 Merge pull request #3575 from element-hq/hs/lint-src-imports
Restrict importing src/lib in most cases for matrix- libs
2025-11-20 14:55:41 +01:00
Half-Shot
07de8483d5 Restrict importing src/lib in most cases for matrix- libs 2025-11-20 13:32:09 +00:00
Valere Fedronic
42b4c8301f Merge pull request #3547 from element-hq/valere/fix_blank_widget_auto_leave
fix: Send close widget action on auto-leave
2025-11-20 11:41:43 +01:00
Valere
c2f541ff8c split test in 2 one testing sound and one to just leave 2025-11-20 11:28:24 +01:00
Valere
c1049b6088 fix import causing lint errors in node_modules 2025-11-20 10:46:24 +01:00
Valere
6577db16ce Merge branch 'livekit' into valere/fix_blank_widget_auto_leave 2025-11-20 10:41:31 +01:00
renovate[bot]
cdc3c2b216 Update LiveKit components (#3573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-19 20:18:18 +01:00
renovate[bot]
47324b52f8 Update dependency livekit-client to v2.16.0 (#3574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-19 20:14:37 +01:00
alutarius
82706cca4f Correct Caddy reverse proxy config example (#3572) 2025-11-18 18:46:00 +01:00
Timo
665a6be484 Merge pull request #3560 from element-hq/voip-team/call-viewmodel-refactor
`CallViewmodel` refactor
2025-11-18 13:48:03 +01:00
Valere
0076b54e52 minor comments/TODO cleaning 2025-11-18 12:14:17 +01:00
Valere
614762b5f8 fixup headers 2025-11-18 10:13:10 +01:00
Timo
222982c52c Merge pull request #3571 from element-hq/toger5/createCallViewModelFunction$
`createCallViewModel$` function refactor
2025-11-17 19:46:06 +01:00
Robin
171c289f2d Merge pull request #3564 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.15.15
2025-11-17 13:41:44 -05:00
renovate[bot]
9ad029d4ff Update dependency livekit-client to v2.15.15 2025-11-17 02:42:33 +00:00
Ben Banfield-Zanin
a3950869fb Use the dedicated bug report sub-domain (#3557)
* Update docs for 'rageshake.submit_url' to use the dedicated subdomain

* Update Netlify preview deployment to use the dedicated subdomain for 'rageshake.submit_url'
2025-11-13 15:33:59 +00:00
Robin
5afc3c2598 Merge pull request #3556 from element-hq/renovate/node-24.x
Update Node.js to v24
2025-11-05 13:55:24 -05:00
Robin
8ad0cb1955 Merge pull request #3551 from element-hq/renovate/embedded-package-dependencies
Update embedded package dependencies to v8.13.0
2025-11-05 13:53:27 -05:00
Robin
9bd464a7f1 Merge pull request #3550 from element-hq/renovate/github-actions
Update softprops/action-gh-release digest to 6da8fa9
2025-11-05 13:53:08 -05:00
Robin
f0adae41ef Merge pull request #3552 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.15.14
2025-11-05 13:49:44 -05:00
renovate[bot]
f9cc930155 Update Node.js to v24 2025-11-05 18:49:21 +00:00
Robin
eebcd133f3 Merge pull request #3553 from element-hq/robin/trusted-publishing
Remove use of npm access token now that trusted publishing is set up
2025-11-05 13:45:52 -05:00
Robin
79bd458dc2 Remove use of npm access token now that trusted publishing is set up
npm has recently limited the lifetime of all access tokens to 90 days (https://gh.io/npm-token-changes), so it would be a bit inconvenient to stick to our current access token-based method of publishing releases. Meanwhile npm has implemented a more secure publishing method based on OIDC in which you tell the registry that a particular GitHub Actions workflow should be a "trusted publisher" for a given package, and then the CLI will authenticate automatically. (https://docs.npmjs.com/trusted-publishers)

I've already set trusted publishing up on the registry side, and since we're already granting the job permission to generate ID tokens for provenance, there should be no additional lines of config needed to make it work. Let's take away the access token and see how this goes next time we release.
2025-11-03 17:13:03 -05:00
renovate[bot]
a4b1ce52cd Update dependency livekit-client to v2.15.14 2025-11-03 01:02:47 +00:00
renovate[bot]
b22a937e7c Update softprops/action-gh-release digest to 6da8fa9 2025-11-01 02:02:21 +00:00
Valere
0309ceb6aa fix lint 2025-10-27 15:59:01 +01:00
Valere
3ecffccd9e fix prettier 2025-10-27 15:41:16 +01:00
Valere
3dd0b156cf fix: Send close widget action on auto-leave 2025-10-27 14:31:23 +01:00
renovate[bot]
214bb75d27 Update embedded package dependencies to v8.13.0 2025-10-01 02:29:37 +00:00
240 changed files with 20577 additions and 9141 deletions

View File

@@ -8,13 +8,14 @@ Please see LICENSE in the repository root for full details.
`;
module.exports = {
plugins: ["matrix-org", "rxjs"],
plugins: ["matrix-org", "rxjs", "jsdoc"],
extends: [
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:matrix-org/typescript",
"prettier",
"plugin:rxjs/recommended",
"plugin:storybook/recommended",
],
parserOptions: {
ecmaVersion: "latest",
@@ -26,6 +27,13 @@ module.exports = {
node: true,
},
rules: {
"jsdoc/no-types": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/check-values": "error",
"jsdoc/check-param-names": "warn",
// "jsdoc/require-param": "warn",
"jsdoc/require-param-description": "warn",
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
"jsx-a11y/media-has-caption": "off",
"react/display-name": "error",
@@ -43,6 +51,29 @@ module.exports = {
// To encourage good usage of RxJS:
"rxjs/no-exposed-subjects": "error",
"rxjs/finnish": ["error", { names: { "^this$": false } }],
"no-restricted-imports": [
"error",
{
paths: ["matrix-widget-api", "matrix-js-sdk"].flatMap((lib) =>
["src", "src/", "src/index", "lib", "lib/", "lib/index"]
.map((path) => `${lib}/${path}`)
.map((name) => ({ name, message: `Please use ${lib} instead` })),
),
patterns: [
...["matrix-widget-api"].map((lib) => ({
group: ["src", "src/", "src/**", "lib", "lib/", "lib/**"].map(
(path) => `${lib}/${path}`,
),
message: `Please use ${lib} instead`,
})),
// XXX: We use /lib in lots of places, so allow for now.
...["matrix-js-sdk"].map((lib) => ({
group: ["src", "src/", "src/**"].map((path) => `${lib}/${path}`),
message: `Please use ${lib} instead`,
})),
],
},
],
},
overrides: [
{
@@ -52,6 +83,30 @@ module.exports = {
"no-console": ["error"],
},
},
{
files: [
"**/*.test.ts",
"**/*.test.tsx",
"**/test.ts",
"**/test.tsx",
"**/test-**",
],
rules: {
"jsdoc/no-types": "off",
"jsdoc/empty-tags": "off",
"jsdoc/check-property-names": "off",
"jsdoc/check-values": "off",
"jsdoc/check-param-names": "off",
"jsdoc/require-param-description": "off",
},
},
{
files: ["playwright/**"],
rules: {
// Playwright as a `use` function that has nothing to do with React hooks.
"react-hooks/rules-of-hooks": "off",
},
},
],
settings: {
react: {

39
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,39 @@
<!-- Thanks for submitting a PR! Please ensure the following requirements are met in order for us to review your PR -->
## Content
<!-- Describe shortly what has been changed -->
## Motivation and context
<!-- Provide link to the corresponding issue if applicable or explain the context -->
## Screenshots / GIFs
<!--
You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|
|Before|After|
|-|-|
|||
-->
## Tests
<!-- Explain how you tested your development -->
- Step 1
- Step 2
- Step ...
-
## Checklist
- [ ] I have read through [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md).
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Tests written for new code (and old code if feasible).
- [ ] Linter and other CI checks pass.
- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-call)

View File

@@ -1,7 +1,16 @@
name: Prevent blocked
on:
# zizmor: ignore[dangerous-triggers]
# Reason: This workflow does not checkout code or use secrets.
# It only reads labels to set a failure status on the PR.
pull_request_target:
types: [opened, labeled, unlabeled, synchronize]
permissions:
pull-requests: read
# Required to fail the check on the PR
statuses: write
jobs:
prevent-blocked:
name: Prevent blocked

View File

@@ -20,10 +20,13 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write # required to upload release asset
packages: write
packages: write # needed for publishing packages to GHCR
id-token: write # needed for login into tailscale with GitHub OIDC Token
steps:
- name: Check it out
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: 📥 Download artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -34,26 +37,64 @@ jobs:
path: dist
- name: Log in to container registry
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Connect to Tailscale
uses: tailscale/github-action@306e68a486fd2350f2bfc3b19fcd143891a4a2d8 # v4
if: github.event_name != 'pull_request'
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
audience: ${{ secrets.TS_AUDIENCE }}
tags: tag:github-actions
- name: Compute vault jwt role name
id: vault-jwt-role
if: github.event_name != 'pull_request'
run: |
echo "role_name=github_service_management_$( echo "${{ github.repository }}" | sed -r 's|[/-]|_|g')" | tee -a "$GITHUB_OUTPUT"
- name: Get team registry token
id: import-secrets
uses: hashicorp/vault-action@4c06c5ccf5c0761b6029f56cfb1dcf5565918a3b # v3
if: github.event_name != 'pull_request'
with:
url: https://vault.infra.ci.i.element.dev
role: ${{ steps.vault-jwt-role.outputs.role_name }}
path: service-management/github-actions
jwtGithubAudience: https://vault.infra.ci.i.element.dev
method: jwt
secrets: |
services/voip-repositories/secret/data/oci.element.io username | OCI_USERNAME ;
services/voip-repositories/secret/data/oci.element.io password | OCI_PASSWORD ;
- name: Login to oci.element.io Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
if: github.event_name != 'pull_request'
with:
registry: oci-push.vpn.infra.element.io
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: ${{ inputs.docker_tags}}
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
oci-push.vpn.infra.element.io/element-call
tags: ${{ inputs.docker_tags }}
labels: |
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build and push Docker image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: .
platforms: linux/amd64,linux/arm64

View File

@@ -7,7 +7,7 @@ on:
type: string
package:
type: string # This would ideally be a `choice` type, but that isn't supported yet
description: The package type to be built. Must be one of 'full' or 'embedded'
description: The package type to be built. Must be one of 'full', 'embedded', or 'sdk'
required: true
build_mode:
type: string # This would ideally be a `choice` type, but that isn't supported yet
@@ -32,7 +32,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
@@ -43,7 +45,7 @@ jobs:
- name: Install dependencies
run: "yarn install --immutable"
- name: Build Element Call
run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }}
run: yarn run build:"$PACKAGE":"$BUILD_MODE"
env:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
@@ -52,6 +54,8 @@ jobs:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
NODE_OPTIONS: "--max-old-space-size=4096"
PACKAGE: ${{ inputs.package }}
BUILD_MODE: ${{ inputs.build_mode }}
- name: Upload Artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:

View File

@@ -49,7 +49,9 @@ jobs:
permissions:
contents: write
packages: write
id-token: write
uses: ./.github/workflows/build-and-publish-docker.yaml
secrets: inherit
with:
artifact_run_id: ${{ github.run_id }}
docker_tags: |
@@ -69,3 +71,17 @@ jobs:
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build_sdk_element_call:
# Use the embedded package vite build
uses: ./.github/workflows/build-element-call.yaml
with:
package: sdk
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -1,8 +1,16 @@
name: PR changelog label
on:
# zizmor: ignore[dangerous-triggers]
# This is safe because we do not use actions/checkout or execute untrusted code.
# Using pull_request_target is necessary to allow status writes for PRs from forks.
pull_request_target:
types: [labeled, unlabeled, opened]
types: [labeled, unlabeled, opened, synchronize]
permissions:
pull-requests: read
statuses: write
jobs:
pr-changelog-label:
runs-on: ubuntu-latest

View File

@@ -14,6 +14,10 @@ on:
deployment_ref:
required: true
type: string
package:
required: true
type: string
description: Which package to deploy - 'full', 'embedded', or 'sdk'
artifact_run_id:
required: false
type: string
@@ -50,7 +54,7 @@ jobs:
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ inputs.artifact_run_id }}
name: build-output-full
name: build-output-${{ inputs.package }}
path: webapp
- name: Add redirects file
@@ -58,15 +62,23 @@ jobs:
run: curl -s https://raw.githubusercontent.com/element-hq/element-call/main/config/netlify_redirects > webapp/_redirects
- name: Add config file
run: curl -s "https://raw.githubusercontent.com/${{ inputs.pr_head_full_name }}/${{ inputs.pr_head_ref }}/config/config_netlify_preview.json" > webapp/config.json
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
else
curl -s "https://raw.githubusercontent.com/${INPUTS_PR_HEAD_FULL_NAME}/${INPUTS_PR_HEAD_REF}/config/config_netlify_preview_sdk.json" > webapp/config.json
fi
env:
INPUTS_PACKAGE: ${{ inputs.package }}
INPUTS_PR_HEAD_FULL_NAME: ${{ inputs.pr_head_full_name }}
INPUTS_PR_HEAD_REF: ${{ inputs.pr_head_ref }}
- name: ☁️ Deploy to Netlify
id: netlify
uses: nwtgck/actions-netlify@4cbaf4c08f1a7bfa537d6113472ef4424e4eb654 # v3.0
with:
publish-dir: webapp
deploy-message: "Deploy from GitHub Actions"
alias: pr${{ inputs.pr_number }}
alias: ${{ inputs.package == 'sdk' && format('pr{0}-sdk', 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

@@ -7,7 +7,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: Yarn cache

View File

@@ -1,5 +1,7 @@
name: Deploy previews for PRs
on:
# zizmor: ignore[dangerous-triggers]
# Reason: This is now restricted to internal PRs only using the 'if' condition below.
workflow_run:
workflows: ["Build"]
types:
@@ -7,7 +9,14 @@ on:
jobs:
prdetails:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' }}
# Logic:
# 1. Build must be successful
# 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.event == 'pull_request' &&
github.event.workflow_run.head_repository.full_name == github.repository
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.prdetails.outputs.pr_id }}
@@ -20,7 +29,7 @@ jobs:
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
branch: ${{ github.event.workflow_run.head_branch }}
netlify:
netlify-full:
needs: prdetails
permissions:
deployments: write
@@ -31,6 +40,24 @@ jobs:
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: full
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
netlify-sdk:
needs: prdetails
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: sdk
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
@@ -42,7 +69,9 @@ jobs:
permissions:
contents: write
packages: write
id-token: write
uses: ./.github/workflows/build-and-publish-docker.yaml
secrets: inherit
with:
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
docker_tags: |

View File

@@ -22,8 +22,18 @@ jobs:
TAG: ${{ steps.tag.outputs.TAG }}
steps:
- name: Calculate VERSION
# We should only use the hard coded test value for a dry run
run: echo "VERSION=${{ github.event_name == 'release' && github.event.release.tag_name || 'v0.0.0-pre.0' }}" >> "$GITHUB_ENV"
# Safely store dynamic values in environment variables
# to prevent shell injection (template-injection)
run: |
# The logic is executed within the shell using the env variables
if [ "$EVENT_NAME" = "release" ]; then
echo "VERSION=$RELEASE_TAG" >> "$GITHUB_ENV"
else
echo "VERSION=v0.0.0-pre.0" >> "$GITHUB_ENV"
fi
env:
RELEASE_TAG: ${{ github.event.release.tag_name }}
EVENT_NAME: ${{ github.event_name }}
- id: dry_run
name: Set DRY_RUN
# We perform a dry run for all events except releases.
@@ -71,7 +81,9 @@ jobs:
contents: write # required to upload release asset
steps:
- name: Determine filename
run: echo "FILENAME_PREFIX=element-call-embedded-${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
run: echo "FILENAME_PREFIX=element-call-embedded-${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
env:
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
@@ -80,12 +92,12 @@ jobs:
name: build-output-embedded
path: ${{ env.FILENAME_PREFIX}}
- name: Create Tarball
run: tar --numeric-owner -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}
run: tar --numeric-owner -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX}
- name: Create Checksum
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
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@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -100,10 +112,12 @@ jobs:
ARTIFACT_VERSION: ${{ steps.artifact_version.outputs.ARTIFACT_VERSION }}
permissions:
contents: read
id-token: write # required for the provenance flag on npm publish
id-token: write # Allow npm to authenticate as a trusted publisher
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -123,15 +137,16 @@ jobs:
- name: Publish npm
working-directory: embedded/web
run: |
npm version ${{ needs.versioning.outputs.PREFIXED_VERSION }} --no-git-tag-version
npm version ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION} --no-git-tag-version
echo "ARTIFACT_VERSION=$(jq '.version' --raw-output package.json)" >> "$GITHUB_ENV"
npm publish --provenance --access public --tag ${{ needs.versioning.outputs.TAG }} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
npm publish --provenance --access public --tag ${NEEDS_VERSIONING_OUTPUTS_TAG} ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }}
NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }}
- id: artifact_version
name: Output artifact version
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
publish_android:
needs: [build_element_call, versioning]
@@ -144,7 +159,9 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -155,7 +172,7 @@ jobs:
path: embedded/android/lib/src/main/assets/element-call
- name: ☕️ Setup Java
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4
with:
distribution: "temurin"
java-version: "17"
@@ -163,16 +180,19 @@ jobs:
- name: Get artifact version
# Anything that is not a final release will be tagged as a snapshot
run: |
if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
if [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "latest" ]]; then
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
elif [[ "${NEEDS_VERSIONING_OUTPUTS_TAG}" == "rc" ]]; then
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
else
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV"
echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}-SNAPSHOT" >> "$GITHUB_ENV"
fi
env:
NEEDS_VERSIONING_OUTPUTS_TAG: ${{ needs.versioning.outputs.TAG }}
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
- name: Set version string
run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt
run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" embedded/android/lib/src/main/kotlin/io/element/android/call/embedded/Version.kt
- name: Publish AAR
working-directory: embedded/android
@@ -186,7 +206,7 @@ jobs:
- id: artifact_version
name: Output artifact version
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
publish_ios:
needs: [build_element_call, versioning]
@@ -199,9 +219,10 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
path: element-call
persist-credentials: false
- name: 📥 Download built element-call artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
@@ -212,20 +233,23 @@ jobs:
path: element-call/embedded/ios/Sources/dist
- name: Checkout element-call-swift
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: element-hq/element-call-swift
path: element-call-swift
token: ${{ secrets.SWIFT_RELEASE_TOKEN }}
persist-credentials: false
- name: Copy files
run: rsync -a --delete --exclude .git element-call/embedded/ios/ element-call-swift
- name: Get artifact version
run: echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
run: echo "ARTIFACT_VERSION=${NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION}" >> "$GITHUB_ENV"
env:
NEEDS_VERSIONING_OUTPUTS_UNPREFIXED_VERSION: ${{ needs.versioning.outputs.UNPREFIXED_VERSION }}
- name: Set version string
run: sed -i "s/0.0.0/${{ env.ARTIFACT_VERSION }}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift
run: sed -i "s/0.0.0/${ARTIFACT_VERSION}/g" element-call-swift/Sources/EmbeddedElementCall/EmbeddedElementCall.swift
- name: Test build
working-directory: element-call-swift
@@ -237,17 +261,22 @@ jobs:
git config --global user.email "ci@element.io"
git config --global user.name "Element CI"
git add -A
git commit -am "Release ${{ needs.versioning.outputs.PREFIXED_VERSION }}"
git tag -a ${{ env.ARTIFACT_VERSION }} -m "${{ github.event.release.html_url }}"
git commit -am "Release ${NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION}"
git tag -a ${ARTIFACT_VERSION} -m "${GITHUB_EVENT_RELEASE_HTML_URL}"
env:
NEEDS_VERSIONING_OUTPUTS_PREFIXED_VERSION: ${{ needs.versioning.outputs.PREFIXED_VERSION }}
GITHUB_EVENT_RELEASE_HTML_URL: ${{ github.event.release.html_url }}
- name: Push
working-directory: element-call-swift
run: |
git push --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
env:
SWIFT_RELEASE_TOKEN: ${{ secrets.SWIFT_RELEASE_TOKEN }}
- id: artifact_version
name: Output artifact version
run: echo "ARTIFACT_VERSION=${{env.ARTIFACT_VERSION}}" >> "$GITHUB_OUTPUT"
run: echo "ARTIFACT_VERSION=${ARTIFACT_VERSION}" >> "$GITHUB_OUTPUT"
release_notes:
needs: [versioning, publish_npm, publish_android, publish_ios]
@@ -259,12 +288,16 @@ jobs:
steps:
- name: Log versions
run: |
echo "NPM: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}"
echo "Android: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}"
echo "iOS: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}"
echo "NPM: ${NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION}"
echo "Android: ${NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION}"
echo "iOS: ${NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION}"
env:
NEEDS_PUBLISH_NPM_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_npm.outputs.ARTIFACT_VERSION }}
NEEDS_PUBLISH_ANDROID_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_android.outputs.ARTIFACT_VERSION }}
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@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
append_body: true
body: |

View File

@@ -38,11 +38,11 @@ jobs:
name: build-output-full
path: ${{ env.FILENAME_PREFIX }}
- name: Create Tarball
run: tar --numeric-owner --transform "s/dist/${{ env.FILENAME_PREFIX }}/" -cvzf ${{ env.FILENAME_PREFIX }}.tar.gz ${{ env.FILENAME_PREFIX }}
run: tar --numeric-owner --transform "s/dist/${FILENAME_PREFIX}/" -cvzf ${FILENAME_PREFIX}.tar.gz ${FILENAME_PREFIX}
- name: Create Checksum
run: find ${{ env.FILENAME_PREFIX }} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${{ env.FILENAME_PREFIX }}.sha256
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
- name: Upload
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -55,12 +55,15 @@ jobs:
permissions:
contents: write
packages: write
id-token: write
uses: ./.github/workflows/build-and-publish-docker.yaml
secrets: inherit
with:
artifact_run_id: ${{ github.event.workflow_run.id || github.run_id }}
docker_tags: |
type=sha,format=short,event=branch
type=raw,value=${{ github.event.release.tag_name }}
type=raw,value=latest
# Like before, using ${{ env.VERSION }} above doesn't work
add_docker_release_note:
needs: publish_docker
@@ -68,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Add release note
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
with:
append_body: true
body: |

View File

@@ -9,7 +9,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- name: Yarn cache
@@ -22,7 +24,7 @@ jobs:
- name: Vitest
run: "yarn run test:coverage"
- name: Upload to codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
@@ -33,7 +35,9 @@ jobs:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

View File

@@ -13,7 +13,9 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -42,7 +44,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
branch: actions/localazy-download

View File

@@ -14,7 +14,9 @@ jobs:
steps:
- name: Checkout the code
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false
- name: Upload
uses: localazy/upload@27e6b5c0fddf4551596b42226b1c24124335d24a # v1

23
.github/workflows/zizmor.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: GitHub Actions Security Analysis with zizmor 🌈
on:
push:
branches: ["livekit", "full-mesh"]
pull_request: {}
permissions: {}
jobs:
zizmor:
name: Run zizmor 🌈
runs-on: ubuntu-latest
permissions:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2

3
.gitignore vendored
View File

@@ -30,3 +30,6 @@ yarn-error.log
/playwright-report/
/blob-report/
/playwright/.cache/
*storybook.log
storybook-static

View File

@@ -1 +1 @@
22
24

8
.storybook/main.ts Normal file
View File

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

24
.storybook/manager.ts Normal file
View File

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

49
.storybook/preview.tsx Normal file
View File

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

View File

@@ -3,6 +3,7 @@
[![Chat](https://img.shields.io/matrix/webrtc:matrix.org)](https://matrix.to/#/#webrtc:matrix.org)
[![Localazy](https://img.shields.io/endpoint?url=https%3A%2F%2Fconnect.localazy.com%2Fstatus%2Felement-call%2Fdata%3Fcontent%3Dall%26title%3Dlocalazy%26logo%3Dtrue)](https://localazy.com/p/element-call)
[![License](https://img.shields.io/github/license/element-hq/element-call)](LICENSE-AGPL-3.0)
[![Codecov](https://img.shields.io/codecov/c/github/element-hq/element-call)](https://app.codecov.io/gh/element-hq/element-call)
[🎬 Live Demo 🎬](https://call.element.io)

View File

@@ -38,6 +38,8 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no

View File

@@ -38,7 +38,7 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# sticky events for matrixRTC user state
# sticky events for MatrixRTC user state
msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as

View File

@@ -181,7 +181,10 @@ server {
}
# Convenience reverse proxy for the call.m.localhost domain to yarn dev --host
# Convenience reverse proxy for the call.m.localhost domain to element call
# running on the host either via
# - yarn dev --host or
# - falling back to http (the element call docker container)
server {
listen 80;
listen [::]:80;
@@ -197,7 +200,7 @@ server {
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
# 1. Attempt HTTPS first
location ^~ / {
proxy_set_header Host $host;
@@ -208,8 +211,23 @@ server {
proxy_pass https://host.docker.internal:3000;
proxy_ssl_verify off;
# 2. Redirect specific errors (e.g., 502 Bad Gateway or 504 Timeout)
# to the named fallback location
error_page 502 503 504 = @http_fallback;
}
# 3. Fallback location using HTTP
location @http_fallback {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://host.docker.internal:8080;
}
error_page 500 502 503 504 /50x.html;
}

View File

@@ -38,6 +38,8 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no

View File

@@ -38,6 +38,8 @@ experimental_features:
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no

View File

@@ -13,7 +13,6 @@ coverage:
informational: true
patch:
default:
# Encourage (but don't enforce) 80% coverage on all lines that a PR
# Enforce 80% coverage on all lines that a PR
# touches
target: 80%
informational: true

View File

@@ -18,7 +18,7 @@
"api_host": "https://posthog-element-call.element.io"
},
"rageshake": {
"submit_url": "https://element.io/bugreports/submit"
"submit_url": "https://rageshakes.element.io/api/submit"
},
"sentry": {
"environment": "netlify-pr-preview",

View File

@@ -0,0 +1,16 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://call-unstable.ems.host",
"server_name": "call-unstable.ems.host"
}
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 100
}
}

View File

@@ -1,5 +1,12 @@
# OpenTelemetry Collector for development
## Edit:
Open telemetry has been removed in: https://github.com/element-hq/element-call/pull/3586
Check this PR to get back the implementation or to use it as reference to add it back.
---
This directory contains a docker compose file that starts a jaeger all-in-one instance
with an in-memory database, along with a standalone OpenTelemetry collector that forwards
traces into the jaeger. Jaeger has a built-in OpenTelemetry collector, but it can't be

View File

@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
pull_policy: always
hostname: auth-server
environment:
@@ -25,7 +25,7 @@ services:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
pull_policy: always
hostname: auth-server-1
environment:
@@ -47,7 +47,7 @@ services:
- ecbackend
livekit:
image: livekit/livekit-server:latest
image: livekit/livekit-server:v1.9.11
pull_policy: always
hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml
@@ -67,7 +67,7 @@ services:
- ecbackend
livekit-1:
image: livekit/livekit-server:latest
image: livekit/livekit-server:v1.9.11
pull_policy: always
hostname: livekit-sfu-1
command: --dev --config /etc/livekit.yaml
@@ -88,7 +88,7 @@ services:
synapse:
hostname: homeserver
image: docker.io/matrixdotorg/synapse:latest
image: ghcr.io/element-hq/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
@@ -106,7 +106,7 @@ services:
synapse-1:
hostname: homeserver-1
image: docker.io/matrixdotorg/synapse:latest
image: ghcr.io/element-hq/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml

View File

@@ -136,8 +136,8 @@ handle @jwt_service {
reverse_proxy http://[::1]:8080 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
}
}
@@ -146,12 +146,46 @@ handle {
reverse_proxy http://localhost:7880 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
}
}
```
Using Haproxy, you can achieve this by:
```
# Frontend
# Match /livekit/sfu/ path
acl is_sfu path_beg -i /livekit/sfu/
use_backend sfu_backend if is_sfu matrixrtc_domain
acl is_mxrtc_auth path_beg -i /sfu/get
use_backend mxrtc_auth_backend if is_mxrtc_auth matrixrtc_domain
# Backend
## MatrixRTC backend
backend sfu_backend
server livekit 127.0.0.1:7880
http-request set-path %[path,regsub(^/livekit/sfu/,/)]
http-request set-header Host %[req.hdr(host)]
timeout server 120s
# WebSocket support
option forwardfor
option http-server-close
option http-buffer-request
backend mxrtc_auth_backend
server sfu 127.0.0.1:8070
http-request set-header Host %[req.hdr(host)]
timeout server 120s
# WebSocket support
option forwardfor
option http-server-close
option http-buffer-request
```
#### MatrixRTC backend announcement
> [!IMPORTANT]

View File

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

View File

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

View File

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

12
knip.ts
View File

@@ -9,7 +9,7 @@ import { type KnipConfig } from "knip";
export default {
vite: {
config: ["vite.config.ts", "vite-embedded.config.ts"],
config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"],
},
entry: ["src/main.tsx", "i18next-parser.config.ts"],
ignoreBinaries: [
@@ -34,10 +34,12 @@ export default {
// then Knip will flag it as a false positive
// https://github.com/webpro-nl/knip/issues/766
"@vector-im/compound-web",
// We need this so that TypeScript is happy with @livekit/track-processors.
// This might be a bug in the LiveKit repo but for now we fix it on the
// Element Call side.
"@types/dom-mediacapture-transform",
// 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

@@ -64,6 +64,14 @@
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}",
"custom_livekit_url": {
"current_url": "Currently set to: ",
"from_config": "Currently, no overwrite is set. Url from well-known or config is used.",
"label": "Custom Livekit-url",
"reset": "Reset overwrite",
"save": "Save",
"saving": "Saving..."
},
"debug_tile_layout_label": "Debug tile layout",
"device_id": "Device ID: {{id}}",
"duplicate_tiles_label": "Number of additional tile copies per participant",
@@ -100,11 +108,15 @@
"connection_lost_description": "You were disconnected from the call.",
"e2ee_unsupported": "Incompatible browser",
"e2ee_unsupported_description": "Your web browser does not support encrypted calls. Supported browsers include Chrome, Safari, and Firefox 117+.",
"failed_to_start_livekit": "Failed to start Livekit connection",
"generic": "Something went wrong",
"generic_description": "Submitting debug logs will help us track down the problem.",
"insufficient_capacity": "Insufficient capacity",
"insufficient_capacity_description": "The server has reached its maximum capacity and you cannot join the call at this time. Try again later, or contact your server admin if the problem persists.",
"matrix_rtc_transport_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"membership_manager": "Membership Manager Error",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consequtive failed network requests.",
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
"open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"room_creation_restricted": "Failed to create call",
@@ -237,12 +249,14 @@
"version": "{{productName}} version: {{version}}",
"video_tile": {
"always_show": "Always show",
"call_ended": "Call ended",
"calling": "Calling…",
"camera_starting": "Video loading...",
"change_fit_contain": "Fit to frame",
"collapse": "Collapse",
"expand": "Expand",
"mute_for_me": "Mute for me",
"muted_for_me": "Muted for me",
"screen_share_volume": "Screen share volume",
"volume": "Volume",
"waiting_for_media": "Waiting for media..."
}

View File

@@ -13,6 +13,9 @@
"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",
"serve": "vite preview",
"prettier:check": "prettier -c .",
"prettier:format": "prettier -w .",
@@ -30,7 +33,9 @@
"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"
"links:disable": "mv .links.yaml .links.disabled.yaml",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"devDependencies": {
"@babel/core": "^7.16.5",
@@ -40,38 +45,32 @@
"@codecov/vite-plugin": "^1.3.0",
"@fontsource/inconsolata": "^5.1.0",
"@fontsource/inter": "^5.1.0",
"@formatjs/intl-durationformat": "^0.7.0",
"@formatjs/intl-durationformat": "^0.10.0",
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.12.0",
"@livekit/components-react": "^2.0.0",
"@livekit/protocol": "^1.42.2",
"@livekit/track-processors": "^0.5.5",
"@livekit/track-processors": "^0.7.1",
"@mediapipe/tasks-vision": "^0.10.18",
"@opentelemetry/api": "^1.4.0",
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
"@opentelemetry/resources": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@playwright/test": "^1.56.1",
"@playwright/test": "^1.57.0",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-spring/web": "^10.0.0",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^3.0.0",
"@storybook/addon-docs": "^10.3.3",
"@storybook/react-vite": "^10.3.3",
"@stylistic/eslint-plugin": "^3.0.0",
"@testing-library/dom": "^10.1.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.1",
"@types/content-type": "^1.1.5",
"@types/dom-mediacapture-transform": "^0.1.11",
"@types/grecaptcha": "^3.0.9",
"@types/jsdom": "^21.1.7",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.0.0",
"@types/node": "^24.0.0",
"@types/pako": "^2.0.3",
"@types/qrcode": "^1.5.5",
"@types/react": "^19.0.0",
@@ -81,36 +80,39 @@
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-design-tokens": "^9.0.0",
"@vector-im/compound-web": "^8.0.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^3.0.0",
"@vitest/coverage-v8": "^4.0.18",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"eslint": "^8.14.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "^0.8.2",
"eslint-plugin-deprecate": "^0.9.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsdoc": "^61.5.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "2.1.0",
"eslint-plugin-react": "^7.29.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-rxjs": "^5.0.3",
"eslint-plugin-storybook": "^10.3.3",
"eslint-plugin-unicorn": "^56.0.0",
"fetch-mock": "11.1.5",
"global-jsdom": "^26.0.0",
"i18next": "^24.0.0",
"i18next": "^25.0.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-parser": "^9.1.0",
"jsdom": "^26.0.0",
"knip": "^5.27.2",
"knip": "^5.86.0",
"livekit-client": "^2.13.0",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=toger5/sticky-events&commit=e7f5bec51b6f70501a025b79fe5021c933385b21",
"matrix-widget-api": "^1.13.0",
"matrix-js-sdk": "matrix-org/matrix-js-sdk#6e3efef0c5f660df47cf00874927dec1c75cc3cf",
"matrix-widget-api": "^1.16.1",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"pako": "^2.0.4",
@@ -121,25 +123,33 @@
"qrcode": "^1.5.4",
"react": "19",
"react-dom": "19",
"react-i18next": "^15.0.0",
"react-i18next": "^16.0.0 <16.7.0",
"react-router-dom": "^7.0.0",
"react-use-measure": "^2.1.1",
"rxjs": "^7.8.1",
"sass": "^1.42.1",
"storybook": "^10.3.3",
"typescript": "^5.8.3",
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"uuid": "^13.0.0",
"vaul": "^1.0.0",
"vite": "^7.0.0",
"vite": "^8.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-node-stdlib-browser": "^0.2.1",
"vite-plugin-svgr": "^4.0.0",
"vitest": "^3.0.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"
"@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"
},
"packageManager": "yarn@4.7.0"
}

View File

@@ -1,5 +1,6 @@
/*
Copyright 2025 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.
@@ -38,6 +39,7 @@ export default defineConfig({
projects: [
{
name: "chromium",
testIgnore: "**/mobile/**",
use: {
...devices["Desktop Chrome"],
permissions: [
@@ -56,9 +58,9 @@ export default defineConfig({
},
},
},
{
name: "firefox",
testIgnore: "**/mobile/**",
use: {
...devices["Desktop Firefox"],
ignoreHTTPSErrors: true,
@@ -70,6 +72,27 @@ export default defineConfig({
},
},
},
{
name: "mobile",
testMatch: "**/mobile/**",
use: {
...devices["Pixel 7"],
ignoreHTTPSErrors: true,
permissions: [
"clipboard-write",
"clipboard-read",
"microphone",
"camera",
],
launchOptions: {
args: [
"--use-fake-ui-for-media-stream",
"--use-fake-device-for-media-stream",
"--mute-audio",
],
},
},
},
// No safari for now, until I find a solution to fix `Not allowed to request resource` due to calling
// clear http to the homeserver

View File

@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers.ts";
test("Sign up a new account, then login, then logout", async ({ browser }) => {
const userId = `test_user-id_${Date.now()}`;
@@ -69,12 +71,7 @@ test("As a guest, create a call, share link and other join", async ({
// ========
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
// ========
await creatorPage.getByTestId("home_callName").click();
await creatorPage.getByTestId("home_callName").fill("Welcome");
await creatorPage.getByTestId("home_displayName").click();
await creatorPage.getByTestId("home_displayName").fill("Inviter");
await creatorPage.getByTestId("home_go").click();
await expect(creatorPage.locator("video")).toBeVisible();
await SpaHelpers.createCall(creatorPage, "Inviter", "Welcome");
// join
await creatorPage.getByTestId("lobby_joinCall").click();
@@ -82,19 +79,7 @@ test("As a guest, create a call, share link and other join", async ({
await creatorPage.getByRole("radio", { name: "Spotlight" }).check();
// Get the invite link
await creatorPage.getByRole("button", { name: "Invite" }).click();
await expect(
creatorPage.getByRole("heading", { name: "Invite to this call" }),
).toBeVisible();
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await creatorPage.getByTestId("modal_inviteLink").click();
const inviteLink = (await creatorPage.evaluate(
"navigator.clipboard.readText()",
)) as string;
expect(inviteLink).toContain("room/#/");
const inviteLink = await SpaHelpers.getCallInviteLink(creatorPage);
// ========
// ACT: The other user use the invite link to join the call as a guest
@@ -103,13 +88,7 @@ test("As a guest, create a call, share link and other join", async ({
reducedMotion: "reduce",
});
const guestPage = await guestInviteeContext.newPage();
await guestPage.goto(inviteLink);
await guestPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible();
await guestPage.getByTestId("joincall_joincall").click();
await guestPage.getByTestId("lobby_joinCall").click();
await guestPage.getByRole("radio", { name: "Spotlight" }).check();
await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink);
// ========
// ASSERT: check that there are two members in the call

View File

@@ -22,8 +22,8 @@ test("Start a new call then leave and show the feedback screen", async ({
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
// Check the button toolbar
// await expect(page.getByRole('button', { name: 'Mute microphone' })).toBeVisible();
// await expect(page.getByRole('button', { name: 'Stop video' })).toBeVisible();
// await expect(page.getByRole('switch', { name: 'Mute microphone' })).toBeVisible();
// await expect(page.getByRole('switch', { name: 'Stop video' })).toBeVisible();
await expect(page.getByRole("button", { name: "Settings" })).toBeVisible();
await expect(page.getByRole("button", { name: "End call" })).toBeVisible();

View File

@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test";
import { createJTWToken } from "./fixtures/jwt-token";
test("Should show error screen if fails to get JWT token", async ({ page }) => {
await page.goto("/");
@@ -75,7 +77,12 @@ test("Should automatically retry non fatal JWT errors", async ({
test("Should show error screen if call creation is restricted", async ({
page,
browserName,
}) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment.",
);
await page.goto("/");
// We need the socket connection to fail, but this cannot be done by using the websocket route.
@@ -88,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({
contentType: "application/json",
body: JSON.stringify({
url: "wss://badurltotricktest/livekit/sfu",
jwt: "FAKE",
jwt: createJTWToken("@fake:user", "!fake:room"),
}),
}),
);

View File

@@ -0,0 +1,73 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Browser, type Page, test, expect } from "@playwright/test";
export interface MobileCreateFixtures {
asMobile: {
creatorPage: Page;
inviteLink: string;
};
}
export const mobileTest = test.extend<MobileCreateFixtures>({
asMobile: async ({ browser }, pUse) => {
const fixtures = await createCallAndInvite(browser);
await pUse({
creatorPage: fixtures.page,
inviteLink: fixtures.inviteLink,
});
},
});
/**
* Create a call and generate an invite link
*/
async function createCallAndInvite(
browser: Browser,
): Promise<{ page: Page; inviteLink: string }> {
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
const creatorPage = await creatorContext.newPage();
await creatorPage.goto("/");
// ========
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
// ========
await creatorPage.getByTestId("home_callName").click();
await creatorPage.getByTestId("home_callName").fill("Welcome");
await creatorPage.getByTestId("home_displayName").click();
await creatorPage.getByTestId("home_displayName").fill("Inviter");
await creatorPage.getByTestId("home_go").click();
await expect(creatorPage.locator("video")).toBeVisible();
await creatorPage
.getByRole("button", { name: "Continue in browser" })
.click();
// join
await creatorPage.getByTestId("lobby_joinCall").click();
// Get the invite link
await creatorPage.getByRole("button", { name: "Invite" }).click();
await expect(
creatorPage.getByRole("heading", { name: "Invite to this call" }),
).toBeVisible();
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await creatorPage.getByTestId("modal_inviteLink").click();
const inviteLink = (await creatorPage.evaluate(
"navigator.clipboard.readText()",
)) as string;
expect(inviteLink).toContain("room/#/");
return {
page: creatorPage,
inviteLink,
};
}

View File

@@ -0,0 +1,22 @@
/*
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.
*/
export function createJTWToken(sub: string, room: string): string {
return [
{}, // header
{
// payload
sub,
video: {
room,
},
},
{}, // signature
]
.map((d) => global.btoa(JSON.stringify(d)))
.join(".");
}

View File

@@ -1,22 +1,19 @@
/*
Copyright 2025 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.
*/
import {
type Browser,
type Page,
test,
expect,
type JSHandle,
} from "@playwright/test";
import { type Page, test, expect, type JSHandle } from "@playwright/test";
import type { MatrixClient } from "matrix-js-sdk";
import { HOST1, TestHelpers } from "../widget/test-helpers.ts";
export type UserBaseFixture = {
mxId: string;
displayName: string;
page: Page;
clientHandle: JSHandle<MatrixClient>;
};
@@ -28,10 +25,10 @@ export type BaseWidgetSetup = {
export interface MyFixtures {
asWidget: BaseWidgetSetup;
callType: "room" | "dm";
addUser: (username: string, host: string) => Promise<UserBaseFixture>;
}
const PASSWORD = "foobarbaz1!";
// Minimal config.json for the local element-web instance
const CONFIG_JSON = {
default_server_config: {
@@ -65,132 +62,91 @@ const CONFIG_JSON = {
},
};
/**
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
* @param page
*/
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
? async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"http://localhost:8080/room",
);
});
}
: async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
});
};
/**
* Registers a new user and returns page, clientHandle and mxId.
*/
async function registerUser(
browser: Browser,
username: string,
): Promise<{ page: Page; clientHandle: JSHandle<MatrixClient>; mxId: string }> {
const userContext = await browser.newContext({
reducedMotion: "reduce",
});
const page = await userContext.newPage();
await page.goto("http://localhost:8081/#/welcome");
await page.getByRole("link", { name: "Create Account" }).click();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await page.getByRole("textbox", { name: "Confirm password" }).click();
await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD);
await page.getByRole("button", { name: "Register" }).click();
const continueButton = page.getByRole("button", { name: "Continue" });
try {
await expect(continueButton).toBeVisible({ timeout: 5000 });
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await continueButton.click();
} catch {
// continueButton not visible, continue as normal
}
await expect(
page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible();
await setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const mxId = (await clientHandle.evaluate(
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
return { page, clientHandle, mxId };
}
export const widgetTest = test.extend<MyFixtures>({
asWidget: async ({ browser, context }, pUse) => {
// allow per-test override: `widgetTest.use({ callType: "dm" })`
callType: ["room", { option: true }],
asWidget: async ({ browser, context, callType }, pUse) => {
await context.route(`http://localhost:8081/config.json*`, async (route) => {
await route.fulfill({ json: CONFIG_JSON });
});
const userA = `brooks_${Date.now()}`;
const userB = `whistler_${Date.now()}`;
const brooksDisplayName = `brooks_${Date.now()}`;
const whistlerDisplayName = `whistler_${Date.now()}`;
// Register users
const {
page: ewPage1,
clientHandle: brooksClientHandle,
mxId: brooksMxId,
} = await registerUser(browser, userA);
} = await TestHelpers.registerUser(browser, brooksDisplayName);
const {
page: ewPage2,
clientHandle: whistlerClientHandle,
mxId: whistlerMxId,
} = await registerUser(browser, userB);
} = await TestHelpers.registerUser(browser, whistlerDisplayName);
// Invite the second user
await ewPage1.getByRole("button", { name: "Add", exact: true }).click();
await ewPage1.getByRole("menuitem", { name: "New Room" }).click();
await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room");
await ewPage1.getByRole("button", { name: "Create room" }).click();
await expect(ewPage1.getByText("You created this room.")).toBeVisible();
await expect(ewPage1.getByText("Encryption enabled")).toBeVisible();
if (callType === "room") {
await TestHelpers.createRoom("Welcome Room", ewPage1);
await ewPage1
.getByRole("button", { name: "Invite to this room", exact: true })
.click();
await expect(
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
).toBeVisible();
await ewPage1
.getByRole("button", { name: "Invite to this room", exact: true })
.click();
await expect(
ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }),
).toBeVisible();
// To get the invite textbox we need to specifically select within the
// dialog, since there is another textbox in the background (the message
// composer). In theory the composer shouldn't be visible to Playwright at
// all because the invite dialog has trapped focus, but the focus trap
// doesn't quite work right on Firefox.
await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId);
await ewPage1.getByRole("dialog").getByRole("textbox").click();
await ewPage1.getByRole("button", { name: "Invite" }).click();
// To get the invite textbox we need to specifically select within the
// dialog, since there is another textbox in the background (the message
// composer). In theory the composer shouldn't be visible to Playwright at
// all because the invite dialog has trapped focus, but the focus trap
// doesn't quite work right on Firefox.
await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId);
await ewPage1.getByRole("dialog").getByRole("textbox").click();
await ewPage1.getByRole("button", { name: "Invite" }).click();
// Accept the invite
await expect(
ewPage2.getByRole("option", { name: "Welcome Room" }),
).toBeVisible();
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }),
).toBeVisible();
// Accept the invite
await expect(
ewPage2.getByRole("option", { name: "Welcome Room" }),
).toBeVisible();
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2
.getByRole("main")
.getByRole("heading", { name: "Welcome Room" }),
).toBeVisible();
} else if (callType === "dm") {
await ewPage1
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
await ewPage1.getByRole("button", { name: "Go" }).click();
// Wait and send the first message to create the DM
await expect(
ewPage1.getByText(/Send your first message to invite/),
).toBeVisible();
await ewPage1.locator(".mx_BasicMessageComposer_input > div").click();
await ewPage1
.getByRole("textbox", { name: "Send a message…" })
.fill("Hello!");
await ewPage1.getByRole("button", { name: "Send message" }).click();
await expect(
ewPage1.getByText("This is the beginning of your"),
).toBeVisible();
// Accept the DM invite from brooks
// This how playwright record selects the DM invite in the room list
await ewPage2.getByRole("option", { name: "Open room" }).click();
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
}
// Renamed use to pUse, as a workaround for eslint error that was thinking this use was a react use.
await pUse({
@@ -198,12 +154,40 @@ export const widgetTest = test.extend<MyFixtures>({
mxId: brooksMxId,
page: ewPage1,
clientHandle: brooksClientHandle,
displayName: brooksDisplayName,
},
whistler: {
mxId: whistlerMxId,
page: ewPage2,
clientHandle: whistlerClientHandle,
displayName: whistlerDisplayName,
},
});
},
/**
* Provide a way to add additional users within a test.
* The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp.
*/
addUser: async ({ browser }, use) => {
await use(
async (
username: string,
host: string = HOST1,
): Promise<UserBaseFixture> => {
const uniqueSuffix = Date.now();
const { page, clientHandle, mxId } = await TestHelpers.registerUser(
browser,
`${username.toLowerCase()}_${uniqueSuffix}`,
host,
);
return {
mxId,
displayName: username,
page,
clientHandle,
};
},
);
},
});

View File

@@ -0,0 +1,123 @@
/*
Copyright 2025 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 { mobileTest } from "../fixtures/fixture-mobile-create.ts";
test("@mobile Start a new call then leave and show the feedback screen", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
// await page.pause();
await expect(page.locator("video")).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Continue in browser" }).click();
// Join the call
await page.getByTestId("lobby_joinCall").click();
// Ensure that the call is connected
await page
.locator("div")
.filter({ hasText: /^HelloCall$/ })
.click();
// Check the number of participants
await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible();
// The tooltip with the name should be visible
await expect(page.getByTestId("name_tag")).toContainText("John Doe");
// leave the call
await page.getByTestId("incall_leave").click();
await expect(page.getByRole("heading")).toContainText(
"John Doe, your call has ended. How did it go?",
);
await expect(page.getByRole("main")).toContainText(
"Why not finish by setting up a password to keep your account?",
);
await expect(
page.getByRole("link", { name: "Not now, return to home screen" }),
).toBeVisible();
});
mobileTest(
"Test earpiece overlay in controlledAudioDevices mode",
async ({ asMobile, browser }) => {
test.slow(); // Triples the timeout
const { creatorPage, inviteLink } = asMobile;
// ========
// ACT: The other user use the invite link to join the call as a guest
// ========
const guestInviteeContext = await browser.newContext({
reducedMotion: "reduce",
});
const guestPage = await guestInviteeContext.newPage();
await guestPage.goto(inviteLink + "&controlledAudioDevices=true");
await guestPage
.getByRole("button", { name: "Continue in browser" })
.click();
await guestPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible();
await guestPage.getByTestId("joincall_joincall").click();
await guestPage.getByTestId("lobby_joinCall").click();
// ========
// ASSERT: check that there are two members in the call
// ========
// There should be two participants now
await expect(
guestPage.getByTestId("roomHeader_participants_count"),
).toContainText("2");
expect(await guestPage.getByTestId("videoTile").count()).toBe(2);
// Same in creator page
await expect(
creatorPage.getByTestId("roomHeader_participants_count"),
).toContainText("2");
expect(await creatorPage.getByTestId("videoTile").count()).toBe(2);
// TEST: control audio devices from the invitee page
await guestPage.evaluate(() => {
window.controls.setAvailableAudioDevices([
{ id: "speaker", name: "Speaker", isSpeaker: true },
{ id: "earpiece", name: "Handset", isEarpiece: true },
{ id: "headphones", name: "Headphones" },
]);
});
// Open settings to select earpiece
await guestPage.getByRole("button", { name: "Settings" }).click();
await guestPage.getByText("Handset", { exact: true }).click();
// dismiss settings
await guestPage.locator("#root").getByLabel("Settings").press("Escape");
await guestPage.pause();
await expect(
guestPage.getByRole("heading", { name: "Handset Mode" }),
).toBeVisible();
await expect(
guestPage.getByRole("button", { name: "Back to Speaker Mode" }),
).toBeVisible();
// Should auto-mute the video when earpiece is selected
await expect(guestPage.getByTestId("incall_videomute")).toBeDisabled();
},
);

View File

@@ -49,12 +49,12 @@ test("can only interact with header and footer while reconnecting", async ({
).toBeVisible();
// Tab order should jump directly from header to footer, skipping media tiles
await page.getByRole("button", { name: "Mute microphone" }).focus();
await page.getByRole("switch", { name: "Mute microphone" }).focus();
await expect(
page.getByRole("button", { name: "Mute microphone" }),
page.getByRole("switch", { name: "Mute microphone" }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused();
await expect(page.getByRole("switch", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click();
});

View File

@@ -68,11 +68,6 @@ test("When creator left, avoid reconnect to the same SFU", async ({
reducedMotion: "reduce",
});
const guestCPage = await guestC.newPage();
let sfuGetCallCount = 0;
await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => {
sfuGetCallCount++;
await route.continue();
});
// Track WebSocket connections
let wsConnectionCount = 0;
await guestCPage.routeWebSocket("**", (ws) => {
@@ -100,5 +95,4 @@ test("When creator left, avoid reconnect to the same SFU", async ({
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1);
expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
});

View File

@@ -0,0 +1,135 @@
/*
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,
type Page,
test,
type Request,
type Browser,
} from "@playwright/test";
import { SpaHelpers } from "./spa-helpers";
async function setupTwoUserSpaCall(
browser: Browser,
page: Page,
browserName: string,
): Promise<{ guestPage: Page }> {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
await page.goto("/");
let androlHasSentStickyEvent = false;
await interceptEventSend(
page,
// This room is not encrypted, so the event is sent in clear
"org.matrix.msc4143.rtc.member",
(req) => {
androlHasSentStickyEvent =
androlHasSentStickyEvent || isStickySend(req.url());
},
);
await SpaHelpers.createCall(page, "Androl", "HelloCall", true, "2_0");
const inviteLink = await SpaHelpers.getCallInviteLink(page);
// Other
const guestInviteeContext = await browser.newContext({
reducedMotion: "reduce",
});
const guestPage = await guestInviteeContext.newPage();
await guestPage.goto("/");
let pevaraHasSentStickyEvent = false;
await interceptEventSend(
guestPage,
// This room is not encrypted, so the event is sent in clear
"org.matrix.msc4143.rtc.member",
(req) => {
pevaraHasSentStickyEvent =
pevaraHasSentStickyEvent || isStickySend(req.url());
},
);
await SpaHelpers.joinCallFromInviteLink(
guestPage,
inviteLink,
"Pevara",
"2_0",
);
// Assert both sides have sent sticky membership events
expect(androlHasSentStickyEvent).toEqual(true);
expect(pevaraHasSentStickyEvent).toEqual(true);
return { guestPage };
}
test("One to One call using matrix rtc 2.0 aka sticky events", async ({
browser,
page,
browserName,
}) => {
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
});
// This issue occurs when a member leave but does not clean up their sticky event.
// If they rejoin they will use a new stickye key (stickyKey = member.id = UUID())
// We end up with two memberships with the same user and device id. This previously
// was a impossible case since that would be the same state event. Now its possible.
// We need to ALWAYS key by userId, deviceId and member.id. This test checks that.
test("One to One rejoin after improper leave does not crash EC", async ({
browser,
page,
browserName,
}) => {
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
await SpaHelpers.expectVideoTilesCount(page, 2);
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
await guestPage.reload();
await expect(guestPage.getByTestId("lobby_joinCall")).toBeVisible();
// Check if rejoining with the same browser context (device) breaks EC.
// This has happened on versions that do not consider the member.id as part of the key for a media tile.
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);
});
function isStickySend(url: string): boolean {
return !!new URL(url).searchParams.get(
"org.matrix.msc4354.sticky_duration_ms",
);
}
async function interceptEventSend(
page: Page,
eventType: string,
callback: (request: Request) => void,
): Promise<void> {
await page.route(
`**/_matrix/client/v3/rooms/**/send/${eventType}/**`,
async (route, req) => {
callback(req);
return route.continue();
},
);
}

148
playwright/spa-helpers.ts Normal file
View File

@@ -0,0 +1,148 @@
/*
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, type Page } from "@playwright/test";
import { type RtcMode } from "./widget/test-helpers.ts";
/**
* Create and join a call from the SPA home page.
*
* @param page - The Playwright page object
* @param userName - The display name to use for the call
* @param callName - The name of the call to create
* @param autoJoin - Whether to automatically join the call after creating it
* @param mode - The RTC mode to use for the call
*/
async function createCall(
page: Page,
userName: string,
callName: string,
autoJoin: boolean = false,
mode: RtcMode | undefined = undefined,
): Promise<void> {
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill(callName);
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill(userName);
await page.getByTestId("home_go").click();
await expect(page.locator("video")).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
if (mode) {
await setRtcModeFromSettings(page, mode);
}
if (autoJoin) {
// Join the call
await page.getByTestId("lobby_joinCall").click();
}
}
/**
* Get the invite link for the current call.
*/
async function getCallInviteLink(page: Page): Promise<string> {
await page.getByRole("button", { name: "Invite" }).click();
await expect(
page.getByRole("heading", { name: "Invite to this call" }),
).toBeVisible();
await expect(page.getByRole("img", { name: "QR Code" })).toBeVisible();
await expect(page.getByTestId("modal_inviteLink")).toBeVisible();
await expect(page.getByTestId("modal_inviteLink")).toBeVisible();
await page.getByTestId("modal_inviteLink").click();
const inviteLink = (await page.evaluate(
"navigator.clipboard.readText()",
)) as string;
expect(inviteLink).toContain("room/#/");
return inviteLink;
}
/**
* Join a call from an invitation link.
* @param page - The Playwright page object
* @param inviteLink - The invite link to join
* @param displayName - The display name to use when joining the call
* @param mode - The RTC mode to use for the call
*/
async function joinCallFromInviteLink(
page: Page,
inviteLink: string,
displayName: string = "Invitee",
mode: RtcMode | undefined = undefined,
): Promise<void> {
await page.goto(inviteLink);
await page.getByTestId("joincall_displayName").fill(displayName);
await expect(page.getByTestId("joincall_joincall")).toBeVisible();
await page.getByTestId("joincall_joincall").click();
if (mode) {
await setRtcModeFromSettings(page, mode);
}
await page.getByTestId("lobby_joinCall").click();
await page.getByRole("radio", { name: "Spotlight" }).check();
}
async function setRtcModeFromSettings(
page: Page,
mode: RtcMode,
): Promise<void> {
await page.getByRole("button", { name: "Settings" }).click();
await page.getByRole("tab", { name: "Preferences" }).click();
await page.getByText("Developer mode", { exact: true }).check(); // Idempotent: won't uncheck if already checked
// Move to Developer tab now
await page.getByRole("tab", { name: "Developer" }).click();
if (mode == "legacy") {
await page.getByText("Legacy: state events").click();
} else if (mode == "2_0") {
await page.getByText("Matrix 2.0").click();
} else {
// compat
await page.getByText("Compatibility: state events").click();
}
await page.getByTestId("modal_close").click();
}
/**
* Expect a certain number of video tiles to be present and visible.
*/
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();
// There should be 5 video elements, visible and autoplaying
const videoElements = await page.locator("video").all();
expect(videoElements.length).toBe(count);
const blockDisplayCount = await page
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(count);
}
export const SpaHelpers = {
createCall,
getCallInviteLink,
joinCallFromInviteLink,
expectVideoTilesCount,
};

View File

@@ -0,0 +1,86 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user";
import { HOST1, HOST2, type RtcMode, TestHelpers } from "./test-helpers";
const modePairs: [RtcMode, RtcMode][] = [
["compat", "compat"],
["legacy", "legacy"],
["legacy", "compat"],
["compat", "legacy"],
];
modePairs.forEach(([rtcMode1, rtcMode2]) => {
widgetTest(
`Test federated call with rtc modes ${rtcMode1} and ${rtcMode2}`,
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",
);
const florian = await addUser("floriant", HOST1);
const timo = await addUser("timo", HOST2);
const roomName = "Call Room";
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
await TestHelpers.acceptRoomInvite(roomName, timo.page);
await florian.page.pause();
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
rtcMode1,
);
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
timo.page,
rtcMode2,
);
await TestHelpers.startCallInCurrentRoom(florian.page, false);
await TestHelpers.joinCallFromLobby(florian.page);
// timo joins
await TestHelpers.joinCallInCurrentRoom(timo.page);
// We should see 2 video tiles everywhere now
for (const user of [timo, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
// 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();
// 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 florian.page.pause();
},
);
});

View File

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

View File

@@ -0,0 +1,104 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user";
import { HOST1, HOST2, TestHelpers } from "./test-helpers";
// ## Issue
// This test reproduces an issue with the publisher.
// When switching local focus, we need to recreate the publisher.
// This failed because of a dead lock in the old publishers destruction.
//
// There are numerus ways to enforece this situation:
// - oldest member swap (manually set the oldest member focus and leave with the prev oldest member)
// This almost never happens in the real worls since clients will set their preferredFoci list to what the oldest member is.
// - switch from oldest member to multi sfu as the NOT the first joiner + the first joiner is on a different sfu than your preferred sfu.
//
// This test uses the "switch from oldest member to multi sfu" approach.
//
// It is a copy of federated-call.test.ts in the `["legacy", "legacy"]` setup,
// which once connected will make the second user switch to multi sfu.
widgetTest(
`Test swapping publisher from ${HOST1} to ${HOST2}`,
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",
);
const florian = await addUser("floriant", HOST1);
const timo = await addUser("timo", HOST2);
const roomName = "Call Room";
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
await TestHelpers.acceptRoomInvite(roomName, timo.page);
await florian.page.pause();
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
"legacy",
);
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
timo.page,
"legacy",
);
await TestHelpers.startCallInCurrentRoom(florian.page, false);
await TestHelpers.joinCallFromLobby(florian.page);
// timo joins
await TestHelpers.joinCallInCurrentRoom(timo.page);
// We should see 2 video tiles everywhere now
for (const user of [timo, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
// 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();
// 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);
}
// 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);
},
);

View File

@@ -0,0 +1,135 @@
/*
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("Create and join a 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 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 roomName = "Group Call Room";
await TestHelpers.createRoom(roomName, valere.page, [
timo.mxId,
robin.mxId,
halfshot.mxId,
florian.mxId,
]);
for (const user of [timo, robin, halfshot, florian]) {
// 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);
}
// Start the call as Valere
await TestHelpers.startCallInCurrentRoom(valere.page, false);
await expect(
valere.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
await TestHelpers.joinCallFromLobby(valere.page);
for (const user of [timo, robin, halfshot, florian]) {
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();
}
// 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();
}
// 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,
);
expect(blockDisplayCount).toBe(5);
}
// Quickly test muting one participant to see it reflects and that our asserts works
const florianFrame = florian.page
.locator('iframe[title="Element Call"]')
.contentFrame();
const florianVideoButton = florianFrame.getByRole("switch", {
name: /video/,
});
await expect(florianVideoButton).toHaveAccessibleName("Stop video");
await expect(florianVideoButton).toBeChecked();
await florianVideoButton.click();
// Now the button should indicate we can start video
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,
);
// 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);
}
});

View File

@@ -0,0 +1,73 @@
/*
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("Footer interaction in PiP", 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();
const valere = await addUser("Valere", HOST1);
const callRoom = "CallRoom";
await TestHelpers.createRoom("CallRoom", valere.page);
await TestHelpers.createRoom("OtherRoom", valere.page);
await TestHelpers.switchToRoomNamed(valere.page, callRoom);
// Start the call as Valere
await TestHelpers.startCallInCurrentRoom(valere.page, false);
await expect(
valere.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
await TestHelpers.joinCallFromLobby(valere.page);
// wait a bit so that the PIP has rendered
await valere.page.waitForTimeout(600);
// Switch to the other room, the call should go to PIP
await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom");
// We should see the PIP overlay
const iFrame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
{
// Check for a bug where the video had the wrong fit in PIP
const audioBtn = iFrame.getByRole("switch", { name: /microphone/ });
const videoBtn = iFrame.getByRole("switch", { name: /video/ });
await expect(
iFrame.getByRole("button", { name: "End call" }),
).toBeVisible();
await expect(audioBtn).toBeVisible();
await expect(videoBtn).toBeVisible();
await expect(audioBtn).toHaveAccessibleName("Mute microphone");
await expect(audioBtn).toBeChecked();
await expect(videoBtn).toHaveAccessibleName("Stop video");
await expect(videoBtn).toBeChecked();
await videoBtn.click();
await audioBtn.click();
// stop hovering on any of the buttons
await iFrame.getByTestId("videoTile").hover();
await expect(audioBtn).toHaveAccessibleName("Unmute microphone");
await expect(audioBtn).not.toBeChecked();
await expect(videoBtn).toHaveAccessibleName("Start video");
await expect(videoBtn).not.toBeChecked();
}
});

View File

@@ -0,0 +1,72 @@
/*
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("Put call in PIP", 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();
const valere = await addUser("Valere", HOST1);
const timo = await addUser("Timo", HOST1);
const callRoom = "TeamRoom";
await TestHelpers.createRoom(callRoom, valere.page, [timo.mxId]);
await TestHelpers.createRoom("DoubleTask", valere.page);
await TestHelpers.acceptRoomInvite(callRoom, timo.page);
await TestHelpers.switchToRoomNamed(valere.page, callRoom);
// Start the call as Valere
await TestHelpers.startCallInCurrentRoom(valere.page, false);
await expect(
valere.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
await TestHelpers.joinCallFromLobby(valere.page);
await TestHelpers.joinCallInCurrentRoom(timo.page);
const frame = timo.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// check that the video is on
await expect(
frame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible();
// Switch to the other room, the call should go to PIP
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
// We should see the PIP overlay
await expect(valere.page.getByTestId("widget-pip-container")).toBeVisible();
{
// wait a bit so that the PIP has rendered the video
await valere.page.waitForTimeout(600);
// Check for a bug where the video had the wrong fit in PIP
const frame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
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

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

View File

@@ -0,0 +1,311 @@
/*
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 Browser,
expect,
type JSHandle,
type Page,
} from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk";
const PASSWORD = "foobarbaz1!";
export const HOST1 = "https://app.m.localhost/#/welcome";
export const HOST2 = "https://app.othersite.m.localhost/#/welcome";
export type RtcMode = "legacy" | "compat" | "2_0";
export class TestHelpers {
public static async startCallInCurrentRoom(
page: Page,
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("menuitem", { name: "Element Call" }).click();
}
public static async joinCallFromLobby(page: Page): Promise<void> {
await expect(
page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall"),
).toBeVisible();
await page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall")
.click();
}
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();
}
/**
* Registers a new user and returns page, clientHandle and mxId.
*/
public static async registerUser(
browser: Browser,
username: string,
host: string = HOST1,
): Promise<{
page: Page;
clientHandle: JSHandle<MatrixClient>;
mxId: string;
}> {
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 expect(
page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible({
// Increase timeout as registration can be slow :/
timeout: 15_000,
});
await this.maybeDismissBrowserNotSupportedToast(page);
await this.maybeDismissServiceWorkerWarningToast(page);
await TestHelpers.setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const mxId = (await clientHandle.evaluate(
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
return { page, clientHandle, mxId };
}
private static async maybeDismissBrowserNotSupportedToast(
page: Page,
): Promise<void> {
const browserUnsupportedToast = page
.getByText("Element does not support this browser")
.locator("..")
.locator("..");
// 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
}
}
private static async maybeDismissServiceWorkerWarningToast(
page: Page,
): Promise<void> {
const toast = page
.locator(".mx_Toast_toast")
.getByText("Failed to load service worker");
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
}
}
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
}
}
public static async createRoom(
name: string,
page: Page,
andInvite: string[] = [],
): Promise<void> {
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New Room" }).click();
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("Encryption enabled")).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page);
// Invite users if any
if (andInvite.length > 0) {
await page
.getByRole("button", { name: "Invite to this room", exact: true })
.click();
const inviteInput = page.getByRole("dialog").getByRole("textbox");
for (const mxId of andInvite) {
await inviteInput.focus();
await inviteInput.fill(mxId);
await inviteInput.press("Enter");
}
await page.getByRole("button", { name: "Invite" }).click();
}
}
/**
* Accepts a room invite using the room name.
* Locatest the invite in the room list.
*
*/
public static async acceptRoomInvite(
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 expect(
page.getByRole("main").getByRole("heading", { name: roomName }),
).toBeVisible();
await TestHelpers.maybeDismissKeyBackupToast(page);
}
/**
* Opens the widget and then goes to the settings to set the RTC mode.
* then closes the widget lobby.
*
* intended to be used before joining!
*
* WORKS IF A ROOM IS CURRENTLY OPENED IN THE PAGE
*/
public static async openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
page: Page,
mode: RtcMode,
): Promise<void> {
await page.getByRole("button", { name: "Video call" }).click();
await page.getByRole("menuitem", { name: "Element Call" }).click();
await TestHelpers.setEmbeddedElementCallRtcMode(page, mode);
await page.getByRole("button", { name: "Close lobby" }).click();
}
/**
* Goes to the settings to set the RTC mode.
* then closes the settings modal.
*
* WORKS IF A ROOM IS CURRENTLY SHOWING THE EC WIDGET
*/
public static async setEmbeddedElementCallRtcMode(
page: Page,
mode: RtcMode,
): Promise<void> {
const iframe = page.locator('iframe[title="Element Call"]').contentFrame();
await iframe.getByRole("button", { name: "Settings" }).click();
await iframe.getByRole("tab", { name: "Preferences" }).click();
// await iframe.getByText("Developer mode", { exact: true }).click();
await iframe.getByText("Developer mode", { exact: true }).check(); // Idempotent: won't uncheck if already checked
// Move to Developer tab now
await iframe.getByRole("tab", { name: "Developer" }).click();
if (mode == "legacy") {
await iframe.getByText("Legacy: state events").click();
} else if (mode == "2_0") {
await iframe.getByText("Matrix 2.0").click();
} else {
// compat
await iframe.getByText("Compatibility: state events").click();
}
await iframe.getByTestId("modal_close").click();
}
/**
* Sets the current Element Web app to use the dev Element Call URL.
* @param page - The EW page
*/
public static async setDevToolElementCallDevUrl(page: Page): Promise<void> {
if (process.env.USE_DOCKER) {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://call.m.localhost/room",
);
});
} else {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
});
}
}
/**
* Switches to a room in the room list by its name.
* @param page - The EW page
* @param roomName - The name of the room to switch to
*/
public static async switchToRoomNamed(
page: Page,
roomName: string,
): Promise<void> {
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
}
}

View File

@@ -0,0 +1,241 @@
/*
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 { TestHelpers } from "./test-helpers.ts";
widgetTest.use({ callType: "dm" });
widgetTest(
"Start a new voice call in DM as widget",
async ({ asWidget, 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(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, true);
await expect(
brooks.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
const brooksFrame = brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// We should show a ringing tile, let's check for that
await expect(
brooksFrame
.getByTestId("videoTile")
.filter({ has: brooksFrame.getByText(whistler.displayName) })
.filter({ has: brooksFrame.getByText("Calling…") }),
).toBeVisible();
await expect(whistler.page.getByText("Incoming voice call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Accept" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
const whistlerFrame = whistler.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// ASSERT the button states for whistler (the callee)
// video should be off by default in a voice call
await expect(
whistlerFrame.getByRole("switch", {
name: "Start video",
checked: false,
}),
).toBeVisible();
// audio should be on for the voice call
await expect(
whistlerFrame.getByRole("switch", {
name: "Mute microphone",
checked: true,
}),
).toBeVisible();
// ASSERT the button states for brools (the caller)
// video should be off by default in a voice call
await expect(
whistlerFrame.getByRole("switch", {
name: "Start video",
checked: false,
}),
).toBeVisible();
// audio should be on for the voice call
await expect(
whistlerFrame.getByRole("switch", {
name: "Mute microphone",
checked: true,
}),
).toBeVisible();
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
// So first we need to confirm that it is hidden when in the call.
await expect(
whistler.page.locator(".mx_BasicMessageComposer"),
).not.toBeVisible();
await expect(
brooks.page.locator(".mx_BasicMessageComposer"),
).not.toBeVisible();
// ASSERT hanging up on one side ends the call for both
await brooksFrame.getByRole("button", { name: "End call" }).click();
// The widget should be closed on both sides and the timeline should be back on screen
await expect(
whistler.page.locator(".mx_BasicMessageComposer"),
).toBeVisible();
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
},
);
widgetTest(
"Start a new video call in DM as widget",
async ({ asWidget, 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(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
await expect(
brooks.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
const brooksFrame = brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// We should show a ringing tile, let's check for that
await expect(
brooksFrame
.getByTestId("videoTile")
.filter({ has: brooksFrame.getByText(whistler.displayName) })
.filter({ has: brooksFrame.getByText("Calling…") }),
).toBeVisible();
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Accept" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
const whistlerFrame = whistler.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// ASSERT the button states for whistler (the callee)
// video should be off by default in a video call
await expect(
whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible();
// audio should be on too
await expect(
whistlerFrame.getByRole("switch", {
name: "Mute microphone",
checked: true,
}),
).toBeVisible();
// ASSERT the button states for brools (the caller)
// video should be off by default in a video call
await expect(
whistlerFrame.getByRole("switch", { name: "Stop video", checked: true }),
).toBeVisible();
// audio should be on too
await expect(
whistlerFrame.getByRole("switch", {
name: "Mute microphone",
checked: true,
}),
).toBeVisible();
// In order to confirm that the call is disconnected we will check that the message composer is shown again.
// So first we need to confirm that it is hidden when in the call.
await expect(
whistler.page.locator(".mx_BasicMessageComposer"),
).not.toBeVisible();
await expect(
brooks.page.locator(".mx_BasicMessageComposer"),
).not.toBeVisible();
// ASSERT hanging up on one side ends the call for both
await brooksFrame.getByRole("button", { name: "End call" }).click();
// The widget should be closed on both sides and the timeline should be back on screen
await expect(
whistler.page.locator(".mx_BasicMessageComposer"),
).toBeVisible();
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
},
);
widgetTest(
"Decline a new video call in DM as widget",
async ({ asWidget, 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(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
await expect(
brooks.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
const brooksFrame = brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// We should show a ringing tile, let's check for that
await expect(
brooksFrame
.getByTestId("videoTile")
.filter({ has: brooksFrame.getByText(whistler.displayName) })
.filter({ has: brooksFrame.getByText("Calling…") }),
).toBeVisible();
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Decline" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
).not.toBeVisible();
// The widget should be closed and the timeline should be back on screen
await expect(
brooks.page.locator('iframe[title="Element Call"]'),
).not.toBeVisible();
await expect(
brooks.page.getByText("This is the beginning of your"),
).toBeVisible();
},
);

59
sdk/README.md Normal file
View File

@@ -0,0 +1,59 @@
# SDK mode (EXPERIMENTAL)
EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps.
It allows to use matrixRTC in combination with livekit without relying on element call.
This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods.
This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver allowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template.
## Getting started
To get started run
```
yarn
yarn build:sdk
```
in the repository root.
It will create a `dist` folder containing the compiled js file.
This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a remote server.
Now you just need to add the widget to element web via:
```
/addwidget http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
## Widgets
The sdk mode is particularly interesting to be used in widgets. In widgets you do not need to pay attention to matrix login/cs api ...
To create a widget see the example `index.html` file in this folder. And add it to EW via:
`/addwidget <widgetUrl>` (see **url parameters** for more details on `<widgetUrl>`)
### url parameters
The url parameters are needed to pass initial data to the widget. They will automatically be used
by the matrixRTCSdk to start the postmessage widget api (communication between the client (e.g. Element Web) and the widget)
```
widgetId = $matrix_widget_id
perParticipantE2EE = true
userId = $matrix_user_id
deviceId = $org.matrix.msc3819.matrix_device_id
baseUrl = $org.matrix.msc4039.matrix_base_url
```
`parentUrl = // will be inserted automatically`
Full template use as `<widgetUrl>`:
```
http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)

52
sdk/helper.ts Normal file
View File

@@ -0,0 +1,52 @@
/*
Copyright 2025 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 file contains helper functions and types for the MatrixRTC SDK.
*/
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { scan } from "rxjs";
import { type WidgetHelpers } from "../src/widget";
import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel";
export const logger = rootLogger.getChild("[MatrixRTCSdk]");
export const tryMakeSticky = (widget: WidgetHelpers): void => {
logger.info("try making sticky MatrixRTCSdk");
void widget.api
.setAlwaysOnScreen(true)
.then(() => {
logger.info("sticky MatrixRTCSdk");
})
.catch((error) => {
logger.error("failed to make sticky MatrixRTCSdk", error);
});
};
export const TEXT_LK_TOPIC = "matrixRTC";
/**
* simple helper operator to combine the last emitted and the current emitted value of a rxjs observable
*
* I think there should be a builtin for this but i did not find it...
*/
export const currentAndPrev = scan<
LivekitRoomItem[],
{
prev: LivekitRoomItem[];
current: LivekitRoomItem[];
}
>(
({ current: lastCurrentVal }, items) => ({
prev: lastCurrentVal,
current: items,
}),
{
prev: [],
current: [],
},
);

68
sdk/index.html Normal file
View File

@@ -0,0 +1,68 @@
<!doctype html>
<html>
<head>
<title>MatrixRTC Widget</title>
<meta charset="utf-8" />
<script type="module">
// TODO use the url where the matrixrtc-sdk.js file from dist is hosted
import { createMatrixRTCSdk } from "http://localhost:8123/matrixrtc-sdk.js";
try {
window.matrixRTCSdk = await createMatrixRTCSdk(
"com.github.toger5.rtc-application-type", // rtc application type
);
console.info("createMatrixRTCSdk was created!");
} catch (e) {
console.error("createMatrixRTCSdk", e);
}
const sdk = window.matrixRTCSdk;
console.info("matrixRTCSdk join ", sdk);
const connectionState = sdk.join();
console.info("matrixRTCSdk joined");
const div = document.getElementById("data");
div.innerHTML = "<h3>Data:</h3>";
sdk.data$.subscribe((data) => {
const child = document.createElement("p");
child.innerHTML = JSON.stringify(data);
div.appendChild(child);
});
sdk.members$.subscribe((memberObjects) => {
const div = document.getElementById("members");
div.innerHTML = "<h3>Members:</h3>";
// Create member list
const members = memberObjects.map((member) => member.membership.sender);
console.info("members changed", members);
for (const m of members) {
console.info("member", m);
const child = document.createElement("p");
child.innerHTML = m;
div.appendChild(child);
}
});
sdk.connected$.subscribe((connected) => {
console.info("connected changed", connected);
const div = document.getElementById("connect_status");
div.innerHTML = connected ? "Connected" : "Disconnected";
});
</script>
</head>
<body>
<div
style="position: absolute; top: 0; right: 0; background-color: #ffffff10"
>
<div id="connect_status"></div>
<button onclick="window.matrixRTCSdk.leave()">Leave</button>
<button onclick="window.matrixRTCSdk.sendData({ prop: 'Hello, world!' })">
Send Text
</button>
<div id="members"></div>
<div id="data"></div>
</div>
</body>
</html>

368
sdk/main.ts Normal file
View File

@@ -0,0 +1,368 @@
/*
Copyright 2025-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.
*/
/**
* EXPERIMENTAL
*
* This file is the entrypoint for the sdk build of element call: `yarn 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.
* It takes care of all the tricky bits:
* - sending delayed events
* - finding the right sfu
* - handling the media stream
* - sending join/leave state or sticky events
* - setting up encryption and scharing keys
*/
import {
combineLatest,
map,
type Observable,
of,
shareReplay,
Subject,
switchMap,
tap,
} from "rxjs";
import {
type CallMembership,
MatrixRTCSessionEvent,
MatrixRTCSessionManager,
} from "matrix-js-sdk/lib/matrixrtc";
import {
type Room as LivekitRoom,
type TextStreamReader,
type LocalParticipant,
type RemoteParticipant,
} from "livekit-client";
// TODO how can this get fixed? to just be part of `livekit-client`
// Can this be done in the tsconfig.json
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
import { type Behavior, constant } from "../src/state/Behavior";
import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel";
import { ObservableScope } from "../src/state/ObservableScope";
import { getUrlParams } from "../src/UrlParams";
import { MuteStates } from "../src/state/MuteStates";
import { MediaDevices } from "../src/state/MediaDevices";
import { E2eeType } from "../src/e2ee/e2eeType";
import { currentAndPrev, logger, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
import {
ElementWidgetActions,
widget as _widget,
initializeWidget,
} from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
interface MatrixRTCSdk {
/**
* observe connected$ to track the state.
* @returns
*/
join: () => void;
/** @throws on leave errors */
leave: () => void;
/**
* Ends the rtc sdk. This will unsubscribe any event listeners. And end the associated scope.
* No updates can be received from the rtc sdk. The sdk cannot be restarted after.
* A new sdk needs to be created via createMatrixRTCSdk.
*/
stop: () => void;
data$: Observable<{ rtcBackendIdentity: string; data: string }>;
/**
* flattened list of members
*/
members$: Behavior<
{
connection: Connection | null;
membership: CallMembership;
participant: LocalParticipant | RemoteParticipant | null;
}[]
>;
/**
* flattened local members
*/
localMember$: Behavior<{
connection: Connection | null;
membership: CallMembership;
participant: LocalParticipant | null;
} | null>;
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
connected$: Behavior<boolean>;
sendData?: (data: unknown) => Promise<void>;
sendRoomMessage?: (message: string) => Promise<void>;
}
export async function createMatrixRTCSdk(
application: string = "m.call",
id: string = "",
sticky: boolean = false,
): Promise<MatrixRTCSdk> {
const scope = new ObservableScope();
// widget client
initializeWidget(application, true);
const widget = _widget;
if (!widget) throw Error("No widget. This webapp can only start as a widget");
const client = await widget.client;
logger.info("client created");
// url params
const { roomId } = getUrlParams();
if (roomId === null) throw Error("could not get roomId from url params");
const room = client.getRoom(roomId);
if (room === null) throw Error("could not get room from client");
// rtc session
const slot = { application, id };
const rtcSessionManager = new MatrixRTCSessionManager(logger, client, slot);
rtcSessionManager.start();
const rtcSession = rtcSessionManager.getRoomSession(room);
// media devices
const mediaDevices = new MediaDevices(scope);
const muteStates = new MuteStates(scope, mediaDevices, {
audioEnabled: false,
videoEnabled: false,
});
// call view model
const callViewModel = createCallViewModel$(
scope,
rtcSession,
room,
mediaDevices,
muteStates,
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
of({}),
of({}),
constant({ supported: false, processor: undefined }),
);
logger.info("CallViewModelCreated");
// create data listener
const data$ = new Subject<{ rtcBackendIdentity: string; data: string }>();
const lkTextStreamHandlerFunction = async (
reader: TextStreamReader,
participantInfo: { identity: string },
livekitRoom: LivekitRoom,
): Promise<void> => {
const info = reader.info;
logger.info(
`Received text stream from ${participantInfo.identity}\n` +
` Topic: ${info.topic}\n` +
` Timestamp: ${info.timestamp}\n` +
` ID: ${info.id}\n` +
` Size: ${info.size}`, // Optional, only available if the stream was sent with `sendText`
);
const participants = callViewModel.livekitRoomItems$.value.find(
(i) => i.livekitRoom === livekitRoom,
)?.participants;
if (participants && participants.includes(participantInfo.identity)) {
const text = await reader.readAll();
logger.info(`Received text: ${text}`);
data$.next({ rtcBackendIdentity: participantInfo.identity, data: text });
} else {
logger.warn(
"Received text from unknown participant",
participantInfo.identity,
);
}
};
const livekitRoomItemsSub = callViewModel.livekitRoomItems$
.pipe(
tap((beforecurrentAndPrev) => {
logger.info(
`LiveKit room items updated: ${beforecurrentAndPrev.length}`,
beforecurrentAndPrev,
);
}),
currentAndPrev,
tap((aftercurrentAndPrev) => {
logger.info(
`LiveKit room items updated: ${aftercurrentAndPrev.current.length}, ${aftercurrentAndPrev.prev.length}`,
aftercurrentAndPrev,
);
}),
)
.subscribe({
next: ({ prev, current }) => {
const prevRooms = prev.map((i) => i.livekitRoom);
const currentRooms = current.map((i) => i.livekitRoom);
const addedRooms = currentRooms.filter((r) => !prevRooms.includes(r));
const removedRooms = prevRooms.filter((r) => !currentRooms.includes(r));
addedRooms.forEach((r) => {
logger.info(`Registering text stream handler for room `);
r.registerTextStreamHandler(
TEXT_LK_TOPIC,
(reader, participantInfo) =>
void lkTextStreamHandlerFunction(reader, participantInfo, r),
);
});
removedRooms.forEach((r) => {
logger.info(`Unregistering text stream handler for room `);
r.unregisterTextStreamHandler(TEXT_LK_TOPIC);
});
},
complete: () => {
logger.info("Livekit room items subscription completed");
for (const item of callViewModel.livekitRoomItems$.value) {
logger.info("unregistering room item from room", item.url);
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
}
},
});
// create sendData function
const sendFn: Behavior<(data: string) => Promise<TextStreamInfo>> =
scope.behavior(
callViewModel.localMatrixLivekitMember$.pipe(
switchMap((m) => {
if (!m)
return of((data: string): never => {
throw Error("local membership not yet ready.");
});
return m.participant.value$.pipe(
map((p) => {
if (p === null) {
return (data: string): never => {
throw Error("local participant not yet ready to send data.");
};
} else {
return async (data: string): Promise<TextStreamInfo> =>
p.sendText(data, { topic: TEXT_LK_TOPIC });
}
}),
);
}),
),
);
const sendData = async (data: unknown): Promise<void> => {
const dataString = JSON.stringify(data);
logger.info("try sending: ", dataString);
try {
await Promise.resolve();
const info = await sendFn.value(dataString);
logger.info(`Sent text with stream ID: ${info.id}`);
} catch (e) {
logger.error("failed sending: ", dataString, e);
}
};
const sendRoomMessage = async (message: string): Promise<void> => {
const messageString = JSON.stringify(message);
logger.info("try sending to room: ", messageString);
try {
await client.sendTextMessage(room.roomId, message);
} catch (e) {
logger.error("failed sending to room: ", messageString, e);
}
};
// after hangup gets called
const leaveSubs = callViewModel.leave$.subscribe(() => {
const scheduleWidgetCloseOnLeave = async (): Promise<void> => {
const leaveResolver = Promise.withResolvers<void>();
logger.info("waiting for RTC leave");
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, (isJoined) => {
logger.info("received RTC join update: ", isJoined);
if (!isJoined) leaveResolver.resolve();
});
await leaveResolver.promise;
logger.info("send Unstick");
await widget.api
.setAlwaysOnScreen(false)
.catch((e) =>
logger.error(
"Failed to set call widget `alwaysOnScreen` to false",
e,
),
);
logger.info("send Close");
await widget.api.transport
.send(ElementWidgetActions.Close, {})
.catch((e) => logger.error("Failed to send close action", e));
};
// schedule close first and then leave (scope.end)
void scheduleWidgetCloseOnLeave();
});
logger.info("createMatrixRTCSdk done");
return {
join: (): void => {
// first lets try making the widget sticky
if (sticky) tryMakeSticky(widget);
callViewModel.join();
},
leave: (): void => {
callViewModel.leave();
},
stop: (): void => {
leaveSubs.unsubscribe();
livekitRoomItemsSub.unsubscribe();
scope.end();
},
data$,
localMember$: scope.behavior(
callViewModel.localMatrixLivekitMember$.pipe(
tap((member) =>
logger.info("localMatrixLivekitMember$ next: ", member),
),
switchMap((member) => {
if (member === null) return of(null);
return combineLatest([
member.connection$,
member.membership$,
member.participant.value$,
]).pipe(
map(([connection, membership, participant]) => ({
connection,
membership,
participant,
})),
);
}),
tap((member) => logger.info("localMember$ next: ", member)),
),
),
connected$: callViewModel.connected$,
members$: scope.behavior(
callViewModel.matrixLivekitMembers$.pipe(
switchMap((members) => {
const listOfMemberObservables = members.map((member) =>
combineLatest([
member.connection$,
member.membership$,
member.participant.value$,
]).pipe(
map(([connection, membership, participant]) => ({
connection,
membership,
participant,
})),
// using shareReplay instead of a Behavior here because the behavior would need
// a tricky scope.end() setup.
shareReplay({ bufferSize: 1, refCount: true }),
),
);
return combineLatest(listOfMemberObservables);
}),
),
[],
),
sendData,
sendRoomMessage,
};
}

View File

@@ -0,0 +1,147 @@
/* eslint-disable */
// The contents of this file below the line are copied from
// @types/dom-mediacapture-transform, which is inlined here into Element Call so
// that we can avoid the package's dependency on @types/dom-webcodecs, which is
// broken in TypeScript 5.9.
// (https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/74294)
// If that issue is resolved, we can remove this file and return to depending on
// @types/dom-mediacapture-transform.
// -----------------------------------------------------------------------------
// This project is licensed under the MIT license.
// Copyrights are respective of each contributor listed at the beginning of each definition file.
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
// In general, these types are only available behind a command line flag or an origin trial in
// Chrome 90+.
// This API depends on WebCodecs.
// Versioning:
// Until the above-mentioned spec is finalized, the major version number is 0. Although not
// necessary for version 0, consider incrementing the minor version number for breaking changes.
// The following modify existing DOM types to allow defining type-safe APIs on audio and video tracks.
/** Specialize MediaStreamTrack so that we can refer specifically to an audio track. */
interface MediaStreamAudioTrack extends MediaStreamTrack {
readonly kind: "audio";
clone(): MediaStreamAudioTrack;
}
/** Specialize MediaStreamTrack so that we can refer specifically to a video track. */
interface MediaStreamVideoTrack extends MediaStreamTrack {
readonly kind: "video";
clone(): MediaStreamVideoTrack;
}
/** Assert that getAudioTracks and getVideoTracks return the tracks with the appropriate kind. */
interface MediaStream {
getAudioTracks(): MediaStreamAudioTrack[];
getVideoTracks(): MediaStreamVideoTrack[];
}
// The following were originally generated from the spec using
// https://github.com/microsoft/TypeScript-DOM-lib-generator, then heavily modified.
/**
* A track sink that is capable of exposing the unencoded frames from the track to a
* ReadableStream, and exposes a control channel for signals going in the oppposite direction.
*/
interface MediaStreamTrackProcessor<T extends AudioData | VideoFrame> {
/**
* Allows reading the frames flowing through the MediaStreamTrack provided to the constructor.
*/
readonly readable: ReadableStream<T>;
/** Allows sending control signals to the MediaStreamTrack provided to the constructor. */
readonly writableControl: WritableStream<MediaStreamTrackSignal>;
}
declare var MediaStreamTrackProcessor: {
prototype: MediaStreamTrackProcessor<any>;
/** Constructor overrides based on the type of track. */
new (
init: MediaStreamTrackProcessorInit & { track: MediaStreamAudioTrack },
): MediaStreamTrackProcessor<AudioData>;
new (
init: MediaStreamTrackProcessorInit & { track: MediaStreamVideoTrack },
): MediaStreamTrackProcessor<VideoFrame>;
};
interface MediaStreamTrackProcessorInit {
track: MediaStreamTrack;
/**
* If media frames are not read from MediaStreamTrackProcessor.readable quickly enough, the
* MediaStreamTrackProcessor will internally buffer up to maxBufferSize of the frames produced
* by the track. If the internal buffer is full, each time the track produces a new frame, the
* oldest frame in the buffer will be dropped and the new frame will be added to the buffer.
*/
maxBufferSize?: number | undefined;
}
/**
* Takes video frames as input, and emits control signals that result from subsequent processing.
*/
interface MediaStreamTrackGenerator<
T extends AudioData | VideoFrame,
> extends MediaStreamTrack {
/**
* Allows writing media frames to the MediaStreamTrackGenerator, which is itself a
* MediaStreamTrack. When a frame is written to writable, the frames close() method is
* automatically invoked, so that its internal resources are no longer accessible from
* JavaScript.
*/
readonly writable: WritableStream<T>;
/**
* Allows reading control signals sent from any sinks connected to the
* MediaStreamTrackGenerator.
*/
readonly readableControl: ReadableStream<MediaStreamTrackSignal>;
}
type MediaStreamAudioTrackGenerator = MediaStreamTrackGenerator<AudioData> &
MediaStreamAudioTrack;
type MediaStreamVideoTrackGenerator = MediaStreamTrackGenerator<VideoFrame> &
MediaStreamVideoTrack;
declare var MediaStreamTrackGenerator: {
prototype: MediaStreamTrackGenerator<any>;
/** Constructor overrides based on the type of track. */
new (
init: MediaStreamTrackGeneratorInit & {
kind: "audio";
signalTarget?: MediaStreamAudioTrack | undefined;
},
): MediaStreamAudioTrackGenerator;
new (
init: MediaStreamTrackGeneratorInit & {
kind: "video";
signalTarget?: MediaStreamVideoTrack | undefined;
},
): MediaStreamVideoTrackGenerator;
};
interface MediaStreamTrackGeneratorInit {
kind: MediaStreamTrackGeneratorKind;
/**
* (Optional) track to which the MediaStreamTrackGenerator will automatically forward control
* signals. If signalTarget is provided and signalTarget.kind and kind do not match, the
* MediaStreamTrackGenerators constructor will raise an exception.
*/
signalTarget?: MediaStreamTrack | undefined;
}
type MediaStreamTrackGeneratorKind = "audio" | "video";
type MediaStreamTrackSignalType = "request-frame";
interface MediaStreamTrackSignal {
signalType: MediaStreamTrackSignalType;
}

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

@@ -0,0 +1,12 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { JSX as ReactJSX } from "react";
declare module "mdx/types.js" {
export import JSX = ReactJSX;
}

View File

@@ -9,14 +9,18 @@ import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk";
import { type FC, type PropsWithChildren } from "react";
import { type WidgetApi } from "matrix-widget-api";
import { ClientContextProvider } from "./ClientContext";
import { Avatar } from "./Avatar";
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
import { widget } from "./widget";
const TestComponent: FC<
PropsWithChildren<{ client: MatrixClient; supportsThumbnails?: boolean }>
> = ({ client, children, supportsThumbnails }) => {
PropsWithChildren<{
client: MatrixClient;
}>
> = ({ client, children }) => {
return (
<ClientContextProvider
value={{
@@ -24,7 +28,6 @@ const TestComponent: FC<
disconnected: false,
supportedFeatures: {
reactions: true,
thumbnails: supportsThumbnails ?? true,
},
setClient: vi.fn(),
authenticated: {
@@ -40,6 +43,12 @@ const TestComponent: FC<
);
};
vi.mock("./widget", () => ({
widget: {
api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough
},
}));
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -73,36 +82,7 @@ test("should just render a placeholder when the user has no avatar", () => {
expect(client.mxcUrlToHttp).toBeCalledTimes(0);
});
test("should just render a placeholder when thumbnails are not supported", () => {
const client = vi.mocked<MatrixClient>({
getAccessToken: () => "my-access-token",
mxcUrlToHttp: () => vi.fn(),
} as unknown as MatrixClient);
vi.spyOn(client, "mxcUrlToHttp");
const member = mockMatrixRoomMember(
mockRtcMembership("@alice:example.org", "AAAA"),
{
getMxcAvatarUrl: () => "mxc://example.org/alice-avatar",
},
);
const displayName = "Alice";
render(
<TestComponent client={client} supportsThumbnails={false}>
<Avatar
id={member.userId}
name={displayName}
size={96}
src={member.getMxcAvatarUrl()}
/>
</TestComponent>,
);
const element = screen.getByRole("img", { name: "@alice:example.org" });
expect(element.tagName).toEqual("SPAN");
expect(client.mxcUrlToHttp).toBeCalledTimes(0);
});
test("should attempt to fetch authenticated media", async () => {
test("should attempt to fetch authenticated media from the server", async () => {
const expectedAuthUrl = "http://example.org/media/alice-avatar";
const expectedObjectURL = "my-object-url";
const accessToken = "my-access-token";
@@ -154,3 +134,80 @@ test("should attempt to fetch authenticated media", async () => {
headers: { Authorization: `Bearer ${accessToken}` },
});
});
test("should attempt to use widget API if running as a widget", async () => {
const expectedMXCUrl = "mxc://example.org/alice-avatar";
const expectedObjectURL = "my-object-url";
const theBlob = new Blob([]);
// vitest doesn't have a implementation of create/revokeObjectURL, so we need
// to delete the property. It's a bit odd, but it works.
Reflect.deleteProperty(global.window.URL, "createObjectURL");
globalThis.URL.createObjectURL = vi.fn().mockReturnValue(expectedObjectURL);
Reflect.deleteProperty(global.window.URL, "revokeObjectURL");
globalThis.URL.revokeObjectURL = vi.fn();
const client = vi.mocked<MatrixClient>({
getAccessToken: () => undefined,
} as unknown as MatrixClient);
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
const member = mockMatrixRoomMember(
mockRtcMembership("@alice:example.org", "AAAA"),
{
getMxcAvatarUrl: () => expectedMXCUrl,
},
);
const displayName = "Alice";
render(
<TestComponent client={client}>
<Avatar
id={member.userId}
name={displayName}
size={96}
src={member.getMxcAvatarUrl()}
/>
</TestComponent>,
);
// Fetch is asynchronous, so wait for this to resolve.
await vi.waitUntil(() =>
document.querySelector(`img[src='${expectedObjectURL}']`),
);
expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl);
});
test("Supports download files as base64", async () => {
const expectedMXCUrl = "mxc://example.org/alice-avatar";
const expectedBase64 =
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" +
"+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" +
"ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" +
"UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" +
"Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" +
"oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" +
"A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" +
"nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" +
"ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" +
"Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" +
"2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" +
"M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" +
"kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" +
"uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" +
"XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" +
"YII=";
const mockWidgetAPI = {
downloadFile: vi.fn().mockImplementation(async (contentUri) => {
if (contentUri !== expectedMXCUrl) {
return Promise.reject(new Error("Unexpected content URI"));
}
return { file: expectedBase64 };
}),
} as unknown as WidgetApi;
const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl);
expect(blob).toBeInstanceOf(Blob);
});

View File

@@ -14,8 +14,10 @@ import {
} from "react";
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { type WidgetApi } from "matrix-widget-api";
import { useClientState } from "./ClientContext";
import { widget } from "./widget";
export enum Size {
XS = "xs",
@@ -78,50 +80,54 @@ export const Avatar: FC<Props> = ({
const sizePx = useMemo(
() =>
Object.values(Size).includes(size as Size)
? sizes.get(size as Size)
? sizes.get(size as Size)!
: (size as number),
[size],
);
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
// In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs.
useEffect(() => {
if (clientState?.state !== "valid") {
return;
}
const { authenticated, supportedFeatures } = clientState;
const client = authenticated?.client;
if (!client || !src || !sizePx || !supportedFeatures.thumbnails) {
if (!src) {
setAvatarUrl(undefined);
return;
}
const token = client.getAccessToken();
if (!token) {
return;
}
const resolveSrc = getAvatarUrl(client, src, sizePx);
if (!resolveSrc) {
let blob: Promise<Blob>;
if (widget?.api) {
blob = getAvatarFromWidgetAPI(widget.api, src);
} else if (
clientState?.state === "valid" &&
clientState.authenticated?.client &&
sizePx
) {
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
} else {
setAvatarUrl(undefined);
return;
}
let objectUrl: string | undefined;
fetch(resolveSrc, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then(async (req) => req.blob())
let stale = false;
blob
.then((blob) => {
if (stale) {
return;
}
objectUrl = URL.createObjectURL(blob);
setAvatarUrl(objectUrl);
})
.catch((ex) => {
if (stale) {
return;
}
setAvatarUrl(undefined);
});
return (): void => {
stale = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
@@ -140,3 +146,50 @@ export const Avatar: FC<Props> = ({
/>
);
};
async function getAvatarFromServer(
client: MatrixClient,
src: string,
sizePx: number,
): Promise<Blob> {
const httpSrc = getAvatarUrl(client, src, sizePx);
if (!httpSrc) {
throw new Error("Failed to get http avatar URL");
}
const token = client.getAccessToken();
if (!token) {
throw new Error("Failed to get access token");
}
const request = await fetch(httpSrc, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const blob = await request.blob();
return blob;
}
// export for testing
export async function getAvatarFromWidgetAPI(
api: WidgetApi,
src: string,
): Promise<Blob> {
const response = await api.downloadFile(src);
const file = response.file;
// element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that
if (file instanceof Blob) {
return file;
} else if (typeof file === "string") {
// it is a base64 string
const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0));
return new Blob([bytes]);
}
throw new Error(
"Downloaded file format is not supported: " + typeof file + "",
);
}

View File

@@ -48,7 +48,6 @@ export type ValidClientState = {
disconnected: boolean;
supportedFeatures: {
reactions: boolean;
thumbnails: boolean;
};
setClient: (client: MatrixClient, session: Session) => void;
};
@@ -249,7 +248,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
const [isDisconnected, setIsDisconnected] = useState(false);
const [supportsReactions, setSupportsReactions] = useState(false);
const [supportsThumbnails, setSupportsThumbnails] = useState(false);
const state: ClientState | undefined = useMemo(() => {
if (alreadyOpenedErr) {
@@ -275,7 +273,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
disconnected: isDisconnected,
supportedFeatures: {
reactions: supportsReactions,
thumbnails: supportsThumbnails,
},
};
}, [
@@ -286,7 +283,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
setClient,
isDisconnected,
supportsReactions,
supportsThumbnails,
]);
const onSync = useCallback(
@@ -312,8 +308,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
}
if (initClientState.widgetApi) {
// There is currently no widget API for authenticated media thumbnails.
setSupportsThumbnails(false);
const reactSend = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.send.event:m.reaction",
);
@@ -335,7 +329,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
}
} else {
setSupportsReactions(true);
setSupportsThumbnails(true);
}
return (): void => {

View File

@@ -20,6 +20,7 @@ interface Props {
audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
focusUrl?: string;
rtcBackendIdentity?: string;
}
const extractDomain = (url: string): string => {
@@ -37,6 +38,7 @@ export const RTCConnectionStats: FC<Props> = ({
audio,
video,
focusUrl,
rtcBackendIdentity,
...rest
}) => {
const [showModal, setShowModal] = useState(false);
@@ -71,6 +73,9 @@ export const RTCConnectionStats: FC<Props> = ({
</pre>
</div>
</Modal>
<Text as="span" size="xs" title="rtcBackendIdentity">
rtcBackendIdentity:{rtcBackendIdentity}
</Text>
{focusUrl && (
<div>
<Text as="span" size="xs" title="focusURL">

View File

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

View File

@@ -1,5 +1,6 @@
/*
Copyright 2023, 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.
@@ -256,8 +257,6 @@ describe("UrlParams", () => {
skipLobby: false,
returnToLobby: false,
sendNotificationType: "notification",
defaultAudioEnabled: true,
defaultVideoEnabled: true,
});
it("use no-intent-defaults with unknown intent", () => {
expect(computeUrlParams()).toMatchObject(noIntentDefaults);
@@ -272,7 +271,11 @@ describe("UrlParams", () => {
computeUrlParams(
"?intent=start_call&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false });
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: "video",
});
});
it("accepts start_call_dm mobile", () => {
@@ -309,6 +312,29 @@ describe("UrlParams", () => {
),
).toMatchObject(joinExistingCallDefaults("desktop"));
});
it("accepts start_call_voice", () => {
expect(
computeUrlParams(
"?intent=start_call_voice&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({
...startNewCallDefaults("desktop"),
skipLobby: false,
callIntent: "audio",
});
});
it("accepts join_existing_voice", () => {
expect(
computeUrlParams(
"?intent=join_existing_voice&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({
...joinExistingCallDefaults("desktop"),
callIntent: "audio",
});
});
});
describe("skipLobby", () => {
@@ -332,6 +358,42 @@ describe("UrlParams", () => {
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
});
});
describe("noiseSuppression", () => {
it("defaults to true", () => {
expect(computeUrlParams().noiseSuppression).toBe(true);
});
it("is parsed", () => {
expect(
computeUrlParams("?intent=start_call&noiseSuppression=true")
.noiseSuppression,
).toBe(true);
expect(
computeUrlParams("?intent=start_call&noiseSuppression&bar=foo")
.noiseSuppression,
).toBe(true);
expect(computeUrlParams("?noiseSuppression=false").noiseSuppression).toBe(
false,
);
});
});
describe("echoCancellation", () => {
it("defaults to true", () => {
expect(computeUrlParams().echoCancellation).toBe(true);
});
it("is parsed", () => {
expect(computeUrlParams("?echoCancellation=true").echoCancellation).toBe(
true,
);
expect(computeUrlParams("?echoCancellation=false").echoCancellation).toBe(
false,
);
});
});
describe("header", () => {
it("uses header if provided", () => {
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
@@ -359,8 +421,6 @@ describe("UrlParams", () => {
expect.any(Object),
"configuration:",
expect.any(Object),
"intentAndPlatformDerivedConfiguration:",
{},
);
});
});

View File

@@ -1,5 +1,6 @@
/*
Copyright 2022-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.
@@ -28,6 +29,8 @@ interface RoomIdentifier {
export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallVoice = "start_call_voice",
JoinExistingCallVoice = "join_existing_voice",
StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm",
@@ -233,26 +236,26 @@ export interface UrlConfiguration {
*/
waitForCallPickup: boolean;
/**
* Whether to enable echo cancellation for audio capture.
* Defaults to true.
*/
echoCancellation?: boolean;
/**
* Whether to enable noise suppression for audio capture.
* Defaults to true.
*/
noiseSuppression?: boolean;
callIntent?: RTCCallIntent;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams
extends UrlProperties,
UrlConfiguration,
IntentAndPlatformDerivedConfiguration {}
export interface UrlParams extends UrlProperties, UrlConfiguration {}
// This is here as a stopgap, but what would be far nicer is a function that
// takes a UrlParams and returns a query string. That would enable us to
@@ -413,6 +416,15 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.StartNewCallVoice:
intentPreset.skipLobby = false;
intentPreset.callIntent = "audio";
break;
case UserIntent.JoinExistingCallVoice:
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = false;
intentPreset.callIntent = "audio";
break;
case UserIntent.StartNewCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
@@ -452,29 +464,6 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
};
}
const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
{};
// Desktop also includes web. Its anything that is not mobile.
const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
switch (desktopMobile) {
case "desktop":
case "mobile":
switch (intent) {
case UserIntent.StartNewCall:
case UserIntent.JoinExistingCall:
case UserIntent.StartNewCallDM:
case UserIntent.JoinExistingCallDM:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
break;
case UserIntent.StartNewCallDMVoice:
case UserIntent.JoinExistingCallDMVoice:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = false;
break;
}
}
const properties: UrlProperties = {
widgetId,
parentUrl,
@@ -495,8 +484,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
homeserver: !isWidget ? parser.getParam("homeserver") : null,
posthogApiHost: parser.getParam("posthogApiHost"),
posthogApiKey: parser.getParam("posthogApiKey"),
posthogUserId:
parser.getParam("posthogUserId") ?? parser.getParam("analyticsID"),
posthogUserId: parser.getParam("posthogUserId"),
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
sentryDsn: parser.getParam("sentryDsn"),
sentryEnvironment: parser.getParam("sentryEnvironment"),
@@ -525,6 +513,8 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
]),
waitForCallPickup: parser.getFlag("waitForCallPickup"),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
noiseSuppression: parser.getFlagParam("noiseSuppression", true),
echoCancellation: parser.getFlagParam("echoCancellation", true),
};
// Log the final configuration for debugging purposes.
@@ -537,15 +527,12 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
properties,
"configuration:",
configuration,
"intentAndPlatformDerivedConfiguration:",
intentAndPlatformDerivedConfiguration,
);
return {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
...intentAndPlatformDerivedConfiguration,
};
};

View File

@@ -13,15 +13,15 @@ exports[`AppBar > renders 1`] = `
class="nav leftNav"
>
<button
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8"
aria-labelledby="_r_0_"
class="_icon-button_1215g_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg

View File

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

View File

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

View File

@@ -247,9 +247,8 @@ export class PosthogAnalytics {
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
// until the next time account data is refreshed and this function is called (most likely on next
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
const accountDataAnalyticsId = analyticsIdGenerator();
await this.setAccountAnalyticsId(accountDataAnalyticsId);
analyticsID = await this.hashedEcAnalyticsId(accountDataAnalyticsId);
analyticsID = analyticsIdGenerator();
await this.setAccountAnalyticsId(analyticsID);
}
} catch (e) {
// The above could fail due to network requests, but not essential to starting the application,
@@ -270,37 +269,14 @@ export class PosthogAnalytics {
private async getAnalyticsId(): Promise<string | null> {
const client: MatrixClient = window.matrixclient;
let accountAnalyticsId: string | null;
if (widget) {
accountAnalyticsId = getUrlParams().posthogUserId;
return getUrlParams().posthogUserId;
} else {
const accountData = await client.getAccountDataFromServer(
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
);
accountAnalyticsId = accountData?.id ?? null;
return accountData?.id ?? null;
}
if (accountAnalyticsId) {
// we dont just use the element web analytics ID because that would allow to associate
// users between the two posthog instances. By using a hash from the username and the element web analytics id
// it is not possible to conclude the element web posthog user id from the element call user id and vice versa.
return await this.hashedEcAnalyticsId(accountAnalyticsId);
}
return null;
}
private async hashedEcAnalyticsId(
accountAnalyticsId: string,
): Promise<string> {
const client: MatrixClient = window.matrixclient;
const posthogIdMaterial = "ec" + accountAnalyticsId + client.getUserId();
const bufferForPosthogId = await crypto.subtle.digest(
"sha-256",
new TextEncoder().encode(posthogIdMaterial),
);
const view = new Int32Array(bufferForPosthogId);
return Array.from(view)
.map((b) => Math.abs(b).toString(16).padStart(2, "0"))
.join("");
}
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {

View File

@@ -1,157 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type SpanProcessor,
type ReadableSpan,
type Span,
} from "@opentelemetry/sdk-trace-base";
import { hrTimeToMilliseconds } from "@opentelemetry/core";
import { logger } from "matrix-js-sdk/lib/logger";
import { PosthogAnalytics } from "./PosthogAnalytics";
interface PrevCall {
callId: string;
hangupTs: number;
}
/**
* The maximum time between hanging up and joining the same call that we would
* consider a 'rejoin' on the user's part.
*/
const maxRejoinMs = 2 * 60 * 1000; // 2 minutes
/**
* Span processor that extracts certain metrics from spans to send to PostHog
*/
export class PosthogSpanProcessor implements SpanProcessor {
public async forceFlush(): Promise<void> {}
public onStart(span: Span): void {
// Hack: Yield to allow attributes to be set before processing
try {
switch (span.name) {
case "matrix.groupCallMembership":
this.onGroupCallMembershipStart(span);
return;
case "matrix.groupCallMembership.summaryReport":
this.onSummaryReportStart(span);
return;
}
} catch (e) {
// log to avoid tripping @typescript-eslint/no-unused-vars
logger.debug(e);
}
}
public onEnd(span: ReadableSpan): void {
switch (span.name) {
case "matrix.groupCallMembership":
this.onGroupCallMembershipEnd(span);
return;
}
}
private get prevCall(): PrevCall | null {
// This is stored in localStorage so we can remember the previous call
// across app restarts
const data = localStorage.getItem("matrix-prev-call");
if (data === null) return null;
try {
return JSON.parse(data);
} catch (e) {
logger.warn("Invalid prev call data", data, "error:", e);
return null;
}
}
private set prevCall(data: PrevCall | null) {
localStorage.setItem("matrix-prev-call", JSON.stringify(data));
}
private onGroupCallMembershipStart(span: ReadableSpan): void {
const prevCall = this.prevCall;
const newCallId = span.attributes["matrix.confId"] as string;
// If the user joined the same call within a short time frame, log this as a
// rejoin. This is interesting as a call quality metric, since rejoins may
// indicate that users had to intervene to make the product work.
if (prevCall !== null && newCallId === prevCall.callId) {
const duration = hrTimeToMilliseconds(span.startTime) - prevCall.hangupTs;
if (duration <= maxRejoinMs) {
PosthogAnalytics.instance.trackEvent({
eventName: "Rejoin",
callId: prevCall.callId,
rejoinDuration: duration,
});
}
}
}
private onGroupCallMembershipEnd(span: ReadableSpan): void {
this.prevCall = {
callId: span.attributes["matrix.confId"] as string,
hangupTs: hrTimeToMilliseconds(span.endTime),
};
}
private onSummaryReportStart(span: ReadableSpan): void {
// Searching for an event like this:
// matrix.stats.summary
// matrix.stats.summary.percentageReceivedAudioMedia: 0.75
// matrix.stats.summary.percentageReceivedMedia: 1
// matrix.stats.summary.percentageReceivedVideoMedia: 0.75
// matrix.stats.summary.maxJitter: 100
// matrix.stats.summary.maxPacketLoss: 20
const event = span.events.find((e) => e.name === "matrix.stats.summary");
if (event !== undefined) {
const attributes = event.attributes;
if (attributes) {
const mediaReceived = `${attributes["matrix.stats.summary.percentageReceivedMedia"]}`;
const videoReceived = `${attributes["matrix.stats.summary.percentageReceivedVideoMedia"]}`;
const audioReceived = `${attributes["matrix.stats.summary.percentageReceivedAudioMedia"]}`;
const maxJitter = `${attributes["matrix.stats.summary.maxJitter"]}`;
const maxPacketLoss = `${attributes["matrix.stats.summary.maxPacketLoss"]}`;
const peerConnections = `${attributes["matrix.stats.summary.peerConnections"]}`;
const percentageConcealedAudio = `${attributes["matrix.stats.summary.percentageConcealedAudio"]}`;
const opponentUsersInCall = `${attributes["matrix.stats.summary.opponentUsersInCall"]}`;
const opponentDevicesInCall = `${attributes["matrix.stats.summary.opponentDevicesInCall"]}`;
const diffDevicesToPeerConnections = `${attributes["matrix.stats.summary.diffDevicesToPeerConnections"]}`;
const ratioPeerConnectionToDevices = `${attributes["matrix.stats.summary.ratioPeerConnectionToDevices"]}`;
PosthogAnalytics.instance.trackEvent(
{
eventName: "MediaReceived",
callId: span.attributes["matrix.confId"] as string,
mediaReceived: mediaReceived,
audioReceived: audioReceived,
videoReceived: videoReceived,
maxJitter: maxJitter,
maxPacketLoss: maxPacketLoss,
peerConnections: peerConnections,
percentageConcealedAudio: percentageConcealedAudio,
opponentUsersInCall: opponentUsersInCall,
opponentDevicesInCall: opponentDevicesInCall,
diffDevicesToPeerConnections: diffDevicesToPeerConnections,
ratioPeerConnectionToDevices: ratioPeerConnectionToDevices,
},
// Send instantly because the window might be closing
{ send_instantly: true },
);
}
}
}
/**
* Shutdown the processor.
*/
public async shutdown(): Promise<void> {
return Promise.resolve();
}
}

View File

@@ -1,135 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type AttributeValue, type Attributes } from "@opentelemetry/api";
import { hrTimeToMicroseconds } from "@opentelemetry/core";
import {
type SpanProcessor,
type ReadableSpan,
type Span,
} from "@opentelemetry/sdk-trace-base";
const dumpAttributes = (
attr: Attributes,
): {
key: string;
type:
| "string"
| "number"
| "bigint"
| "boolean"
| "symbol"
| "undefined"
| "object"
| "function";
value: AttributeValue | undefined;
}[] =>
Object.entries(attr).map(([key, value]) => ({
key,
type: typeof value,
value,
}));
/**
* Exports spans on demand to the Jaeger JSON format, which can be attached to
* rageshakes and loaded into analysis tools like Jaeger and Stalk.
*/
export class RageshakeSpanProcessor implements SpanProcessor {
private readonly spans: ReadableSpan[] = [];
public async forceFlush(): Promise<void> {}
public onStart(span: Span): void {
this.spans.push(span);
}
public onEnd(): void {}
/**
* Dumps the spans collected so far as Jaeger-compatible JSON.
*/
public dump(): string {
const now = Date.now() * 1000; // Jaeger works in microseconds
const traces = new Map<string, ReadableSpan[]>();
// Organize spans by their trace IDs
for (const span of this.spans) {
const traceId = span.spanContext().traceId;
let trace = traces.get(traceId);
if (trace === undefined) {
trace = [];
traces.set(traceId, trace);
}
trace.push(span);
}
const processId = "p1";
const processes = {
[processId]: {
serviceName: "element-call",
tags: [],
},
warnings: null,
};
return JSON.stringify({
// Honestly not sure what some of these fields mean, I just know that
// they're present in Jaeger JSON exports
total: 0,
limit: 0,
offset: 0,
errors: null,
data: [...traces.entries()].map(([traceId, spans]) => ({
traceID: traceId,
warnings: null,
processes,
spans: spans.map((span) => {
const ctx = span.spanContext();
const startTime = hrTimeToMicroseconds(span.startTime);
// If the span has not yet ended, pretend that it ends now
const duration =
span.duration[0] === -1
? now - startTime
: hrTimeToMicroseconds(span.duration);
return {
traceID: traceId,
spanID: ctx.spanId,
operationName: span.name,
processID: processId,
warnings: null,
startTime,
duration,
references:
span.parentSpanContext?.spanId === undefined
? []
: [
{
refType: "CHILD_OF",
traceID: traceId,
spanID: span.parentSpanContext?.spanId,
},
],
tags: dumpAttributes(span.attributes),
logs: span.events.map((event) => ({
timestamp: hrTimeToMicroseconds(event.time),
// The name of the event is in the "event" field, aparently.
fields: [
...dumpAttributes(event.attributes ?? {}),
{ key: "event", type: "string", value: event.name },
],
})),
};
}),
})),
});
}
public async shutdown(): Promise<void> {}
}

View File

@@ -1,5 +1,6 @@
/*
Copyright 2022-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.
@@ -21,22 +22,25 @@ import {
import styles from "./Button.module.css";
interface MicButtonProps extends ComponentPropsWithoutRef<"button"> {
muted: boolean;
enabled: boolean;
size?: "sm" | "lg";
}
export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
export const MicButton: FC<MicButtonProps> = ({ enabled, ...props }) => {
const { t } = useTranslation();
const Icon = muted ? MicOffSolidIcon : MicOnSolidIcon;
const label = muted
? t("unmute_microphone_button_label")
: t("mute_microphone_button_label");
const Icon = enabled ? MicOnSolidIcon : MicOffSolidIcon;
const label = enabled
? t("mute_microphone_button_label")
: t("unmute_microphone_button_label");
return (
<Tooltip label={label}>
<CpdButton
iconOnly
Icon={Icon}
kind={muted ? "primary" : "secondary"}
kind={enabled ? "primary" : "secondary"}
role="switch"
aria-checked={enabled}
{...props}
/>
</Tooltip>
@@ -44,22 +48,25 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
};
interface VideoButtonProps extends ComponentPropsWithoutRef<"button"> {
muted: boolean;
enabled: boolean;
size?: "sm" | "lg";
}
export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
export const VideoButton: FC<VideoButtonProps> = ({ enabled, ...props }) => {
const { t } = useTranslation();
const Icon = muted ? VideoCallOffSolidIcon : VideoCallSolidIcon;
const label = muted
? t("start_video_button_label")
: t("stop_video_button_label");
const Icon = enabled ? VideoCallSolidIcon : VideoCallOffSolidIcon;
const label = enabled
? t("stop_video_button_label")
: t("start_video_button_label");
return (
<Tooltip label={label}>
<CpdButton
iconOnly
Icon={Icon}
kind={muted ? "primary" : "secondary"}
kind={enabled ? "primary" : "secondary"}
role="switch"
aria-checked={enabled}
{...props}
/>
</Tooltip>
@@ -68,6 +75,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
size: "sm" | "lg";
}
export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
@@ -85,13 +93,19 @@ export const ShareScreenButton: FC<ShareScreenButtonProps> = ({
iconOnly
Icon={ShareScreenSolidIcon}
kind={enabled ? "primary" : "secondary"}
role="switch"
aria-checked={enabled}
{...props}
/>
</Tooltip>
);
};
export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
interface EndCallButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "sm" | "lg";
}
export const EndCallButton: FC<EndCallButtonProps> = ({
className,
...props
}) => {
@@ -110,9 +124,10 @@ export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
);
};
export const SettingsButton: FC<ComponentPropsWithoutRef<"button">> = (
props,
) => {
interface SettingsButtonProps extends ComponentPropsWithoutRef<"button"> {
size?: "sm" | "lg";
}
export const SettingsButton: FC<SettingsButtonProps> = (props) => {
const { t } = useTranslation();
return (

View File

@@ -18,7 +18,8 @@ import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { type MockRTCSession } from "../utils/test";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { initializeWidget } from "../widget";
initializeWidget();
vi.mock("livekit-client/e2ee-worker?worker");
const localIdent = `${localRtcMember.userId}:${localRtcMember.deviceId}`;

View File

@@ -166,6 +166,7 @@ export function ReactionPopupMenu({
interface ReactionToggleButtonProps extends ComponentPropsWithoutRef<"button"> {
identifier: string;
vm: CallViewModel;
size?: "sm" | "lg";
}
export function ReactionToggleButton({

View File

@@ -9,8 +9,8 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="«rbb»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
aria-labelledby="_r_bb_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -43,8 +43,8 @@ exports[`Can fully expand emoji picker 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="«r7m»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
aria-labelledby="_r_7m_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -74,8 +74,8 @@ exports[`Can lower hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="«r36»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
aria-labelledby="_r_36_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="secondary"
data-size="lg"
role="button"
@@ -108,8 +108,8 @@ exports[`Can open menu 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="«r0»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
aria-labelledby="_r_0_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
data-size="lg"
role="button"
@@ -139,8 +139,8 @@ exports[`Can raise hand 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="«r1j»"
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
aria-labelledby="_r_1j_"
class="_button_13vu4_8 raisedButton _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
data-size="lg"
role="button"

View File

@@ -1,5 +1,6 @@
/*
Copyright 2022-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.
@@ -122,7 +123,7 @@ export interface ConfigOptions {
delayed_leave_event_delay_ms?: number;
/**
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
* The time (in milliseconds) after which we consider a delayed event restart http request to have failed.
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
*
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
@@ -163,10 +164,6 @@ export interface ResolvedConfigOptions extends ConfigOptions {
};
};
ssla: string;
media_devices: {
enable_audio: boolean;
enable_video: boolean;
};
app_prompt: boolean;
}
@@ -181,9 +178,5 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
feature_use_device_session_member_events: true,
},
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
media_devices: {
enable_audio: true,
enable_video: true,
},
app_prompt: true,
};

View File

@@ -33,12 +33,38 @@ export interface Controls {
showNativeOutputDevicePicker?: () => void;
}
/**
* Output Audio device when using the controlled audio output mode (mobile).
*/
export interface OutputDevice {
id: string;
name: string;
/**
* `forEarpiece` in an iOS only flag, that will be set on the default speaker device.
* The default speaker device will be used for the earpiece mode by
* using a stereo pan and reducing the volume significantly. (in combination this is similar to a dedicated earpiece mode)
* - on iOS this is true if output is routed to speaker.
* In that case then ElementCalls manually appends an earpiece device with id `EARPIECE_CONFIG_ID` and `{ type: "earpiece" }`
* - on Android this is unused.
*/
forEarpiece?: boolean;
/**
* Is the device the OS earpiece audio configuration?
* - on iOS always undefined
* - on Android true for the `TYPE_BUILTIN_EARPIECE`
*/
isEarpiece?: boolean;
/**
* Is the device the OS default speaker:
* - on iOS always true if output is routed to speaker. In other case iOS on declare a `dummy` id device.
* - on Android true for the `TYPE_BUILTIN_SPEAKER`
*/
isSpeaker?: boolean;
/**
* Is the device the OS default external headset (bluetooth):
* - on iOS always undefined.
* - on Android true for the `TYPE_BLUETOOTH_SCO`
*/
isExternalHeadset?: boolean;
}
@@ -47,8 +73,16 @@ export interface OutputDevice {
*/
export const setPipEnabled$ = new Subject<boolean>();
/**
* Stores the list of available controlled audio output devices.
* This is set when the native code calls `setAvailableAudioDevices` with the list of available audio output devices.
*/
export const availableOutputDevices$ = new Subject<OutputDevice[]>();
/**
* Stores the current audio output device id.
* This is set when the native code calls `setAudioDevice`
*/
export const outputDevice$ = new Subject<string>();
/**
@@ -80,16 +114,41 @@ window.controls = {
setPipEnabled$.next(false);
},
/**
* Reverse engineered:
*
* - on iOS:
* This always a list of one thing. If current route output is speaker it returns
* the single `{"id":"Speaker","name":"Speaker","forEarpiece":true,"isSpeaker":true}` Notice that EC will
* also manually add a virtual earpiece device with id `EARPIECE_CONFIG_ID` and `{ type: "earpiece" }`.
* If the route output is not speaker then it will be `{id: 'dummy', name: 'dummy'}`
*
*
* - on Android:
* This is a list of all available output audio devices. The `id` is the Android AudioDeviceInfo.getId()
* and the `name` is based the Android AudioDeviceInfo.productName (mapped to static strings for known types)
* The `isEarpiece`, `isSpeaker` and `isExternalHeadset` are set based on the Android AudioDeviceInfo.type
* matching the corresponding types for earpiece, speaker and bluetooth headset.
*/
setAvailableAudioDevices(devices: OutputDevice[]): void {
logger.info("setAvailableAudioDevices called from native:", devices);
logger.info(
"[MediaDevices controls] setAvailableAudioDevices called from native:",
devices,
);
availableOutputDevices$.next(devices);
},
setAudioDevice(id: string): void {
logger.info("setAudioDevice called from native", id);
logger.info(
"[MediaDevices controls] setAudioDevice called from native",
id,
);
outputDevice$.next(id);
},
setAudioEnabled(enabled: boolean): void {
logger.info("setAudioEnabled called from native:", enabled);
logger.info(
"[MediaDevices controls] setAudioEnabled called from native:",
enabled,
);
if (!setAudioEnabled$.observed)
throw new Error(
"Output controls are disabled. No setAudioEnabled$ observer",

View File

@@ -6,11 +6,13 @@ Please see LICENSE in the repository root for full details.
*/
import { BaseKeyProvider } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
const logger = rootLogger.getChild("[MatrixKeyProvider]");
export class MatrixKeyProvider extends BaseKeyProvider {
private rtcSession?: MatrixRTCSession;
@@ -40,9 +42,10 @@ export class MatrixKeyProvider extends BaseKeyProvider {
}
private onEncryptionKeyChanged = (
encryptionKey: Uint8Array,
encryptionKey: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
participantId: string,
membershipParts: CallMembershipIdentityParts,
rtcBackendIdentity: string,
): void => {
crypto.subtle
.importKey("raw", encryptionKey, "HKDF", false, [
@@ -53,17 +56,17 @@ export class MatrixKeyProvider extends BaseKeyProvider {
(keyMaterial) => {
this.onSetEncryptionKey(
keyMaterial,
participantId,
rtcBackendIdentity,
encryptionKeyIndex,
);
logger.debug(
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
`Sent new key to livekit room=${this.rtcSession?.room.roomId} participantId=${rtcBackendIdentity} (before hash: ${membershipParts.userId}:${membershipParts.deviceId}) encryptionKeyIndex=${encryptionKeyIndex}`,
);
},
(e) => {
logger.error(
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId=${participantId} encryptionKeyIndex=${encryptionKeyIndex}`,
`Failed to create key material from buffer for livekit room=${this.rtcSession?.room.roomId} participantId before hash=${membershipParts.userId}:${membershipParts.deviceId} encryptionKeyIndex=${encryptionKeyIndex}`,
e,
);
},

View File

@@ -34,8 +34,8 @@ const getRoomSharedKeyLocalStorageKey = (roomId: string): string =>
`room-shared-key-${roomId}`;
/**
* An upto-date shared key for the room. Either from local storage or the value from `setInitialValue`.
* @param roomId
* An up-to-date shared key for the room. Either from local storage or the value from `setInitialValue`.
* @param roomId The room ID we want the shared key for.
* @param setInitialValue The value we get from the URL. The hook will overwrite the local storage value with this.
* @returns [roomSharedKey, setRoomSharedKey] like a react useState hook.
*/

View File

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

View File

@@ -200,8 +200,11 @@ interface Drag {
export type DragCallback = (drag: Drag) => void;
interface LayoutMemoProps<LayoutModel, TileModel, R extends HTMLElement>
extends LayoutProps<LayoutModel, TileModel, R> {
interface LayoutMemoProps<
LayoutModel,
TileModel,
R extends HTMLElement,
> extends LayoutProps<LayoutModel, TileModel, R> {
Layout: ComponentType<LayoutProps<LayoutModel, TileModel, R>>;
}

View File

@@ -51,15 +51,15 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
return (
<div ref={ref} className={styles.layer}>
<Slot
id={model.remote.id}
model={model.remote}
id={model.spotlight.id}
model={model.spotlight}
className={styles.container}
style={{ width: tileWidth, height: tileHeight }}
>
<Slot
className={classNames(styles.slot, styles.local)}
id={model.local.id}
model={model.local}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
data-block-alignment={pipAlignmentValue.block}
data-inline-alignment={pipAlignmentValue.inline}

View File

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

View File

@@ -27,7 +27,13 @@ interface Props<M, R extends HTMLElement> {
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0],
) => void
> | null;
/**
* The width this tile will have once its animations have settled.
*/
targetWidth: number;
/**
* The width this tile will have once its animations have settled.
*/
targetHeight: number;
model: M;
Tile: ComponentType<TileProps<M, R>>;

View File

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

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 298 B

View File

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

Before

Width:  |  Height:  |  Size: 620 B

After

Width:  |  Height:  |  Size: 628 B

View File

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

View File

@@ -16,7 +16,7 @@ import LanguageDetector from "i18next-browser-languagedetector";
import * as Sentry from "@sentry/react";
import { logger } from "matrix-js-sdk/lib/logger";
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill";
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js";
import {
useLocation,
useNavigationType,
@@ -26,9 +26,9 @@ import {
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { ElementCallOpenTelemetry } from "./otel/otel";
import { platform } from "./Platform";
import { isFailure } from "./utils/fetch";
import { initializeWidget } from "./widget";
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
// {
@@ -101,7 +101,6 @@ enum LoadState {
class DependencyLoadStates {
public config: LoadState = LoadState.None;
public sentry: LoadState = LoadState.None;
public openTelemetry: LoadState = LoadState.None;
public allDepsAreLoaded(): boolean {
return !Object.values(this).some((s) => s !== LoadState.Loaded);
@@ -117,13 +116,15 @@ export class Initializer {
}
public static async initBeforeReact(): Promise<void> {
initializeWidget();
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillSegmenter()) {
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
}
if (shouldPolyfillDurationFormat()) {
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force"));
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force.js"));
}
await Promise.all(polyfills);
@@ -266,15 +267,6 @@ export class Initializer {
this.loadStates.sentry = LoadState.Loaded;
}
// OpenTelemetry (also only after config loaded)
if (
this.loadStates.openTelemetry === LoadState.None &&
this.loadStates.config === LoadState.Loaded
) {
ElementCallOpenTelemetry.globalInit();
this.loadStates.openTelemetry = LoadState.Loaded;
}
if (this.loadStates.allDepsAreLoaded()) {
// resolve if there is no dependency that is not loaded
resolve();

View File

@@ -0,0 +1,25 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { StarRatingInput } from "./StarRatingInput";
const meta = {
component: StarRatingInput,
} satisfies Meta<typeof StarRatingInput>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
starCount: 5,
onChange: fn(),
},
};

View File

@@ -28,8 +28,15 @@ import {
mockRemoteParticipant,
mockTrack,
} from "../utils/test";
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
import { initializeWidget } from "../widget";
initializeWidget();
export const TestAudioContextConstructor = vi.fn(
class {
public constructor() {
return testAudioContext;
}
},
);
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;

View File

@@ -15,7 +15,6 @@ import {
type AudioTrackProps,
} from "@livekit/components-react";
import { logger } from "matrix-js-sdk/lib/logger";
import { type ParticipantId } from "matrix-js-sdk/lib/matrixrtc";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
@@ -32,7 +31,7 @@ export interface MatrixAudioRendererProps {
* This list needs to be composed based on the matrixRTC members so that we do not play audio from users
* that are not expected to be in the rtc session (local user is excluded).
*/
validIdentities: ParticipantId[];
validIdentities: string[];
/**
* If set to `true`, mutes all audio tracks rendered by the component.
* @remarks
@@ -79,6 +78,7 @@ export function LivekitRoomAudioRenderer({
.filter((ref) => {
const isValid = validIdentities.includes(ref.participant.identity);
if (!isValid) {
// TODO make sure to also skip the warn logging for the local identity
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
prefixedLogger.warn(
`Audio track ${ref.participant.identity} from ${url} has no matching matrix call member`,
@@ -166,7 +166,11 @@ interface StereoPanAudioTrackProps {
* It main purpose is to remount the AudioTrack component when switching from
* audioContext to normal audio playback.
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
* @param param0
* @param props The component props
* @param props.trackRef The track reference
* @param props.muted If the track should be muted
* @param props.audioContext The audio context to use
* @param props.audioNodes The audio nodes to use
* @returns
*/
function AudioTrackWithAudioNodes({

View File

@@ -0,0 +1,221 @@
/*
Copyright 2025 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 {
beforeEach,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../utils/test-fixtures";
import { ownMemberMock } from "../utils/test";
import { FailToGetOpenIdToken } from "../utils/errors";
const sfuUrl = "https://sfu.example.org";
describe("getSFUConfigWithOpenID", () => {
let matrixClient: MockedObject<OpenIDClientParts>;
beforeEach(() => {
fetchMock.catch(404);
matrixClient = {
getOpenIdToken: vitest.fn(),
getDeviceId: vitest.fn(),
};
});
afterEach(() => {
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
);
void (await fetchMock.flush());
return;
}
expect.fail("Expected test to throw;");
});
it("should try legacy and then new endpoint with delay delegation", async () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
},
);
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
);
void (await fetchMock.flush());
}
const calls = fetchMock.calls();
expect(calls.length).toBe(2);
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
expect(calls[1][1]).toStrictEqual({
body: '{"room":"!example_room_id","device_id":"DEVICE"}',
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
});
it("dont try legacy if endpoint with delay delegation is sucessful", async () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
},
);
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
);
void (await fetchMock.flush());
}
const calls = fetchMock.calls();
expect(calls.length).toBe(1);
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":1000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
});

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