Compare commits

..

1 Commits

Author SHA1 Message Date
Valere
b9addddbd8 bump js-sdk to matrix-js-sdk 0ce944f3daa2c8926c673af33ae24e89b1e6796a 2025-07-21 16:58:43 +02:00
52 changed files with 707 additions and 1468 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,PR-Breaking-Change"
REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies"
REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one 'PR-' label"
BANNED_LABELS: "banned"

View File

@@ -143,8 +143,7 @@ via the `org.matrix.msc4143.rtc_foci` key, e.g.:
where the format for MatrixRTC using LiveKit backend is defined in
[MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
In the example above Matrix clients do discover a focus of type `livekit` which
points them to a [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
via `livekit_service_url`.
points them to a Matrix LiveKit JWT Auth Service via `livekit_service_url`.
### Backend Selection
@@ -155,9 +154,9 @@ via `livekit_service_url`.
the **first participant who joined the call** defines via the `foci_preferred`
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
will be used for this call.
- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)**
provides the client with the **LiveKit SFU WebSocket URL** and an
**access JWT token** in order to exchange media via WebRTC.
- During the actual call join flow, the **LiveKit JWT Auth Service** provides
the client with the **LiveKit SFU WebSocket URL** and an **access JWT token**
in order to exchange media via WebRTC.
The example below illustrates how backend selection works across **Matrix
federation**, using the setup from sites A, B, and C. It demonstrates backend
@@ -209,7 +208,7 @@ A docker compose file `dev-backend-docker-compose.yml` is provided to start the
whole stack of components which is required for a local development environment:
- Minimum Synapse Setup (servername: `synapse.m.localhost`)
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- LiveKit Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Minimum LiveKit SFU Setup using dev defaults for config
- Redis db for completeness
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)

View File

@@ -21,5 +21,3 @@ turn:
external_tls: true
keys:
devkey: secret
room:
auto_create: false

View File

@@ -14,7 +14,6 @@ services:
# If the configured homeserver runs on localhost, it'll probably be using
# a self-signed certificate
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
deploy:
restart_policy:
condition: on-failure
@@ -102,6 +101,4 @@ services:
depends_on:
- synapse
networks:
ecbackend:
aliases:
- matrix-rtc.m.localhost
- ecbackend

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 941 KiB

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 886 KiB

View File

