Merge branch 'robin/switch-camera-tile' into robin/reactions-small

This commit is contained in:
Robin
2025-08-14 16:39:51 +02:00
80 changed files with 2782 additions and 1783 deletions

View File

@@ -44,7 +44,7 @@ module.exports = {
], ],
// To encourage good usage of RxJS: // To encourage good usage of RxJS:
"rxjs/no-exposed-subjects": "error", "rxjs/no-exposed-subjects": "error",
"rxjs/finnish": "error", "rxjs/finnish": ["error", { names: { "^this$": false } }],
}, },
settings: { settings: {
react: { react: {

View File

@@ -9,6 +9,6 @@ jobs:
steps: steps:
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2 - uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
with: 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" REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one 'PR-' label"
BANNED_LABELS: "banned" BANNED_LABELS: "banned"

1
.gitignore vendored
View File

@@ -9,6 +9,7 @@ dist-ssr
public/config.json public/config.json
backend/synapse_tmp/* backend/synapse_tmp/*
/coverage /coverage
config.json
# Yarn # Yarn
yarn-error.log yarn-error.log

View File

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

View File

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

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", "type": "livekit",
"livekit_service_url": "https://matrix-rtc-2.example.com/livekit/jwt" "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 | | 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. | | `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. | | `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. | | `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`. | | `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. | | `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. | | `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. | | `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.) | | `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. | | `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
@@ -69,6 +69,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) | | `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | | `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. | | `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
### Widget-only parameters ### Widget-only parameters

View File

@@ -2,11 +2,11 @@
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format # https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions] [versions]
android_gradle_plugin = "8.11.0" android_gradle_plugin = "8.11.1"
[libraries] [libraries]
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" } android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
[plugins] [plugins]
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" } 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 distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View File

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

View File

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

View File

@@ -61,6 +61,7 @@
"video": "Video" "video": "Video"
}, },
"developer_mode": { "developer_mode": {
"always_show_iphone_earpiece": "Vis mulighed for iPhone-høretelefon på alle platforme",
"crypto_version": "Krypto-version: {{version}}", "crypto_version": "Krypto-version: {{version}}",
"debug_tile_layout_label": "Fejlfinding af fliselayout", "debug_tile_layout_label": "Fejlfinding af fliselayout",
"device_id": "Enheds-id: {{id}}", "device_id": "Enheds-id: {{id}}",
@@ -70,6 +71,7 @@
"livekit_server_info": "LiveKit Serverinfo", "livekit_server_info": "LiveKit Serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}", "livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}", "matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Slå al lyd fra (deltagere, reaktioner, deltagelseslyde)",
"show_connection_stats": "Vis forbindelsesstatistik", "show_connection_stats": "Vis forbindelsesstatistik",
"show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer", "show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer",
"url_params": "URL-parametre", "url_params": "URL-parametre",
@@ -80,7 +82,7 @@
"error": { "error": {
"call_is_not_supported": "Opkald er ikke understøttet", "call_is_not_supported": "Opkald er ikke understøttet",
"call_not_found": "Opkald ikke fundet", "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": "Forbindelsen gik tabt",
"connection_lost_description": "Du blev afbrudt fra opkaldet.", "connection_lost_description": "Du blev afbrudt fra opkaldet.",
"e2ee_unsupported": "Inkompatibel browser", "e2ee_unsupported": "Inkompatibel browser",
@@ -164,12 +166,17 @@
"effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.", "effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.",
"effect_volume_label": "Lydstyrke for lydeffekter" "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", "developer_tab_title": "Udvikler",
"devices": { "devices": {
"camera": "Kamera", "camera": "Kamera",
"camera_numbered": "Kamera {{n}}", "camera_numbered": "Kamera {{n}}",
"change_device_button": "Skift lydenhed",
"default": "Standard", "default": "Standard",
"default_named": "Standard <2>({{name}})</2>", "default_named": "Standard <2>({{name}})</2>",
"loudspeaker": "Højttaler",
"microphone": "Mikrofon", "microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}", "microphone_numbered": "Mikrofon {{n}}",
"speaker": "Højttaler", "speaker": "Højttaler",

View File

@@ -82,7 +82,7 @@
"error": { "error": {
"call_is_not_supported": "Anrufe werden nicht unterstützt", "call_is_not_supported": "Anrufe werden nicht unterstützt",
"call_not_found": "Anruf nicht gefunden", "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": "Verbindung verloren",
"connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.", "connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.",
"e2ee_unsupported": "Inkompatibler Browser", "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 }}).", "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": "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.", "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." "unexpected_ec_error": "Ein unerwarteter Fehler ist aufgetreten (<0>Fehlercode: </0> <1>{{ errorCode }}</1>). Bitte den Serveradministrator kontaktieren."
}, },
"group_call_loader": { "group_call_loader": {
@@ -105,6 +107,11 @@
"knock_reject_heading": "Zugriff verweigert", "knock_reject_heading": "Zugriff verweigert",
"reason": "Grund: {{reason}}" "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", "hangup_button_label": "Anruf beenden",
"header_label": "Element Call-Startseite", "header_label": "Element Call-Startseite",
"header_participants_label": "Teilnehmende", "header_participants_label": "Teilnehmende",
@@ -173,9 +180,11 @@
"devices": { "devices": {
"camera": "Kamera", "camera": "Kamera",
"camera_numbered": "Kamera {{n}}", "camera_numbered": "Kamera {{n}}",
"change_device_button": "Audiogerät wechseln",
"default": "Standard", "default": "Standard",
"default_named": "Standard<2> ({{name}} )</2>", "default_named": "Standard<2> ({{name}} )</2>",
"earpiece": "Ohrhörer", "handset": "Ohrhörer",
"loudspeaker": "Lautsprecher",
"microphone": "Mikrofon", "microphone": "Mikrofon",
"microphone_numbered": "Mikrofon{{n}}", "microphone_numbered": "Mikrofon{{n}}",
"speaker": "Lautsprecher", "speaker": "Lautsprecher",

View File

@@ -79,11 +79,6 @@
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key" "use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
}, },
"disconnected_banner": "Connectivity to the server has been lost.", "disconnected_banner": "Connectivity to the server has been lost.",
"earpiece": {
"overlay_back_button": "Back to Speaker Mode",
"overlay_description": "Only works while using app",
"overlay_title": "Earpiece Mode"
},
"error": { "error": {
"call_is_not_supported": "Call is not supported", "call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found", "call_not_found": "Call not found",
@@ -99,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 }}).", "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": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.", "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." "unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
}, },
"group_call_loader": { "group_call_loader": {
@@ -110,6 +107,11 @@
"knock_reject_heading": "Access denied", "knock_reject_heading": "Access denied",
"reason": "Reason: {{reason}}" "reason": "Reason: {{reason}}"
}, },
"handset": {
"overlay_back_button": "Back to Speaker Mode",
"overlay_description": "Only works while using app",
"overlay_title": "Handset Mode"
},
"hangup_button_label": "End call", "hangup_button_label": "End call",
"header_label": "Element Call Home", "header_label": "Element Call Home",
"header_participants_label": "Participants", "header_participants_label": "Participants",
@@ -181,7 +183,7 @@
"change_device_button": "Change audio device", "change_device_button": "Change audio device",
"default": "Default", "default": "Default",
"default_named": "Default <2>({{name}})</2>", "default_named": "Default <2>({{name}})</2>",
"earpiece": "Earpiece", "handset": "Handset",
"loudspeaker": "Loudspeaker", "loudspeaker": "Loudspeaker",
"microphone": "Microphone", "microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}", "microphone_numbered": "Microphone {{n}}",

View File

@@ -61,6 +61,7 @@
"video": "Video" "video": "Video"
}, },
"developer_mode": { "developer_mode": {
"always_show_iphone_earpiece": "Näita iPhone'i kuulari valikut kõikidel platvormidel",
"crypto_version": "Krüptoteekide versioon: {{version}}", "crypto_version": "Krüptoteekide versioon: {{version}}",
"debug_tile_layout_label": "Meediapaanide paigutus", "debug_tile_layout_label": "Meediapaanide paigutus",
"device_id": "Seadme tunnus: {{id}}", "device_id": "Seadme tunnus: {{id}}",
@@ -81,7 +82,7 @@
"error": { "error": {
"call_is_not_supported": "Kõne pole toetatud", "call_is_not_supported": "Kõne pole toetatud",
"call_not_found": "Kõnet ei leidu", "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": "Ühendus on katkenud",
"connection_lost_description": "Sinu ühendus selle kõnega on katkenud.", "connection_lost_description": "Sinu ühendus selle kõnega on katkenud.",
"e2ee_unsupported": "Mitteühilduv brauser", "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 }}).", "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": "Avatud teisel vahekaardil",
"open_elsewhere_description": "{{brand}} on avatud teisel vahekaardil. Kui see ei tundu olema õige, proovi selle lehe uuesti laadimist.", "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." "unexpected_ec_error": "Tekkis ootamatu viga (<0>Veakood:</0> <1>{{ errorCode }}</1>). Palun võta ühendust serveri haldajaga."
}, },
"group_call_loader": { "group_call_loader": {
@@ -104,6 +107,10 @@
"knock_reject_heading": "Liitumine pole lubatud", "knock_reject_heading": "Liitumine pole lubatud",
"reason": "Põhjus" "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", "hangup_button_label": "Lõpeta kõne",
"header_label": "Avaleht: Element Call", "header_label": "Avaleht: Element Call",
"header_participants_label": "Osalejad", "header_participants_label": "Osalejad",
@@ -172,8 +179,10 @@
"devices": { "devices": {
"camera": "Kaamera", "camera": "Kaamera",
"camera_numbered": "Kaamera {{n}}", "camera_numbered": "Kaamera {{n}}",
"change_device_button": "Muuda heliseadet",
"default": "Vaikimisi", "default": "Vaikimisi",
"default_named": "Vaikimisi <2>({{name}})</2>", "default_named": "Vaikimisi <2>({{name}})</2>",
"loudspeaker": "Valjuhääldi",
"microphone": "Mikrofon", "microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}", "microphone_numbered": "Mikrofon {{n}}",
"speaker": "Kõlar", "speaker": "Kõlar",

View File

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

View File

@@ -61,6 +61,7 @@
"video": "Video" "video": "Video"
}, },
"developer_mode": { "developer_mode": {
"always_show_iphone_earpiece": "Zobraziť možnosť slúchadla iPhone na všetkých platformách",
"crypto_version": "Krypto verzia: {{version}}", "crypto_version": "Krypto verzia: {{version}}",
"debug_tile_layout_label": "Ladenie rozloženia dlaždíc", "debug_tile_layout_label": "Ladenie rozloženia dlaždíc",
"device_id": "ID zariadenia: {{id}}", "device_id": "ID zariadenia: {{id}}",
@@ -81,7 +82,7 @@
"error": { "error": {
"call_is_not_supported": "Hovor nie je podporovaný", "call_is_not_supported": "Hovor nie je podporovaný",
"call_not_found": "Hovor nebol nájdený", "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": "Strata spojenia",
"connection_lost_description": "Boli ste odpojení od hovoru.", "connection_lost_description": "Boli ste odpojení od hovoru.",
"e2ee_unsupported": "Nekompatibilný prehliadač", "e2ee_unsupported": "Nekompatibilný prehliadač",
@@ -173,8 +174,10 @@
"devices": { "devices": {
"camera": "Kamera", "camera": "Kamera",
"camera_numbered": "Kamera {{n}}", "camera_numbered": "Kamera {{n}}",
"change_device_button": "Zmeniť zvukové zariadenie",
"default": "Predvolené", "default": "Predvolené",
"default_named": "Predvolené <2>({{name}})</2>", "default_named": "Predvolené <2>({{name}})</2>",
"loudspeaker": "Reproduktor",
"microphone": "Mikrofón", "microphone": "Mikrofón",
"microphone_numbered": "Mikrofón {{n}}", "microphone_numbered": "Mikrofón {{n}}",
"speaker": "Reproduktor", "speaker": "Reproduktor",

View File

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

View File

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

View File

@@ -72,3 +72,56 @@ test("Should automatically retry non fatal JWT errors", async ({
await hasRetriedPromise; await hasRetriedPromise;
await expect(page.getByTestId("video").first()).toBeVisible(); 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 // https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU // The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1); expect(wsConnectionCount).toBe(1);
expect(sfuGetCallCount).toBe(1); expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
}); });

View File

@@ -17,10 +17,7 @@ import {
useState, useState,
} from "react"; } from "react";
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web"; import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
import { import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
ArrowLeftIcon,
CollapseIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Header, LeftNav, RightNav } from "./Header"; import { Header, LeftNav, RightNav } from "./Header";
@@ -45,7 +42,6 @@ interface Props {
*/ */
export const AppBar: FC<Props> = ({ children }) => { export const AppBar: FC<Props> = ({ children }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const BackIcon = platform === "ios" ? CollapseIcon : ArrowLeftIcon;
const onBackClick = useCallback((e: MouseEvent) => { const onBackClick = useCallback((e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
window.controls.onBackButtonPressed?.(); window.controls.onBackButtonPressed?.();
@@ -69,7 +65,7 @@ export const AppBar: FC<Props> = ({ children }) => {
<LeftNav> <LeftNav>
<Tooltip label={t("common.back")}> <Tooltip label={t("common.back")}>
<IconButton onClick={onBackClick}> <IconButton onClick={onBackClick}>
<BackIcon /> <CollapseIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
</LeftNav> </LeftNav>

View File

@@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest";
import { import {
getRoomIdentifierFromUrl, getRoomIdentifierFromUrl,
getUrlParams, getUrlParams,
UserIntent, HeaderStyle,
} from "../src/UrlParams"; } from "../src/UrlParams";
const ROOM_NAME = "roomNameHere"; const ROOM_NAME = "roomNameHere";
@@ -82,6 +82,16 @@ describe("UrlParams", () => {
getRoomIdentifierFromUrl("", `?roomId=${ROOM_ID}`, "").roomId, getRoomIdentifierFromUrl("", `?roomId=${ROOM_ID}`, "").roomId,
).toBe(ROOM_ID); ).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", () => { it("ignores room alias", () => {
@@ -201,24 +211,68 @@ describe("UrlParams", () => {
}); });
describe("intent", () => { describe("intent", () => {
it("defaults to unknown", () => { const noIntentDefaults = {
expect(getUrlParams().intent).toBe(UserIntent.Unknown); 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("ignores intent if it is not a valid value", () => { it("ignores intent if it is not a valid value", () => {
expect(getUrlParams("?intent=foo").intent).toBe(UserIntent.Unknown); expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
}); });
it("accepts start_call", () => { it("accepts start_call", () => {
expect(getUrlParams("?intent=start_call").intent).toBe( expect(
UserIntent.StartNewCall, getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"),
); ).toMatchObject(startNewCallDefaults("desktop"));
}); });
it("accepts join_existing", () => { it("accepts join_existing", () => {
expect(getUrlParams("?intent=join_existing").intent).toBe( expect(
UserIntent.JoinExistingCall, getUrlParams(
); "?intent=join_existing&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(joinExistingCallDefaults("desktop"));
}); });
}); });
@@ -250,9 +304,5 @@ describe("UrlParams", () => {
); );
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none"); 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

@@ -8,10 +8,13 @@ Please see LICENSE in the repository root for full details.
import { useMemo } from "react"; import { useMemo } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger"; 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 { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType"; import { E2eeType } from "./e2ee/e2eeType";
import { platform } from "./Platform";
interface RoomIdentifier { interface RoomIdentifier {
roomAlias: string | null; roomAlias: string | null;
@@ -22,6 +25,8 @@ interface RoomIdentifier {
export enum UserIntent { export enum UserIntent {
StartNewCall = "start_call", StartNewCall = "start_call",
JoinExistingCall = "join_existing", JoinExistingCall = "join_existing",
StartNewCallDM = "start_call_dm",
JoinExistingCallDM = "join_existing_dm",
Unknown = "unknown", Unknown = "unknown",
} }
@@ -31,12 +36,12 @@ export enum HeaderStyle {
AppBar = "app_bar", AppBar = "app_bar",
} }
// 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 UrlProperties are used to pass required data to the widget.
// the situations that call for this behavior ('isEmbedded'). This makes it * Those are different in different rooms, users, devices. They do not configure the behavior of the
// clearer what each flag means, and helps us avoid coupling Element Call's * widget but provide the required data to the widget.
// behavior to the needs of specific consumers. */
export interface UrlParams { export interface UrlProperties {
// Widget api related params // Widget api related params
widgetId: string | null; widgetId: string | null;
parentUrl: string | null; parentUrl: string | null;
@@ -48,45 +53,11 @@ export interface UrlParams {
* is also not validated, where it is in useRoomIdentifier(). * is also not validated, where it is in useRoomIdentifier().
*/ */
roomId: string | null; 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). * The user's ID (only used in matryoshka mode).
*/ */
userId: string | null; userId: string | null;
/** /**
* The display name to use for auto-registration. * The display name to use for auto-registration.
*/ */
@@ -124,14 +95,96 @@ export interface UrlParams {
*/ */
posthogApiKey: string | null; posthogApiKey: string | null;
/** /**
* Whether the app is allowed to use fallback STUN servers for ICE in case the * Whether to use end-to-end encryption.
* user's homeserver doesn't provide any.
*/ */
allowIceFallback: boolean; e2eEnabled: boolean;
/** /**
* E2EE password * E2EE password
*/ */
password: string | null; 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;
/** /**
* Whether the app should use per participant keys for E2EE. * Whether the app should use per participant keys for E2EE.
*/ */
@@ -154,47 +207,24 @@ export interface UrlParams {
*/ */
returnToLobby: boolean; returnToLobby: boolean;
/** /**
* The theme to use for element call. * Whether and what type of notification EC should send, when the user joins the call.
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
*/ */
theme: string | null; sendNotificationType?: RTCNotificationType;
/** 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 * Whether the app should automatically leave the call when there
* a new (guest) user. * is no one left in the call.
* This can be user to configure a non default guest user server when * This is one part to make the call matrixRTC session behave like a telephone call.
* creating a spa link.
*/ */
homeserver: string | null; autoLeaveWhenOthersLeft: boolean;
/**
* 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;
} }
// 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 // 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 // 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 // consolidate all the data about URL parameters and their meanings to this one
@@ -235,6 +265,17 @@ class ParamParser {
return this.fragmentParams.get(name) ?? this.queryParams.get(name); 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[] { public getAllParams(name: string): string[] {
return [ return [
...this.fragmentParams.getAll(name), ...this.fragmentParams.getAll(name),
@@ -242,10 +283,20 @@ class ParamParser {
]; ];
} }
/**
* Returns true if the flag exists and is not "false".
*/
public getFlagParam(name: string, defaultValue = false): boolean { public getFlagParam(name: string, defaultValue = false): boolean {
const param = this.getParam(name); const param = this.getParam(name);
return param === null ? defaultValue : param !== "false"; 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;
}
} }
/** /**
@@ -262,41 +313,95 @@ export const getUrlParams = (
const fontScale = parseFloat(parser.getParam("fontScale") ?? ""); 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 widgetId = parser.getParam("widgetId"); const widgetId = parser.getParam("widgetId");
const parentUrl = parser.getParam("parentUrl"); const parentUrl = parser.getParam("parentUrl");
const isWidget = !!widgetId && !!parentUrl; const isWidget = !!widgetId && !!parentUrl;
return { /**
* 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 = {
widgetId, widgetId,
parentUrl, parentUrl,
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl: // 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 // 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. // the room ID is, then that's what it is.
roomId: parser.getParam("roomId"), roomId: parser.getParam("roomId"),
password: parser.getParam("password"), 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, userId: isWidget ? parser.getParam("userId") : null,
displayName: parser.getParam("displayName"), displayName: parser.getParam("displayName"),
deviceId: isWidget ? parser.getParam("deviceId") : null, deviceId: isWidget ? parser.getParam("deviceId") : null,
@@ -304,24 +409,9 @@ export const getUrlParams = (
lang: parser.getParam("lang"), lang: parser.getParam("lang"),
fonts: parser.getAllParams("font"), fonts: parser.getAllParams("font"),
fontScale: Number.isNaN(fontScale) ? null : fontScale, 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"), theme: parser.getParam("theme"),
viaServers: !isWidget ? parser.getParam("viaServers") : null, viaServers: !isWidget ? parser.getParam("viaServers") : null,
homeserver: !isWidget ? parser.getParam("homeserver") : null, homeserver: !isWidget ? parser.getParam("homeserver") : null,
intent,
posthogApiHost: parser.getParam("posthogApiHost"), posthogApiHost: parser.getParam("posthogApiHost"),
posthogApiKey: parser.getParam("posthogApiKey"), posthogApiKey: parser.getParam("posthogApiKey"),
posthogUserId: posthogUserId:
@@ -329,6 +419,36 @@ export const getUrlParams = (
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"), rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
sentryDsn: parser.getParam("sentryDsn"), sentryDsn: parser.getParam("sentryDsn"),
sentryEnvironment: parser.getParam("sentryEnvironment"), 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),
}; };
}; };
@@ -387,10 +507,16 @@ export function getRoomIdentifierFromUrl(
// Make sure roomId is valid // Make sure roomId is valid
let roomId: string | null = parser.getParam("roomId"); let roomId: string | null = parser.getParam("roomId");
if (!roomId?.startsWith("!")) { if (roomId !== null) {
roomId = null; // Replace any non-printable characters that another client may have inserted.
} else if (!roomId.includes("")) { // For instance on iOS, some copied links end up with zero width characters on the end which get encoded into the URL.
roomId = null; // 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;
}
} }
return { return {

View File

@@ -32,7 +32,7 @@ exports[`AppBar > renders 1`] = `
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <path
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0" d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/> />
</svg> </svg>
</div> </div>

View File

@@ -24,8 +24,6 @@ import {
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import classNames from "classnames"; import classNames from "classnames";
import { useObservableState } from "observable-hooks";
import { map } from "rxjs";
import { useReactionsSender } from "../reactions/useReactionsSender"; import { useReactionsSender } from "../reactions/useReactionsSender";
import styles from "./ReactionToggleButton.module.css"; import styles from "./ReactionToggleButton.module.css";
@@ -36,6 +34,7 @@ import {
} from "../reactions"; } from "../reactions";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel";
import { useBehavior } from "../useBehavior";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean; raised: boolean;
@@ -180,12 +179,8 @@ export function ReactionToggleButton({
const [showReactionsMenu, setShowReactionsMenu] = useState(false); const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>(); const [errorText, setErrorText] = useState<string>();
const isHandRaised = useObservableState( const isHandRaised = !!useBehavior(vm.handsRaised$)[identifier];
vm.handsRaised$.pipe(map((v) => !!v[identifier])), const canReact = !useBehavior(vm.reactions$)[identifier];
);
const canReact = useObservableState(
vm.reactions$.pipe(map((v) => !v[identifier])),
);
useEffect(() => { useEffect(() => {
// Clear whenever the reactions menu state changes. // Clear whenever the reactions menu state changes.

View File

@@ -114,24 +114,29 @@ export interface ConfigOptions {
* when someone leaves a call. * when someone leaves a call.
*/ */
wait_for_key_rotation_ms?: number; 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) * 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. * that the server waits before sending the leave MatrixRTC membership event.
*/ */
delayed_leave_event_delay_ms?: number; 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.
* 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;
/** /**
* The time interval (in milliseconds) at which the client sends membership keep-alive * 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. * messages to the server by restarting the timer for the delayed leave event.
*/ */
delayed_leave_event_restart_ms?: number; 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. * 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 { Subject } from "rxjs";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
const logger = rootLogger.getChild("[controlled-output]");
export interface Controls { export interface Controls {
canEnterPip(): boolean; canEnterPip(): boolean;

View File

@@ -24,16 +24,16 @@ import {
createContext, createContext,
memo, memo,
use, use,
useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
useSyncExternalStore,
} from "react"; } from "react";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import classNames from "classnames"; import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import { fromEvent, map, startWith } from "rxjs";
import styles from "./Grid.module.css"; import styles from "./Grid.module.css";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
@@ -155,11 +155,6 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void {
); );
} }
const windowHeightObservable$ = fromEvent(window, "resize").pipe(
startWith(null),
map(() => window.innerHeight),
);
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> { export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref?: Ref<R>; ref?: Ref<R>;
model: LayoutModel; model: LayoutModel;
@@ -261,7 +256,13 @@ export function Grid<
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null); const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2); const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
const windowHeight = useObservableEagerState(windowHeightObservable$); const windowHeight = useSyncExternalStore(
useCallback((onChange) => {
window.addEventListener("resize", onChange);
return (): void => window.removeEventListener("resize", onChange);
}, []),
useCallback(() => window.innerHeight, []),
);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null); const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null); const [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] = const [visibleTilesCallback, setVisibleTilesCallback] =

View File

@@ -13,6 +13,7 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod
import { type CallLayout, arrangeTiles } from "./CallLayout"; import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneLayout.module.css"; import styles from "./OneOnOneLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/** /**
* An implementation of the "one-on-one" layout, in which the remote participant * An implementation of the "one-on-one" layout, in which the remote participant
@@ -32,7 +33,7 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode { scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$); const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useObservableEagerState(pipAlignment$); const pipAlignmentValue = useBehavior(pipAlignment$);
const { tileWidth, tileHeight } = useMemo( const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1), () => arrangeTiles(width, height, 1),
[width, height], [width, height],

View File

@@ -6,12 +6,12 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type ReactNode, useCallback } from "react"; import { type ReactNode, useCallback } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel"; import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
import { type CallLayout } from "./CallLayout"; import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid"; import { type DragCallback, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css"; import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior";
/** /**
* An implementation of the "expanded spotlight" layout, in which the spotlight * An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -46,7 +46,7 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot, Slot,
}): ReactNode { }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment$); const pipAlignmentValue = useBehavior(pipAlignment$);
const onDragPip: DragCallback = useCallback( const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>

View File

@@ -13,6 +13,7 @@ import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel"; import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightPortraitLayout.module.css"; import styles from "./SpotlightPortraitLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid"; import { useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";
interface GridCSSProperties extends CSSProperties { interface GridCSSProperties extends CSSProperties {
"--grid-gap": string; "--grid-gap": string;
@@ -65,8 +66,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
width, width,
model.grid.length, model.grid.length,
); );
const withIndicators = const withIndicators = useBehavior(model.spotlight.media$).length > 1;
useObservableEagerState(model.spotlight.media$).length > 1;
return ( return (
<div <div

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

View File

@@ -22,17 +22,11 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { import {
ElementCallError, ElementCallError,
InsufficientCapacityError, InsufficientCapacityError,
SFURoomCreationRestrictedError,
UnknownCallError, UnknownCallError,
} from "../utils/errors.ts"; } from "../utils/errors.ts";
import { AbortHandle } from "../utils/abortHandle.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 * Additional values for states that a call can be in, beyond what livekit
* provides in ConnectionState. Also reconnects the call if the SFU Config * provides in ConnectionState. Also reconnects the call if the SFU Config
@@ -169,12 +163,7 @@ async function connectAndPublish(
try { try {
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`); 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`); logger.info(`[Lifecycle] ... connected to livekit room`);
} catch (e) { } catch (e) {
logger.error("[Lifecycle] Failed to connect", e); logger.error("[Lifecycle] Failed to connect", e);
@@ -184,11 +173,19 @@ async function connectAndPublish(
// participant limits. // participant limits.
// LiveKit Cloud uses 429 for connection limits. // LiveKit Cloud uses 429 for connection limits.
// Either way, all these errors can be explained as "insufficient capacity". // Either way, all these errors can be explained as "insufficient capacity".
if ( if (e instanceof ConnectionError) {
e instanceof ConnectionError && if (e.status === 503 || e.status === 200 || e.status === 429) {
(e.status === 503 || e.status === 200 || e.status === 429) throw new InsufficientCapacityError();
) }
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; throw e;
} }

View File

@@ -157,10 +157,13 @@ export function useLivekit(
useObservableEagerState( useObservableEagerState(
useObservable( useObservable(
(room$) => (room$) =>
observeTrackReference$( room$.pipe(
room$.pipe(map(([room]) => room.localParticipant)), switchMap(([room]) =>
Track.Source.Camera, observeTrackReference$(
).pipe( room.localParticipant,
Track.Source.Camera,
),
),
map((trackRef) => { map((trackRef) => {
const track = trackRef?.publication?.track; const track = trackRef?.publication?.track;
return track instanceof LocalVideoTrack ? track : null; return track instanceof LocalVideoTrack ? track : null;
@@ -320,16 +323,18 @@ export function useLivekit(
useEffect(() => { useEffect(() => {
// Sync the requested devices with LiveKit's devices // Sync the requested devices with LiveKit's devices
if ( if (room !== undefined && connectionState === ConnectionState.Connected) {
room !== undefined &&
connectionState === ConnectionState.Connected &&
!controlledAudioDevices
) {
const syncDevice = ( const syncDevice = (
kind: MediaDeviceKind, kind: MediaDeviceKind,
selected$: Observable<SelectedDevice | undefined>, selected$: Observable<SelectedDevice | undefined>,
): Subscription => ): Subscription =>
selected$.subscribe((device) => { selected$.subscribe((device) => {
logger.info(
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
room.getActiveDevice(kind),
" !== ",
device?.id,
);
if ( if (
device !== undefined && device !== undefined &&
room.getActiveDevice(kind) !== device.id room.getActiveDevice(kind) !== device.id
@@ -344,7 +349,9 @@ export function useLivekit(
const subscriptions = [ const subscriptions = [
syncDevice("audioinput", devices.audioInput.selected$), syncDevice("audioinput", devices.audioInput.selected$),
syncDevice("audiooutput", devices.audioOutput.selected$), !controlledAudioDevices
? syncDevice("audiooutput", devices.audioOutput.selected$)
: undefined,
syncDevice("videoinput", devices.videoInput.selected$), syncDevice("videoinput", devices.videoInput.selected$),
// Restart the audio input track whenever we detect that the active media // Restart the audio input track whenever we detect that the active media
// device has changed to refer to a different hardware device. We do this // device has changed to refer to a different hardware device. We do this
@@ -384,7 +391,7 @@ export function useLivekit(
]; ];
return (): void => { return (): void => {
for (const s of subscriptions) s.unsubscribe(); for (const s of subscriptions) s?.unsubscribe();
}; };
} }
}, [room, devices, connectionState, controlledAudioDevices]); }, [room, devices, connectionState, controlledAudioDevices]);

View File

@@ -16,12 +16,12 @@ import {
} from "react"; } from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext"; import { useClientState } from "../ClientContext";
import { ElementCallReactionEventType, type ReactionOption } from "."; import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel";
import { useBehavior } from "../useBehavior";
interface ReactionsSenderContextType { interface ReactionsSenderContextType {
supportsReactions: boolean; supportsReactions: boolean;
@@ -70,7 +70,7 @@ export const ReactionsSenderProvider = ({
[memberships, myUserId, myDeviceId], [memberships, myUserId, myDeviceId],
); );
const reactions = useObservableEagerState(vm.reactions$); const reactions = useBehavior(vm.reactions$);
const myReaction = useMemo( const myReaction = useMemo(
() => () =>
myMembershipIdentifier !== undefined myMembershipIdentifier !== undefined
@@ -79,7 +79,7 @@ export const ReactionsSenderProvider = ({
[myMembershipIdentifier, reactions], [myMembershipIdentifier, reactions],
); );
const handsRaised = useObservableEagerState(vm.handsRaised$); const handsRaised = useBehavior(vm.handsRaised$);
const myRaisedHand = useMemo( const myRaisedHand = useMemo(
() => () =>
myMembershipIdentifier !== undefined myMembershipIdentifier !== undefined

View File

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

View File

@@ -61,3 +61,7 @@
.overlay > p { .overlay > p {
text-align: center; text-align: center;
} }
.spacer {
min-height: var(--cpd-space-32x);
}

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type FC } from "react"; import { type FC } from "react";
import { BigIcon, Button, Heading, Text } from "@vector-im/compound-web"; import { BigIcon, Button, Heading, Text } from "@vector-im/compound-web";
import { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styles from "./EarpieceOverlay.module.css"; import styles from "./EarpieceOverlay.module.css";
@@ -22,12 +22,12 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
return ( return (
<div className={styles.overlay} data-show={show}> <div className={styles.overlay} data-show={show}>
<BigIcon className={styles.icon}> <BigIcon className={styles.icon}>
<EarpieceIcon aria-hidden /> <VoiceCallIcon aria-hidden />
</BigIcon> </BigIcon>
<Heading as="h2" weight="semibold" size="md"> <Heading as="h2" weight="semibold" size="md">
{t("earpiece.overlay_title")} {t("handset.overlay_title")}
</Heading> </Heading>
<Text>{t("earpiece.overlay_description")}</Text> <Text>{t("handset.overlay_description")}</Text>
<Button <Button
kind="primary" kind="primary"
size="sm" size="sm"
@@ -35,8 +35,10 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
onBackToVideoPressed?.(); onBackToVideoPressed?.();
}} }}
> >
{t("earpiece.overlay_back_button")} {t("handset.overlay_back_button")}
</Button> </Button>
{/* This spacer is used to give the overlay an offset to the top. */}
<div className={styles.spacer} />
</div> </div>
); );
}; };

View File

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

View File

@@ -16,7 +16,6 @@ import {
import { render, waitFor, screen } from "@testing-library/react"; import { render, waitFor, screen } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { of } from "rxjs";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
@@ -43,6 +42,7 @@ import { MatrixRTCFocusMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams"; import { HeaderStyle } from "../UrlParams";
import { constant } from "../state/Behavior";
vi.mock("../soundUtils"); vi.mock("../soundUtils");
vi.mock("../useAudioContext"); vi.mock("../useAudioContext");
@@ -141,7 +141,7 @@ function createGroupCallView(
room, room,
localRtcMember, localRtcMember,
[], [],
).withMemberships(of([])); ).withMemberships(constant([]));
rtcSession.joined = joined; rtcSession.joined = joined;
const muteState = { const muteState = {
audio: { enabled: false }, audio: { enabled: false },

View File

@@ -24,7 +24,6 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useObservableEagerState } from "observable-hooks";
import type { IWidgetApiRequest } from "matrix-widget-api"; import type { IWidgetApiRequest } from "matrix-widget-api";
import { import {
@@ -72,6 +71,7 @@ import {
import { useTypedEventEmitter } from "../useEvents"; import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx"; import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
declare global { declare global {
interface Window { interface Window {
@@ -110,7 +110,7 @@ export const GroupCallView: FC<Props> = ({
); );
const memberships = useMatrixRTCSessionMemberships(rtcSession); const memberships = useMatrixRTCSessionMemberships(rtcSession);
const muteAllAudio = useObservableEagerState(muteAllAudio$); const muteAllAudio = useBehavior(muteAllAudio$);
const leaveSoundContext = useLatest( const leaveSoundContext = useLatest(
useAudioContext({ useAudioContext({
sounds: callEventAudioSounds, sounds: callEventAudioSounds,
@@ -166,7 +166,11 @@ export const GroupCallView: FC<Props> = ({
const { displayName, avatarUrl } = useProfile(client); const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room); const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room); const roomAvatar = useRoomAvatar(room);
const { perParticipantE2EE, returnToLobby } = useUrlParams(); const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId); const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting); const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
const [useExperimentalToDeviceTransport] = useSetting( const [useExperimentalToDeviceTransport] = useSetting(
@@ -174,7 +178,6 @@ export const GroupCallView: FC<Props> = ({
); );
// Save the password once we start the groupCallView // Save the password once we start the groupCallView
const { password: passwordFromUrl } = useUrlParams();
useEffect(() => { useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl); if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]); }, [passwordFromUrl, room.roomId]);

View File

@@ -25,11 +25,11 @@ import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames"; import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs"; import { BehaviorSubject, map } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks"; import { useObservable, useSubscription } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport"; import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { import {
EarpieceIcon, VoiceCallSolidIcon,
VolumeOnSolidIcon, VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -110,6 +110,7 @@ import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMembership
import { useMediaDevices } from "../MediaDevicesContext.ts"; import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx"; import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx"; import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -137,17 +138,17 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useEffect(() => { useEffect(() => {
logger.info( logger.info(
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`, `[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
); );
return (): void => { return (): void => {
logger.info( logger.info(
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`, `[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
); );
livekitRoom livekitRoom
?.disconnect() ?.disconnect()
.then(() => { .then(() => {
logger.info( logger.info(
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`, `[Lifecycle] Disconnected from livekit room, state:${livekitRoom?.state}`,
); );
}) })
.catch((e) => { .catch((e) => {
@@ -156,6 +157,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
}; };
}, [livekitRoom]); }, [livekitRoom]);
const { autoLeaveWhenOthersLeft } = useUrlParams();
useEffect(() => { useEffect(() => {
if (livekitRoom !== undefined) { if (livekitRoom !== undefined) {
const reactionsReader = new ReactionsReader(props.rtcSession); const reactionsReader = new ReactionsReader(props.rtcSession);
@@ -163,7 +166,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.rtcSession, props.rtcSession,
livekitRoom, livekitRoom,
mediaDevices, mediaDevices,
props.e2eeSystem, {
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
},
connStateObservable$, connStateObservable$,
reactionsReader.raisedHands$, reactionsReader.raisedHands$,
reactionsReader.reactions$, reactionsReader.reactions$,
@@ -180,6 +186,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices, mediaDevices,
props.e2eeSystem, props.e2eeSystem,
connStateObservable$, connStateObservable$,
autoLeaveWhenOthersLeft,
]); ]);
if (livekitRoom === undefined || vm === null) return null; if (livekitRoom === undefined || vm === null) return null;
@@ -249,7 +256,7 @@ export const InCallView: FC<InCallViewProps> = ({
room: livekitRoom, room: livekitRoom,
}); });
const muteAllAudio = useObservableEagerState(muteAllAudio$); const muteAllAudio = useBehavior(muteAllAudio$);
// This seems like it might be enough logic to use move it into the call view model? // This seems like it might be enough logic to use move it into the call view model?
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false); const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
@@ -300,15 +307,16 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(), () => void toggleRaisedHand(),
); );
const windowMode = useObservableEagerState(vm.windowMode$); const windowMode = useBehavior(vm.windowMode$);
const layout = useObservableEagerState(vm.layout$); const layout = useBehavior(vm.layout$);
const tileStoreGeneration = useObservableEagerState(vm.tileStoreGeneration$); const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const [debugTileLayout] = useSetting(debugTileLayoutSetting); const [debugTileLayout] = useSetting(debugTileLayoutSetting);
const gridMode = useObservableEagerState(vm.gridMode$); const gridMode = useBehavior(vm.gridMode$);
const showHeader = useObservableEagerState(vm.showHeader$); const showHeader = useBehavior(vm.showHeader$);
const showFooter = useObservableEagerState(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useObservableEagerState(vm.earpieceMode$); const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
useSubscription(vm.autoLeaveWhenOthersLeft$, onLeave);
// Ideally we could detect taps by listening for click events and checking // 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 // that the pointerType of the event is "touch", but this isn't yet supported
@@ -454,9 +462,9 @@ export const InCallView: FC<InCallViewProps> = ({
useMemo(() => { useMemo(() => {
if (audioOutputSwitcher === null) return null; if (audioOutputSwitcher === null) return null;
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece"; const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
const Icon = isEarpieceTarget ? EarpieceIcon : VolumeOnSolidIcon; const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
const label = isEarpieceTarget const label = isEarpieceTarget
? t("settings.devices.earpiece") ? t("settings.devices.handset")
: t("settings.devices.loudspeaker"); : t("settings.devices.loudspeaker");
return ( return (
@@ -524,16 +532,12 @@ export const InCallView: FC<InCallViewProps> = ({
targetHeight, targetHeight,
model, model,
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode { }: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
const spotlightExpanded = useObservableEagerState( const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
vm.spotlightExpanded$, const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
); const showSpeakingIndicatorsValue = useBehavior(
const onToggleExpanded = useObservableEagerState(
vm.toggleSpotlightExpanded$,
);
const showSpeakingIndicatorsValue = useObservableEagerState(
vm.showSpeakingIndicators$, vm.showSpeakingIndicators$,
); );
const showSpotlightIndicatorsValue = useObservableEagerState( const showSpotlightIndicatorsValue = useBehavior(
vm.showSpotlightIndicators$, vm.showSpotlightIndicators$,
); );

View File

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

View File

@@ -86,6 +86,14 @@ export function useMuteStates(isJoined: boolean): MuteStates {
const audio = useMuteState(devices.audioInput, () => { const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined; return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
}); });
useEffect(() => {
// If audio is enabled, we need to request the device names again,
// because iOS will not be able to switch to the correct device after un-muting.
// This is one of the main changes that makes iOS work with bluetooth audio devices.
if (audio.enabled) {
devices.requestDeviceNames();
}
}, [audio.enabled, devices]);
const isEarpiece = useIsEarpiece(); const isEarpiece = useIsEarpiece();
const video = useMuteState( const video = useMuteState(
devices.videoInput, devices.videoInput,

View File

@@ -6,16 +6,16 @@ Please see LICENSE in the repository root for full details.
*/ */
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { useObservableState } from "observable-hooks";
import styles from "./ReactionsOverlay.module.css"; import styles from "./ReactionsOverlay.module.css";
import { type CallViewModel } from "../state/CallViewModel"; import { type CallViewModel } from "../state/CallViewModel";
import { useBehavior } from "../useBehavior";
export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode { export function ReactionsOverlay({ vm }: { vm: CallViewModel }): ReactNode {
const reactionsIcons = useObservableState(vm.visibleReactions$); const reactionsIcons = useBehavior(vm.visibleReactions$);
return ( return (
<div className={styles.container}> <div className={styles.container}>
{reactionsIcons?.map(({ sender, emoji, startX }) => ( {reactionsIcons.map(({ sender, emoji, startX }) => (
<span <span
// Reactions effects are considered presentation elements. The reaction // Reactions effects are considered presentation elements. The reaction
// is also present on the sender's tile, which assistive technology can // is also present on the sender's tile, which assistive technology can

View File

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

View File

@@ -98,18 +98,30 @@ exports[`InCallView > rendering > renders 1`] = `
width="1em" width="1em"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <g
d="M14 2c3.93 0 7 3.07 7 7a1 1 0 0 1-2 0c0-2.8-2.2-5-5-5S9 6.2 9 9c0 .93.29 1.98.82 2.94.71 1.29 1.53 1.92 2.32 2.53.92.71 1.88 1.44 2.39 3 .5 1.5 1 2.01 1.71 2.38.2.09.47.15.76.15 1.1 0 2-.9 2-2a1 1 0 1 1 2 0 4 4 0 0 1-5.64 3.65c-1.36-.71-2.13-1.73-2.73-3.55-.32-.98-.9-1.43-1.71-2.05-.87-.67-1.94-1.5-2.85-3.15C7.38 11.65 7 10.26 7 9c0-3.93 3.07-7 7-7" clip-path="url(#a)"
/> >
<path <path
d="M6.145 1.3a1 1 0 0 1 1.427 1.4A8.97 8.97 0 0 0 5 9c0 2.3.862 4.397 2.281 5.988l.291.312.069.077A1 1 0 0 1 6.22 16.77l-.075-.07-.356-.38A10.96 10.96 0 0 1 3 9c0-2.998 1.2-5.717 3.145-7.7M14 6.5a2.5 2.5 0 0 1 0 5 2.501 2.501 0 0 1 0-5" clip-rule="evenodd"
/> d="M8.929 15.1a13.6 13.6 0 0 0 4.654 3.066q2.62 1.036 5.492.923h.008l.003-.004.003-.002-.034-3.124-3.52-.483-1.791 1.792-.645-.322a13.5 13.5 0 0 1-3.496-2.52 13.4 13.4 0 0 1-2.52-3.496l-.322-.644 1.792-1.792-.483-3.519-3.123-.034-.003.002-.003.004v.002a13.65 13.65 0 0 0 .932 5.492A13.4 13.4 0 0 0 8.93 15.1m3.92 4.926a15.6 15.6 0 0 1-5.334-3.511 15.4 15.4 0 0 1-3.505-5.346 15.6 15.6 0 0 1-1.069-6.274 1.93 1.93 0 0 1 .589-1.366c.366-.366.84-.589 1.386-.589h.01l3.163.035a1.96 1.96 0 0 1 1.958 1.694v.005l.487 3.545v.003c.043.297.025.605-.076.907a2 2 0 0 1-.485.773l-.762.762a11.4 11.4 0 0 0 3.206 3.54q.457.33.948.614l.762-.761a2 2 0 0 1 .774-.486c.302-.1.61-.118.907-.076l3.553.487a1.96 1.96 0 0 1 1.694 1.958l.034 3.174c0 .546-.223 1.02-.588 1.386-.361.36-.827.582-1.363.588a15.3 15.3 0 0 1-6.29-1.062"
fill-rule="evenodd"
/>
</g>
<defs>
<clippath
id="a"
>
<path
d="M0 0h24v24H0z"
/>
</clippath>
</defs>
</svg> </svg>
</div> </div>
<h2 <h2
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112" class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
> >
Earpiece Mode Handset Mode
</h2> </h2>
<p <p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50" class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
@@ -125,6 +137,9 @@ exports[`InCallView > rendering > renders 1`] = `
> >
Back to Speaker Mode Back to Speaker Mode
</button> </button>
<div
class="spacer"
/>
</div> </div>
<div <div
class="container" class="container"

View File

@@ -70,6 +70,12 @@ test("It joins the correct Session", async () => {
roomId: "roomId", roomId: "roomId",
client: { client: {
getDomain: vi.fn().mockReturnValue("example.org"), 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: [], memberships: [],
@@ -195,6 +201,12 @@ test("It should not fail with configuration error if homeserver config has livek
roomId: "roomId", roomId: "roomId",
client: { client: {
getDomain: vi.fn().mockReturnValue("example.org"), 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: [], memberships: [],

View File

@@ -5,21 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. 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 { import {
isLivekitFocus, isLivekitFocus,
isLivekitFocusConfig, isLivekitFocusConfig,
type LivekitFocus, type LivekitFocus,
type LivekitFocusActive, type LivekitFocusActive,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery"; import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { PosthogAnalytics } from "./analytics/PosthogAnalytics"; import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
import { Config } from "./config/Config"; import { Config } from "./config/Config";
import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget"; import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget";
import { MatrixRTCFocusMissingError } from "./utils/errors.ts"; import { MatrixRTCFocusMissingError } from "./utils/errors";
import { getUrlParams } from "./UrlParams.ts"; import { getUrlParams } from "./UrlParams";
import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts";
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
@@ -46,6 +47,9 @@ async function makePreferredLivekitFoci(
preferredFoci.push(focusInUse); 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 // Prioritize the .well-known/matrix/client, if available, over the configured SFU
const domain = rtcSession.room.client.getDomain(); const domain = rtcSession.room.client.getDomain();
if (domain) { if (domain) {
@@ -55,18 +59,17 @@ async function makePreferredLivekitFoci(
FOCI_WK_KEY FOCI_WK_KEY
]; ];
if (Array.isArray(wellKnownFoci)) { if (Array.isArray(wellKnownFoci)) {
preferredFoci.push( const validWellKnownFoci = wellKnownFoci
...wellKnownFoci .filter((f) => !!f)
.filter((f) => !!f) .filter(isLivekitFocusConfig)
.filter(isLivekitFocusConfig) .map((wellKnownFocus) => {
.map((wellKnownFocus) => { logger.log("Adding livekit focus from well known: ", wellKnownFocus);
logger.log( return { ...wellKnownFocus, livekit_alias: livekitAlias };
"Adding livekit focus from well known: ", });
wellKnownFocus, if (validWellKnownFoci.length > 0) {
); toWarmUp = validWellKnownFoci[0];
return { ...wellKnownFocus, livekit_alias: livekitAlias }; }
}), preferredFoci.push(...validWellKnownFoci);
);
} }
} }
@@ -77,10 +80,15 @@ async function makePreferredLivekitFoci(
livekit_service_url: urlFromConf, livekit_service_url: urlFromConf,
livekit_alias: livekitAlias, livekit_alias: livekitAlias,
}; };
toWarmUp = toWarmUp ?? focusFormConf;
logger.log("Adding livekit focus from config: ", focusFormConf); logger.log("Adding livekit focus from config: ", focusFormConf);
preferredFoci.push(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) if (preferredFoci.length === 0)
throw new MatrixRTCFocusMissingError(domain ?? ""); throw new MatrixRTCFocusMissingError(domain ?? "");
return Promise.resolve(preferredFoci); return Promise.resolve(preferredFoci);
@@ -116,21 +124,20 @@ export async function enterRTCSession(
await makePreferredLivekitFoci(rtcSession, livekitAlias), await makePreferredLivekitFoci(rtcSession, livekitAlias),
makeActiveFocus(), makeActiveFocus(),
{ {
notificationType: getUrlParams().sendNotificationType,
useNewMembershipManager, useNewMembershipManager,
manageMediaKeys: encryptMedia, manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && { ...(useDeviceSessionMemberEvents !== undefined && {
useLegacyMemberEvents: !useDeviceSessionMemberEvents, useLegacyMemberEvents: !useDeviceSessionMemberEvents,
}), }),
delayedLeaveEventRestartMs: delayedLeaveEventRestartMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_ms ?? matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
matrixRtcSessionConfig?.membership_keep_alive_period,
delayedLeaveEventDelayMs: delayedLeaveEventDelayMs:
matrixRtcSessionConfig?.delayed_leave_event_delay_ms ?? matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
matrixRtcSessionConfig?.membership_server_side_expiry_timeout, delayedLeaveEventRestartLocalTimeoutMs:
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms, networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
makeKeyDelay: makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
matrixRtcSessionConfig?.wait_for_key_rotation_ms ??
matrixRtcSessionConfig?.key_rotation_on_leave_delay,
membershipEventExpiryMs: membershipEventExpiryMs:
matrixRtcSessionConfig?.membership_event_expiry_ms, matrixRtcSessionConfig?.membership_event_expiry_ms,
useExperimentalToDeviceTransport, useExperimentalToDeviceTransport,

View File

@@ -98,7 +98,7 @@ export const DeviceSelection: FC<Props> = ({
labelText = t("settings.devices.loudspeaker"); labelText = t("settings.devices.loudspeaker");
break; break;
case "earpiece": case "earpiece":
labelText = t("settings.devices.earpiece"); labelText = t("settings.devices.handset");
break; break;
} }

View File

@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { Button, Root as Form, Separator } from "@vector-im/compound-web"; import { Button, Root as Form, Separator } from "@vector-im/compound-web";
import { type Room as LivekitRoom } from "livekit-client"; import { type Room as LivekitRoom } from "livekit-client";
import { useObservableEagerState } from "observable-hooks";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import styles from "./SettingsModal.module.css"; import styles from "./SettingsModal.module.css";
@@ -34,6 +33,7 @@ import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { FieldRow, InputField } from "../input/Input"; import { FieldRow, InputField } from "../input/Input";
import { useSubmitRageshake } from "./submit-rageshake"; import { useSubmitRageshake } from "./submit-rageshake";
import { useUrlParams } from "../UrlParams"; import { useUrlParams } from "../UrlParams";
import { useBehavior } from "../useBehavior";
type SettingsTab = type SettingsTab =
| "audio" | "audio"
@@ -112,7 +112,7 @@ export const SettingsModal: FC<Props> = ({
// rather than the input section. // rather than the input section.
const { controlledAudioDevices } = useUrlParams(); const { controlledAudioDevices } = useUrlParams();
// If we are on iOS we will show a button to open the native audio device picker. // If we are on iOS we will show a button to open the native audio device picker.
const iosDeviceMenu = useObservableEagerState(iosDeviceMenu$); const iosDeviceMenu = useBehavior(iosDeviceMenu$);
const audioTab: Tab<SettingsTab> = { const audioTab: Tab<SettingsTab> = {
key: "audio", key: "audio",

View File

@@ -6,10 +6,11 @@ Please see LICENSE in the repository root for full details.
*/ */
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { BehaviorSubject, type Observable } from "rxjs"; import { BehaviorSubject } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics"; import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior";
export class Setting<T> { export class Setting<T> {
public constructor( public constructor(
@@ -38,7 +39,7 @@ export class Setting<T> {
private readonly key: string; private readonly key: string;
private readonly _value$: BehaviorSubject<T>; private readonly _value$: BehaviorSubject<T>;
public readonly value$: Observable<T>; public readonly value$: Behavior<T>;
public readonly setValue = (value: T): void => { public readonly setValue = (value: T): void => {
this._value$.next(value); this._value$.next(value);
@@ -53,7 +54,7 @@ export class Setting<T> {
* React hook that returns a settings's current value and a setter. * React hook that returns a settings's current value and a setter.
*/ */
export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] { export function useSetting<T>(setting: Setting<T>): [T, (value: T) => void] {
return [useObservableEagerState(setting.value$), setting.setValue]; return [useBehavior(setting.value$), setting.setValue];
} }
// null = undecided // null = undecided

26
src/state/Behavior.ts Normal file
View File

@@ -0,0 +1,26 @@
/*
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 { BehaviorSubject } from "rxjs";
/**
* A stateful, read-only reactive value. As an Observable, it is "hot" and
* always replays the current value upon subscription.
*
* A Behavior is to BehaviorSubject what Observable is to Subject; it does not
* provide a way to imperatively set new values. For more info on the
* distinction between Behaviors and Observables, see
* https://monoid.dk/post/behaviors-and-streams-why-both/.
*/
export type Behavior<T> = Omit<BehaviorSubject<T>, "next" | "observers">;
/**
* Creates a Behavior which never changes in value.
*/
export function constant<T>(value: T): Behavior<T> {
return new BehaviorSubject(value);
}

View File

@@ -12,9 +12,9 @@ import {
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
map, map,
NEVER,
type Observable, type Observable,
of, of,
skip,
switchMap, switchMap,
} from "rxjs"; } from "rxjs";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
@@ -32,7 +32,11 @@ import {
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
import { CallViewModel, type Layout } from "./CallViewModel"; import {
CallViewModel,
type CallViewModelOptions,
type Layout,
} from "./CallViewModel";
import { import {
mockLivekitRoom, mockLivekitRoom,
mockLocalParticipant, mockLocalParticipant,
@@ -71,14 +75,23 @@ import {
local, local,
localId, localId,
localRtcMember, localRtcMember,
localRtcMemberDevice2,
} from "../utils/test-fixtures"; } from "../utils/test-fixtures";
import { ObservableScope } from "./ObservableScope"; import { ObservableScope } from "./ObservableScope";
import { MediaDevices } from "./MediaDevices"; import { MediaDevices } from "./MediaDevices";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { type Behavior, constant } from "./Behavior";
const getUrlParams = vi.hoisted(() => vi.fn(() => ({}))); const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../UrlParams", () => ({ getUrlParams })); vi.mock("../UrlParams", () => ({ getUrlParams }));
vi.mock("rxjs", async (importOriginal) => ({
...(await importOriginal()),
// Disable interval Observables for the following tests since the test
// scheduler will loop on them forever and never call the test 'done'
interval: (): Observable<number> => NEVER,
}));
vi.mock("@livekit/components-core"); vi.mock("@livekit/components-core");
const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD"); const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
@@ -157,9 +170,10 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "grid": case "grid":
return combineLatest( return combineLatest(
[ [
l.spotlight?.media$ ?? of(undefined), l.spotlight?.media$ ?? constant(undefined),
...l.grid.map((vm) => vm.media$), ...l.grid.map((vm) => vm.media$),
], ],
// eslint-disable-next-line rxjs/finnish -- false positive
(spotlight, ...grid) => ({ (spotlight, ...grid) => ({
type: l.type, type: l.type,
spotlight: spotlight?.map((vm) => vm.id), spotlight: spotlight?.map((vm) => vm.id),
@@ -178,7 +192,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
); );
case "spotlight-expanded": case "spotlight-expanded":
return combineLatest( return combineLatest(
[l.spotlight.media$, l.pip?.media$ ?? of(undefined)], [l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
// eslint-disable-next-line rxjs/finnish -- false positive
(spotlight, pip) => ({ (spotlight, pip) => ({
type: l.type, type: l.type,
spotlight: spotlight.map((vm) => vm.id), spotlight: spotlight.map((vm) => vm.id),
@@ -212,8 +227,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
} }
function withCallViewModel( function withCallViewModel(
remoteParticipants$: Observable<RemoteParticipant[]>, remoteParticipants$: Behavior<RemoteParticipant[]>,
rtcMembers$: Observable<Partial<CallMembership>[]>, rtcMembers$: Behavior<Partial<CallMembership>[]>,
connectionState$: Observable<ECConnectionState>, connectionState$: Observable<ECConnectionState>,
speaking: Map<Participant, Observable<boolean>>, speaking: Map<Participant, Observable<boolean>>,
mediaDevices: MediaDevices, mediaDevices: MediaDevices,
@@ -221,6 +236,10 @@ function withCallViewModel(
vm: CallViewModel, vm: CallViewModel,
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> }, subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
) => void, ) => void,
options: CallViewModelOptions = {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
autoLeaveWhenOthersLeft: false,
},
): void { ): void {
const room = mockMatrixRoom({ const room = mockMatrixRoom({
client: { client: {
@@ -271,9 +290,7 @@ function withCallViewModel(
rtcSession as unknown as MatrixRTCSession, rtcSession as unknown as MatrixRTCSession,
liveKitRoom, liveKitRoom,
mediaDevices, mediaDevices,
{ options,
kind: E2eeType.PER_PARTICIPANT,
},
connectionState$, connectionState$,
raisedHands$, raisedHands$,
new BehaviorSubject({}), new BehaviorSubject({}),
@@ -291,7 +308,7 @@ function withCallViewModel(
} }
test("participants are retained during a focus switch", () => { test("participants are retained during a focus switch", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// Participants disappear on frame 2 and come back on frame 3 // Participants disappear on frame 2 and come back on frame 3
const participantInputMarbles = "a-ba"; const participantInputMarbles = "a-ba";
// Start switching focus on frame 1 and reconnect on frame 3 // Start switching focus on frame 1 and reconnect on frame 3
@@ -300,12 +317,12 @@ test("participants are retained during a focus switch", () => {
const expectedLayoutMarbles = " a"; const expectedLayoutMarbles = " a";
withCallViewModel( withCallViewModel(
hot(participantInputMarbles, { behavior(participantInputMarbles, {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [], b: [],
}), }),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
hot(connectionInputMarbles, { behavior(connectionInputMarbles, {
c: ConnectionState.Connected, c: ConnectionState.Connected,
s: ECAddonConnectionState.ECSwitchingFocus, s: ECAddonConnectionState.ECSwitchingFocus,
}), }),
@@ -328,7 +345,7 @@ test("participants are retained during a focus switch", () => {
}); });
test("screen sharing activates spotlight layout", () => { test("screen sharing activates spotlight layout", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Start with no screen shares, then have Alice and Bob share their screens, // Start with no screen shares, then have Alice and Bob share their screens,
// then return to no screen shares, then have just Alice share for a bit // then return to no screen shares, then have just Alice share for a bit
const participantInputMarbles = " abcda-ba"; const participantInputMarbles = " abcda-ba";
@@ -341,13 +358,13 @@ test("screen sharing activates spotlight layout", () => {
const expectedLayoutMarbles = " abcdaefeg"; const expectedLayoutMarbles = " abcdaefeg";
const expectedShowSpeakingMarbles = "y----nyny"; const expectedShowSpeakingMarbles = "y----nyny";
withCallViewModel( withCallViewModel(
hot(participantInputMarbles, { behavior(participantInputMarbles, {
a: [aliceParticipant, bobParticipant], a: [aliceParticipant, bobParticipant],
b: [aliceSharingScreen, bobParticipant], b: [aliceSharingScreen, bobParticipant],
c: [aliceSharingScreen, bobSharingScreen], c: [aliceSharingScreen, bobSharingScreen],
d: [aliceParticipant, bobSharingScreen], d: [aliceParticipant, bobSharingScreen],
}), }),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -413,7 +430,7 @@ test("screen sharing activates spotlight layout", () => {
}); });
test("participants stay in the same order unless to appear/disappear", () => { test("participants stay in the same order unless to appear/disappear", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
const visibilityInputMarbles = "a"; const visibilityInputMarbles = "a";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
const aSpeakingInputMarbles = " n- 1998ms - 1999ms y"; const aSpeakingInputMarbles = " n- 1998ms - 1999ms y";
@@ -426,13 +443,22 @@ test("participants stay in the same order unless to appear/disappear", () => {
const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a"; const expectedLayoutMarbles = " a 1999ms b 1999ms a 57999ms c 1999ms a";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), constant([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]), constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], aliceParticipant,
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], behavior(aSpeakingInputMarbles, { y: true, n: false }),
],
[
bobParticipant,
behavior(bSpeakingInputMarbles, { y: true, n: false }),
],
[
daveParticipant,
behavior(dSpeakingInputMarbles, { y: true, n: false }),
],
]), ]),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
@@ -472,7 +498,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
}); });
test("participants adjust order when space becomes constrained", () => { test("participants adjust order when space becomes constrained", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Start with all tiles on screen then shrink to 3 // Start with all tiles on screen then shrink to 3
const visibilityInputMarbles = "a-b"; const visibilityInputMarbles = "a-b";
// Bob and Dave speak // Bob and Dave speak
@@ -484,12 +510,18 @@ test("participants adjust order when space becomes constrained", () => {
const expectedLayoutMarbles = " a-b"; const expectedLayoutMarbles = " a-b";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), constant([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]), constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], [
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], bobParticipant,
behavior(bSpeakingInputMarbles, { y: true, n: false }),
],
[
daveParticipant,
behavior(dSpeakingInputMarbles, { y: true, n: false }),
],
]), ]),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
@@ -523,7 +555,7 @@ test("participants adjust order when space becomes constrained", () => {
}); });
test("spotlight speakers swap places", () => { test("spotlight speakers swap places", () => {
withTestScheduler(({ hot, schedule, expectObservable }) => { withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Go immediately into spotlight mode for the test // Go immediately into spotlight mode for the test
const modeInputMarbles = " s"; const modeInputMarbles = " s";
// First Bob speaks, then Dave, then Alice // First Bob speaks, then Dave, then Alice
@@ -537,13 +569,22 @@ test("spotlight speakers swap places", () => {
const expectedLayoutMarbles = "abcd"; const expectedLayoutMarbles = "abcd";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant, daveParticipant]), constant([aliceParticipant, bobParticipant, daveParticipant]),
of([aliceRtcMember, bobRtcMember, daveRtcMember]), constant([aliceRtcMember, bobRtcMember, daveRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map([ new Map([
[aliceParticipant, hot(aSpeakingInputMarbles, { y: true, n: false })], [
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })], aliceParticipant,
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })], behavior(aSpeakingInputMarbles, { y: true, n: false }),
],
[
bobParticipant,
behavior(bSpeakingInputMarbles, { y: true, n: false }),
],
[
daveParticipant,
behavior(dSpeakingInputMarbles, { y: true, n: false }),
],
]), ]),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
@@ -587,8 +628,8 @@ test("layout enters picture-in-picture mode when requested", () => {
const expectedLayoutMarbles = " aba"; const expectedLayoutMarbles = " aba";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), constant([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -629,8 +670,8 @@ test("spotlight remembers whether it's expanded", () => {
const expectedLayoutMarbles = "abcbada"; const expectedLayoutMarbles = "abcbada";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), constant([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -678,7 +719,7 @@ test("spotlight remembers whether it's expanded", () => {
}); });
test("participants must have a MatrixRTCSession to be visible", () => { test("participants must have a MatrixRTCSession to be visible", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// iterate through a number of combinations of participants and MatrixRTC memberships // iterate through a number of combinations of participants and MatrixRTC memberships
// Bob never has an MatrixRTC membership // Bob never has an MatrixRTC membership
const scenarioInputMarbles = " abcdec"; const scenarioInputMarbles = " abcdec";
@@ -686,14 +727,14 @@ test("participants must have a MatrixRTCSession to be visible", () => {
const expectedLayoutMarbles = "a-bc-b"; const expectedLayoutMarbles = "a-bc-b";
withCallViewModel( withCallViewModel(
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [bobParticipant], b: [bobParticipant],
c: [aliceParticipant, bobParticipant], c: [aliceParticipant, bobParticipant],
d: [aliceParticipant, daveParticipant, bobParticipant], d: [aliceParticipant, daveParticipant, bobParticipant],
e: [aliceParticipant, daveParticipant, bobSharingScreen], e: [aliceParticipant, daveParticipant, bobSharingScreen],
}), }),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [], b: [],
c: [aliceRtcMember], c: [aliceRtcMember],
@@ -734,17 +775,17 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
try { try {
// enable the setting: // enable the setting:
showNonMemberTiles.setValue(true); showNonMemberTiles.setValue(true);
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = " abc"; const scenarioInputMarbles = " abc";
const expectedLayoutMarbles = "abc"; const expectedLayoutMarbles = "abc";
withCallViewModel( withCallViewModel(
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [aliceParticipant], b: [aliceParticipant],
c: [aliceParticipant, bobParticipant], c: [aliceParticipant, bobParticipant],
}), }),
of([]), // No one joins the MatrixRTC session constant([]), // No one joins the MatrixRTC session
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -779,15 +820,15 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
}); });
it("should show at least one tile per MatrixRTCSession", () => { it("should show at least one tile per MatrixRTCSession", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
// iterate through some combinations of MatrixRTC memberships // iterate through some combinations of MatrixRTC memberships
const scenarioInputMarbles = " abcd"; const scenarioInputMarbles = " abcd";
// There should always be one tile for each MatrixRTCSession // There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "abcd"; const expectedLayoutMarbles = "abcd";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [aliceRtcMember], b: [aliceRtcMember],
c: [aliceRtcMember, daveRtcMember], c: [aliceRtcMember, daveRtcMember],
@@ -829,13 +870,13 @@ it("should show at least one tile per MatrixRTCSession", () => {
}); });
test("should disambiguate users with the same displayname", () => { test("should disambiguate users with the same displayname", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = "abcde"; const scenarioInputMarbles = "abcde";
const expectedLayoutMarbles = "abcde"; const expectedLayoutMarbles = "abcde";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [aliceRtcMember], b: [aliceRtcMember],
c: [aliceRtcMember, aliceDoppelgangerRtcMember], c: [aliceRtcMember, aliceDoppelgangerRtcMember],
@@ -846,50 +887,46 @@ test("should disambiguate users with the same displayname", () => {
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
// Skip the null state. expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe( // Carol has no displayname - So userId is used.
expectedLayoutMarbles, a: new Map([[carolId, carol.userId]]),
{ b: new Map([
// Carol has no displayname - So userId is used. [carolId, carol.userId],
a: new Map([[carolId, carol.userId]]), [aliceId, alice.rawDisplayName],
b: new Map([ ]),
[carolId, carol.userId], // The second alice joins.
[aliceId, alice.rawDisplayName], c: new Map([
]), [carolId, carol.userId],
// The second alice joins. [aliceId, "Alice (@alice:example.org)"],
c: new Map([ [aliceDoppelgangerId, "Alice (@alice2:example.org)"],
[carolId, carol.userId], ]),
[aliceId, "Alice (@alice:example.org)"], // Bob also joins
[aliceDoppelgangerId, "Alice (@alice2:example.org)"], d: new Map([
]), [carolId, carol.userId],
// Bob also joins [aliceId, "Alice (@alice:example.org)"],
d: new Map([ [aliceDoppelgangerId, "Alice (@alice2:example.org)"],
[carolId, carol.userId], [bobId, bob.rawDisplayName],
[aliceId, "Alice (@alice:example.org)"], ]),
[aliceDoppelgangerId, "Alice (@alice2:example.org)"], // Alice leaves, and the displayname should reset.
[bobId, bob.rawDisplayName], e: new Map([
]), [carolId, carol.userId],
// Alice leaves, and the displayname should reset. [aliceDoppelgangerId, "Alice"],
e: new Map([ [bobId, bob.rawDisplayName],
[carolId, carol.userId], ]),
[aliceDoppelgangerId, "Alice"], });
[bobId, bob.rawDisplayName],
]),
},
);
}, },
); );
}); });
}); });
test("should disambiguate users with invisible characters", () => { test("should disambiguate users with invisible characters", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = "ab"; const scenarioInputMarbles = "ab";
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [bobRtcMember, bobZeroWidthSpaceRtcMember], b: [bobRtcMember, bobZeroWidthSpaceRtcMember],
}), }),
@@ -897,36 +934,32 @@ test("should disambiguate users with invisible characters", () => {
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
// Skip the null state. expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe( // Carol has no displayname - So userId is used.
expectedLayoutMarbles, a: new Map([[carolId, carol.userId]]),
{ // Both Bobs join, and should handle zero width hacks.
// Carol has no displayname - So userId is used. b: new Map([
a: new Map([[carolId, carol.userId]]), [carolId, carol.userId],
// Both Bobs join, and should handle zero width hacks. [bobId, `Bob (${bob.userId})`],
b: new Map([ [
[carolId, carol.userId], bobZeroWidthSpaceId,
[bobId, `Bob (${bob.userId})`], `${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
[ ],
bobZeroWidthSpaceId, ]),
`${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`, });
],
]),
},
);
}, },
); );
}); });
}); });
test("should strip RTL characters from displayname", () => { test("should strip RTL characters from displayname", () => {
withTestScheduler(({ hot, expectObservable }) => { withTestScheduler(({ behavior, expectObservable }) => {
const scenarioInputMarbles = "ab"; const scenarioInputMarbles = "ab";
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
of([]), constant([]),
hot(scenarioInputMarbles, { behavior(scenarioInputMarbles, {
a: [], a: [],
b: [daveRtcMember, daveRTLRtcMember], b: [daveRtcMember, daveRTLRtcMember],
}), }),
@@ -934,35 +967,31 @@ test("should strip RTL characters from displayname", () => {
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
(vm) => { (vm) => {
// Skip the null state. expectObservable(vm.memberDisplaynames$).toBe(expectedLayoutMarbles, {
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe( // Carol has no displayname - So userId is used.
expectedLayoutMarbles, a: new Map([[carolId, carol.userId]]),
{ // Both Dave's join. Since after stripping
// Carol has no displayname - So userId is used. b: new Map([
a: new Map([[carolId, carol.userId]]), [carolId, carol.userId],
// Both Dave's join. Since after stripping // Not disambiguated
b: new Map([ [daveId, "Dave"],
[carolId, carol.userId], // This one is, since it's using RTL.
// Not disambiguated [daveRTLId, `evaD (${daveRTL.userId})`],
[daveId, "Dave"], ]),
// This one is, since it's using RTL. });
[daveRTLId, `evaD (${daveRTL.userId})`],
]),
},
);
}, },
); );
}); });
}); });
it("should rank raised hands above video feeds and below speakers and presenters", () => { 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 // There should always be one tile for each MatrixRTCSession
const expectedLayoutMarbles = "ab"; const expectedLayoutMarbles = "ab";
withCallViewModel( withCallViewModel(
of([aliceParticipant, bobParticipant]), constant([aliceParticipant, bobParticipant]),
of([aliceRtcMember, bobRtcMember]), constant([aliceRtcMember, bobRtcMember]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}), mockMediaDevices({}),
@@ -1015,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", () => { test("audio output changes when toggling earpiece mode", () => {
withTestScheduler(({ schedule, expectObservable }) => { withTestScheduler(({ schedule, expectObservable }) => {
getUrlParams.mockReturnValue({ controlledAudioDevices: true }); getUrlParams.mockReturnValue({ controlledAudioDevices: true });
@@ -1026,7 +1225,7 @@ test("audio output changes when toggling earpiece mode", () => {
window.controls.setAvailableAudioDevices([ window.controls.setAvailableAudioDevices([
{ id: "speaker", name: "Speaker", isSpeaker: true }, { id: "speaker", name: "Speaker", isSpeaker: true },
{ id: "earpiece", name: "Earpiece", isEarpiece: true }, { id: "earpiece", name: "Handset", isEarpiece: true },
{ id: "headphones", name: "Headphones" }, { id: "headphones", name: "Headphones" },
]); ]);
window.controls.setAudioDevice("headphones"); window.controls.setAudioDevice("headphones");
@@ -1036,8 +1235,8 @@ test("audio output changes when toggling earpiece mode", () => {
const expectedTargetStateMarbles = " sese"; const expectedTargetStateMarbles = " sese";
withCallViewModel( withCallViewModel(
of([]), constant([]),
of([]), constant([]),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
devices, devices,

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,6 @@ import {
filter, filter,
map, map,
merge, merge,
of,
pairwise, pairwise,
startWith, startWith,
Subject, Subject,
@@ -18,7 +17,7 @@ import {
type Observable, type Observable,
} from "rxjs"; } from "rxjs";
import { createMediaDeviceObserver } from "@livekit/components-core"; 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 { import {
audioInput as audioInputSetting, audioInput as audioInputSetting,
@@ -34,11 +33,11 @@ import {
import { getUrlParams } from "../UrlParams"; import { getUrlParams } from "../UrlParams";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { switchWhen } from "../utils/observable"; import { switchWhen } from "../utils/observable";
import { type Behavior, constant } from "./Behavior";
// This hardcoded id is used in EX ios! It can only be changed in coordination with // This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team. // the ios swift team.
const EARPIECE_CONFIG_ID = "earpiece-id"; const EARPIECE_CONFIG_ID = "earpiece-id";
const logger = rootLogger.getChild("[MediaDevices]");
export type DeviceLabel = export type DeviceLabel =
| { type: "name"; name: string } | { type: "name"; name: string }
@@ -74,11 +73,11 @@ export interface MediaDevice<Label, Selected> {
/** /**
* A map from available device IDs to labels. * A map from available device IDs to labels.
*/ */
available$: Observable<Map<string, Label>>; available$: Behavior<Map<string, Label>>;
/** /**
* The selected device. * The selected device.
*/ */
selected$: Observable<Selected | undefined>; selected$: Behavior<Selected | undefined>;
/** /**
* Selects a new device. * Selects a new device.
*/ */
@@ -94,35 +93,37 @@ export interface MediaDevice<Label, Selected> {
* `availableOutputDevices$.includes((d)=>d.forEarpiece)` * `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/ */
export const iosDeviceMenu$ = export const iosDeviceMenu$ =
platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$; platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
function availableRawDevices$( function availableRawDevices$(
kind: MediaDeviceKind, kind: MediaDeviceKind,
usingNames$: Observable<boolean>, usingNames$: Behavior<boolean>,
scope: ObservableScope, scope: ObservableScope,
): Observable<MediaDeviceInfo[]> { logger: Logger,
): Behavior<MediaDeviceInfo[]> {
const logError = (e: Error): void => const logError = (e: Error): void =>
logger.error("Error creating MediaDeviceObserver", e); logger.error("Error creating MediaDeviceObserver", e);
const devices$ = createMediaDeviceObserver(kind, logError, false); const devices$ = createMediaDeviceObserver(kind, logError, false);
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true); const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
return usingNames$.pipe( return scope.behavior(
switchMap((withNames) => usingNames$.pipe(
withNames switchMap((withNames) =>
? // It might be that there is already a media stream running somewhere, withNames
// and so we can do without requesting a second one. Only switch to the ? // It might be that there is already a media stream running somewhere,
// device observer that explicitly requests the names if we see that // and so we can do without requesting a second one. Only switch to the
// names are in fact missing from the initial device enumeration. // device observer that explicitly requests the names if we see that
devices$.pipe( // names are in fact missing from the initial device enumeration.
switchWhen( devices$.pipe(
(devices, i) => i === 0 && devices.every((d) => !d.label), switchWhen(
devicesWithNames$, (devices, i) => i === 0 && devices.every((d) => !d.label),
), devicesWithNames$,
) ),
: devices$, )
: devices$,
),
), ),
startWith([]), [],
scope.state(),
); );
} }
@@ -161,34 +162,40 @@ function selectDevice$<Label>(
} }
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> { class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
private readonly availableRaw$: Observable<MediaDeviceInfo[]> = private logger = rootLogger.getChild("[MediaDevices AudioInput]");
availableRawDevices$("audioinput", this.usingNames$, this.scope);
public readonly available$ = this.availableRaw$.pipe( private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
map(buildDeviceMap), availableRawDevices$(
this.scope.state(), "audioinput",
this.usingNames$,
this.scope,
this.logger,
);
public readonly available$ = this.scope.behavior(
this.availableRaw$.pipe(map(buildDeviceMap)),
); );
public readonly selected$ = selectDevice$( public readonly selected$ = this.scope.behavior(
this.available$, selectDevice$(this.available$, audioInputSetting.value$).pipe(
audioInputSetting.value$, map((id) =>
).pipe( id === undefined
map((id) => ? undefined
id === undefined : {
? undefined id,
: { // We can identify when the hardware device has changed by watching for
id, // changes in the group ID
// We can identify when the hardware device has changed by watching for hardwareDeviceChange$: this.availableRaw$.pipe(
// changes in the group ID map(
hardwareDeviceChange$: this.availableRaw$.pipe( (devices) => devices.find((d) => d.deviceId === id)?.groupId,
map((devices) => devices.find((d) => d.deviceId === id)?.groupId), ),
pairwise(), pairwise(),
filter(([before, after]) => before !== after), filter(([before, after]) => before !== after),
map(() => undefined), map(() => undefined),
), ),
}, },
),
), ),
this.scope.state(),
); );
public select(id: string): void { public select(id: string): void {
@@ -196,11 +203,11 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
} }
public constructor( public constructor(
private readonly usingNames$: Observable<boolean>, private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
) { ) {
this.available$.subscribe((available) => { this.available$.subscribe((available) => {
logger.info("[audio-input] available devices:", available); this.logger.info("[audio-input] available devices:", available);
}); });
} }
} }
@@ -208,55 +215,61 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
class AudioOutput class AudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice> implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{ {
public readonly available$ = availableRawDevices$( private logger = rootLogger.getChild("[MediaDevices AudioOutput]");
"audiooutput", public readonly available$ = this.scope.behavior(
this.usingNames$, availableRawDevices$(
this.scope, "audiooutput",
).pipe( this.usingNames$,
map((availableRaw) => { this.scope,
const available: Map<string, AudioOutputDeviceLabel> = this.logger,
buildDeviceMap(availableRaw); ).pipe(
// Create a virtual default audio output for browsers that don't have one. map((availableRaw) => {
// Its device ID must be the empty string because that's what setSinkId let available: Map<string, AudioOutputDeviceLabel> =
// recognizes. buildDeviceMap(availableRaw);
if (available.size && !available.has("") && !available.has("default")) // Create a virtual default audio output for browsers that don't have one.
available.set("", { // Its device ID must be the empty string because that's what setSinkId
type: "default", // recognizes.
name: availableRaw[0]?.label || null, if (available.size && !available.has("") && !available.has("default"))
}); available.set("", {
// Note: creating virtual default input devices would be another problem type: "default",
// entirely, because requesting a media stream from deviceId "" won't name: availableRaw[0]?.label || null,
// automatically track the default device. });
return available; // 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
this.scope.state(), if (isSafari) {
); // set to empty map if we are on Safari, because it does not support setSinkId
available = new Map();
public readonly selected$ = selectDevice$( }
this.available$, // Note: creating virtual default input devices would be another problem
audioOutputSetting.value$, // entirely, because requesting a media stream from deviceId "" won't
).pipe( // automatically track the default device.
map((id) => return available;
id === undefined }),
? undefined
: {
id,
virtualEarpiece: false,
},
), ),
this.scope.state(),
); );
public readonly selected$ = this.scope.behavior(
selectDevice$(this.available$, audioOutputSetting.value$).pipe(
map((id) =>
id === undefined
? undefined
: {
id,
virtualEarpiece: false,
},
),
),
);
public select(id: string): void { public select(id: string): void {
audioOutputSetting.setValue(id); audioOutputSetting.setValue(id);
} }
public constructor( public constructor(
private readonly usingNames$: Observable<boolean>, private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
) { ) {
this.available$.subscribe((available) => { this.available$.subscribe((available) => {
logger.info("[audio-output] available devices:", available); this.logger.info("[audio-output] available devices:", available);
}); });
} }
} }
@@ -264,30 +277,43 @@ class AudioOutput
class ControlledAudioOutput class ControlledAudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice> implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{ {
public readonly available$ = combineLatest( private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$], // We need to subscribe to the raw devices so that the OS does update the input
(availableRaw, iosDeviceMenu) => { // back to what it was before. otherwise we will switch back to the default
const available = new Map<string, AudioOutputDeviceLabel>( // whenever we allocate a new stream.
availableRaw.map( public readonly availableRaw$ = availableRawDevices$(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => { "audiooutput",
let deviceLabel: AudioOutputDeviceLabel; this.usingNames$,
// if (isExternalHeadset) // Do we want this? this.scope,
if (isEarpiece) deviceLabel = { type: "earpiece" }; this.logger,
else if (isSpeaker) deviceLabel = { type: "speaker" }; );
else deviceLabel = { type: "name", name };
return [id, deviceLabel];
},
),
);
// Create a virtual earpiece device in case a non-earpiece device is public readonly available$ = this.scope.behavior(
// designated for this purpose combineLatest(
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece)) [controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" }); (availableRaw, iosDeviceMenu) => {
const available = new Map<string, AudioOutputDeviceLabel>(
availableRaw.map(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
let deviceLabel: AudioOutputDeviceLabel;
// if (isExternalHeadset) // Do we want this?
if (isEarpiece) deviceLabel = { type: "earpiece" };
else if (isSpeaker) deviceLabel = { type: "speaker" };
else deviceLabel = { type: "name", name };
return [id, deviceLabel];
},
),
);
return available; // Create a virtual earpiece device in case a non-earpiece device is
}, // designated for this purpose
).pipe(this.scope.state()); if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
return available;
},
),
);
private readonly deviceSelection$ = new Subject<string>(); private readonly deviceSelection$ = new Subject<string>();
@@ -295,67 +321,82 @@ class ControlledAudioOutput
this.deviceSelection$.next(id); this.deviceSelection$.next(id);
} }
public readonly selected$ = combineLatest( public readonly selected$ = this.scope.behavior(
[ combineLatest(
this.available$, [
merge( this.available$,
controlledOutputSelection$.pipe(startWith(undefined)), merge(
this.deviceSelection$, controlledOutputSelection$.pipe(startWith(undefined)),
), this.deviceSelection$,
], ),
(available, preferredId) => { ],
const id = preferredId ?? available.keys().next().value; (available, preferredId) => {
return id === undefined const id = preferredId ?? available.keys().next().value;
? undefined return id === undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID }; ? undefined
}, : { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
).pipe(this.scope.state()); },
),
);
public constructor(private readonly scope: ObservableScope) { public constructor(
private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope,
) {
this.selected$.subscribe((device) => { this.selected$.subscribe((device) => {
// Let the hosting application know which output device has been selected. // Let the hosting application know which output device has been selected.
// This information is probably only of interest if the earpiece mode has // This information is probably only of interest if the earpiece mode has
// been selected - for example, Element X iOS listens to this to determine // been selected - for example, Element X iOS listens to this to determine
// whether it should enable the proximity sensor. // whether it should enable the proximity sensor.
if (device !== undefined) { if (device !== undefined) {
logger.info("[controlled-output] setAudioDeviceSelect called:", device); this.logger.info(
"[controlled-output] onAudioDeviceSelect called:",
device,
);
window.controls.onAudioDeviceSelect?.(device.id); window.controls.onAudioDeviceSelect?.(device.id);
// Also invoke the deprecated callback for backward compatibility // Also invoke the deprecated callback for backward compatibility
window.controls.onOutputDeviceSelect?.(device.id); window.controls.onOutputDeviceSelect?.(device.id);
} }
}); });
this.available$.subscribe((available) => { this.available$.subscribe((available) => {
logger.info("[controlled-output] available devices:", available); this.logger.info("[controlled-output] available devices:", available);
});
this.availableRaw$.subscribe((availableRaw) => {
this.logger.info(
"[controlled-output] available raw devices:",
availableRaw,
);
}); });
} }
} }
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> { class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
public readonly available$ = availableRawDevices$( private logger = rootLogger.getChild("[MediaDevices VideoInput]");
"videoinput",
this.usingNames$,
this.scope,
).pipe(map(buildDeviceMap));
public readonly selected$ = selectDevice$( public readonly available$ = this.scope.behavior(
this.available$, availableRawDevices$(
videoInputSetting.value$, "videoinput",
).pipe( this.usingNames$,
map((id) => (id === undefined ? undefined : { id })), this.scope,
this.scope.state(), this.logger,
).pipe(map(buildDeviceMap)),
);
public readonly selected$ = this.scope.behavior(
selectDevice$(this.available$, videoInputSetting.value$).pipe(
map((id) => (id === undefined ? undefined : { id })),
),
); );
public select(id: string): void { public select(id: string): void {
videoInputSetting.setValue(id); videoInputSetting.setValue(id);
} }
public constructor( public constructor(
private readonly usingNames$: Observable<boolean>, private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope, private readonly scope: ObservableScope,
) { ) {
// This also has the purpose of subscribing to the available devices // This also has the purpose of subscribing to the available devices
this.available$.subscribe((available) => { this.available$.subscribe((available) => {
logger.info("[video-input] available devices:", available); this.logger.info("[video-input] available devices:", available);
}); });
} }
} }
@@ -378,12 +419,10 @@ export class MediaDevices {
// you to do to receive device names in lieu of a more explicit permissions // you to do to receive device names in lieu of a more explicit permissions
// API. This flag never resets to false, because once permissions are granted // API. This flag never resets to false, because once permissions are granted
// the first time, the user won't be prompted again until reload of the page. // the first time, the user won't be prompted again until reload of the page.
private readonly usingNames$ = this.deviceNamesRequest$.pipe( private readonly usingNames$ = this.scope.behavior(
map(() => true), this.deviceNamesRequest$.pipe(map(() => true)),
startWith(false), false,
this.scope.state(),
); );
public readonly audioInput: MediaDevice< public readonly audioInput: MediaDevice<
DeviceLabel, DeviceLabel,
SelectedAudioInputDevice SelectedAudioInputDevice
@@ -393,7 +432,7 @@ export class MediaDevices {
AudioOutputDeviceLabel, AudioOutputDeviceLabel,
SelectedAudioOutputDevice SelectedAudioOutputDevice
> = getUrlParams().controlledAudioDevices > = getUrlParams().controlledAudioDevices
? new ControlledAudioOutput(this.scope) ? new ControlledAudioOutput(this.usingNames$, this.scope)
: new AudioOutput(this.usingNames$, this.scope); : new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> = public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =

View File

@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
*/ */
import { expect, onTestFinished, test, vi } from "vitest"; import { expect, onTestFinished, test, vi } from "vitest";
import { of } from "rxjs";
import { import {
type LocalTrackPublication, type LocalTrackPublication,
LocalVideoTrack, LocalVideoTrack,
@@ -23,6 +22,7 @@ import {
withTestScheduler, withTestScheduler,
} from "../utils/test"; } from "../utils/test";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { constant } from "./Behavior";
global.MediaStreamTrack = class {} as unknown as { global.MediaStreamTrack = class {} as unknown as {
new (): MediaStreamTrack; new (): MediaStreamTrack;
@@ -174,8 +174,8 @@ test("switch cameras", async () => {
}), }),
mockMediaDevices({ mockMediaDevices({
videoInput: { videoInput: {
available$: of(new Map()), available$: constant(new Map()),
selected$: of(undefined), selected$: constant(undefined),
select: selectVideoInput, select: selectVideoInput,
}, },
}), }),

View File

@@ -55,26 +55,19 @@ import { E2eeType } from "../e2ee/e2eeType";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { type MediaDevices } from "./MediaDevices"; import { type MediaDevices } from "./MediaDevices";
import { type Behavior } from "./Behavior";
export function observeTrackReference$( export function observeTrackReference$(
participant$: Observable<Participant | undefined>, participant: Participant,
source: Track.Source, source: Track.Source,
): Observable<TrackReferenceOrPlaceholder | undefined> { ): Observable<TrackReferenceOrPlaceholder> {
return participant$.pipe( return observeParticipantMedia(participant).pipe(
switchMap((p) => { map(() => ({
if (p) { participant: participant,
return observeParticipantMedia(p).pipe( publication: participant.getTrackPublication(source),
map(() => ({ source,
participant: p, })),
publication: p.getTrackPublication(source), distinctUntilKeyChanged("publication"),
source,
})),
distinctUntilKeyChanged("publication"),
);
} else {
return of(undefined);
}
}),
); );
} }
@@ -86,7 +79,7 @@ export function observeRtpStreamStats$(
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
> { > {
return combineLatest([ return combineLatest([
observeTrackReference$(of(participant), source), observeTrackReference$(participant, source),
interval(1000).pipe(startWith(0)), interval(1000).pipe(startWith(0)),
]).pipe( ]).pipe(
switchMap(async ([trackReference]) => { switchMap(async ([trackReference]) => {
@@ -227,19 +220,31 @@ abstract class BaseMediaViewModel extends ViewModel {
/** /**
* The LiveKit video track for this media. * The LiveKit video track for this media.
*/ */
public readonly video$: Observable<TrackReferenceOrPlaceholder | undefined>; public readonly video$: Behavior<TrackReferenceOrPlaceholder | undefined>;
/** /**
* Whether there should be a warning that this media is unencrypted. * Whether there should be a warning that this media is unencrypted.
*/ */
public readonly unencryptedWarning$: Observable<boolean>; public readonly unencryptedWarning$: Behavior<boolean>;
public readonly encryptionStatus$: Observable<EncryptionStatus>; public readonly encryptionStatus$: Behavior<EncryptionStatus>;
/** /**
* Whether this media corresponds to the local participant. * Whether this media corresponds to the local participant.
*/ */
public abstract readonly local: boolean; public abstract readonly local: boolean;
private observeTrackReference$(
source: Track.Source,
): Behavior<TrackReferenceOrPlaceholder | undefined> {
return this.scope.behavior(
this.participant$.pipe(
switchMap((p) =>
p === undefined ? of(undefined) : observeTrackReference$(p, source),
),
),
);
}
public constructor( public constructor(
/** /**
* An opaque identifier for this media. * An opaque identifier for this media.
@@ -261,84 +266,85 @@ abstract class BaseMediaViewModel extends ViewModel {
audioSource: AudioSource, audioSource: AudioSource,
videoSource: VideoSource, videoSource: VideoSource,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
public readonly displayname$: Observable<string>, public readonly displayName$: Behavior<string>,
) { ) {
super(); super();
const audio$ = observeTrackReference$(participant$, audioSource).pipe(
this.scope.state(),
);
this.video$ = observeTrackReference$(participant$, videoSource).pipe(
this.scope.state(),
);
this.unencryptedWarning$ = combineLatest(
[audio$, this.video$],
(a, v) =>
encryptionSystem.kind !== E2eeType.NONE &&
(a?.publication?.isEncrypted === false ||
v?.publication?.isEncrypted === false),
).pipe(this.scope.state());
this.encryptionStatus$ = this.participant$.pipe( const audio$ = this.observeTrackReference$(audioSource);
switchMap((participant): Observable<EncryptionStatus> => { this.video$ = this.observeTrackReference$(videoSource);
if (!participant) {
return of(EncryptionStatus.Connecting); this.unencryptedWarning$ = this.scope.behavior(
} else if ( combineLatest(
participant.isLocal || [audio$, this.video$],
encryptionSystem.kind === E2eeType.NONE (a, v) =>
) { encryptionSystem.kind !== E2eeType.NONE &&
return of(EncryptionStatus.Okay); (a?.publication?.isEncrypted === false ||
} else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) { v?.publication?.isEncrypted === false),
return combineLatest([ ),
encryptionErrorObservable$( );
livekitRoom,
participant, this.encryptionStatus$ = this.scope.behavior(
encryptionSystem, this.participant$.pipe(
"MissingKey", switchMap((participant): Observable<EncryptionStatus> => {
), if (!participant) {
encryptionErrorObservable$( return of(EncryptionStatus.Connecting);
livekitRoom, } else if (
participant, participant.isLocal ||
encryptionSystem, encryptionSystem.kind === E2eeType.NONE
"InvalidKey", ) {
), return of(EncryptionStatus.Okay);
observeRemoteTrackReceivingOkay$(participant, audioSource), } else if (encryptionSystem.kind === E2eeType.PER_PARTICIPANT) {
observeRemoteTrackReceivingOkay$(participant, videoSource), return combineLatest([
]).pipe( encryptionErrorObservable$(
map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => { livekitRoom,
if (keyMissing) return EncryptionStatus.KeyMissing; participant,
if (keyInvalid) return EncryptionStatus.KeyInvalid; encryptionSystem,
if (audioOkay || videoOkay) return EncryptionStatus.Okay; "MissingKey",
return undefined; // no change ),
}), encryptionErrorObservable$(
filter((x) => !!x), livekitRoom,
startWith(EncryptionStatus.Connecting), participant,
); encryptionSystem,
} else { "InvalidKey",
return combineLatest([ ),
encryptionErrorObservable$( observeRemoteTrackReceivingOkay$(participant, audioSource),
livekitRoom, observeRemoteTrackReceivingOkay$(participant, videoSource),
participant, ]).pipe(
encryptionSystem, map(([keyMissing, keyInvalid, audioOkay, videoOkay]) => {
"InvalidKey", if (keyMissing) return EncryptionStatus.KeyMissing;
), if (keyInvalid) return EncryptionStatus.KeyInvalid;
observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe(
map(
([keyInvalid, audioOkay, videoOkay]):
| EncryptionStatus
| undefined => {
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
if (audioOkay || videoOkay) return EncryptionStatus.Okay; if (audioOkay || videoOkay) return EncryptionStatus.Okay;
return undefined; // no change return undefined; // no change
}, }),
), filter((x) => !!x),
filter((x) => !!x), startWith(EncryptionStatus.Connecting),
startWith(EncryptionStatus.Connecting), );
); } else {
} return combineLatest([
}), encryptionErrorObservable$(
this.scope.state(), livekitRoom,
participant,
encryptionSystem,
"InvalidKey",
),
observeRemoteTrackReceivingOkay$(participant, audioSource),
observeRemoteTrackReceivingOkay$(participant, videoSource),
]).pipe(
map(
([keyInvalid, audioOkay, videoOkay]):
| EncryptionStatus
| undefined => {
if (keyInvalid) return EncryptionStatus.PasswordInvalid;
if (audioOkay || videoOkay) return EncryptionStatus.Okay;
return undefined; // no change
},
),
filter((x) => !!x),
startWith(EncryptionStatus.Connecting),
);
}
}),
),
); );
} }
} }
@@ -358,31 +364,33 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
/** /**
* Whether the participant is speaking. * Whether the participant is speaking.
*/ */
public readonly speaking$ = this.participant$.pipe( public readonly speaking$ = this.scope.behavior(
switchMap((p) => this.participant$.pipe(
p switchMap((p) =>
? observeParticipantEvents(p, ParticipantEvent.IsSpeakingChanged).pipe( p
map((p) => p.isSpeaking), ? observeParticipantEvents(
) p,
: of(false), ParticipantEvent.IsSpeakingChanged,
).pipe(map((p) => p.isSpeaking))
: of(false),
),
), ),
this.scope.state(),
); );
/** /**
* Whether this participant is sending audio (i.e. is unmuted on their side). * Whether this participant is sending audio (i.e. is unmuted on their side).
*/ */
public readonly audioEnabled$: Observable<boolean>; public readonly audioEnabled$: Behavior<boolean>;
/** /**
* Whether this participant is sending video. * Whether this participant is sending video.
*/ */
public readonly videoEnabled$: Observable<boolean>; public readonly videoEnabled$: Behavior<boolean>;
private readonly _cropVideo$ = new BehaviorSubject(true); private readonly _cropVideo$ = new BehaviorSubject(true);
/** /**
* Whether the tile video should be contained inside the tile or be cropped to fit. * Whether the tile video should be contained inside the tile or be cropped to fit.
*/ */
public readonly cropVideo$: Observable<boolean> = this._cropVideo$; public readonly cropVideo$: Behavior<boolean> = this._cropVideo$;
public constructor( public constructor(
id: string, id: string,
@@ -390,9 +398,9 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
participant$: Observable<LocalParticipant | RemoteParticipant | undefined>, participant$: Observable<LocalParticipant | RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayName$: Behavior<string>,
public readonly handRaised$: Observable<Date | null>, public readonly handRaised$: Behavior<Date | null>,
public readonly reaction$: Observable<ReactionOption | null>, public readonly reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
id, id,
@@ -402,18 +410,19 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
Track.Source.Microphone, Track.Source.Microphone,
Track.Source.Camera, Track.Source.Camera,
livekitRoom, livekitRoom,
displayname$, displayName$,
); );
const media$ = participant$.pipe( const media$ = this.scope.behavior(
switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)), participant$.pipe(
this.scope.state(), switchMap((p) => (p && observeParticipantMedia(p)) ?? of(undefined)),
),
); );
this.audioEnabled$ = media$.pipe( this.audioEnabled$ = this.scope.behavior(
map((m) => m?.microphoneTrack?.isMuted === false), media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
); );
this.videoEnabled$ = media$.pipe( this.videoEnabled$ = this.scope.behavior(
map((m) => m?.cameraTrack?.isMuted === false), media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
); );
} }
@@ -460,13 +469,15 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Whether the video should be mirrored. * Whether the video should be mirrored.
*/ */
public readonly mirror$ = this.videoTrack$.pipe( public readonly mirror$ = this.scope.behavior(
// Mirror only front-facing cameras (those that face the user) this.videoTrack$.pipe(
map( // Mirror only front-facing cameras (those that face the user)
(track) => map(
track !== null && facingModeFromLocalTrack(track).facingMode === "user", (track) =>
track !== null &&
facingModeFromLocalTrack(track).facingMode === "user",
),
), ),
this.scope.state(),
); );
/** /**
@@ -479,46 +490,48 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Callback for switching between the front and back cameras. * Callback for switching between the front and back cameras.
*/ */
public readonly switchCamera$: Observable<(() => void) | null> = public readonly switchCamera$: Behavior<(() => void) | null> =
platform === "desktop" this.scope.behavior(
? of(null) platform === "desktop"
: this.videoTrack$.pipe( ? of(null)
map((track) => { : this.videoTrack$.pipe(
if (track === null) return null; map((track) => {
const facingMode = facingModeFromLocalTrack(track).facingMode; if (track === null) return null;
// If the camera isn't front or back-facing, don't provide a switch const facingMode = facingModeFromLocalTrack(track).facingMode;
// camera shortcut at all // If the camera isn't front or back-facing, don't provide a switch
if (facingMode !== "user" && facingMode !== "environment") // camera shortcut at all
return null; if (facingMode !== "user" && facingMode !== "environment")
// Restart the track with a camera facing the opposite direction return null;
return (): void => // Restart the track with a camera facing the opposite direction
void track return (): void =>
.restartTrack({ void track
facingMode: facingMode === "user" ? "environment" : "user", .restartTrack({
}) facingMode: facingMode === "user" ? "environment" : "user",
.then(() => { })
// Inform the MediaDevices which camera was chosen .then(() => {
const deviceId = // Inform the MediaDevices which camera was chosen
track.mediaStreamTrack.getSettings().deviceId; const deviceId =
if (deviceId !== undefined) track.mediaStreamTrack.getSettings().deviceId;
this.mediaDevices.videoInput.select(deviceId); if (deviceId !== undefined)
}) this.mediaDevices.videoInput.select(deviceId);
.catch((e) => })
logger.error("Failed to switch camera", facingMode, e), .catch((e) =>
); logger.error("Failed to switch camera", facingMode, e),
}), );
); }),
),
);
public constructor( public constructor(
id: string, id: string,
member: RoomMember | undefined, member: RoomMember | undefined,
participant$: Observable<LocalParticipant | undefined>, participant$: Behavior<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices, private readonly mediaDevices: MediaDevices,
displayname$: Observable<string>, displayName$: Behavior<string>,
handRaised$: Observable<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Observable<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
id, id,
@@ -526,7 +539,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
participant$, participant$,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$, displayName$,
handRaised$, handRaised$,
reaction$, reaction$,
); );
@@ -565,42 +578,42 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
* The volume to which this participant's audio is set, as a scalar * The volume to which this participant's audio is set, as a scalar
* multiplier. * multiplier.
*/ */
public readonly localVolume$: Observable<number> = merge( public readonly localVolume$ = this.scope.behavior<number>(
this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)), merge(
this.localVolumeAdjustment$, this.locallyMutedToggle$.pipe(map(() => "toggle mute" as const)),
this.localVolumeCommit$.pipe(map(() => "commit" as const)), this.localVolumeAdjustment$,
).pipe( this.localVolumeCommit$.pipe(map(() => "commit" as const)),
accumulate({ volume: 1, committedVolume: 1 }, (state, event) => { ).pipe(
switch (event) { accumulate({ volume: 1, committedVolume: 1 }, (state, event) => {
case "toggle mute": switch (event) {
return { case "toggle mute":
...state, return {
volume: state.volume === 0 ? state.committedVolume : 0, ...state,
}; volume: state.volume === 0 ? state.committedVolume : 0,
case "commit": };
// Dragging the slider to zero should have the same effect as case "commit":
// muting: keep the original committed volume, as if it were never // Dragging the slider to zero should have the same effect as
// dragged // muting: keep the original committed volume, as if it were never
return { // dragged
...state, return {
committedVolume: ...state,
state.volume === 0 ? state.committedVolume : state.volume, committedVolume:
}; state.volume === 0 ? state.committedVolume : state.volume,
default: };
// Volume adjustment default:
return { ...state, volume: event }; // Volume adjustment
} return { ...state, volume: event };
}), }
map(({ volume }) => volume), }),
this.scope.state(), map(({ volume }) => volume),
),
); );
/** /**
* Whether this participant's audio is disabled. * Whether this participant's audio is disabled.
*/ */
public readonly locallyMuted$: Observable<boolean> = this.localVolume$.pipe( public readonly locallyMuted$ = this.scope.behavior<boolean>(
map((volume) => volume === 0), this.localVolume$.pipe(map((volume) => volume === 0)),
this.scope.state(),
); );
public constructor( public constructor(
@@ -609,9 +622,9 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
participant$: Observable<RemoteParticipant | undefined>, participant$: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayname$: Behavior<string>,
handRaised$: Observable<Date | null>, handRaised$: Behavior<Date | null>,
reaction$: Observable<ReactionOption | null>, reaction$: Behavior<ReactionOption | null>,
) { ) {
super( super(
id, id,
@@ -674,7 +687,7 @@ export class ScreenShareViewModel extends BaseMediaViewModel {
participant$: Observable<LocalParticipant | RemoteParticipant>, participant$: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem, encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom, livekitRoom: LivekitRoom,
displayname$: Observable<string>, displayname$: Behavior<string>,
public readonly local: boolean, public readonly local: boolean,
) { ) {
super( super(

View File

@@ -26,11 +26,9 @@ test("muteAllAudio$", () => {
muteAllAudio.unsubscribe(); muteAllAudio.unsubscribe();
expect(valueMock).toHaveBeenCalledTimes(6); expect(valueMock).toHaveBeenCalledTimes(4);
expect(valueMock).toHaveBeenNthCalledWith(1, false); // startWith([false, muteAllAudioSetting.getValue()]); expect(valueMock).toHaveBeenNthCalledWith(1, false); // startWith([false, muteAllAudioSetting.getValue()]);
expect(valueMock).toHaveBeenNthCalledWith(2, true); // setAudioEnabled$.next(false); expect(valueMock).toHaveBeenNthCalledWith(2, true); // setAudioEnabled$.next(false);
expect(valueMock).toHaveBeenNthCalledWith(3, false); // setAudioEnabled$.next(true); expect(valueMock).toHaveBeenNthCalledWith(3, false); // setAudioEnabled$.next(true);
expect(valueMock).toHaveBeenNthCalledWith(4, false); // muteAllAudioSetting.setValue(false); expect(valueMock).toHaveBeenNthCalledWith(4, true); // muteAllAudioSetting.setValue(true);
expect(valueMock).toHaveBeenNthCalledWith(5, true); // muteAllAudioSetting.setValue(true);
expect(valueMock).toHaveBeenNthCalledWith(6, true); // setAudioEnabled$.next(false);
}); });

View File

@@ -9,11 +9,14 @@ import { combineLatest, startWith } from "rxjs";
import { setAudioEnabled$ } from "../controls"; import { setAudioEnabled$ } from "../controls";
import { muteAllAudio as muteAllAudioSetting } from "../settings/settings"; import { muteAllAudio as muteAllAudioSetting } from "../settings/settings";
import { globalScope } from "./ObservableScope";
/** /**
* This can transition into sth more complete: `GroupCallViewModel.ts` * This can transition into sth more complete: `GroupCallViewModel.ts`
*/ */
export const muteAllAudio$ = combineLatest( export const muteAllAudio$ = globalScope.behavior(
[setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$], combineLatest(
(outputEnabled, settingsMute) => !outputEnabled || settingsMute, [setAudioEnabled$.pipe(startWith(true)), muteAllAudioSetting.value$],
(outputEnabled, settingsMute) => !outputEnabled || settingsMute,
),
); );

View File

@@ -6,15 +6,19 @@ Please see LICENSE in the repository root for full details.
*/ */
import { import {
BehaviorSubject,
distinctUntilChanged, distinctUntilChanged,
type Observable, type Observable,
shareReplay,
Subject, Subject,
takeUntil, takeUntil,
} from "rxjs"; } from "rxjs";
import { type Behavior } from "./Behavior";
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>; type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
const nothing = Symbol("nothing");
/** /**
* A scope which limits the execution lifetime of its bound Observables. * A scope which limits the execution lifetime of its bound Observables.
*/ */
@@ -31,20 +35,31 @@ export class ObservableScope {
return this.bindImpl; return this.bindImpl;
} }
private readonly stateImpl: MonoTypeOperator = (o$) =>
o$.pipe(
this.bind(),
distinctUntilChanged(),
shareReplay({ bufferSize: 1, refCount: false }),
);
/** /**
* Transforms an Observable into a hot state Observable which replays its * Converts an Observable to a Behavior. If no initial value is specified, the
* latest value upon subscription, skips updates with identical values, and * Observable must synchronously emit an initial value.
* is bound to this scope.
*/ */
public state(): MonoTypeOperator { public behavior<T>(
return this.stateImpl; setValue$: Observable<T>,
initialValue: T | typeof nothing = nothing,
): Behavior<T> {
const subject$ = new BehaviorSubject(initialValue);
// Push values from the Observable into the BehaviorSubject.
// BehaviorSubjects have an undesirable feature where if you call 'complete',
// they will no longer re-emit their current value upon subscription. We want
// to support Observables that complete (for example `of({})`), so we have to
// take care to not propagate the completion event.
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
next(value) {
subject$.next(value);
},
error(err: unknown) {
subject$.error(err);
},
});
if (subject$.value === nothing)
throw new Error("Behavior failed to synchronously emit an initial value");
return subject$ as Behavior<T>;
} }
/** /**
@@ -55,3 +70,8 @@ export class ObservableScope {
this.ended$.complete(); this.ended$.complete();
} }
} }
/**
* The global scope, a scope which never ends.
*/
export const globalScope = new ObservableScope();

View File

@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type Observable } from "rxjs";
import { ViewModel } from "./ViewModel"; import { ViewModel } from "./ViewModel";
import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel"; import { type MediaViewModel, type UserMediaViewModel } from "./MediaViewModel";
import { type Behavior } from "./Behavior";
let nextId = 0; let nextId = 0;
function createId(): string { function createId(): string {
@@ -18,15 +17,15 @@ function createId(): string {
export class GridTileViewModel extends ViewModel { export class GridTileViewModel extends ViewModel {
public readonly id = createId(); public readonly id = createId();
public constructor(public readonly media$: Observable<UserMediaViewModel>) { public constructor(public readonly media$: Behavior<UserMediaViewModel>) {
super(); super();
} }
} }
export class SpotlightTileViewModel extends ViewModel { export class SpotlightTileViewModel extends ViewModel {
public constructor( public constructor(
public readonly media$: Observable<MediaViewModel[]>, public readonly media$: Behavior<MediaViewModel[]>,
public readonly maximised$: Observable<boolean>, public readonly maximised$: Behavior<boolean>,
) { ) {
super(); super();
} }

View File

@@ -9,7 +9,6 @@ import { type RemoteTrackPublication } from "livekit-client";
import { test, expect } from "vitest"; import { test, expect } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { axe } from "vitest-axe"; import { axe } from "vitest-axe";
import { of } from "rxjs";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { GridTile } from "./GridTile"; import { GridTile } from "./GridTile";
@@ -17,6 +16,7 @@ import { mockRtcMembership, withRemoteMedia } from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel"; import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import type { CallViewModel } from "../state/CallViewModel"; import type { CallViewModel } from "../state/CallViewModel";
import { constant } from "../state/Behavior";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {} public observe(): void {}
@@ -53,13 +53,13 @@ test("GridTile is accessible", async () => {
memberships: [], memberships: [],
} as unknown as MatrixRTCSession; } as unknown as MatrixRTCSession;
const cVm = { const cVm = {
reactions$: of({}), reactions$: constant({}),
handsRaised$: of({}), handsRaised$: constant({}),
} as Partial<CallViewModel> as CallViewModel; } as Partial<CallViewModel> as CallViewModel;
const { container } = render( const { container } = render(
<ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}> <ReactionsSenderProvider vm={cVm} rtcSession={fakeRtcSession}>
<GridTile <GridTile
vm={new GridTileViewModel(of(vm))} vm={new GridTileViewModel(constant(vm))}
onOpenProfile={() => {}} onOpenProfile={() => {}}
targetWidth={300} targetWidth={300}
targetHeight={200} targetHeight={200}

View File

@@ -36,7 +36,7 @@ import {
ToggleMenuItem, ToggleMenuItem,
Menu, Menu,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { useObservableEagerState, useObservableState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import styles from "./GridTile.module.css"; import styles from "./GridTile.module.css";
import { import {
@@ -50,6 +50,7 @@ import { useLatest } from "../useLatest";
import { type GridTileViewModel } from "../state/TileViewModel"; import { type GridTileViewModel } from "../state/TileViewModel";
import { useMergedRefs } from "../useMergedRefs"; import { useMergedRefs } from "../useMergedRefs";
import { useReactionsSender } from "../reactions/useReactionsSender"; import { useReactionsSender } from "../reactions/useReactionsSender";
import { useBehavior } from "../useBehavior";
interface TileProps { interface TileProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -84,19 +85,19 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
}) => { }) => {
const { toggleRaisedHand } = useReactionsSender(); const { toggleRaisedHand } = useReactionsSender();
const { t } = useTranslation(); const { t } = useTranslation();
const video = useObservableEagerState(vm.video$); const video = useBehavior(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$); const encryptionStatus = useBehavior(vm.encryptionStatus$);
const audioStreamStats = useObservableEagerState< const audioStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.audioStreamStats$); >(vm.audioStreamStats$);
const videoStreamStats = useObservableEagerState< const videoStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.videoStreamStats$); >(vm.videoStreamStats$);
const audioEnabled = useObservableEagerState(vm.audioEnabled$); const audioEnabled = useBehavior(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking$); const speaking = useBehavior(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo$); const cropVideo = useBehavior(vm.cropVideo$);
const onSelectFitContain = useCallback( const onSelectFitContain = useCallback(
(e: Event) => { (e: Event) => {
e.preventDefault(); e.preventDefault();
@@ -104,8 +105,8 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
}, },
[vm], [vm],
); );
const handRaised = useObservableState(vm.handRaised$); const handRaised = useBehavior(vm.handRaised$);
const reaction = useObservableState(vm.reaction$); const reaction = useBehavior(vm.reaction$);
const AudioIcon = locallyMuted const AudioIcon = locallyMuted
? VolumeOffSolidIcon ? VolumeOffSolidIcon
@@ -210,9 +211,9 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mirror = useObservableEagerState(vm.mirror$); const mirror = useBehavior(vm.mirror$);
const alwaysShow = useObservableEagerState(vm.alwaysShow$); const alwaysShow = useBehavior(vm.alwaysShow$);
const switchCamera = useObservableEagerState(vm.switchCamera$); const switchCamera = useBehavior(vm.switchCamera$);
const latestAlwaysShow = useLatest(alwaysShow); const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback( const onSelectAlwaysShow = useCallback(
@@ -274,8 +275,8 @@ const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
...props ...props
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const locallyMuted = useObservableEagerState(vm.locallyMuted$); const locallyMuted = useBehavior(vm.locallyMuted$);
const localVolume = useObservableEagerState(vm.localVolume$); const localVolume = useBehavior(vm.localVolume$);
const onSelectMute = useCallback( const onSelectMute = useCallback(
(e: Event) => { (e: Event) => {
e.preventDefault(); e.preventDefault();
@@ -346,8 +347,8 @@ export const GridTile: FC<GridTileProps> = ({
}) => { }) => {
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const media = useObservableEagerState(vm.media$); const media = useBehavior(vm.media$);
const displayName = useObservableEagerState(media.displayname$); const displayName = useBehavior(media.displayName$);
if (media instanceof LocalUserMediaViewModel) { if (media instanceof LocalUserMediaViewModel) {
return ( return (

View File

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

View File

@@ -9,7 +9,6 @@ import { test, expect, vi } from "vitest";
import { isInaccessible, render, screen } from "@testing-library/react"; import { isInaccessible, render, screen } from "@testing-library/react";
import { axe } from "vitest-axe"; import { axe } from "vitest-axe";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { of } from "rxjs";
import { SpotlightTile } from "./SpotlightTile"; import { SpotlightTile } from "./SpotlightTile";
import { import {
@@ -20,6 +19,7 @@ import {
withRemoteMedia, withRemoteMedia,
} from "../utils/test"; } from "../utils/test";
import { SpotlightTileViewModel } from "../state/TileViewModel"; import { SpotlightTileViewModel } from "../state/TileViewModel";
import { constant } from "../state/Behavior";
global.IntersectionObserver = class MockIntersectionObserver { global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {} public observe(): void {}
@@ -48,7 +48,12 @@ test("SpotlightTile is accessible", async () => {
const toggleExpanded = vi.fn(); const toggleExpanded = vi.fn();
const { container } = render( const { container } = render(
<SpotlightTile <SpotlightTile
vm={new SpotlightTileViewModel(of([vm1, vm2]), of(false))} vm={
new SpotlightTileViewModel(
constant([vm1, vm2]),
constant(false),
)
}
targetWidth={300} targetWidth={300}
targetHeight={200} targetHeight={200}
expanded={false} expanded={false}

View File

@@ -23,12 +23,14 @@ import {
} from "@vector-im/compound-design-tokens/assets/web/icons"; } from "@vector-im/compound-design-tokens/assets/web/icons";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import { type Observable, map } from "rxjs"; import { type Observable, map } from "rxjs";
import { useObservableEagerState, useObservableRef } from "observable-hooks"; import { useObservableRef } from "observable-hooks";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core"; import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { type RoomMember } from "matrix-js-sdk"; 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 { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css"; import styles from "./SpotlightTile.module.css";
import { import {
@@ -43,6 +45,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
import { type SpotlightTileViewModel } from "../state/TileViewModel"; import { type SpotlightTileViewModel } from "../state/TileViewModel";
import { useBehavior } from "../useBehavior";
interface SpotlightItemBaseProps { interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
@@ -73,7 +76,7 @@ const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
vm, vm,
...props ...props
}) => { }) => {
const mirror = useObservableEagerState(vm.mirror$); const mirror = useBehavior(vm.mirror$);
return <MediaView mirror={mirror} {...props} />; return <MediaView mirror={mirror} {...props} />;
}; };
@@ -87,8 +90,8 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
vm, vm,
...props ...props
}) => { }) => {
const videoEnabled = useObservableEagerState(vm.videoEnabled$); const videoEnabled = useBehavior(vm.videoEnabled$);
const cropVideo = useObservableEagerState(vm.cropVideo$); const cropVideo = useBehavior(vm.cropVideo$);
const baseProps: SpotlightUserMediaItemBaseProps & const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = { RefAttributes<HTMLDivElement> = {
@@ -130,10 +133,10 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
}) => { }) => {
const ourRef = useRef<HTMLDivElement | null>(null); const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const displayName = useObservableEagerState(vm.displayname$); const displayName = useBehavior(vm.displayName$);
const video = useObservableEagerState(vm.video$); const video = useBehavior(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$); const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$); const encryptionStatus = useBehavior(vm.encryptionStatus$);
// Hook this item up to the intersection observer // Hook this item up to the intersection observer
useEffect(() => { useEffect(() => {
@@ -200,8 +203,8 @@ export const SpotlightTile: FC<Props> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null); const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef); const ref = useMergedRefs(ourRef, theirRef);
const maximised = useObservableEagerState(vm.maximised$); const maximised = useBehavior(vm.maximised$);
const media = useObservableEagerState(vm.media$); const media = useBehavior(vm.media$);
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id); const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
const latestMedia = useLatest(media); const latestMedia = useLatest(media);
const latestVisibleId = useLatest(visibleId); const latestVisibleId = useLatest(visibleId);
@@ -209,6 +212,26 @@ export const SpotlightTile: FC<Props> = ({
const canGoBack = visibleIndex > 0; const canGoBack = visibleIndex > 0;
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1; 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 // 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 // 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 // their effects before their parent does, we need to do this dance with an
@@ -291,17 +314,28 @@ export const SpotlightTile: FC<Props> = ({
/> />
))} ))}
</div> </div>
{onToggleExpanded && ( <div className={styles.bottomRightButtons}>
<button <button
className={classNames(styles.expand)} className={classNames(styles.expand)}
aria-label={ aria-label={"maximise"}
expanded ? t("video_tile.collapse") : t("video_tile.expand") onClick={onToggleFullscreen}
}
onClick={onToggleExpanded}
> >
<ToggleExpandIcon aria-hidden width={20} height={20} /> <FullScreenIcon aria-hidden width={20} height={20} />
</button> </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 && ( {canGoToNext && (
<button <button
className={classNames(styles.advance, styles.next)} className={classNames(styles.advance, styles.next)}

View File

@@ -10,12 +10,12 @@ import { type FC } from "react";
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import userEvent, { type UserEvent } from "@testing-library/user-event"; import userEvent, { type UserEvent } from "@testing-library/user-event";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { of } from "rxjs";
import { MediaDevicesContext } from "./MediaDevicesContext"; import { MediaDevicesContext } from "./MediaDevicesContext";
import { useAudioContext } from "./useAudioContext"; import { useAudioContext } from "./useAudioContext";
import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings"; import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings";
import { mockMediaDevices } from "./utils/test"; import { mockMediaDevices } from "./utils/test";
import { constant } from "./state/Behavior";
const staticSounds = Promise.resolve({ const staticSounds = Promise.resolve({
aSound: new ArrayBuffer(0), aSound: new ArrayBuffer(0),
@@ -128,8 +128,8 @@ test("will use the correct device", () => {
<MediaDevicesContext <MediaDevicesContext
value={mockMediaDevices({ value={mockMediaDevices({
audioOutput: { audioOutput: {
available$: of(new Map<never, never>()), available$: constant(new Map<never, never>()),
selected$: of({ id: "chosen-device", virtualEarpiece: false }), selected$: constant({ id: "chosen-device", virtualEarpiece: false }),
select: () => {}, select: () => {},
}, },
})} })}
@@ -161,8 +161,8 @@ test("will use the pan if earpiece is selected", async () => {
<MediaDevicesContext <MediaDevicesContext
value={mockMediaDevices({ value={mockMediaDevices({
audioOutput: { audioOutput: {
available$: of(new Map<never, never>()), available$: constant(new Map<never, never>()),
selected$: of({ id: "chosen-device", virtualEarpiece: true }), selected$: constant({ id: "chosen-device", virtualEarpiece: true }),
select: () => {}, select: () => {},
}, },
})} })}

25
src/useBehavior.ts Normal file
View File

@@ -0,0 +1,25 @@
/*
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 { useCallback, useSyncExternalStore } from "react";
import { type Behavior } from "./state/Behavior";
/**
* React hook which reactively reads the value of a behavior.
*/
export function useBehavior<T>(behavior: Behavior<T>): T {
const subscribe = useCallback(
(onChange: () => void) => {
const s = behavior.subscribe(onChange);
return (): void => s.unsubscribe();
},
[behavior],
);
const getValue = useCallback(() => behavior.value, [behavior]);
return useSyncExternalStore(subscribe, getValue);
}

View File

@@ -17,6 +17,7 @@ export enum ErrorCode {
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR", INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED", E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
OPEN_ID_ERROR = "OPEN_ID_ERROR", OPEN_ID_ERROR = "OPEN_ID_ERROR",
SFU_ERROR = "SFU_ERROR",
UNKNOWN_ERROR = "UNKNOWN_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, mockLocalParticipant,
} from "./test"; } 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 local = mockMatrixRoomMember(localRtcMember);
export const localParticipant = mockLocalParticipant({ identity: "" }); export const localParticipant = mockLocalParticipant({ identity: "" });
export const localId = `${local.userId}:${localRtcMember.deviceId}`; export const localId = `${local.userId}:${localRtcMember.deviceId}`;

View File

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

View File

@@ -47,6 +47,8 @@ import {
} from "../config/ConfigOptions"; } from "../config/ConfigOptions";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { type MediaDevices } from "../state/MediaDevices"; import { type MediaDevices } from "../state/MediaDevices";
import { type Behavior, constant } from "../state/Behavior";
import { ObservableScope } from "../state/ObservableScope";
export function withFakeTimers(continuation: () => void): void { export function withFakeTimers(continuation: () => void): void {
vi.useFakeTimers(); vi.useFakeTimers();
@@ -67,6 +69,12 @@ export interface OurRunHelpers extends RunHelpers {
* diagram. * diagram.
*/ */
schedule: (marbles: string, actions: Record<string, () => void>) => void; schedule: (marbles: string, actions: Record<string, () => void>) => void;
behavior<T = string>(
marbles: string,
values?: { [marble: string]: T },
error?: unknown,
): Behavior<T>;
scope: ObservableScope;
} }
interface TestRunnerGlobal { interface TestRunnerGlobal {
@@ -82,12 +90,14 @@ export function withTestScheduler(
const scheduler = new TestScheduler((actual, expected) => { const scheduler = new TestScheduler((actual, expected) => {
expect(actual).deep.equals(expected); expect(actual).deep.equals(expected);
}); });
const scope = new ObservableScope();
// we set the test scheduler as a global so that you can watch it in a debugger // we set the test scheduler as a global so that you can watch it in a debugger
// and get the frame number. e.g. `rxjsTestScheduler?.now()` // and get the frame number. e.g. `rxjsTestScheduler?.now()`
(global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler; (global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler;
scheduler.run((helpers) => scheduler.run((helpers) =>
continuation({ continuation({
...helpers, ...helpers,
scope,
schedule(marbles, actions) { schedule(marbles, actions) {
const actionsObservable$ = helpers const actionsObservable$ = helpers
.cold(marbles) .cold(marbles)
@@ -98,8 +108,36 @@ export function withTestScheduler(
// Run the actions and verify that none of them error // Run the actions and verify that none of them error
helpers.expectObservable(actionsObservable$).toBe(marbles, results); helpers.expectObservable(actionsObservable$).toBe(marbles, results);
}, },
behavior<T>(
marbles: string,
values?: { [marble: string]: T },
error?: unknown,
) {
// Generate a hot Observable with helpers.hot and use it as a Behavior.
// To do this, we need to ensure that the initial value emits
// synchronously upon subscription. The issue is that helpers.hot emits
// frame 0 of the marble diagram *asynchronously*, only once we return
// from the continuation, so we need to splice out the initial marble
// and turn it into a proper initial value.
const initialMarbleIndex = marbles.search(/[^ ]/);
if (initialMarbleIndex === -1)
throw new Error("Behavior must have an initial value");
const initialMarble = marbles[initialMarbleIndex];
const initialValue =
values === undefined ? (initialMarble as T) : values[initialMarble];
// The remainder of the marble diagram should start on frame 1
return scope.behavior(
helpers.hot(
`-${marbles.slice(initialMarbleIndex + 1)}`,
values,
error,
),
initialValue,
);
},
}), }),
); );
scope.end();
} }
interface EmitterMock<T> { interface EmitterMock<T> {
@@ -212,15 +250,15 @@ export async function withLocalMedia(
const vm = new LocalUserMediaViewModel( const vm = new LocalUserMediaViewModel(
"local", "local",
mockMatrixRoomMember(localRtcMember, roomMember), mockMatrixRoomMember(localRtcMember, roomMember),
of(localParticipant), constant(localParticipant),
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({ localParticipant }), mockLivekitRoom({ localParticipant }),
mediaDevices, mediaDevices,
of(roomMember.rawDisplayName ?? "nodisplayname"), constant(roomMember.rawDisplayName ?? "nodisplayname"),
of(null), constant(null),
of(null), constant(null),
); );
try { try {
await continuation(vm); await continuation(vm);
@@ -257,9 +295,9 @@ export async function withRemoteMedia(
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }), mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
of(roomMember.rawDisplayName ?? "nodisplayname"), constant(roomMember.rawDisplayName ?? "nodisplayname"),
of(null), constant(null),
of(null), constant(null),
); );
try { try {
await continuation(vm); await continuation(vm);
@@ -301,7 +339,7 @@ export class MockRTCSession extends TypedEventEmitter<
} }
public withMemberships( public withMemberships(
rtcMembers$: Observable<Partial<CallMembership>[]>, rtcMembers$: Behavior<Partial<CallMembership>[]>,
): MockRTCSession { ): MockRTCSession {
rtcMembers$.subscribe((m) => { rtcMembers$.subscribe((m) => {
const old = this.memberships; const old = this.memberships;

View File

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

807
yarn.lock

File diff suppressed because it is too large Load Diff