Compare commits

..

40 Commits

Author SHA1 Message Date
Robin
f8a051ee11 Address review feedback 2025-08-20 12:07:06 +02:00
Robin
0ed47c2588 Merge branch 'livekit' into robin/switch-camera-tile 2025-08-14 16:39:08 +02:00
Timo
a733461845 Hangup when last person in call (based on url params) (#3372)
* Introduce condigurable auto leave option

* Read url params for auto leave

* add tests

* rename url param to `autoLeave`

* lint

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

* fix scope in CallViewModel tests

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

* use auto leave in DM case

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

* Make last once leave logic based on matrix user id (was participant id before)

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

* add test for multi device auto leave

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-08 17:15:47 +02:00
Timo
6667fc54c0 Add a fullscreen button that uses the element request Fullscreen browser api (#3447)
* Add a fullscreen button that uses the element request Fullscreen browser api

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

* use body instead of root node

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-08 17:09:25 +02:00
Timo
c0aab96968 Add intents for DM (#3445)
* Add intents for DM

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

* review

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-07 15:16:41 +02:00
Valere Fedronic
610032ad1d Merge pull request #3444 from element-hq/valere/force_jwt_service_before_start
Ensure that the jwt service is called before starting a call
2025-08-06 14:54:24 +02:00
Valere Fedronic
e4d14b4892 Update src/rtcSessionHelpers.ts
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2025-08-06 14:22:10 +02:00
Valere
8509efb48b review 2025-08-06 13:59:53 +02:00
Valere
6dcc44b631 comment 2025-08-06 09:26:07 +02:00
Valere
fce7b6d456 refactor: move warmup to makePreferredLivekitFoci 2025-08-05 17:44:22 +02:00
Valere
ef7c9a166a fix eslint 2025-08-05 15:58:46 +02:00
Valere
dba8a43495 fixup test mock 2025-08-05 15:30:15 +02:00
Valere
1a3e88c19a Ensure that the the jwt service is called before starting a call 2025-08-05 15:13:04 +02:00
renovate[bot]
955c22c8dc Update dependency @vector-im/compound-design-tokens to v6 (#3438)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-05 11:34:24 +02:00
ElementRobot
518039f7c3 Merge pull request #3441 from element-hq/actions/localazy-download
Localazy Download
2025-08-04 17:13:41 +01:00
fkwp
f5094be194 Translations updates 2025-08-04 16:01:34 +00:00
Timo
fd426191af Stop reading deprecated config options (DONT USE: membership_keep_alive_period, membership_server_side_expiry_timeout, key_rotation_on_leave_delay) (#3416)
* Stop reading deprecated config options

* add PR-Breaking-Change as one of the possible PR- prefix labels

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-04 17:59:04 +02:00
renovate[bot]
f9d87ea934 Update dependency livekit-client to v2.15.4 (#3425)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-04 17:56:33 +02:00
Timo
77ba88b76c Set available devices to empty map on safari. Safari does not allow changing output devices so the output device dialog is confusing. (#3426)
* Set available devices to empty map on safari.

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

* better safari check

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

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-04 17:46:56 +02:00
fkwp
8615d3adb0 improve web endpoint routing details (#3440) 2025-08-04 17:14:29 +02:00
Timo
7971ea76cd Dont construct logger before rageshake initialization (#3434)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-04 16:43:08 +02:00
Robin
5d992a398d Merge pull request #3437 from element-hq/fkwp/doc/self-hosting_cleanup_backend_announcement
Cleanup self-hosting guide
2025-08-01 17:47:22 +02:00
fkwp
1aa76925f7 removing nextgen_new_foci_type item from foci array since it introduces confusion 2025-08-01 15:39:58 +02:00
Robin
4888efe2f7 Merge pull request #3436 from robintown/default-timeout
Reset LiveKit connection timeouts to their defaults
2025-08-01 15:13:41 +02:00
Robin
879816ac6d Reset LiveKit connection timeouts to their defaults
I believe that the issue we were originally investigating using these increased timeouts was the fault of my earlier ISP in SW Virginia. I can't recall reproducing the exact issue on other networks.
2025-08-01 14:52:47 +02:00
Robin
6de8820590 Merge pull request #3432 from element-hq/renovate/embedded-package-dependencies
Update embedded package dependencies
2025-08-01 14:03:45 +02:00
Valere Fedronic
415f83ace5 Merge pull request #3428 from element-hq/valere/restricted_room_creation_error
Display a custom error for restricted SFU config error
2025-08-01 13:04:27 +02:00
Robin
c9fb80f702 Migrate away from Sonatype repo for embedded builds
It's been shut down and we can no longer publish to it.
2025-08-01 12:38:41 +02:00
Valere
9bf63cce25 fix the test to properly fail 2025-08-01 11:58:05 +02:00
renovate[bot]
16947e7c39 Update all non-major dependencies (#3433)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-01 11:07:01 +02:00
renovate[bot]
b84c8ab82b Update embedded package dependencies 2025-08-01 02:31:35 +00:00
Valere
078c67bfb2 fix bad copy paste comment 2025-07-31 17:47:13 +02:00
Valere
81555c8d51 add a playwright test 2025-07-31 17:46:09 +02:00
Valere
d294be8bd4 custom error for restricted SFU config error 2025-07-31 17:18:02 +02:00
Timo
51672599ba Patch base-x and undici to versions without known vulnerabilities (#3431)
Signed-off-by: Timo K <toger5@hotmail.de>
2025-07-31 17:16:49 +02:00
Valere Fedronic
2ade9b5c1e Merge pull request #3429 from element-hq/valere/devx/update_dev_config
DevX: update dev backend to support jwt-token v3
2025-07-31 17:15:44 +02:00
Robin
31bb46485f Put a switch camera button on the local user's tile 2025-07-10 15:29:59 -04:00
Robin
f53558cb81 Fix bug in drag handler which breaks buttons inside tiles 2025-07-10 15:29:39 -04:00
Robin
0c194617a3 Add camera switching to the media view model 2025-07-10 15:29:39 -04:00
Robin
7c5336fc40 Remove the switch camera button 2025-07-10 15:29:38 -04:00
52 changed files with 1374 additions and 724 deletions

View File

@@ -9,6 +9,6 @@ jobs:
steps:
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
with:
REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies"
REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies,PR-Breaking-Change"
REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one 'PR-' label"
BANNED_LABELS: "banned"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -145,10 +145,6 @@ server {
{
"type": "livekit",
"livekit_service_url": "https://matrix-rtc-2.example.com/livekit/jwt"
},
{
"type": "nextgen_new_foci_type",
"props_for_nextgen_foci": "val"
}
]
```

View File

@@ -48,6 +48,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| Name | Values | Required for widget | Required for SPA | Description |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesnt provide any. |
| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
@@ -59,7 +60,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
| `intent` | `start_call` or `join_existing` | No, defaults to `start_call` | No, defaults to `start_call` | The intent of the user with respect to the call. e.g. if they clicked a Start Call button, this would be `start_call`. If it was a Join Call button, it would be `join_existing`. |
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |

View File

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

View File

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

View File

@@ -5,8 +5,6 @@
* Please see LICENSE files in the repository root for full details.
*/
import com.vanniktech.maven.publish.SonatypeHost
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.maven.publish)
@@ -27,7 +25,7 @@ android {
}
mavenPublishing {
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
publishToMavenCentral(automaticRelease = true)
signAllPublications()

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Zobrazit možnost sluchátek pro iPhone na všech platformách",
"crypto_version": "Kryptografická verze: {{version}}",
"debug_tile_layout_label": "Ladění rozložení dlaždic",
"device_id": "ID zařízení: {{id}}",
@@ -173,6 +174,7 @@
"devices": {
"camera": "Fotoaparát",
"camera_numbered": "Fotoaparát {{n}}",
"change_device_button": "Změnit zvukové zařízení",
"default": "Výchozí",
"default_named": "Výchozí <2> ({{name}}) </2>",
"microphone": "Mikrofon",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Vis mulighed for iPhone-høretelefon på alle platforme",
"crypto_version": "Krypto-version: {{version}}",
"debug_tile_layout_label": "Fejlfinding af fliselayout",
"device_id": "Enheds-id: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "LiveKit Serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Slå al lyd fra (deltagere, reaktioner, deltagelseslyde)",
"show_connection_stats": "Vis forbindelsesstatistik",
"show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer",
"url_params": "URL-parametre",
@@ -80,7 +82,7 @@
"error": {
"call_is_not_supported": "Opkald er ikke understøttet",
"call_not_found": "Opkald ikke fundet",
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller<1> opret et nyt</1>.</0>",
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller <2> opret et nyt</2>.</0>",
"connection_lost": "Forbindelsen gik tabt",
"connection_lost_description": "Du blev afbrudt fra opkaldet.",
"e2ee_unsupported": "Inkompatibel browser",
@@ -164,12 +166,17 @@
"effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.",
"effect_volume_label": "Lydstyrke for lydeffekter"
},
"background_blur_header": "Baggrund",
"background_blur_label": "Gør videoens baggrund sløret",
"blur_not_supported_by_browser": "(Baggrundssløring understøttes ikke af denne enhed.)",
"developer_tab_title": "Udvikler",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Skift lydenhed",
"default": "Standard",
"default_named": "Standard <2>({{name}})</2>",
"loudspeaker": "Højttaler",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Højttaler",

View File

@@ -82,7 +82,7 @@
"error": {
"call_is_not_supported": "Anrufe werden nicht unterstützt",
"call_not_found": "Anruf nicht gefunden",
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Vergewissern Sie sich, dass Sie den richtigen Link haben, oder <1> erstellen Sie einen neuen</1>. </0>",
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Es sollte geprüft werden, ob der Link korrekt ist, oder <2>ein neuer erstellt werden</2>.</0>",
"connection_lost": "Verbindung verloren",
"connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.",
"e2ee_unsupported": "Inkompatibler Browser",
@@ -94,6 +94,8 @@
"matrix_rtc_focus_missing": "Der Server ist nicht für die Verwendung mit {{brand}} konfiguriert. Bitte den Serveradministrator kontaktieren (Domain: {{domain}}, Fehlercode: {{ errorCode }}).",
"open_elsewhere": "In einem anderen Tab geöffnet",
"open_elsewhere_description": "{{brand}} wurde in einem anderen Tab geöffnet. Wenn das nicht richtig klingt, versuchen Sie, die Seite neu zu laden.",
"room_creation_restricted": "Anruf konnte nicht erstellt werden",
"room_creation_restricted_description": "Das Erstellen von Anrufen ist nur für autorisierte Nutzer möglich. Versuche es später erneut oder kontaktiere deinen Serveradministrator, falls das Problem weiterhin besteht.",
"unexpected_ec_error": "Ein unerwarteter Fehler ist aufgetreten (<0>Fehlercode: </0> <1>{{ errorCode }}</1>). Bitte den Serveradministrator kontaktieren."
},
"group_call_loader": {
@@ -105,6 +107,11 @@
"knock_reject_heading": "Zugriff verweigert",
"reason": "Grund: {{reason}}"
},
"handset": {
"overlay_back_button": "Zurück zum Lautsprechermodus",
"overlay_description": "Nur wenn App im Vordergrund nutzbar",
"overlay_title": "Ohrhörer Modus"
},
"hangup_button_label": "Anruf beenden",
"header_label": "Element Call-Startseite",
"header_participants_label": "Teilnehmende",
@@ -173,9 +180,11 @@
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Audiogerät wechseln",
"default": "Standard",
"default_named": "Standard<2> ({{name}} )</2>",
"handset": "Ohrhörer",
"loudspeaker": "Lautsprecher",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon{{n}}",
"speaker": "Lautsprecher",

View File

@@ -94,6 +94,8 @@
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"room_creation_restricted": "Failed to create call",
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
},
"group_call_loader": {

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Näita iPhone'i kuulari valikut kõikidel platvormidel",
"crypto_version": "Krüptoteekide versioon: {{version}}",
"debug_tile_layout_label": "Meediapaanide paigutus",
"device_id": "Seadme tunnus: {{id}}",
@@ -81,7 +82,7 @@
"error": {
"call_is_not_supported": "Kõne pole toetatud",
"call_not_found": "Kõnet ei leidu",
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <1>loo uus</1>.</0>",
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <2>loo uus</2>.</0>",
"connection_lost": "Ühendus on katkenud",
"connection_lost_description": "Sinu ühendus selle kõnega on katkenud.",
"e2ee_unsupported": "Mitteühilduv brauser",
@@ -93,6 +94,8 @@
"matrix_rtc_focus_missing": "See server pole seadistatud töötama rakendusega {{brand}}. Palun võta ühendust serveri halduriga (domeen: {{domain}}, veakood: {{ errorCode }}).",
"open_elsewhere": "Avatud teisel vahekaardil",
"open_elsewhere_description": "{{brand}} on avatud teisel vahekaardil. Kui see ei tundu olema õige, proovi selle lehe uuesti laadimist.",
"room_creation_restricted": "Kõne loomine ei õnnestunud",
"room_creation_restricted_description": "Kõne loomine võib olla lubatud ainult volitatud kasutajatele. Proovi hiljem uuesti või probleemi püsimisel võta ühendust oma serveri haldajaga.",
"unexpected_ec_error": "Tekkis ootamatu viga (<0>Veakood:</0> <1>{{ errorCode }}</1>). Palun võta ühendust serveri haldajaga."
},
"group_call_loader": {
@@ -104,6 +107,10 @@
"knock_reject_heading": "Liitumine pole lubatud",
"reason": "Põhjus"
},
"handset": {
"overlay_back_button": "Tagasi esineja vaatesse",
"overlay_description": "See toimib vaid rakenduse kasutamise ajal"
},
"hangup_button_label": "Lõpeta kõne",
"header_label": "Avaleht: Element Call",
"header_participants_label": "Osalejad",
@@ -172,8 +179,10 @@
"devices": {
"camera": "Kaamera",
"camera_numbered": "Kaamera {{n}}",
"change_device_button": "Muuda heliseadet",
"default": "Vaikimisi",
"default_named": "Vaikimisi <2>({{name}})</2>",
"loudspeaker": "Valjuhääldi",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Kõlar",

View File

@@ -61,6 +61,7 @@
"video": "Видео"
},
"developer_mode": {
"always_show_iphone_earpiece": "Показать опцию наушников для iPhone на всех платформах",
"crypto_version": "Версия криптографии: {{version}}",
"debug_tile_layout_label": "Отладка расположения плиток",
"device_id": "Идентификатор устройства: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "Информация о сервере LiveKit",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Отключить все звуки (участников, реакции, звуки присоединения)",
"show_connection_stats": "Показать статистику подключений",
"show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками",
"url_params": "Параметры URL-адреса",
@@ -172,6 +174,7 @@
"devices": {
"camera": "Камера",
"camera_numbered": "Камера {{n}}",
"change_device_button": "Изменить аудиоустройство",
"default": "По умолчанию",
"default_named": "По умолчанию <2>({{name}})</2>",
"microphone": "Микрофон",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Zobraziť možnosť slúchadla iPhone na všetkých platformách",
"crypto_version": "Krypto verzia: {{version}}",
"debug_tile_layout_label": "Ladenie rozloženia dlaždíc",
"device_id": "ID zariadenia: {{id}}",
@@ -81,7 +82,7 @@
"error": {
"call_is_not_supported": "Hovor nie je podporovaný",
"call_not_found": "Hovor nebol nájdený",
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <1> vytvorte nový</1>. </0>",
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <2>vytvorte nový</2>.</0>",
"connection_lost": "Strata spojenia",
"connection_lost_description": "Boli ste odpojení od hovoru.",
"e2ee_unsupported": "Nekompatibilný prehliadač",
@@ -173,8 +174,10 @@
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Zmeniť zvukové zariadenie",
"default": "Predvolené",
"default_named": "Predvolené <2>({{name}})</2>",
"loudspeaker": "Reproduktor",
"microphone": "Mikrofón",
"microphone_numbered": "Mikrofón {{n}}",
"speaker": "Reproduktor",

View File

@@ -61,6 +61,7 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Visa iPhone-hörsnäckealternativ på alla plattformar",
"crypto_version": "Kryptoversion: {{version}}",
"debug_tile_layout_label": "Felsök panelarrangemang",
"device_id": "Enhets-ID: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "LiveKit-serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix-ID: {{id}}",
"mute_all_audio": "Tysta allt ljud (deltagare, reaktioner, anslutningsljud)",
"show_connection_stats": "Visa anslutningsstatistik",
"show_non_member_tiles": "Visa paneler för media som inte är medlemmar",
"url_params": "URL-parametrar",
@@ -164,10 +166,14 @@
"effect_volume_description": "Justera volymen vid vilken reaktioner och handuppräckningseffekter spelas",
"effect_volume_label": "Ljudeffektsvolym"
},
"background_blur_header": "Bakgrund",
"background_blur_label": "Gör bakgrunden i videon suddig",
"blur_not_supported_by_browser": "(Bakgrundssuddighet stöds inte av den här enheten.)",
"developer_tab_title": "Utvecklare",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Byt ljudenhet",
"default": "Förval",
"default_named": "Förval <2>({{name}})</2>",
"microphone": "Mikrofon",

View File

@@ -49,7 +49,7 @@
"@mediapipe/tasks-vision": "^0.10.18",
"@opentelemetry/api": "^1.4.0",
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.202.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",
@@ -81,7 +81,7 @@
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^5.0.0",
"@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-web": "^8.0.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^3.0.0",

View File

@@ -72,3 +72,56 @@ test("Should automatically retry non fatal JWT errors", async ({
await hasRetriedPromise;
await expect(page.getByTestId("video").first()).toBeVisible();
});
test("Should show error screen if call creation is restricted", async ({
page,
}) => {
await page.goto("/");
// We need the socket connection to fail, but this cannot be done by using the websocket route.
// Instead, we will trick the app by returning a bad URL for the SFU that will not be reachable an error out.
await page.route(
"**/matrix-rtc.m.localhost/livekit/jwt/sfu/get",
async (route) =>
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
url: "wss://badurltotricktest/livekit/sfu",
jwt: "FAKE",
}),
}),
);
// Then if the socket connection fails, livekit will try to validate the token!
// Livekit will not auto_create anymore and will return a 404 error.
await page.route(
"**/badurltotricktest/livekit/sfu/rtc/validate?**",
async (route) =>
await route.fulfill({
status: 404,
contentType: "text/plain",
body: "requested room does not exist",
}),
);
await page.pause();
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
// Join the call
await page.getByTestId("lobby_joinCall").click();
await page.pause();
// Should fail
await expect(page.getByText("Failed to create call")).toBeVisible();
await expect(
page.getByText(
/Call creation might be restricted to authorized users only/,
),
).toBeVisible();
});

View File

@@ -0,0 +1,75 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { sleep } from "matrix-js-sdk/lib/utils.js";
test("Should request JWT token before starting the call", async ({ page }) => {
await page.goto("/");
let sfGetTimestamp = 0;
let sendStateEventTimestamp = 0;
await page.route(
"**/matrix-rtc.m.localhost/livekit/jwt/sfu/get",
async (route) => {
await sleep(2000); // Simulate very slow request
await route.continue();
sfGetTimestamp = Date.now();
},
);
await page.route(
"**/state/org.matrix.msc3401.call.member/**",
async (route) => {
await route.continue();
sendStateEventTimestamp = Date.now();
},
);
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
// Join the call
await page.getByTestId("lobby_joinCall").click();
await page.waitForTimeout(4000);
// Ensure that the call is connected
await page
.locator("div")
.filter({ hasText: /^HelloCall$/ })
.click();
expect(sfGetTimestamp).toBeGreaterThan(0);
expect(sendStateEventTimestamp).toBeGreaterThan(0);
expect(sfGetTimestamp).toBeLessThan(sendStateEventTimestamp);
});
test("Error when pre-warming the focus are caught by the ErrorBoundary", async ({
page,
}) => {
await page.goto("/");
await page.route("**/openid/request_token", async (route) => {
await route.fulfill({
status: 418, // Simulate an error not retryable
});
});
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("HelloCall");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("John Doe");
await page.getByTestId("home_go").click();
// Join the call
await page.getByTestId("lobby_joinCall").click();
// Should fail
await expect(page.getByText("Something went wrong")).toBeVisible();
});

View File

@@ -100,5 +100,5 @@ 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(1);
expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
});

View File

@@ -23,9 +23,10 @@ interface RoomIdentifier {
}
export enum UserIntent {
// TODO: add DM vs room call
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallDM = "start_call_dm",
JoinExistingCallDM = "join_existing_dm",
Unknown = "unknown",
}
@@ -209,6 +210,12 @@ export interface UrlConfiguration {
* Whether and what type of notification EC should send, when the user joins the call.
*/
sendNotificationType?: RTCNotificationType;
/**
* Whether the app should automatically leave the call when there
* is no one left in the call.
* This is one part to make the call matrixRTC session behave like a telephone call.
*/
autoLeaveWhenOthersLeft: boolean;
}
// If you need to add a new flag to this interface, prefer a name that describes
@@ -276,10 +283,16 @@ class ParamParser {
];
}
/**
* Returns true if the flag exists and is not "false".
*/
public getFlagParam(name: string, defaultValue = false): boolean {
const param = this.getParam(name);
return param === null ? defaultValue : param !== "false";
}
/**
* Returns the value of the flag if it exists, or undefined if it does not.
*/
public getFlag(name: string): boolean | undefined {
const param = this.getParam(name);
return param !== null ? param !== "false" : undefined;
@@ -333,6 +346,7 @@ export const getUrlParams = (
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification" as RTCNotificationType,
autoLeaveWhenOthersLeft: false,
};
switch (intent) {
case UserIntent.StartNewCall:
@@ -347,6 +361,20 @@ export const getUrlParams = (
skipLobby: false,
};
break;
case UserIntent.StartNewCallDM:
intentPreset = {
...inAppDefault,
skipLobby: true,
autoLeaveWhenOthersLeft: true,
};
break;
case UserIntent.JoinExistingCallDM:
intentPreset = {
...inAppDefault,
skipLobby: true,
autoLeaveWhenOthersLeft: true,
};
break;
// Non widget usecase defaults
default:
intentPreset = {
@@ -362,6 +390,7 @@ export const getUrlParams = (
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false,
};
}
@@ -413,12 +442,13 @@ export const getUrlParams = (
"ring",
"notification",
]),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
};
return {
...properties,
...intentPreset,
...pickBy(configuration, (v) => v !== undefined),
...pickBy(configuration, (v?: unknown) => v !== undefined),
};
};

View File

@@ -16,7 +16,6 @@ import {
EndCallIcon,
ShareScreenSolidIcon,
SettingsSolidIcon,
SwitchCameraSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./Button.module.css";
@@ -67,23 +66,6 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
);
};
export const SwitchCameraButton: FC<ComponentPropsWithoutRef<"button">> = (
props,
) => {
const { t } = useTranslation();
return (
<Tooltip label={t("switch_camera")}>
<CpdButton
iconOnly
Icon={SwitchCameraSolidIcon}
kind="secondary"
{...props}
/>
</Tooltip>
);
};
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
enabled: boolean;
}

View File

@@ -114,16 +114,12 @@ export interface ConfigOptions {
* when someone leaves a call.
*/
wait_for_key_rotation_ms?: number;
/** @deprecated use wait_for_key_rotation_ms instead */
key_rotation_on_leave_delay?: number;
/**
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
* that the server waits before sending the leave MatrixRTC membership event.
*/
delayed_leave_event_delay_ms?: number;
/** @deprecated use delayed_leave_event_delay_ms instead */
membership_server_side_expiry_timeout?: number;
/**
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
@@ -141,8 +137,6 @@ export interface ConfigOptions {
* messages to the server by restarting the timer for the delayed leave event.
*/
delayed_leave_event_restart_ms?: number;
/** @deprecated use delayed_leave_event_restart_ms instead */
membership_keep_alive_period?: number;
/**
* How long we wait before retrying after a network error on any of the requests.

View File

@@ -6,9 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { Subject } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
const logger = rootLogger.getChild("[controlled-output]");
import { logger } from "matrix-js-sdk/lib/logger";
export interface Controls {
canEnterPip(): boolean;

View File

@@ -61,7 +61,12 @@ const TileWrapper_ = memo(
useDrag((state) => onDrag?.current!(id, state), {
target: ref,
filterTaps: true,
preventScroll: true,
// Previous designs, which allowed tiles to be dragged and dropped around
// the scrolling grid, required us to set preventScroll to true here. But
// our designs no longer call for this, and meanwhile there's a bug in
// use-gesture that causes filterTaps + preventScroll to break buttons
// within tiles (like the 'switch camera' button) on mobile.
// https://github.com/pmndrs/use-gesture/issues/593
});
return (

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M4 14C4.55228 14 5 14.4477 5 15V19H9C9.55228 19 10 19.4477 10 20C10 20.5523 9.55228 21 9 21H3V15C3 14.4477 3.44772 14 4 14Z"/>
<path d="M20 14C20.5523 14 21 14.4477 21 15V21H15C14.4477 21 14 20.5523 14 20C14 19.4477 14.4477 19 15 19H19V15C19 14.4477 19.4477 14 20 14Z" />
<path d="M9 3C9.55228 3 10 3.44772 10 4C10 4.55228 9.55228 5 9 5H5V9C5 9.55228 4.55228 10 4 10C3.44772 10 3 9.55228 3 9V3H9Z" />
<path d="M21 9C21 9.55228 20.5523 10 20 10C19.4477 10 19 9.55228 19 9V5H15C14.4477 5 14 4.55228 14 4C14 3.44772 14.4477 3 15 3H21V9Z" />
</svg>

After

Width:  |  Height:  |  Size: 658 B

View File

@@ -0,0 +1,6 @@
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M10 20C10 20.5523 9.55228 21 9 21C8.44772 21 8 20.5523 8 20V16H4C3.44772 16 3 15.5523 3 15C3 14.4477 3.44772 14 4 14H10V20Z" />
<path d="M20 14C20.5523 14 21 14.4477 21 15C21 15.5523 20.5523 16 20 16H16V20C16 20.5523 15.5523 21 15 21C14.4477 21 14 20.5523 14 20V14H20Z" />
<path d="M9 3C9.55228 3 10 3.44772 10 4V10H4C3.44772 10 3 9.55228 3 9C3 8.44772 3.44772 8 4 8H8V4C8 3.44772 8.44772 3 9 3Z" />
<path d="M15 3C15.5523 3 16 3.44772 16 4V8H20C20.5523 8 21 8.44772 21 9C21 9.55228 20.5523 10 20 10H14V4C14 3.44772 14.4477 3 15 3Z" />
</svg>

After

Width:  |  Height:  |  Size: 656 B

View File

@@ -14,13 +14,12 @@ import {
type AudioTrackProps,
} from "@livekit/components-react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls";
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
export interface MatrixAudioRendererProps {
/**
* The list of participants to render audio for.
@@ -72,7 +71,7 @@ export function MatrixAudioRenderer({
const logInvalid = (identity: string, validIdentities: Set<string>): void => {
if (loggedInvalidIdentities.current.has(identity)) return;
logger.warn(
`Audio track ${identity} has no matching matrix call member`,
`[MatrixAudioRenderer] Audio track ${identity} has no matching matrix call member`,
`current members: ${Array.from(validIdentities.values())}`,
`track will not get rendered`,
);
@@ -102,7 +101,7 @@ export function MatrixAudioRenderer({
useEffect(() => {
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
logger.debug(
`All audio tracks have a matching matrix call member identity.`,
`[MatrixAudioRenderer] All audio tracks have a matching matrix call member identity.`,
);
loggedInvalidIdentities.current.clear();
}
@@ -182,7 +181,7 @@ interface StereoPanAudioTrackProps {
/**
* This wraps `livekit.AudioTrack` to allow adding audio nodes to a track.
* It main purpose is to remount the AudioTrack component when switching from
* audiooContext to normal audio playback.
* audioContext to normal audio playback.
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
* @param param0
* @returns
@@ -202,7 +201,7 @@ function AudioTrackWithAudioNodes({
const [trackReady, setTrackReady] = useReactiveState(
() => false,
// We only want the track to reset once both (audioNodes and audioContext) are set.
// for unsetting the audioContext its enough if one of the the is undefined.
// for unsetting the audioContext its enough if one of the two is undefined.
[audioContext && audioNodes],
);

View File

@@ -22,17 +22,11 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import {
ElementCallError,
InsufficientCapacityError,
SFURoomCreationRestrictedError,
UnknownCallError,
} from "../utils/errors.ts";
import { AbortHandle } from "../utils/abortHandle.ts";
declare global {
interface Window {
peerConnectionTimeout?: number;
websocketTimeout?: number;
}
}
/*
* Additional values for states that a call can be in, beyond what livekit
* provides in ConnectionState. Also reconnects the call if the SFU Config
@@ -169,12 +163,7 @@ async function connectAndPublish(
try {
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
// Due to stability issues on Firefox we are testing the effect of different
// timeouts, and allow these values to be set through the console
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
websocketTimeout: window.websocketTimeout ?? 45000,
});
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt);
logger.info(`[Lifecycle] ... connected to livekit room`);
} catch (e) {
logger.error("[Lifecycle] Failed to connect", e);
@@ -184,11 +173,19 @@ async function connectAndPublish(
// participant limits.
// LiveKit Cloud uses 429 for connection limits.
// Either way, all these errors can be explained as "insufficient capacity".
if (
e instanceof ConnectionError &&
(e.status === 503 || e.status === 200 || e.status === 429)
)
throw new InsufficientCapacityError();
if (e instanceof ConnectionError) {
if (e.status === 503 || e.status === 200 || e.status === 429) {
throw new InsufficientCapacityError();
}
if (e.status === 404) {
// error msg is "Could not establish signal connection: requested room does not exist"
// The room does not exist. There are two different modes of operation for the SFU:
// - the room is created on the fly when connecting (livekit `auto_create` option)
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
// In the first case there will not be a 404, so we are in the second case.
throw new SFURoomCreationRestrictedError();
}
}
throw e;
}

View File

@@ -60,7 +60,7 @@ export function CallEventAudioRenderer({
const audioEngineRef = useLatest(audioEngineCtx);
useEffect(() => {
const joinSub = vm.memberChanges$
const joinSub = vm.participantChanges$
.pipe(
filter(
({ joined, ids }) =>
@@ -72,7 +72,7 @@ export function CallEventAudioRenderer({
void audioEngineRef.current?.playSound("join");
});
const leftSub = vm.memberChanges$
const leftSub = vm.participantChanges$
.pipe(
filter(
({ ids, left }) =>

View File

@@ -166,7 +166,11 @@ export const GroupCallView: FC<Props> = ({
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const { perParticipantE2EE, returnToLobby } = useUrlParams();
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
const [useExperimentalToDeviceTransport] = useSetting(
@@ -174,7 +178,6 @@ export const GroupCallView: FC<Props> = ({
);
// Save the password once we start the groupCallView
const { password: passwordFromUrl } = useUrlParams();
useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]);

View File

@@ -115,7 +115,6 @@ Please see LICENSE in the repository root for full details.
@media (max-width: 340px) {
.invite,
.switchCamera,
.shareScreen {
display: none;
}

View File

@@ -25,7 +25,7 @@ import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { useObservable } from "observable-hooks";
import { useObservable, useSubscription } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import {
@@ -44,7 +44,6 @@ import {
ShareScreenButton,
SettingsButton,
ReactionToggleButton,
SwitchCameraButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type HeaderStyle, useUrlParams } from "../UrlParams";
@@ -94,7 +93,6 @@ import {
useReactionsSender,
} from "../reactions/useReactionsSender";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { useSwitchCamera } from "./useSwitchCamera";
import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
@@ -140,17 +138,17 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useEffect(() => {
logger.info(
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
);
return (): void => {
logger.info(
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
);
livekitRoom
?.disconnect()
.then(() => {
logger.info(
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`,
`[Lifecycle] Disconnected from livekit room, state:${livekitRoom?.state}`,
);
})
.catch((e) => {
@@ -159,6 +157,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
}, [livekitRoom]);
const { autoLeaveWhenOthersLeft } = useUrlParams();
useEffect(() => {
if (livekitRoom !== undefined) {
const reactionsReader = new ReactionsReader(props.rtcSession);
@@ -166,7 +166,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.rtcSession,
livekitRoom,
mediaDevices,
props.e2eeSystem,
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
},
connStateObservable$,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
@@ -183,6 +186,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices,
props.e2eeSystem,
connStateObservable$,
autoLeaveWhenOthersLeft,
]);
if (livekitRoom === undefined || vm === null) return null;
@@ -312,7 +316,7 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const switchCamera = useSwitchCamera(vm.localVideo$);
useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave);
// Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported
@@ -669,15 +673,6 @@ export const InCallView: FC<InCallViewProps> = ({
data-testid="incall_videomute"
/>,
);
if (switchCamera !== null)
buttons.push(
<SwitchCameraButton
key="switch_camera"
className={styles.switchCamera}
onClick={switchCamera}
onTouchEnd={onControlsTouchEnd}
/>,
);
if (canScreenshare && !hideScreensharing) {
buttons.push(
<ShareScreenButton

View File

@@ -24,8 +24,7 @@ import {
type LocalVideoTrack,
Track,
} from "livekit-client";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { map } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { useNavigate } from "react-router-dom";
import inCallStyles from "./InCallView.module.css";
@@ -38,7 +37,6 @@ import {
EndCallButton,
MicButton,
SettingsButton,
SwitchCameraButton,
VideoButton,
} from "../button/Button";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
@@ -47,7 +45,6 @@ import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link";
import { useMediaDevices } from "../MediaDevicesContext";
import { useInitial } from "../useInitial";
import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera";
import {
useTrackProcessor,
useTrackProcessorSync,
@@ -195,12 +192,6 @@ export const LobbyView: FC<Props> = ({
}, [devices, videoInputId, videoTrack]);
useTrackProcessorSync(videoTrack);
const showSwitchCamera = useShowSwitchCamera(
useObservable(
(inputs$) => inputs$.pipe(map(([video]) => video)),
[videoTrack],
),
);
// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
@@ -257,9 +248,6 @@ export const LobbyView: FC<Props> = ({
onClick={onVideoPress}
disabled={muteStates.video.setEnabled === null}
/>
{showSwitchCamera && (
<SwitchCameraButton onClick={showSwitchCamera} />
)}
<SettingsButton onClick={openSettings} />
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
</div>

View File

@@ -1,93 +0,0 @@
/*
Copyright 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 {
fromEvent,
map,
merge,
type Observable,
of,
startWith,
switchMap,
} from "rxjs";
import {
facingModeFromLocalTrack,
type LocalVideoTrack,
TrackEvent,
} from "livekit-client";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { useMediaDevices } from "../MediaDevicesContext";
import { platform } from "../Platform";
import { useLatest } from "../useLatest";
/**
* Determines whether the user should be shown a button to switch their camera,
* producing a callback if so.
*/
export function useSwitchCamera(
video$: Observable<LocalVideoTrack | null>,
): (() => void) | null {
const mediaDevices = useMediaDevices();
const setVideoInput = useLatest(mediaDevices.videoInput.select);
// Produce an observable like the input 'video' observable, except make it
// emit whenever the track is muted or the device changes
const videoTrack$: Observable<LocalVideoTrack | null> = useObservable(
(inputs$) =>
inputs$.pipe(
switchMap(([video$]) => video$),
switchMap((video) => {
if (video === null) return of(null);
return merge(
fromEvent(video, TrackEvent.Restarted).pipe(
startWith(null),
map(() => video),
),
fromEvent(video, TrackEvent.Muted).pipe(map(() => null)),
);
}),
),
[video$],
);
const switchCamera$: Observable<(() => void) | null> = useObservable(
(inputs$) =>
platform === "desktop"
? of(null)
: inputs$.pipe(
switchMap(([track$]) => track$),
map((track) => {
if (track === null) return null;
const facingMode = facingModeFromLocalTrack(track).facingMode;
// If the camera isn't front or back-facing, don't provide a switch
// camera shortcut at all
if (facingMode !== "user" && facingMode !== "environment")
return null;
// Restart the track with a camera facing the opposite direction
return (): void =>
void track
.restartTrack({
facingMode: facingMode === "user" ? "environment" : "user",
})
.then(() => {
// Inform the MediaDeviceContext which camera was chosen
const deviceId =
track.mediaStreamTrack.getSettings().deviceId;
if (deviceId !== undefined) setVideoInput.current(deviceId);
})
.catch((e) =>
logger.error("Failed to switch camera", facingMode, e),
);
}),
),
[videoTrack$],
);
return useObservableEagerState(switchCamera$);
}

View File

@@ -70,6 +70,12 @@ test("It joins the correct Session", async () => {
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
@@ -195,6 +201,12 @@ test("It should not fail with configuration error if homeserver config has livek
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],

View File

@@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import {
isLivekitFocus,
isLivekitFocusConfig,
type LivekitFocus,
type LivekitFocusActive,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
@@ -20,6 +20,7 @@ import { Config } from "./config/Config";
import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget";
import { MatrixRTCFocusMissingError } from "./utils/errors";
import { getUrlParams } from "./UrlParams";
import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts";
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
@@ -46,6 +47,9 @@ async function makePreferredLivekitFoci(
preferredFoci.push(focusInUse);
}
// Warm up the first focus we owned, to ensure livekit room is created before any state event sent.
let toWarmUp: LivekitFocus | undefined;
// Prioritize the .well-known/matrix/client, if available, over the configured SFU
const domain = rtcSession.room.client.getDomain();
if (domain) {
@@ -55,18 +59,17 @@ async function makePreferredLivekitFoci(
FOCI_WK_KEY
];
if (Array.isArray(wellKnownFoci)) {
preferredFoci.push(
...wellKnownFoci
.filter((f) => !!f)
.filter(isLivekitFocusConfig)
.map((wellKnownFocus) => {
logger.log(
"Adding livekit focus from well known: ",
wellKnownFocus,
);
return { ...wellKnownFocus, livekit_alias: livekitAlias };
}),
);
const validWellKnownFoci = wellKnownFoci
.filter((f) => !!f)
.filter(isLivekitFocusConfig)
.map((wellKnownFocus) => {
logger.log("Adding livekit focus from well known: ", wellKnownFocus);
return { ...wellKnownFocus, livekit_alias: livekitAlias };
});
if (validWellKnownFoci.length > 0) {
toWarmUp = validWellKnownFoci[0];
}
preferredFoci.push(...validWellKnownFoci);
}
}
@@ -77,10 +80,15 @@ async function makePreferredLivekitFoci(
livekit_service_url: urlFromConf,
livekit_alias: livekitAlias,
};
toWarmUp = toWarmUp ?? focusFormConf;
logger.log("Adding livekit focus from config: ", focusFormConf);
preferredFoci.push(focusFormConf);
}
if (toWarmUp) {
// this will call the jwt/sfu/get endpoint to pre create the livekit room.
await getSFUConfigWithOpenID(rtcSession.room.client, toWarmUp);
}
if (preferredFoci.length === 0)
throw new MatrixRTCFocusMissingError(domain ?? "");
return Promise.resolve(preferredFoci);
@@ -123,17 +131,13 @@ export async function enterRTCSession(
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms ??
matrixRtcSessionConfig?.membership_keep_alive_period,
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms ??
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay:
matrixRtcSessionConfig?.wait_for_key_rotation_ms ??
matrixRtcSessionConfig?.key_rotation_on_leave_delay,
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport,

View File

@@ -32,7 +32,11 @@ import {
} from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { CallViewModel, type Layout } from "./CallViewModel";
import {
CallViewModel,
type CallViewModelOptions,
type Layout,
} from "./CallViewModel";
import {
mockLivekitRoom,
mockLocalParticipant,
@@ -71,6 +75,7 @@ import {
local,
localId,
localRtcMember,
localRtcMemberDevice2,
} from "../utils/test-fixtures";
import { ObservableScope } from "./ObservableScope";
import { MediaDevices } from "./MediaDevices";
@@ -231,6 +236,10 @@ function withCallViewModel(
vm: CallViewModel,
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
) => void,
options: CallViewModelOptions = {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
autoLeaveWhenOthersLeft: false,
},
): void {
const room = mockMatrixRoom({
client: {
@@ -281,9 +290,7 @@ function withCallViewModel(
rtcSession as unknown as MatrixRTCSession,
liveKitRoom,
mediaDevices,
{
kind: E2eeType.PER_PARTICIPANT,
},
options,
connectionState$,
raisedHands$,
new BehaviorSubject({}),
@@ -978,7 +985,7 @@ test("should strip RTL characters from displayname", () => {
});
it("should rank raised hands above video feeds and below speakers and presenters", () => {
withTestScheduler(({ schedule, expectObservable }) => {
withTestScheduler(({ schedule, expectObservable, behavior }) => {
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "ab";
@@ -1037,6 +1044,176 @@ it("should rank raised hands above video feeds and below speakers and presenters
});
});
function nooneEverThere$<T>(
hot: (marbles: string, values: Record<string, T[]>) => Observable<T[]>,
): Observable<T[]> {
return hot("a-b-c-d", {
a: [], // Start empty
b: [], // Alice joins
c: [], // Alice still there
d: [], // Alice leaves
});
}
function participantJoinLeave$(
hot: (
marbles: string,
values: Record<string, RemoteParticipant[]>,
) => Observable<RemoteParticipant[]>,
): Observable<RemoteParticipant[]> {
return hot("a-b-c-d", {
a: [], // Start empty
b: [aliceParticipant], // Alice joins
c: [aliceParticipant], // Alice still there
d: [], // Alice leaves
});
}
function rtcMemberJoinLeave$(
hot: (
marbles: string,
values: Record<string, CallMembership[]>,
) => Observable<CallMembership[]>,
): Observable<CallMembership[]> {
return hot("a-b-c-d", {
a: [], // Start empty
b: [aliceRtcMember], // Alice joins
c: [aliceRtcMember], // Alice still there
d: [], // Alice leaves
});
}
test("allOthersLeft$ emits only when someone joined and then all others left", () => {
withTestScheduler(({ hot, expectObservable, scope }) => {
// Test scenario 1: No one ever joins - should only emit initial false and never emit again
withCallViewModel(
scope.behavior(nooneEverThere$(hot), []),
scope.behavior(nooneEverThere$(hot), []),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
expectObservable(vm.allOthersLeft$).toBe("n------", { n: false });
},
);
});
});
test("allOthersLeft$ emits true when someone joined and then all others left", () => {
withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel(
scope.behavior(participantJoinLeave$(hot), []),
scope.behavior(rtcMemberJoinLeave$(hot), []),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
expectObservable(vm.allOthersLeft$).toBe(
"n-----u", // false initially, then at frame 6: true then false emissions in same frame
{ n: false, u: true }, // map(() => {})
);
},
);
});
});
test("autoLeaveWhenOthersLeft$ emits only when autoLeaveWhenOthersLeft option is enabled", () => {
withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel(
scope.behavior(participantJoinLeave$(hot), []),
scope.behavior(rtcMemberJoinLeave$(hot), []),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe(
"------e", // false initially, then at frame 6: true then false emissions in same frame
{ e: undefined },
);
},
{
autoLeaveWhenOthersLeft: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("autoLeaveWhenOthersLeft$ never emits autoLeaveWhenOthersLeft option is enabled but no-one is there", () => {
withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel(
scope.behavior(nooneEverThere$(hot), []),
scope.behavior(nooneEverThere$(hot), []),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
},
{
autoLeaveWhenOthersLeft: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("autoLeaveWhenOthersLeft$ doesn't emit when autoLeaveWhenOthersLeft option is disabled and all others left", () => {
withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel(
scope.behavior(participantJoinLeave$(hot), []),
scope.behavior(rtcMemberJoinLeave$(hot), []),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("-------");
},
{
autoLeaveWhenOthersLeft: false,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("autoLeaveWhenOthersLeft$ doesn't emits when autoLeaveWhenOthersLeft option is enabled and all others left", () => {
withTestScheduler(({ hot, expectObservable, scope }) => {
withCallViewModel(
scope.behavior(
hot("a-b-c-d", {
a: [], // Alone
b: [aliceParticipant], // Alice joins
c: [aliceParticipant],
d: [], // Local joins with a second device
}),
[], //Alice leaves
),
scope.behavior(
hot("a-b-c-d", {
a: [localRtcMember], // Start empty
b: [localRtcMember, aliceRtcMember], // Alice joins
c: [localRtcMember, aliceRtcMember, localRtcMemberDevice2], // Alice still there
d: [localRtcMember, localRtcMemberDevice2], // The second Alice leaves
}),
[],
),
of(ConnectionState.Connected),
new Map(),
mockMediaDevices({}),
(vm) => {
expectObservable(vm.autoLeaveWhenOthersLeft$).toBe("------e", {
e: undefined,
});
},
{
autoLeaveWhenOthersLeft: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("audio output changes when toggling earpiece mode", () => {
withTestScheduler(({ schedule, expectObservable }) => {
getUrlParams.mockReturnValue({ controlledAudioDevices: true });

View File

@@ -13,10 +13,8 @@ import {
import {
type Room as LivekitRoom,
type LocalParticipant,
LocalVideoTrack,
ParticipantEvent,
type RemoteParticipant,
Track,
} from "livekit-client";
import { RoomStateEvent, type Room, type RoomMember } from "matrix-js-sdk";
import {
@@ -60,7 +58,6 @@ import {
import {
LocalUserMediaViewModel,
type MediaViewModel,
observeTrackReference$,
RemoteUserMediaViewModel,
ScreenShareViewModel,
type UserMediaViewModel,
@@ -96,6 +93,10 @@ import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type MediaDevices } from "./MediaDevices";
import { type Behavior } from "./Behavior";
export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem;
autoLeaveWhenOthersLeft?: boolean;
}
// How long we wait after a focus switch before showing the real participant
// list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
@@ -259,6 +260,7 @@ class UserMedia {
participant: LocalParticipant | RemoteParticipant | undefined,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
mediaDevices: MediaDevices,
displayname$: Observable<string>,
handRaised$: Observable<Date | null>,
reaction$: Observable<ReactionOption | null>,
@@ -272,6 +274,7 @@ class UserMedia {
this.participant$ as Behavior<LocalParticipant>,
encryptionSystem,
livekitRoom,
mediaDevices,
this.scope.behavior(displayname$),
this.scope.behavior(handRaised$),
this.scope.behavior(reaction$),
@@ -386,18 +389,6 @@ function getRoomMemberFromRtcMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel {
public readonly localVideo$ = this.scope.behavior<LocalVideoTrack | null>(
observeTrackReference$(
this.livekitRoom.localParticipant,
Track.Source.Camera,
).pipe(
map((trackRef) => {
const track = trackRef?.publication?.track;
return track instanceof LocalVideoTrack ? track : null;
}),
),
);
/**
* The raw list of RemoteParticipants as reported by LiveKit
*/
@@ -473,49 +464,47 @@ export class CallViewModel extends ViewModel {
),
);
private readonly memberships$: Observable<CallMembership[]> = merge(
// Handle call membership changes.
fromEvent(this.matrixRTCSession, MatrixRTCSessionEvent.MembershipsChanged),
// Handle room membership changes (and displayname updates)
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
).pipe(
startWith(this.matrixRTCSession.memberships),
map(() => {
return this.matrixRTCSession.memberships;
}),
);
/**
* Displaynames for each member of the call. This will disambiguate
* any displaynames that clashes with another member. Only members
* joined to the call are considered here.
*/
public readonly memberDisplaynames$ = this.scope.behavior(
merge(
// Handle call membership changes.
fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
),
// Handle room membership changes (and displayname updates)
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
).pipe(
startWith(null),
map(() => {
const displaynameMap = new Map<string, string>();
const { room, memberships } = this.matrixRTCSession;
public readonly memberDisplaynames$ = this.memberships$.pipe(
map((memberships) => {
const displaynameMap = new Map<string, string>();
const { room } = this.matrixRTCSession;
// We only consider RTC members for disambiguation as they are the only visible members.
for (const rtcMember of memberships) {
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
if (!member) {
logger.error(
"Could not find member for media id:",
matrixIdentifier,
);
continue;
}
const disambiguate = shouldDisambiguate(member, memberships, room);
displaynameMap.set(
matrixIdentifier,
calculateDisplayName(member, disambiguate),
);
// We only consider RTC members for disambiguation as they are the only visible members.
for (const rtcMember of memberships) {
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
const { member } = getRoomMemberFromRtcMember(rtcMember, room);
if (!member) {
logger.error("Could not find member for media id:", matrixIdentifier);
continue;
}
return displaynameMap;
}),
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
// don't do this work more times than we need to. This is achieved by converting to a behavior:
),
const disambiguate = shouldDisambiguate(member, memberships, room);
displaynameMap.set(
matrixIdentifier,
calculateDisplayName(member, disambiguate),
);
}
return displaynameMap;
}),
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
// don't do this work more times than we need to. This is achieved by converting to a behavior:
);
public readonly handsRaised$ = this.scope.behavior(this.handsRaisedSubject$);
@@ -612,8 +601,9 @@ export class CallViewModel extends ViewModel {
indexedMediaId,
member,
participant,
this.encryptionSystem,
this.options.encryptionSystem,
this.livekitRoom,
this.mediaDevices,
this.memberDisplaynames$.pipe(
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
),
@@ -635,7 +625,7 @@ export class CallViewModel extends ViewModel {
screenShareId,
member,
participant,
this.encryptionSystem,
this.options.encryptionSystem,
this.livekitRoom,
this.memberDisplaynames$.pipe(
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
@@ -676,8 +666,9 @@ export class CallViewModel extends ViewModel {
nonMemberId,
undefined,
participant,
this.encryptionSystem,
this.options.encryptionSystem,
this.livekitRoom,
this.mediaDevices,
this.memberDisplaynames$.pipe(
map(
(m) => m.get(participant.identity) ?? "[👻]",
@@ -726,18 +717,77 @@ export class CallViewModel extends ViewModel {
),
);
public readonly memberChanges$ = this.userMedia$
.pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
.pipe(
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
(prev, ids) => {
const left = prev.ids.filter((id) => !ids.includes(id));
const joined = ids.filter((id) => !prev.ids.includes(id));
return { ids, joined, left };
},
{ ids: [], joined: [], left: [] },
),
);
/**
* This observable tracks the currently connected participants.
*
* - Each participant has one livekit connection
* - Each participant has a corresponding MatrixRTC membership state event
* - There can be multiple participants for one matrix user.
*/
public readonly participantChanges$ = this.userMedia$.pipe(
map((mediaItems) => mediaItems.map((m) => m.id)),
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
(prev, ids) => {
const left = prev.ids.filter((id) => !ids.includes(id));
const joined = ids.filter((id) => !prev.ids.includes(id));
return { ids, joined, left };
},
{ ids: [], joined: [], left: [] },
),
);
/**
* This observable tracks the matrix users that are currently in the call.
* There can be just one matrix user with multiple participants (see also participantChanges$)
*/
public readonly matrixUserChanges$ = this.userMedia$.pipe(
map(
(mediaItems) =>
new Set(
mediaItems
.map((m) => m.vm.member?.userId)
.filter((id) => id !== undefined),
),
),
scan<
Set<string>,
{
userIds: Set<string>;
joinedUserIds: Set<string>;
leftUserIds: Set<string>;
}
>(
(prevState, userIds) => {
const left = new Set(
[...prevState.userIds].filter((id) => !userIds.has(id)),
);
const joined = new Set(
[...userIds].filter((id) => !prevState.userIds.has(id)),
);
return { userIds: userIds, joinedUserIds: joined, leftUserIds: left };
},
{ userIds: new Set(), joinedUserIds: new Set(), leftUserIds: new Set() },
),
);
public readonly allOthersLeft$ = this.matrixUserChanges$.pipe(
map(({ userIds, leftUserIds }) => {
const userId = this.matrixRTCSession.room.client.getUserId();
if (!userId) {
logger.warn("Could access client.getUserId to compute allOthersLeft");
return false;
}
return userIds.size === 1 && userIds.has(userId) && leftUserIds.size > 0;
}),
startWith(false),
distinctUntilChanged(),
);
public readonly autoLeaveWhenOthersLeft$ = this.allOthersLeft$.pipe(
distinctUntilChanged(),
filter((leave) => (leave && this.options.autoLeaveWhenOthersLeft) ?? false),
map(() => {}),
);
/**
* List of MediaItems that we want to display, that are of type ScreenShare
@@ -1426,7 +1476,7 @@ export class CallViewModel extends ViewModel {
private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices,
private readonly encryptionSystem: EncryptionSystem,
private readonly options: CallViewModelOptions,
private readonly connectionState$: Observable<ECConnectionState>,
private readonly handsRaisedSubject$: Observable<
Record<string, RaisedHandInfo>

View File

@@ -17,7 +17,7 @@ import {
type Observable,
} from "rxjs";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
audioInput as audioInputSetting,
@@ -38,7 +38,6 @@ import { type Behavior, constant } from "./Behavior";
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
const EARPIECE_CONFIG_ID = "earpiece-id";
const logger = rootLogger.getChild("[MediaDevices]");
export type DeviceLabel =
| { type: "name"; name: string }
@@ -100,6 +99,7 @@ function availableRawDevices$(
kind: MediaDeviceKind,
usingNames$: Behavior<boolean>,
scope: ObservableScope,
logger: Logger,
): Behavior<MediaDeviceInfo[]> {
const logError = (e: Error): void =>
logger.error("Error creating MediaDeviceObserver", e);
@@ -162,8 +162,15 @@ function selectDevice$<Label>(
}
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
private logger = rootLogger.getChild("[MediaDevices AudioInput]");
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
availableRawDevices$("audioinput", this.usingNames$, this.scope);
availableRawDevices$(
"audioinput",
this.usingNames$,
this.scope,
this.logger,
);
public readonly available$ = this.scope.behavior(
this.availableRaw$.pipe(map(buildDeviceMap)),
@@ -200,7 +207,7 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
private readonly scope: ObservableScope,
) {
this.available$.subscribe((available) => {
logger.info("[audio-input] available devices:", available);
this.logger.info("[audio-input] available devices:", available);
});
}
}
@@ -208,10 +215,16 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
class AudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
private logger = rootLogger.getChild("[MediaDevices AudioOutput]");
public readonly available$ = this.scope.behavior(
availableRawDevices$("audiooutput", this.usingNames$, this.scope).pipe(
availableRawDevices$(
"audiooutput",
this.usingNames$,
this.scope,
this.logger,
).pipe(
map((availableRaw) => {
const available: Map<string, AudioOutputDeviceLabel> =
let available: Map<string, AudioOutputDeviceLabel> =
buildDeviceMap(availableRaw);
// Create a virtual default audio output for browsers that don't have one.
// Its device ID must be the empty string because that's what setSinkId
@@ -221,6 +234,12 @@ class AudioOutput
type: "default",
name: availableRaw[0]?.label || null,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
if (isSafari) {
// set to empty map if we are on Safari, because it does not support setSinkId
available = new Map();
}
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
@@ -250,7 +269,7 @@ class AudioOutput
private readonly scope: ObservableScope,
) {
this.available$.subscribe((available) => {
logger.info("[audio-output] available devices:", available);
this.logger.info("[audio-output] available devices:", available);
});
}
}
@@ -258,6 +277,7 @@ class AudioOutput
class ControlledAudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
// We need to subscribe to the raw devices so that the OS does update the input
// back to what it was before. otherwise we will switch back to the default
// whenever we allocate a new stream.
@@ -265,6 +285,7 @@ class ControlledAudioOutput
"audiooutput",
this.usingNames$,
this.scope,
this.logger,
);
public readonly available$ = this.scope.behavior(
@@ -328,26 +349,37 @@ class ControlledAudioOutput
// been selected - for example, Element X iOS listens to this to determine
// whether it should enable the proximity sensor.
if (device !== undefined) {
logger.info("[controlled-output] onAudioDeviceSelect called:", device);
this.logger.info(
"[controlled-output] onAudioDeviceSelect called:",
device,
);
window.controls.onAudioDeviceSelect?.(device.id);
// Also invoke the deprecated callback for backward compatibility
window.controls.onOutputDeviceSelect?.(device.id);
}
});
this.available$.subscribe((available) => {
logger.info("[controlled-output] available devices:", available);
this.logger.info("[controlled-output] available devices:", available);
});
this.availableRaw$.subscribe((availableRaw) => {
logger.info("[controlled-output] available raw devices:", availableRaw);
this.logger.info(
"[controlled-output] available raw devices:",
availableRaw,
);
});
}
}
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
private logger = rootLogger.getChild("[MediaDevices VideoInput]");
public readonly available$ = this.scope.behavior(
availableRawDevices$("videoinput", this.usingNames$, this.scope).pipe(
map(buildDeviceMap),
),
availableRawDevices$(
"videoinput",
this.usingNames$,
this.scope,
this.logger,
).pipe(map(buildDeviceMap)),
);
public readonly selected$ = this.scope.behavior(
selectDevice$(this.available$, videoInputSetting.value$).pipe(
@@ -364,7 +396,7 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
) {
// This also has the purpose of subscribing to the available devices
this.available$.subscribe((available) => {
logger.info("[video-input] available devices:", available);
this.logger.info("[video-input] available devices:", available);
});
}
}

View File

@@ -5,14 +5,40 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, vi } from "vitest";
import { expect, onTestFinished, test, vi } from "vitest";
import {
type LocalTrackPublication,
LocalVideoTrack,
TrackEvent,
} from "livekit-client";
import { waitFor } from "@testing-library/dom";
import {
mockLocalParticipant,
mockMediaDevices,
mockRtcMembership,
withLocalMedia,
withRemoteMedia,
withTestScheduler,
} from "../utils/test";
import { getValue } from "../utils/observable";
import { constant } from "./Behavior";
global.MediaStreamTrack = class {} as unknown as {
new (): MediaStreamTrack;
prototype: MediaStreamTrack;
};
global.MediaStream = class {} as unknown as {
new (): MediaStream;
prototype: MediaStream;
};
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
get platform(): string {
return platformMock();
},
}));
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
@@ -79,17 +105,23 @@ test("toggle fit/contain for a participant's video", async () => {
});
test("local media remembers whether it should always be shown", async () => {
await withLocalMedia(rtcMembership, {}, (vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
}),
await withLocalMedia(
rtcMembership,
{},
mockLocalParticipant({}),
mockMediaDevices({}),
(vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
}),
);
// Next local media should start out *not* always shown
await withLocalMedia(
rtcMembership,
{},
mockLocalParticipant({}),
mockMediaDevices({}),
(vm) =>
withTestScheduler(({ expectObservable, schedule }) => {
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
@@ -97,3 +129,77 @@ test("local media remembers whether it should always be shown", async () => {
}),
);
});
test("switch cameras", async () => {
// Camera switching is only available on mobile
platformMock.mockReturnValue("android");
onTestFinished(() => void platformMock.mockReset());
// Construct a mock video track which knows how to be restarted
const track = new LocalVideoTrack({
getConstraints() {},
addEventListener() {},
removeEventListener() {},
} as unknown as MediaStreamTrack);
let deviceId = "front camera";
const restartTrack = vi.fn(async ({ facingMode }) => {
deviceId = facingMode === "user" ? "front camera" : "back camera";
track.emit(TrackEvent.Restarted);
return Promise.resolve();
});
track.restartTrack = restartTrack;
Object.defineProperty(track, "mediaStreamTrack", {
get() {
return {
label: "Video",
getSettings: (): object => ({
deviceId,
facingMode: deviceId === "front camera" ? "user" : "environment",
}),
};
},
});
const selectVideoInput = vi.fn();
await withLocalMedia(
rtcMembership,
{},
mockLocalParticipant({
getTrackPublication() {
return { track } as unknown as LocalTrackPublication;
},
}),
mockMediaDevices({
videoInput: {
available$: constant(new Map()),
selected$: constant(undefined),
select: selectVideoInput,
},
}),
async (vm) => {
// Switch to back camera
getValue(vm.switchCamera$)!();
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
facingMode: "environment",
});
await waitFor(() => {
expect(selectVideoInput).toHaveBeenCalledTimes(1);
expect(selectVideoInput).toHaveBeenCalledWith("back camera");
});
expect(deviceId).toBe("back camera");
// Switch to front camera
getValue(vm.switchCamera$)!();
expect(restartTrack).toHaveBeenCalledTimes(2);
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
await waitFor(() => {
expect(selectVideoInput).toHaveBeenCalledTimes(2);
expect(selectVideoInput).toHaveBeenLastCalledWith("front camera");
});
expect(deviceId).toBe("front camera");
},
);
});

View File

@@ -16,6 +16,7 @@ import {
import {
type LocalParticipant,
LocalTrack,
LocalVideoTrack,
type Participant,
ParticipantEvent,
type RemoteParticipant,
@@ -27,6 +28,7 @@ import {
RemoteTrack,
} from "livekit-client";
import { type RoomMember } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
type Observable,
@@ -51,6 +53,8 @@ import { accumulate } from "../utils/observable";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { type ReactionOption } from "../reactions";
import { platform } from "../Platform";
import { type MediaDevices } from "./MediaDevices";
import { type Behavior } from "./Behavior";
export function observeTrackReference$(
@@ -443,20 +447,38 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
*/
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/**
* Whether the video should be mirrored.
* The local video track as an observable that emits whenever the track
* changes, the camera is switched, or the track is muted.
*/
public readonly mirror$ = this.scope.behavior(
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
this.video$.pipe(
switchMap((v) => {
const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe(
startWith(null),
// Mirror only front-facing cameras (those that face the user)
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
if (!(track instanceof LocalVideoTrack)) return of(null);
return merge(
// Watch for track restarts because they indicate a camera switch.
// This event is also emitted when unmuting the track object.
fromEvent(track, TrackEvent.Restarted).pipe(
startWith(null),
map(() => track),
),
// When the track object is muted, reset it to null.
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
);
}),
);
/**
* Whether the video should be mirrored.
*/
public readonly mirror$ = this.scope.behavior(
this.videoTrack$.pipe(
// Mirror only front-facing cameras (those that face the user)
map(
(track) =>
track !== null &&
facingModeFromLocalTrack(track).facingMode === "user",
),
),
);
@@ -467,12 +489,48 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
public readonly alwaysShow$ = alwaysShowSelf.value$;
public readonly setAlwaysShow = alwaysShowSelf.setValue;
/**
* Callback for switching between the front and back cameras.
*/
public readonly switchCamera$: Behavior<(() => void) | null> =
this.scope.behavior(
platform === "desktop"
? of(null)
: this.videoTrack$.pipe(
map((track) => {
if (track === null) return null;
const facingMode = facingModeFromLocalTrack(track).facingMode;
// If the camera isn't front or back-facing, don't provide a switch
// camera shortcut at all
if (facingMode !== "user" && facingMode !== "environment")
return null;
// Restart the track with a camera facing the opposite direction
return (): void =>
void track
.restartTrack({
facingMode: facingMode === "user" ? "environment" : "user",
})
.then(() => {
// Inform the MediaDevices which camera was chosen
const deviceId =
track.mediaStreamTrack.getSettings().deviceId;
if (deviceId !== undefined)
this.mediaDevices.videoInput.select(deviceId);
})
.catch((e) =>
logger.error("Failed to switch camera", facingMode, e),
);
}),
),
);
public constructor(
id: string,
member: RoomMember | undefined,
participant$: Behavior<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices,
displayName$: Behavior<string>,
handRaised$: Behavior<Date | null>,
reaction$: Behavior<ReactionOption | null>,

View File

@@ -83,3 +83,25 @@ borders don't support gradients */
.volumeSlider {
width: 100%;
}
.tile .switchCamera {
opacity: 1;
background: var(--cpd-color-bg-action-secondary-rest);
border: 1px solid var(--cpd-color-border-interactive-secondary);
}
.tile .switchCamera > svg {
color: var(--cpd-color-icon-primary);
}
@media (hover) {
.tile .switchCamera:hover {
background: var(--cpd-color-bg-subtle-secondary);
border-color: var(--cpd-color-border-interactive-hovered);
}
}
.tile .switchCamera:active {
background: var(--cpd-color-bg-subtle-primary);
border-color: var(--cpd-color-border-interactive-hovered);
}

View File

@@ -28,6 +28,7 @@ import {
UserProfileIcon,
ExpandIcon,
VolumeOffSolidIcon,
SwitchCameraSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ContextMenu,
@@ -65,6 +66,7 @@ interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel;
mirror: boolean;
locallyMuted: boolean;
primaryButton?: ReactNode;
menuStart?: ReactNode;
menuEnd?: ReactNode;
}
@@ -74,6 +76,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
vm,
showSpeakingIndicators,
locallyMuted,
primaryButton,
menuStart,
menuEnd,
className,
@@ -160,20 +163,22 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
primaryButton ?? (
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
)
}
raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined}
@@ -208,6 +213,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
const { t } = useTranslation();
const mirror = useBehavior(vm.mirror$);
const alwaysShow = useBehavior(vm.alwaysShow$);
const switchCamera = useBehavior(vm.switchCamera$);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
(e: Event) => {
@@ -223,6 +230,17 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
vm={vm}
locallyMuted={false}
mirror={mirror}
primaryButton={
switchCamera === null ? undefined : (
<button
className={styles.switchCamera}
aria-label={t("switch_camera")}
onClick={switchCamera}
>
<SwitchCameraSolidIcon aria-hidden width={20} height={20} />
</button>
)
}
menuStart={
<ToggleMenuItem
Icon={VisibilityOnIcon}

View File

@@ -85,6 +85,7 @@ unconditionally select the container so we can use cqmin units */
.nameTag {
grid-area: nameTag;
place-self: end start;
padding: var(--cpd-space-1x);
padding-block: var(--cpd-space-1x);
color: var(--cpd-color-text-primary);
@@ -173,7 +174,7 @@ unconditionally select the container so we can use cqmin units */
}
.fg > button:active {
background: var(--cpd-color-bg-action-primary-pressed) !important;
background: var(--cpd-color-bg-action-primary-pressed);
}
.fg > button[data-state="open"] {

View File

@@ -88,40 +88,48 @@ Please see LICENSE in the repository root for full details.
padding: var(--cpd-space-2x);
border: none;
border-radius: var(--cpd-radius-pill-effect);
background: var(--cpd-color-alpha-gray-1400);
background: rgba(from var(--cpd-color-gray-100) r g b / 0.6);
box-shadow: var(--small-drop-shadow);
transition:
opacity 0.15s,
background-color 0.1s;
position: absolute;
z-index: 1;
--inset: 6px;
inset-block-end: var(--inset);
inset-inline-end: var(--inset);
}
.bottomRightButtons {
display: flex;
gap: var(--cpd-space-2x);
position: absolute;
inset-block-end: var(--cpd-space-1x);
inset-inline-end: var(--cpd-space-1x);
z-index: 1;
}
.expand > svg {
display: block;
color: var(--cpd-color-icon-on-solid-primary);
color: var(--cpd-color-icon-primary);
}
@media (hover) {
.expand:hover {
background: var(--cpd-color-bg-action-primary-hovered);
background: var(--cpd-color-gray-400);
}
}
.expand:active {
background: var(--cpd-color-bg-action-primary-pressed);
background: var(--cpd-color-gray-100);
}
@media (hover) {
.tile:hover > button {
.tile:hover > div > button {
opacity: 1;
}
}
.tile:has(:focus-visible) > button {
.tile:has(:focus-visible) > div > button {
opacity: 1;
}

View File

@@ -12,6 +12,8 @@ import userEvent from "@testing-library/user-event";
import { SpotlightTile } from "./SpotlightTile";
import {
mockLocalParticipant,
mockMediaDevices,
mockRtcMembership,
withLocalMedia,
withRemoteMedia,
@@ -39,6 +41,8 @@ test("SpotlightTile is accessible", async () => {
rawDisplayName: "Bob",
getMxcAvatarUrl: () => "mxc://dlskf",
},
mockLocalParticipant({}),
mockMediaDevices({}),
async (vm2) => {
const user = userEvent.setup();
const toggleExpanded = vi.fn();

View File

@@ -29,6 +29,8 @@ import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { type RoomMember } from "matrix-js-sdk";
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
import { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css";
import {
@@ -210,6 +212,26 @@ export const SpotlightTile: FC<Props> = ({
const canGoBack = visibleIndex > 0;
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
const isFullscreen = useCallback((): boolean => {
const rootElement = document.body;
if (rootElement && document.fullscreenElement) return true;
return false;
}, []);
const FullScreenIcon = isFullscreen()
? FullScreenMinimiseIcon
: FullScreenMaximiseIcon;
const onToggleFullscreen = useCallback(() => {
const rootElement = document.body;
if (!rootElement) return;
if (isFullscreen()) {
void document?.exitFullscreen();
} else {
void rootElement.requestFullscreen();
}
}, [isFullscreen]);
// To keep track of which item is visible, we need an intersection observer
// hooked up to the root element and the items. Because the items will run
// their effects before their parent does, we need to do this dance with an
@@ -292,17 +314,28 @@ export const SpotlightTile: FC<Props> = ({
/>
))}
</div>
{onToggleExpanded && (
<div className={styles.bottomRightButtons}>
<button
className={classNames(styles.expand)}
aria-label={
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
aria-label={"maximise"}
onClick={onToggleFullscreen}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
)}
{onToggleExpanded && (
<button
className={classNames(styles.expand)}
aria-label={
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
)}
</div>
{canGoToNext && (
<button
className={classNames(styles.advance, styles.next)}

View File

@@ -17,6 +17,7 @@ export enum ErrorCode {
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
OPEN_ID_ERROR = "OPEN_ID_ERROR",
SFU_ERROR = "SFU_ERROR",
UNKNOWN_ERROR = "UNKNOWN_ERROR",
}
@@ -129,3 +130,14 @@ export class InsufficientCapacityError extends ElementCallError {
);
}
}
export class SFURoomCreationRestrictedError extends ElementCallError {
public constructor() {
super(
t("error.room_creation_restricted"),
ErrorCode.SFU_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
t("error.room_creation_restricted_description"),
);
}
}

View File

@@ -12,7 +12,11 @@ import {
mockLocalParticipant,
} from "./test";
export const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
export const localRtcMember = mockRtcMembership("@carol:example.org", "1111");
export const localRtcMemberDevice2 = mockRtcMembership(
"@carol:example.org",
"2222",
);
export const local = mockMatrixRoomMember(localRtcMember);
export const localParticipant = mockLocalParticipant({ identity: "" });
export const localId = `${local.userId}:${localRtcMember.deviceId}`;

View File

@@ -139,7 +139,7 @@ export function getBasicCallViewModelEnvironment(
liveKitRoom,
mockMediaDevices({}),
{
kind: E2eeType.PER_PARTICIPANT,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
of(ConnectionState.Connected),
handRaisedSubject$,

View File

@@ -74,6 +74,7 @@ export interface OurRunHelpers extends RunHelpers {
values?: { [marble: string]: T },
error?: unknown,
): Behavior<T>;
scope: ObservableScope;
}
interface TestRunnerGlobal {
@@ -96,6 +97,7 @@ export function withTestScheduler(
scheduler.run((helpers) =>
continuation({
...helpers,
scope,
schedule(marbles, actions) {
const actionsObservable$ = helpers
.cold(marbles)
@@ -241,9 +243,10 @@ export function mockLocalParticipant(
export async function withLocalMedia(
localRtcMember: CallMembership,
roomMember: Partial<RoomMember>,
localParticipant: LocalParticipant,
mediaDevices: MediaDevices,
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
): Promise<void> {
const localParticipant = mockLocalParticipant({});
const vm = new LocalUserMediaViewModel(
"local",
mockMatrixRoomMember(localRtcMember, roomMember),
@@ -252,6 +255,7 @@ export async function withLocalMedia(
kind: E2eeType.PER_PARTICIPANT,
},
mockLivekitRoom({ localParticipant }),
mediaDevices,
constant(roomMember.rawDisplayName ?? "nodisplayname"),
constant(null),
constant(null),

771
yarn.lock

File diff suppressed because it is too large Load Diff