@@ -64,7 +64,7 @@ rc_delayed_event_mgmt:
```
As a prerequisite for the
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
[Matrix LiveKit JWT auth service](https://github.com/element-hq/lk-jwt-service)
make sure that your Synapse server has either a `federation` or `openid`
[listener configured](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#listeners).
@@ -77,7 +77,7 @@ required for each site deployment.
As depicted above in the `example.com` site deployment, Element Call requires a
[Livekit SFU](https://github.com/livekit/livekit) alongside a
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
[Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service)
to implement
[MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
@@ -89,7 +89,7 @@ the example above, this results in:
| Service | Endpoint | Example |
| -------- | ------- | ------- |
| [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` |
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
| [Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
Using Nginx, you can achieve this by:
@@ -102,7 +102,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# MatrixRTC Authorization Service running at port 8080
# JWT Service running at port 8080
proxy_pass http://localhost:8080/;
}
@@ -145,6 +145,10 @@ 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,7 +48,6 @@ 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. |
@@ -60,6 +59,7 @@ 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.1"
android_gradle_plugin = "8.11.0"
[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.33.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.2-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -5,6 +5,8 @@
* 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)
@@ -25,7 +27,7 @@ android {
}
mavenPublishing {
publishToMavenCentral(automaticRelease = true)
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
signAllPublications()

View File

@@ -61,7 +61,6 @@
"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}}",
@@ -174,7 +173,6 @@
"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,7 +61,6 @@
"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}}",
@@ -71,7 +70,6 @@
"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",
@@ -82,7 +80,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 <2> opret et nyt</2>.</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<1> opret et nyt</1>.</0>",
"connection_lost": "Forbindelsen gik tabt",
"connection_lost_description": "Du blev afbrudt fra opkaldet.",
"e2ee_unsupported": "Inkompatibel browser",
@@ -166,17 +164,12 @@
"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. Es sollte geprüft werden, ob der Link korrekt ist, oder <2>ein neuer erstellt werden</2>.</0>",
"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>",
"connection_lost": "Verbindung verloren",
"connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.",
"e2ee_unsupported": "Inkompatibler Browser",
@@ -94,8 +94,6 @@
"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": {
@@ -107,11 +105,6 @@
"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",
@@ -180,11 +173,9 @@
"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,8 +94,6 @@
"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,7 +61,6 @@
"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}}",
@@ -82,7 +81,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 <2>loo uus</2>.</0>",
"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>",
"connection_lost": "Ühendus on katkenud",
"connection_lost_description": "Sinu ühendus selle kõnega on katkenud.",
"e2ee_unsupported": "Mitteühilduv brauser",
@@ -94,8 +93,6 @@
"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": {
@@ -107,10 +104,6 @@
"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",
@@ -179,10 +172,8 @@
"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,7 +61,6 @@
"video": "Видео"
},
"developer_mode": {
"always_show_iphone_earpiece": "Показать опцию наушников для iPhone на всех платформах",
"crypto_version": "Версия криптографии: {{version}}",
"debug_tile_layout_label": "Отладка расположения плиток",
"device_id": "Идентификатор устройства: {{id}}",
@@ -71,7 +70,6 @@
"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-адреса",
@@ -174,7 +172,6 @@
"devices": {
"camera": "Камера",
"camera_numbered": "Камера {{n}}",
"change_device_button": "Изменить аудиоустройство",
"default": "По умолчанию",
"default_named": "По умолчанию <2>({{name}})</2>",
"microphone": "Микрофон",

View File

@@ -61,7 +61,6 @@
"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}}",
@@ -82,7 +81,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 <2>vytvorte nový</2>.</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 <1> vytvorte nový</1>. </0>",
"connection_lost": "Strata spojenia",
"connection_lost_description": "Boli ste odpojení od hovoru.",
"e2ee_unsupported": "Nekompatibilný prehliadač",
@@ -174,10 +173,8 @@
"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,7 +61,6 @@
"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}}",
@@ -71,7 +70,6 @@
"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",
@@ -166,14 +164,10 @@
"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.203.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.202.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": "^6.0.0",
"@vector-im/compound-design-tokens": "^5.0.0",
"@vector-im/compound-web": "^8.0.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^3.0.0",

View File

@@ -72,56 +72,3 @@ 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

@@ -1,75 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
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(2 /* the first one is for the warmup */);
expect(sfuGetCallCount).toBe(1);
});

View File

@@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest";
import {
getRoomIdentifierFromUrl,
getUrlParams,
HeaderStyle,
UserIntent,
} from "../src/UrlParams";
const ROOM_NAME = "roomNameHere";
@@ -82,16 +82,6 @@ describe("UrlParams", () => {
getRoomIdentifierFromUrl("", `?roomId=${ROOM_ID}`, "").roomId,
).toBe(ROOM_ID);
});
it("(roomId with unprintable characters)", () => {
const invisibleChar = "\u2066";
expect(
getRoomIdentifierFromUrl(
"",
`?roomId=${invisibleChar}${ROOM_ID}${invisibleChar}`,
"",
).roomId,
).toBe(ROOM_ID);
});
});
it("ignores room alias", () => {
@@ -211,68 +201,24 @@ describe("UrlParams", () => {
});
describe("intent", () => {
const noIntentDefaults = {
confineToRoom: false,
appPrompt: true,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
};
const startNewCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
});
const joinExistingCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: false,
returnToLobby: false,
sendNotificationType: "notification",
});
it("use no-intent-defaults with unknown intent", () => {
expect(getUrlParams()).toMatchObject(noIntentDefaults);
it("defaults to unknown", () => {
expect(getUrlParams().intent).toBe(UserIntent.Unknown);
});
it("ignores intent if it is not a valid value", () => {
expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
expect(getUrlParams("?intent=foo").intent).toBe(UserIntent.Unknown);
});
it("accepts start_call", () => {
expect(
getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"),
).toMatchObject(startNewCallDefaults("desktop"));
expect(getUrlParams("?intent=start_call").intent).toBe(
UserIntent.StartNewCall,
);
});
it("accepts join_existing", () => {
expect(
getUrlParams(
"?intent=join_existing&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(joinExistingCallDefaults("desktop"));
expect(getUrlParams("?intent=join_existing").intent).toBe(
UserIntent.JoinExistingCall,
);
});
});
@@ -304,5 +250,9 @@ describe("UrlParams", () => {
);
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
});
it("converts hideHeader to the correct header value", () => {
expect(getUrlParams("?hideHeader=true").header).toBe("none");
expect(getUrlParams("?hideHeader=false").header).toBe("standard");
});
});
});

View File

@@ -9,12 +9,10 @@ import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
import { pickBy } from "lodash-es";
import { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";
import { platform } from "./Platform";
interface RoomIdentifier {
roomAlias: string | null;
@@ -25,8 +23,6 @@ interface RoomIdentifier {
export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallDM = "start_call_dm",
JoinExistingCallDM = "join_existing_dm",
Unknown = "unknown",
}
@@ -36,12 +32,12 @@ export enum HeaderStyle {
AppBar = "app_bar",
}
/**
* The UrlProperties are used to pass required data to the widget.
* Those are different in different rooms, users, devices. They do not configure the behavior of the
* widget but provide the required data to the widget.
*/
export interface UrlProperties {
// 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 {
// Widget api related params
widgetId: string | null;
parentUrl: string | null;
@@ -53,11 +49,45 @@ export interface UrlProperties {
* is also not validated, where it is in useRoomIdentifier().
*/
roomId: string | null;
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* The app prompt must also be enabled in the config for this to take effect.
*/
appPrompt: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* 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.
*/
header: HeaderStyle;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether to use end-to-end encryption.
*/
e2eEnabled: boolean;
/**
* The user's ID (only used in matryoshka mode).
*/
userId: string | null;
/**
* The display name to use for auto-registration.
*/
@@ -94,97 +124,15 @@ export interface UrlProperties {
* The Posthog API key. This is only used in the embedded package of Element Call.
*/
posthogApiKey: string | null;
/**
* Whether to use end-to-end encryption.
*/
e2eEnabled: boolean;
/**
* E2EE password
*/
password: string | null;
/** This defines the homeserver that is going to be used when joining a room.
* It has to be set to a non default value for links to rooms
* that are not on the default homeserver,
* that is in use for the current user.
*/
viaServers: string | null;
/**
* This defines the homeserver that is going to be used when registering
* a new (guest) user.
* This can be user to configure a non default guest user server when
* creating a spa link.
*/
homeserver: string | null;
/**
* The rageshake submit URL. This is only used in the embedded package of Element Call.
*/
rageshakeSubmitUrl: string | null;
/**
* The Sentry DSN. This is only used in the embedded package of Element Call.
*/
sentryDsn: string | null;
/**
* The Sentry environment. This is only used in the embedded package of Element Call.
*/
sentryEnvironment: string | null;
/**
* The theme to use for element call.
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
*/
theme: string | null;
}
/**
* The configuration for the app, which can be set via URL parameters.
* Those property are different to the UrlProperties, since they are all optional
* and configure the behavior of the app. Their value is the same if EC is used in
* the same context but with different accounts/users.
*
* Their defaults can be controlled by the `intent` property.
*/
export interface UrlConfiguration {
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* The app prompt must also be enabled in the config for this to take effect.
*/
appPrompt: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* 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.
*/
header: HeaderStyle;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
*/
allowIceFallback: boolean;
/**
* E2EE password
*/
password: string | null;
/**
* Whether the app should use per participant keys for E2EE.
*/
@@ -206,25 +154,52 @@ export interface UrlConfiguration {
* This is useful for video rooms.
*/
returnToLobby: boolean;
/**
* The theme to use for element call.
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
*/
theme: string | null;
/** This defines the homeserver that is going to be used when joining a room.
* It has to be set to a non default value for links to rooms
* that are not on the default homeserver,
* that is in use for the current user.
*/
viaServers: string | null;
/**
* This defines the homeserver that is going to be used when registering
* a new (guest) user.
* This can be user to configure a non default guest user server when
* creating a spa link.
*/
homeserver: string | null;
/**
* The user's intent 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`.
*/
intent: string | null;
/**
* The rageshake submit URL. This is only used in the embedded package of Element Call.
*/
rageshakeSubmitUrl: string | null;
/**
* The Sentry DSN. This is only used in the embedded package of Element Call.
*/
sentryDsn: string | null;
/**
* The Sentry environment. This is only used in the embedded package of Element Call.
*/
sentryEnvironment: string | null;
/**
* 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
// 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 {}
// This is here as a stopgap, but what would be far nicer is a function that
// takes a UrlParams and returns a query string. That would enable us to
// consolidate all the data about URL parameters and their meanings to this one
@@ -265,17 +240,6 @@ class ParamParser {
return this.fragmentParams.get(name) ?? this.queryParams.get(name);
}
public getEnumParam<T extends string>(
name: string,
type: { [s: string]: T } | ArrayLike<T>,
): T | undefined {
const value = this.getParam(name);
if (value !== null && Object.values(type).includes(value as T)) {
return value as T;
}
return undefined;
}
public getAllParams(name: string): string[] {
return [
...this.fragmentParams.getAll(name),
@@ -283,20 +247,10 @@ 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;
}
}
/**
@@ -313,95 +267,46 @@ export const getUrlParams = (
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
let intent = parser.getParam("intent");
if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) {
intent = UserIntent.Unknown;
}
// Check hideHeader for backwards compatibility. If header is set, hideHeader
// is ignored.
const header =
parser.getParam("header") ??
(parser.getFlagParam("hideHeader")
? HeaderStyle.None
: HeaderStyle.Standard);
const sendNotificationType = ["ring", "notification"].includes(
parser.getParam("sendNotificationType") ?? "",
)
? (parser.getParam("sendNotificationType") as RTCNotificationType)
: undefined;
const widgetId = parser.getParam("widgetId");
const parentUrl = parser.getParam("parentUrl");
const isWidget = !!widgetId && !!parentUrl;
/**
* The user's intent 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`.
* This is a platform specific default set of parameters, that allows to minize the configuration
* needed to start a call. And empowers the EC codebase to control the platform/intent behavior in
* a central place.
*
* In short: either provide url query parameters of UrlConfiguration or set the intent
* (or the global defaults will be used).
*/
const intent = !isWidget
? UserIntent.Unknown
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
// Here we only use constants and `platform` to determine the intent preset.
let intentPreset: UrlConfiguration;
const inAppDefault = {
confineToRoom: true,
appPrompt: false,
preload: true,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification" as RTCNotificationType,
autoLeaveWhenOthersLeft: false,
};
switch (intent) {
case UserIntent.StartNewCall:
intentPreset = {
...inAppDefault,
skipLobby: true,
};
break;
case UserIntent.JoinExistingCall:
intentPreset = {
...inAppDefault,
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 = {
confineToRoom: false,
appPrompt: true,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false,
};
}
const properties: UrlProperties = {
return {
widgetId,
parentUrl,
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
// what would we do if it were invalid? If the widget API says that's what
// the room ID is, then that's what it is.
roomId: parser.getParam("roomId"),
password: parser.getParam("password"),
// This flag has 'embed' as an alias for historical reasons
confineToRoom:
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
appPrompt: parser.getFlagParam("appPrompt", true),
preload: isWidget ? parser.getFlagParam("preload") : false,
header: header as HeaderStyle,
showControls: parser.getFlagParam("showControls", true),
hideScreensharing: parser.getFlagParam("hideScreensharing"),
e2eEnabled: parser.getFlagParam("enableE2EE", true),
userId: isWidget ? parser.getParam("userId") : null,
displayName: parser.getParam("displayName"),
deviceId: isWidget ? parser.getParam("deviceId") : null,
@@ -409,9 +314,24 @@ export const getUrlParams = (
lang: parser.getParam("lang"),
fonts: parser.getAllParams("font"),
fontScale: Number.isNaN(fontScale) ? null : fontScale,
allowIceFallback: parser.getFlagParam("allowIceFallback"),
perParticipantE2EE: parser.getFlagParam("perParticipantE2EE"),
controlledAudioDevices: parser.getFlagParam(
"controlledAudioDevices",
// the deprecated property name
parser.getFlagParam("controlledMediaDevices"),
),
skipLobby: parser.getFlagParam(
"skipLobby",
isWidget && intent === UserIntent.StartNewCall,
),
// In SPA mode the user should always exit to the home screen when hanging
// up, rather than being sent back to the lobby
returnToLobby: isWidget ? parser.getFlagParam("returnToLobby") : false,
theme: parser.getParam("theme"),
viaServers: !isWidget ? parser.getParam("viaServers") : null,
homeserver: !isWidget ? parser.getParam("homeserver") : null,
intent,
posthogApiHost: parser.getParam("posthogApiHost"),
posthogApiKey: parser.getParam("posthogApiKey"),
posthogUserId:
@@ -419,36 +339,7 @@ export const getUrlParams = (
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
sentryDsn: parser.getParam("sentryDsn"),
sentryEnvironment: parser.getParam("sentryEnvironment"),
e2eEnabled: parser.getFlagParam("enableE2EE", true),
};
const configuration: Partial<UrlConfiguration> = {
confineToRoom: parser.getFlag("confineToRoom"),
appPrompt: parser.getFlag("appPrompt"),
preload: isWidget ? parser.getFlag("preload") : undefined,
// Check hideHeader for backwards compatibility. If header is set, hideHeader
// is ignored.
header: parser.getEnumParam("header", HeaderStyle),
showControls: parser.getFlag("showControls"),
hideScreensharing: parser.getFlag("hideScreensharing"),
allowIceFallback: parser.getFlag("allowIceFallback"),
perParticipantE2EE: parser.getFlag("perParticipantE2EE"),
controlledAudioDevices: parser.getFlag("controlledAudioDevices"),
skipLobby: isWidget ? parser.getFlag("skipLobby") : false,
// In SPA mode the user should always exit to the home screen when hanging
// up, rather than being sent back to the lobby
returnToLobby: isWidget ? parser.getFlag("returnToLobby") : false,
sendNotificationType: parser.getEnumParam("sendNotificationType", [
"ring",
"notification",
]),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
};
return {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
sendNotificationType,
};
};
@@ -507,16 +398,10 @@ export function getRoomIdentifierFromUrl(
// Make sure roomId is valid
let roomId: string | null = parser.getParam("roomId");
if (roomId !== null) {
// Replace any non-printable characters that another client may have inserted.
// For instance on iOS, some copied links end up with zero width characters on the end which get encoded into the URL.
// This isn't valid for a roomId, so we can freely strip the content.
roomId = roomId.replaceAll(/^[^ -~]+|[^ -~]+$/g, "");
if (!roomId.startsWith("!")) {
roomId = null;
} else if (!roomId.includes("")) {
roomId = null;
}
if (!roomId?.startsWith("!")) {
roomId = null;
} else if (!roomId.includes("")) {
roomId = null;
}
return {

View File

@@ -114,29 +114,24 @@ 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;
/**
* The time (in milliseconds) after which a 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
* helps by keeping more delayed event reset candidates in flight,
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/
delayed_leave_event_restart_local_timeout_ms?: number;
/** @deprecated use delayed_leave_event_delay_ms instead */
membership_server_side_expiry_timeout?: number;
/**
* The time interval (in milliseconds) at which the client sends membership keep-alive
* 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,7 +6,9 @@ Please see LICENSE in the repository root for full details.
*/
import { Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
const logger = rootLogger.getChild("[controlled-output]");
export interface Controls {
canEnterPip(): boolean;

View File

@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 658 B

View File

@@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 656 B

View File

@@ -14,12 +14,13 @@ import {
type AudioTrackProps,
} from "@livekit/components-react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger } 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.
@@ -71,7 +72,7 @@ export function MatrixAudioRenderer({
const logInvalid = (identity: string, validIdentities: Set<string>): void => {
if (loggedInvalidIdentities.current.has(identity)) return;
logger.warn(
`[MatrixAudioRenderer] Audio track ${identity} has no matching matrix call member`,
`Audio track ${identity} has no matching matrix call member`,
`current members: ${Array.from(validIdentities.values())}`,
`track will not get rendered`,
);
@@ -101,7 +102,7 @@ export function MatrixAudioRenderer({
useEffect(() => {
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
logger.debug(
`[MatrixAudioRenderer] All audio tracks have a matching matrix call member identity.`,
`All audio tracks have a matching matrix call member identity.`,
);
loggedInvalidIdentities.current.clear();
}
@@ -181,7 +182,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
* audioContext to normal audio playback.
* audiooContext to normal audio playback.
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
* @param param0
* @returns
@@ -201,7 +202,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 two is undefined.
// for unsetting the audioContext its enough if one of the the is undefined.
[audioContext && audioNodes],
);

View File

@@ -22,11 +22,17 @@ 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
@@ -163,7 +169,12 @@ async function connectAndPublish(
try {
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt);
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,
});
logger.info(`[Lifecycle] ... connected to livekit room`);
} catch (e) {
logger.error("[Lifecycle] Failed to connect", e);
@@ -173,19 +184,11 @@ 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) {
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();
}
}
if (
e instanceof ConnectionError &&
(e.status === 503 || e.status === 200 || e.status === 429)
)
throw new InsufficientCapacityError();
throw e;
}

View File

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

View File

@@ -21,7 +21,6 @@ import {
OfflineIcon,
WebBrowserIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web";
import {
ConnectionLostError,
@@ -94,13 +93,9 @@ const ErrorPage: FC<ErrorPageProps> = ({
</p>
{actions &&
actions.map((action, index) => (
<Button
kind="secondary"
onClick={action.onClick}
key={`action${index}`}
>
<button onClick={action.onClick} key={`action${index}`}>
{action.label}
</Button>
</button>
))}
</ErrorView>
</FullScreenView>

View File

@@ -166,11 +166,7 @@ export const GroupCallView: FC<Props> = ({
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
} = useUrlParams();
const { perParticipantE2EE, returnToLobby } = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
const [useExperimentalToDeviceTransport] = useSetting(
@@ -178,6 +174,7 @@ 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

@@ -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, useSubscription } from "observable-hooks";
import { useObservable } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import {
@@ -140,17 +140,17 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useEffect(() => {
logger.info(
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
);
return (): void => {
logger.info(
`[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
);
livekitRoom
?.disconnect()
.then(() => {
logger.info(
`[Lifecycle] Disconnected from livekit room, state:${livekitRoom?.state}`,
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`,
);
})
.catch((e) => {
@@ -159,8 +159,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
}, [livekitRoom]);
const { autoLeaveWhenOthersLeft } = useUrlParams();
useEffect(() => {
if (livekitRoom !== undefined) {
const reactionsReader = new ReactionsReader(props.rtcSession);
@@ -168,10 +166,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.rtcSession,
livekitRoom,
mediaDevices,
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
},
props.e2eeSystem,
connStateObservable$,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
@@ -188,7 +183,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices,
props.e2eeSystem,
connStateObservable$,
autoLeaveWhenOthersLeft,
]);
if (livekitRoom === undefined || vm === null) return null;
@@ -319,7 +313,6 @@ export const InCallView: FC<InCallViewProps> = ({
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

View File

@@ -191,11 +191,7 @@ describe("useMuteStates", () => {
mockConfig();
render(
<MemoryRouter
initialEntries={[
"/room/?skipLobby=true&widgetId=1234&parentUrl=www.parent.org",
]}
>
<MemoryRouter initialEntries={["/room/?skipLobby=true"]}>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>

View File

@@ -132,13 +132,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
<p>
You were disconnected from the call.
</p>
<button
class="_button_vczzf_8"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
<button>
Reconnect
</button>
<button
@@ -748,13 +742,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
<p>
You were disconnected from the call.
</p>
<button
class="_button_vczzf_8"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
<button>
Reconnect
</button>
<button

View File

@@ -70,12 +70,6 @@ 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: [],
@@ -201,12 +195,6 @@ 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,7 +20,6 @@ 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";
@@ -47,9 +46,6 @@ 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) {
@@ -59,17 +55,18 @@ async function makePreferredLivekitFoci(
FOCI_WK_KEY
];
if (Array.isArray(wellKnownFoci)) {
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);
preferredFoci.push(
...wellKnownFoci
.filter((f) => !!f)
.filter(isLivekitFocusConfig)
.map((wellKnownFocus) => {
logger.log(
"Adding livekit focus from well known: ",
wellKnownFocus,
);
return { ...wellKnownFocus, livekit_alias: livekitAlias };
}),
);
}
}
@@ -80,15 +77,10 @@ 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);
@@ -131,13 +123,15 @@ export async function enterRTCSession(
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}),
delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
matrixRtcSessionConfig?.delayed_leave_event_restart_ms ??
matrixRtcSessionConfig?.membership_keep_alive_period,
delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
matrixRtcSessionConfig?.delayed_leave_event_delay_ms ??
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
makeKeyDelay:
matrixRtcSessionConfig?.wait_for_key_rotation_ms ??
matrixRtcSessionConfig?.key_rotation_on_leave_delay,
membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport,

View File

@@ -32,11 +32,7 @@ import {
} from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import {
CallViewModel,
type CallViewModelOptions,
type Layout,
} from "./CallViewModel";
import { CallViewModel, type Layout } from "./CallViewModel";
import {
mockLivekitRoom,
mockLocalParticipant,
@@ -75,7 +71,6 @@ import {
local,
localId,
localRtcMember,
localRtcMemberDevice2,
} from "../utils/test-fixtures";
import { ObservableScope } from "./ObservableScope";
import { MediaDevices } from "./MediaDevices";
@@ -236,10 +231,6 @@ 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: {
@@ -290,7 +281,9 @@ function withCallViewModel(
rtcSession as unknown as MatrixRTCSession,
liveKitRoom,
mediaDevices,
options,
{
kind: E2eeType.PER_PARTICIPANT,
},
connectionState$,
raisedHands$,
new BehaviorSubject({}),
@@ -985,7 +978,7 @@ test("should strip RTL characters from displayname", () => {
});
it("should rank raised hands above video feeds and below speakers and presenters", () => {
withTestScheduler(({ schedule, expectObservable, behavior }) => {
withTestScheduler(({ schedule, expectObservable }) => {
// There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "ab";
@@ -1044,176 +1037,6 @@ 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

@@ -96,10 +96,6 @@ 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;
@@ -477,47 +473,49 @@ 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.memberships$.pipe(
map((memberships) => {
const displaynameMap = new Map<string, string>();
const { room } = this.matrixRTCSession;
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;
// 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;
// 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),
);
}
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:
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$);
@@ -614,7 +612,7 @@ export class CallViewModel extends ViewModel {
indexedMediaId,
member,
participant,
this.options.encryptionSystem,
this.encryptionSystem,
this.livekitRoom,
this.memberDisplaynames$.pipe(
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
@@ -637,7 +635,7 @@ export class CallViewModel extends ViewModel {
screenShareId,
member,
participant,
this.options.encryptionSystem,
this.encryptionSystem,
this.livekitRoom,
this.memberDisplaynames$.pipe(
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
@@ -678,7 +676,7 @@ export class CallViewModel extends ViewModel {
nonMemberId,
undefined,
participant,
this.options.encryptionSystem,
this.encryptionSystem,
this.livekitRoom,
this.memberDisplaynames$.pipe(
map(
@@ -728,77 +726,18 @@ export class CallViewModel extends ViewModel {
),
);
/**
* 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(() => {}),
);
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: [] },
),
);
/**
* List of MediaItems that we want to display, that are of type ScreenShare
@@ -1487,7 +1426,7 @@ export class CallViewModel extends ViewModel {
private readonly matrixRTCSession: MatrixRTCSession,
private readonly livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices,
private readonly options: CallViewModelOptions,
private readonly encryptionSystem: EncryptionSystem,
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 { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
audioInput as audioInputSetting,
@@ -38,6 +38,7 @@ 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 }
@@ -99,7 +100,6 @@ 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,15 +162,8 @@ 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,
this.logger,
);
availableRawDevices$("audioinput", this.usingNames$, this.scope);
public readonly available$ = this.scope.behavior(
this.availableRaw$.pipe(map(buildDeviceMap)),
@@ -207,7 +200,7 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
private readonly scope: ObservableScope,
) {
this.available$.subscribe((available) => {
this.logger.info("[audio-input] available devices:", available);
logger.info("[audio-input] available devices:", available);
});
}
}
@@ -215,16 +208,10 @@ 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,
this.logger,
).pipe(
availableRawDevices$("audiooutput", this.usingNames$, this.scope).pipe(
map((availableRaw) => {
let available: Map<string, AudioOutputDeviceLabel> =
const 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
@@ -234,12 +221,6 @@ 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.
@@ -269,7 +250,7 @@ class AudioOutput
private readonly scope: ObservableScope,
) {
this.available$.subscribe((available) => {
this.logger.info("[audio-output] available devices:", available);
logger.info("[audio-output] available devices:", available);
});
}
}
@@ -277,7 +258,6 @@ 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.
@@ -285,7 +265,6 @@ class ControlledAudioOutput
"audiooutput",
this.usingNames$,
this.scope,
this.logger,
);
public readonly available$ = this.scope.behavior(
@@ -349,37 +328,26 @@ class ControlledAudioOutput
// been selected - for example, Element X iOS listens to this to determine
// whether it should enable the proximity sensor.
if (device !== undefined) {
this.logger.info(
"[controlled-output] onAudioDeviceSelect called:",
device,
);
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) => {
this.logger.info("[controlled-output] available devices:", available);
logger.info("[controlled-output] available devices:", available);
});
this.availableRaw$.subscribe((availableRaw) => {
this.logger.info(
"[controlled-output] available raw devices:",
availableRaw,
);
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,
this.logger,
).pipe(map(buildDeviceMap)),
availableRawDevices$("videoinput", this.usingNames$, this.scope).pipe(
map(buildDeviceMap),
),
);
public readonly selected$ = this.scope.behavior(
selectDevice$(this.available$, videoInputSetting.value$).pipe(
@@ -396,7 +364,7 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
) {
// This also has the purpose of subscribing to the available devices
this.available$.subscribe((available) => {
this.logger.info("[video-input] available devices:", available);
logger.info("[video-input] available devices:", available);
});
}
}

View File

@@ -88,48 +88,40 @@ 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: rgba(from var(--cpd-color-gray-100) r g b / 0.6);
background: var(--cpd-color-alpha-gray-1400);
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-primary);
color: var(--cpd-color-icon-on-solid-primary);
}
@media (hover) {
.expand:hover {
background: var(--cpd-color-gray-400);
background: var(--cpd-color-bg-action-primary-hovered);
}
}
.expand:active {
background: var(--cpd-color-gray-100);
background: var(--cpd-color-bg-action-primary-pressed);
}
@media (hover) {
.tile:hover > div > button {
.tile:hover > button {
opacity: 1;
}
}
.tile:has(:focus-visible) > div > button {
.tile:has(:focus-visible) > button {
opacity: 1;
}

View File

@@ -29,8 +29,6 @@ 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 {
@@ -212,26 +210,6 @@ 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
@@ -314,28 +292,17 @@ export const SpotlightTile: FC<Props> = ({
/>
))}
</div>
<div className={styles.bottomRightButtons}>
{onToggleExpanded && (
<button
className={classNames(styles.expand)}
aria-label={"maximise"}
onClick={onToggleFullscreen}
aria-label={
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
>
<FullScreenIcon aria-hidden width={20} height={20} />
<ToggleExpandIcon 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,7 +17,6 @@ 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",
}
@@ -130,14 +129,3 @@ 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,11 +12,7 @@ import {
mockLocalParticipant,
} from "./test";
export const localRtcMember = mockRtcMembership("@carol:example.org", "1111");
export const localRtcMemberDevice2 = mockRtcMembership(
"@carol:example.org",
"2222",
);
export const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
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({}),
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
handRaisedSubject$,

View File

@@ -74,7 +74,6 @@ export interface OurRunHelpers extends RunHelpers {
values?: { [marble: string]: T },
error?: unknown,
): Behavior<T>;
scope: ObservableScope;
}
interface TestRunnerGlobal {
@@ -97,7 +96,6 @@ export function withTestScheduler(
scheduler.run((helpers) =>
continuation({
...helpers,
scope,
schedule(marbles, actions) {
const actionsObservable$ = helpers
.cold(marbles)

View File

@@ -106,10 +106,6 @@ export const widget = ((): WidgetHelpers | null => {
if (!baseUrl) throw new Error("Base URL must be supplied");
// These are all the event types the app uses
const sendEvent = [
EventType.CallNotify, // Sent as a deprecated fallback
EventType.RTCNotification,
];
const sendRecvEvent = [
"org.matrix.rageshake_request",
EventType.CallEncryptionKeysPrefix,
@@ -133,7 +129,6 @@ export const widget = ((): WidgetHelpers | null => {
{ eventType: EventType.RoomEncryption },
{ eventType: EventType.GroupCallMemberPrefix },
];
const sendRecvToDevice = [
EventType.CallInvite,
EventType.CallCandidates,
@@ -151,7 +146,7 @@ export const widget = ((): WidgetHelpers | null => {
const client = createRoomWidgetClient(
api,
{
sendEvent: [...sendEvent, ...sendRecvEvent],
sendEvent: sendRecvEvent,
receiveEvent: sendRecvEvent,
sendState,
receiveState,

781
yarn.lock

File diff suppressed because it is too large Load Diff