Merge branch 'livekit' into fkwp/change_video_codec

This commit is contained in:
fkwp
2025-08-05 14:02:52 +02:00
113 changed files with 5182 additions and 3411 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,6 @@
"membership_event_expiry_ms": 180000000, "membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000, "delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000, "delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 4000 "network_error_retry_ms": 100
} }
} }

View File

@@ -17,6 +17,6 @@
"membership_event_expiry_ms": 180000000, "membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000, "delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000, "delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 4000 "network_error_retry_ms": 100
} }
} }

View File

@@ -11,7 +11,7 @@
"membership_event_expiry_ms": 180000000, "membership_event_expiry_ms": 180000000,
"delayed_leave_event_delay_ms": 18000, "delayed_leave_event_delay_ms": 18000,
"delayed_leave_event_restart_ms": 4000, "delayed_leave_event_restart_ms": 4000,
"network_error_retry_ms": 4000 "network_error_retry_ms": 100
}, },
"posthog": { "posthog": {
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU", "api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",

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

@@ -17,9 +17,15 @@ On mobile platforms (iOS, Android), web views do not reliably support selecting
- `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output. - `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
- `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example. - `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example.
- `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default. - `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default.
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
This button is only shown on iOS. (`userAgent.includes("iPhone")`)
- `controls.onAudioPlaybackStarted: ((id: string) => void) | undefined`: This will be called the first time we start - `controls.onAudioPlaybackStarted: ((id: string) => void) | undefined`: This will be called the first time we start
playing audio in the webview. It can be helpful to do device setup on the native app when the webviews audio is ready. playing audio in the webview. It can be helpful to do device setup on the native app when the webviews audio is ready.
In particular android is using it to setup the output channel so that the call volume can In particular android is using it to setup the output channel so that the call volume can
be controlled by the hardware volume rocker. be controlled by the hardware volume rocker.
## Element Call button delegation
Callbacks for buttons in EC that are handled by the native application
- `showNativeAudioDevicePicker: (() => void) | undefined`. Callback called whenever the user presses the output button in the settings menu.
This button is only shown on iOS. (`/iPad|iPhone|iPod|Mac/.test(navigator.userAgent)`)
- `onBackButtonPressed: (() => void) | undefined`. Callback when the webview detects a tab on the header's back button.

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

@@ -56,7 +56,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. | | `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. | | `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. | | `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. | | `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`. | | `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`. |
@@ -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.10.1" 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.32.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.1-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

@@ -82,7 +82,7 @@
"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",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one</1>.</0>", "call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
"connection_lost": "Connection lost", "connection_lost": "Connection lost",
"connection_lost_description": "You were disconnected from the call.", "connection_lost_description": "You were disconnected from the call.",
"e2ee_unsupported": "Incompatible browser", "e2ee_unsupported": "Incompatible browser",
@@ -94,6 +94,8 @@
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).", "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": {
@@ -105,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",
@@ -176,7 +183,8 @@
"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",
"microphone": "Microphone", "microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}", "microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker", "speaker": "Speaker",

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.201.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,8 +81,8 @@
"@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": "^7.12.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",
"babel-plugin-transform-vite-meta-env": "^1.0.3", "babel-plugin-transform-vite-meta-env": "^1.0.3",

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,104 @@
/*
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";
test("When creator left, avoid reconnect to the same SFU", async ({
browser,
}) => {
// Use reduce motion to disable animations that are making the tests a bit flaky
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
const creatorPage = await creatorContext.newPage();
await creatorPage.goto("/");
// ========
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
// ========
await creatorPage.getByTestId("home_callName").click();
await creatorPage.getByTestId("home_callName").fill("Welcome");
await creatorPage.getByTestId("home_displayName").click();
await creatorPage.getByTestId("home_displayName").fill("Inviter");
await creatorPage.getByTestId("home_go").click();
await expect(creatorPage.locator("video")).toBeVisible();
// join
await creatorPage.getByTestId("lobby_joinCall").click();
// Spotlight mode to make checking the test visually clearer
await creatorPage.getByRole("radio", { name: "Spotlight" }).check();
// Get the invite link
await creatorPage.getByRole("button", { name: "Invite" }).click();
await expect(
creatorPage.getByRole("heading", { name: "Invite to this call" }),
).toBeVisible();
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
await creatorPage.getByTestId("modal_inviteLink").click();
const inviteLink = (await creatorPage.evaluate(
"navigator.clipboard.readText()",
)) as string;
expect(inviteLink).toContain("room/#/");
// ========
// ACT: The other user use the invite link to join the call as a guest
// ========
const guestB = await browser.newContext({
reducedMotion: "reduce",
});
const guestBPage = await guestB.newPage();
await guestBPage.goto(inviteLink);
await guestBPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestBPage.getByTestId("joincall_joincall")).toBeVisible();
await guestBPage.getByTestId("joincall_joincall").click();
await guestBPage.getByTestId("lobby_joinCall").click();
await guestBPage.getByRole("radio", { name: "Spotlight" }).check();
// ========
// ACT: add a third user to the call to reproduce the bug
// ========
const guestC = await browser.newContext({
reducedMotion: "reduce",
});
const guestCPage = await guestC.newPage();
let sfuGetCallCount = 0;
await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => {
sfuGetCallCount++;
await route.continue();
});
// Track WebSocket connections
let wsConnectionCount = 0;
await guestCPage.routeWebSocket("**", (ws) => {
// For some reason the interception is not working with the **
if (ws.url().includes("livekit/sfu/rtc")) {
wsConnectionCount++;
}
ws.connectToServer();
});
await guestCPage.goto(inviteLink);
await guestCPage.getByTestId("joincall_displayName").fill("Invitee");
await expect(guestCPage.getByTestId("joincall_joincall")).toBeVisible();
await guestCPage.getByTestId("joincall_joincall").click();
await guestCPage.getByTestId("lobby_joinCall").click();
await guestCPage.getByRole("radio", { name: "Spotlight" }).check();
await guestCPage.waitForTimeout(1000);
// ========
// the creator leaves the call
await creatorPage.getByTestId("incall_leave").click();
await guestCPage.waitForTimeout(2000);
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1);
expect(sfuGetCallCount).toBe(1);
});

View File

@@ -5,7 +5,14 @@ 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 FC, type JSX, Suspense, useEffect, useState } from "react"; import {
type FC,
type JSX,
Suspense,
useEffect,
useMemo,
useState,
} from "react";
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web"; import { TooltipProvider } from "@vector-im/compound-web";
@@ -19,10 +26,13 @@ import { ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView"; import { ErrorPage, LoadingPage } from "./FullScreenView";
import { DisconnectedBanner } from "./DisconnectedBanner"; import { DisconnectedBanner } from "./DisconnectedBanner";
import { Initializer } from "./initializer"; import { Initializer } from "./initializer";
import { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
import { widget } from "./widget"; import { widget } from "./widget";
import { useTheme } from "./useTheme"; import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
import { getUrlParams, HeaderStyle } from "./UrlParams";
import { AppBar } from "./AppBar";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -50,7 +60,11 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
return children; return children;
}; };
export const App: FC = () => { interface Props {
vm: AppViewModel;
}
export const App: FC<Props> = ({ vm }) => {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
Initializer.init() Initializer.init()
@@ -62,41 +76,43 @@ export const App: FC = () => {
.catch(logger.error); .catch(logger.error);
}); });
// Since we are outside the router component, we cannot use useUrlParams here
const { header } = useMemo(getUrlParams, []);
const content = loaded ? (
<ClientProvider>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} widget={widget} />}
>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute path="/register" element={<RegisterPage />} />
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
) : (
<LoadingPage />
);
return ( return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<BrowserRouter> <BrowserRouter>
<BackgroundProvider> <BackgroundProvider>
<ThemeProvider> <ThemeProvider>
<TooltipProvider> <TooltipProvider>
{loaded ? ( <Suspense fallback={null}>
<Suspense fallback={null}> {header === HeaderStyle.AppBar ? (
<ClientProvider> <AppBar>{content}</AppBar>
<MediaDevicesProvider> ) : (
<ProcessorProvider> content
<Sentry.ErrorBoundary )}
fallback={(error) => ( </Suspense>
<ErrorPage error={error} widget={widget} />
)}
>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute
path="/register"
element={<RegisterPage />}
/>
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesProvider>
</ClientProvider>
</Suspense>
) : (
<LoadingPage />
)}
</TooltipProvider> </TooltipProvider>
</ThemeProvider> </ThemeProvider>
</BackgroundProvider> </BackgroundProvider>

23
src/AppBar.module.css Normal file
View File

@@ -0,0 +1,23 @@
.bar {
block-size: 64px;
flex-shrink: 0;
}
.bar > header {
position: absolute;
inset-inline: 0;
inset-block-start: 0;
block-size: 64px;
z-index: var(--call-view-header-footer-layer);
}
.bar svg path {
fill: var(--cpd-color-icon-primary);
}
.bar > header > h1 {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

25
src/AppBar.test.tsx 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 { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { AppBar } from "./AppBar";
describe("AppBar", () => {
it("renders", () => {
const { container } = render(
<TooltipProvider>
<AppBar>
<p>This is the content.</p>
</AppBar>
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
});
});

130
src/AppBar.tsx Normal file
View File

@@ -0,0 +1,130 @@
/*
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 {
createContext,
type FC,
type MouseEvent,
type ReactNode,
use,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { Header, LeftNav, RightNav } from "./Header";
import { platform } from "./Platform";
import styles from "./AppBar.module.css";
interface AppBarContext {
setTitle: (value: string) => void;
setSecondaryButton: (value: ReactNode) => void;
setHidden: (value: boolean) => void;
}
const AppBarContext = createContext<AppBarContext | null>(null);
interface Props {
children: ReactNode;
}
/**
* A "top app bar" featuring a back button, title and possibly a secondary
* button, similar to what you might see in mobile apps.
*/
export const AppBar: FC<Props> = ({ children }) => {
const { t } = useTranslation();
const onBackClick = useCallback((e: MouseEvent) => {
e.preventDefault();
window.controls.onBackButtonPressed?.();
}, []);
const [title, setTitle] = useState<string>("");
const [hidden, setHidden] = useState<boolean>(false);
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
const context = useMemo(
() => ({ setTitle, setSecondaryButton, setHidden }),
[setTitle, setHidden, setSecondaryButton],
);
return (
<>
<div
style={{ display: hidden ? "none" : "block" }}
className={styles.bar}
>
<Header>
<LeftNav>
<Tooltip label={t("common.back")}>
<IconButton onClick={onBackClick}>
<CollapseIcon />
</IconButton>
</Tooltip>
</LeftNav>
{title && (
<Heading
type="body"
size="lg"
weight={platform === "android" ? "medium" : "semibold"}
>
{title}
</Heading>
)}
<RightNav>{secondaryButton}</RightNav>
</Header>
</div>
<AppBarContext value={context}>{children}</AppBarContext>
</>
);
};
/**
* React hook which sets the title to be shown in the app bar, if present. It is
* an error to call this hook from multiple sites in the same component tree.
*/
export function useAppBarTitle(title: string): void {
const setTitle = use(AppBarContext)?.setTitle;
useEffect(() => {
if (setTitle !== undefined) {
setTitle(title);
return (): void => setTitle("");
}
}, [title, setTitle]);
}
/**
* React hook which sets the title to be shown in the app bar, if present. It is
* an error to call this hook from multiple sites in the same component tree.
*/
export function useAppBarHidden(hidden: boolean): void {
const setHidden = use(AppBarContext)?.setHidden;
useEffect(() => {
if (setHidden !== undefined) {
setHidden(hidden);
return (): void => setHidden(false);
}
}, [setHidden, hidden]);
}
/**
* React hook which sets the secondary button to be shown in the app bar, if
* present. It is an error to call this hook from multiple sites in the same
* component tree.
*/
export function useAppBarSecondaryButton(button: ReactNode): void {
const setSecondaryButton = use(AppBarContext)?.setSecondaryButton;
useEffect(() => {
if (setSecondaryButton !== undefined) {
setSecondaryButton(button);
return (): void => setSecondaryButton("");
}
}, [button, setSecondaryButton]);
}

View File

@@ -11,7 +11,7 @@ import {
useEffect, useEffect,
useState, useState,
createContext, createContext,
useContext, use,
useRef, useRef,
useMemo, useMemo,
type JSX, type JSX,
@@ -69,8 +69,7 @@ const ClientContext = createContext<ClientState | undefined>(undefined);
export const ClientContextProvider = ClientContext.Provider; export const ClientContextProvider = ClientContext.Provider;
export const useClientState = (): ClientState | undefined => export const useClientState = (): ClientState | undefined => use(ClientContext);
useContext(ClientContext);
export function useClient(): { export function useClient(): {
client?: MatrixClient; client?: MatrixClient;
@@ -350,9 +349,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
return <ErrorPage widget={widget} error={alreadyOpenedErr} />; return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
} }
return ( return <ClientContext value={state}>{children}</ClientContext>;
<ClientContext.Provider value={state}>{children}</ClientContext.Provider>
);
}; };
export type InitResult = { export type InitResult = {

View File

@@ -12,6 +12,7 @@
.error > h1 { .error > h1 {
margin: 0; margin: 0;
text-align: center;
} }
.error > p { .error > p {

View File

@@ -99,7 +99,7 @@ export const ErrorView: FC<Props> = ({
return ( return (
<div className={styles.error}> <div className={styles.error}>
<BigIcon className={styles.icon}> <BigIcon className={styles.icon}>
<Icon /> <Icon aria-hidden />
</BigIcon> </BigIcon>
<Heading as="h1" weight="semibold" size="md"> <Heading as="h1" weight="semibold" size="md">
{title} {title}

View File

@@ -28,10 +28,10 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
className, className,
children, children,
}) => { }) => {
const { hideHeader } = useUrlParams(); const { header } = useUrlParams();
return ( return (
<div className={classNames(styles.page, className)}> <div className={classNames(styles.page, className)}>
{!hideHeader && ( {header === "standard" && (
<Header> <Header>
<LeftNav> <LeftNav>
<HeaderLogo /> <HeaderLogo />

View File

@@ -6,12 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import { import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react";
type FC,
type HTMLAttributes,
type ReactNode,
forwardRef,
} from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web"; import { Heading, Text } from "@vector-im/compound-web";
@@ -24,23 +19,27 @@ import { EncryptionLock } from "./room/EncryptionLock";
import { useMediaQuery } from "./useMediaQuery"; import { useMediaQuery } from "./useMediaQuery";
interface HeaderProps extends HTMLAttributes<HTMLElement> { interface HeaderProps extends HTMLAttributes<HTMLElement> {
ref?: Ref<HTMLElement>;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
} }
export const Header = forwardRef<HTMLElement, HeaderProps>( export const Header: FC<HeaderProps> = ({
({ children, className, ...rest }, ref) => { ref,
return ( children,
<header className,
ref={ref} ...rest
className={classNames(styles.header, className)} }) => {
{...rest} return (
> <header
{children} ref={ref}
</header> className={classNames(styles.header, className)}
); {...rest}
}, >
); {children}
</header>
);
};
Header.displayName = "Header"; Header.displayName = "Header";

View File

@@ -0,0 +1,56 @@
/*
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 { createContext, use, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type MediaDevices } from "./state/MediaDevices";
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
undefined,
);
export function useMediaDevices(): MediaDevices {
const mediaDevices = use(MediaDevicesContext);
if (mediaDevices === undefined)
throw new Error(
"useMediaDevices must be used within a MediaDevices context provider",
);
return mediaDevices;
}
export const useIsEarpiece = (): boolean => {
const devices = useMediaDevices();
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
const available = useObservableEagerState(devices.audioOutput.available$);
if (!audioOutput?.id) return false;
return available.get(audioOutput.id)?.type === "earpiece";
};
/**
* A convenience hook to get the audio node configuration for the earpiece.
* It will check the `useAsEarpiece` of the `audioOutput` device and return
* the appropriate pan and volume values.
*
* @returns pan and volume values for the earpiece audio node configuration.
*/
export const useEarpieceAudioConfig = (): {
pan: number;
volume: number;
} => {
const devices = useMediaDevices();
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
return {
// We use only the right speaker (pan = 1) for the earpiece.
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
pan: useMemo(() => (isVirtualEarpiece ? 1 : 0), [isVirtualEarpiece]),
// We also do lower the volume by a factor of 10 to optimize for the usecase where
// a user is holding the phone to their ear.
volume: useMemo(() => (isVirtualEarpiece ? 0.1 : 1), [isVirtualEarpiece]),
};
};

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"));
}); });
}); });
@@ -243,4 +297,12 @@ describe("UrlParams", () => {
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false); expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
}); });
}); });
describe("header", () => {
it("uses header if provided", () => {
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe(
"app_bar",
);
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
});
});
}); });

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;
@@ -20,17 +23,24 @@ interface RoomIdentifier {
} }
export enum UserIntent { export enum UserIntent {
// TODO: add DM vs room call
StartNewCall = "start_call", StartNewCall = "start_call",
JoinExistingCall = "join_existing", JoinExistingCall = "join_existing",
Unknown = "unknown", Unknown = "unknown",
} }
// If you need to add a new flag to this interface, prefer a name that describes export enum HeaderStyle {
// a specific behavior (such as 'confineToRoom'), rather than one that describes None = "none",
// the situations that call for this behavior ('isEmbedded'). This makes it Standard = "standard",
// clearer what each flag means, and helps us avoid coupling Element Call's AppBar = "app_bar",
// behavior to the needs of specific consumers. }
export interface UrlParams {
/**
* The UrlProperties are used to pass required data to the widget.
* Those are different in different rooms, users, devices. They do not configure the behavior of the
* widget but provide the required data to the widget.
*/
export interface UrlProperties {
// Widget api related params // Widget api related params
widgetId: string | null; widgetId: string | null;
parentUrl: string | null; parentUrl: string | null;
@@ -42,42 +52,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;
/**
* Whether to hide the room header when in a call.
*/
hideHeader: boolean;
/**
* 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.
*/ */
@@ -115,14 +94,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.
*/ */
@@ -145,47 +206,18 @@ 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
* a new (guest) user.
* This can be user to configure a non default guest user server when
* creating a spa link.
*/
homeserver: string | null;
/**
* The user's intent with respect to the call.
* e.g. if they clicked a Start Call button, this would be `start_call`.
* If it was a Join Call button, it would be `join_existing`.
*/
intent: string | null;
/**
* The rageshake submit URL. This is only used in the embedded package of Element Call.
*/
rageshakeSubmitUrl: string | null;
/**
* The Sentry DSN. This is only used in the embedded package of Element Call.
*/
sentryDsn: string | null;
/**
* The Sentry environment. This is only used in the embedded package of Element Call.
*/
sentryEnvironment: string | null;
} }
// 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
@@ -226,6 +258,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),
@@ -237,6 +280,10 @@ class ParamParser {
const param = this.getParam(name); const param = this.getParam(name);
return param === null ? defaultValue : param !== "false"; return param === null ? defaultValue : param !== "false";
} }
public getFlag(name: string): boolean | undefined {
const param = this.getParam(name);
return param !== null ? param !== "false" : undefined;
}
} }
/** /**
@@ -253,32 +300,79 @@ 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;
}
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,
};
switch (intent) {
case UserIntent.StartNewCall:
intentPreset = {
...inAppDefault,
skipLobby: true,
};
break;
case UserIntent.JoinExistingCall:
intentPreset = {
...inAppDefault,
skipLobby: false,
};
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,
};
}
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,
hideHeader: parser.getFlagParam("hideHeader"),
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,
@@ -286,24 +380,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:
@@ -311,6 +390,35 @@ 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",
]),
};
return {
...properties,
...intentPreset,
...pickBy(configuration, (v) => v !== undefined),
}; };
}; };
@@ -369,10 +477,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

@@ -0,0 +1,50 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AppBar > renders 1`] = `
<div>
<div
class="bar"
style="display: block;"
>
<header
class="header"
>
<div
class="nav leftNav"
>
<button
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
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>
</div>
</button>
</div>
<div
class="nav rightNav"
/>
</header>
</div>
<p>
This is the content.
</p>
</div>
`;

View File

@@ -5,11 +5,7 @@ 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 { import { type ComponentProps, type FC, type MouseEvent } from "react";
type ComponentPropsWithoutRef,
forwardRef,
type MouseEvent,
} from "react";
import { Link as CpdLink } from "@vector-im/compound-web"; import { Link as CpdLink } from "@vector-im/compound-web";
import { type LinkProps, useHref, useLinkClickHandler } from "react-router-dom"; import { type LinkProps, useHref, useLinkClickHandler } from "react-router-dom";
import classNames from "classnames"; import classNames from "classnames";
@@ -26,31 +22,30 @@ export function useLink(
return [href, onClick]; return [href, onClick];
} }
type Props = Omit< type Props = Omit<ComponentProps<typeof CpdLink>, "href" | "onClick"> & {
ComponentPropsWithoutRef<typeof CpdLink>, to: LinkProps["to"];
"href" | "onClick" state?: unknown;
> & { to: LinkProps["to"]; state?: unknown }; };
/** /**
* A version of Compound's link component that integrates with our router setup. * A version of Compound's link component that integrates with our router setup.
* This is only for app-internal links. * This is only for app-internal links.
*/ */
export const Link = forwardRef<HTMLAnchorElement, Props>(function Link( export const Link: FC<Props> = ({ ref, to, state, ...props }) => {
{ to, state, ...props },
ref,
) {
const [path, onClick] = useLink(to, state); const [path, onClick] = useLink(to, state);
return <CpdLink ref={ref} {...props} href={path} onClick={onClick} />; return <CpdLink ref={ref} {...props} href={path} onClick={onClick} />;
}); };
/** /**
* A link to an external web page, made to fit into blocks of text more subtly * A link to an external web page, made to fit into blocks of text more subtly
* than the normal Compound link component. * than the normal Compound link component.
*/ */
export const ExternalLink = forwardRef< export const ExternalLink: FC<ComponentProps<"a">> = ({
HTMLAnchorElement, ref,
ComponentPropsWithoutRef<"a"> className,
>(function ExternalLink({ className, children, ...props }, ref) { children,
...props
}) => {
return ( return (
<a <a
ref={ref} ref={ref}
@@ -62,4 +57,4 @@ export const ExternalLink = forwardRef<
{children} {children}
</a> </a>
); );
}); };

View File

@@ -5,24 +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 ComponentPropsWithoutRef, forwardRef } from "react"; import { type ComponentProps, type FC } from "react";
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import type { LinkProps } from "react-router-dom"; import type { LinkProps } from "react-router-dom";
import { useLink } from "./Link"; import { useLink } from "./Link";
type Props = Omit< type Props = Omit<ComponentProps<typeof Button<"a">>, "as" | "href"> & {
ComponentPropsWithoutRef<typeof Button<"a">>, to: LinkProps["to"];
"as" | "href" state?: unknown;
> & { to: LinkProps["to"]; state?: unknown }; };
/** /**
* A version of Compound's button component that acts as a link and integrates * A version of Compound's button component that acts as a link and integrates
* with our router setup. * with our router setup.
*/ */
export const LinkButton = forwardRef<HTMLAnchorElement, Props>( export const LinkButton: FC<Props> = ({ ref, to, state, ...props }) => {
function LinkButton({ to, state, ...props }, ref) { const [path, onClick] = useLink(to, state);
const [path, onClick] = useLink(to, state); return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />; };
},
);

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

@@ -1,32 +1,36 @@
/* /*
Copyright 2024 New Vector Ltd. Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial 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 { BehaviorSubject, Subject } from "rxjs"; import { Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
export interface Controls { export interface Controls {
canEnterPip(): boolean; canEnterPip(): boolean;
enablePip(): void; enablePip(): void;
disablePip(): void; disablePip(): void;
/** @deprecated use setAvailableAudioDevices instead*/
setAvailableOutputDevices(devices: OutputDevice[]): void;
setAvailableAudioDevices(devices: OutputDevice[]): void; setAvailableAudioDevices(devices: OutputDevice[]): void;
/** @deprecated use setAudioDevice instead*/
setOutputDevice(id: string): void;
setAudioDevice(id: string): void; setAudioDevice(id: string): void;
/** @deprecated use onAudioDeviceSelect instead*/
onOutputDeviceSelect?: (id: string) => void;
onAudioDeviceSelect?: (id: string) => void; onAudioDeviceSelect?: (id: string) => void;
onAudioPlaybackStarted?: () => void; onAudioPlaybackStarted?: () => void;
setAudioEnabled(enabled: boolean): void;
showNativeAudioDevicePicker?: () => void;
onBackButtonPressed?: () => void;
/** @deprecated use setAvailableAudioDevices instead*/
setAvailableOutputDevices(devices: OutputDevice[]): void;
/** @deprecated use setAudioDevice instead*/
setOutputDevice(id: string): void;
/** @deprecated use onAudioDeviceSelect instead*/
onOutputDeviceSelect?: (id: string) => void;
/** @deprecated use setAudioEnabled instead*/ /** @deprecated use setAudioEnabled instead*/
setOutputEnabled(enabled: boolean): void; setOutputEnabled(enabled: boolean): void;
setAudioEnabled(enabled: boolean): void;
/** @deprecated use showNativeAudioDevicePicker instead*/ /** @deprecated use showNativeAudioDevicePicker instead*/
showNativeOutputDevicePicker?: () => void; showNativeOutputDevicePicker?: () => void;
showNativeAudioDevicePicker?: () => void;
} }
export interface OutputDevice { export interface OutputDevice {
@@ -42,12 +46,11 @@ export interface OutputDevice {
* If pipMode is enabled, EC will render a adapted call view layout. * If pipMode is enabled, EC will render a adapted call view layout.
*/ */
export const setPipEnabled$ = new Subject<boolean>(); export const setPipEnabled$ = new Subject<boolean>();
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
// We want the devices that have been set during loading to be available immediately once loaded. export const availableOutputDevices$ = new Subject<OutputDevice[]>();
export const availableOutputDevices$ = new BehaviorSubject<OutputDevice[]>([]);
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state) export const outputDevice$ = new Subject<string>();
// We want the device that has been set during loading to be available immediately once loaded.
export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
/** /**
* This allows the os to mute the call if the user * This allows the os to mute the call if the user
* presses the volume down button when it is at the minimum volume. * presses the volume down button when it is at the minimum volume.
@@ -55,6 +58,7 @@ export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
* This should also be used to display a darkened overlay screen letting the user know that audio is muted. * This should also be used to display a darkened overlay screen letting the user know that audio is muted.
*/ */
export const setAudioEnabled$ = new Subject<boolean>(); export const setAudioEnabled$ = new Subject<boolean>();
let playbackStartedEmitted = false; let playbackStartedEmitted = false;
export const setPlaybackStarted = (): void => { export const setPlaybackStarted = (): void => {
if (!playbackStartedEmitted) { if (!playbackStartedEmitted) {
@@ -62,6 +66,7 @@ export const setPlaybackStarted = (): void => {
window.controls.onAudioPlaybackStarted?.(); window.controls.onAudioPlaybackStarted?.();
} }
}; };
window.controls = { window.controls = {
canEnterPip(): boolean { canEnterPip(): boolean {
return setPipEnabled$.observed; return setPipEnabled$.observed;
@@ -74,13 +79,17 @@ window.controls = {
if (!setPipEnabled$.observed) throw new Error("No call is running"); if (!setPipEnabled$.observed) throw new Error("No call is running");
setPipEnabled$.next(false); setPipEnabled$.next(false);
}, },
setAvailableAudioDevices(devices: OutputDevice[]): void { setAvailableAudioDevices(devices: OutputDevice[]): void {
logger.info("setAvailableAudioDevices called from native:", devices);
availableOutputDevices$.next(devices); availableOutputDevices$.next(devices);
}, },
setAudioDevice(id: string): void { setAudioDevice(id: string): void {
logger.info("setAudioDevice called from native", id);
outputDevice$.next(id); outputDevice$.next(id);
}, },
setAudioEnabled(enabled: boolean): void { setAudioEnabled(enabled: boolean): void {
logger.info("setAudioEnabled called from native:", enabled);
if (!setAudioEnabled$.observed) if (!setAudioEnabled$.observed)
throw new Error( throw new Error(
"Output controls are disabled. No setAudioEnabled$ observer", "Output controls are disabled. No setAudioEnabled$ observer",

View File

@@ -6,28 +6,32 @@ Please see LICENSE in the repository root for full details.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import { type FormEventHandler, forwardRef, type ReactNode } from "react"; import {
type FC,
type Ref,
type FormEventHandler,
type ReactNode,
} from "react";
import styles from "./Form.module.css"; import styles from "./Form.module.css";
interface FormProps { interface FormProps {
ref?: Ref<HTMLFormElement>;
className: string; className: string;
onSubmit: FormEventHandler<HTMLFormElement>; onSubmit: FormEventHandler<HTMLFormElement>;
children: ReactNode[]; children: ReactNode[];
} }
export const Form = forwardRef<HTMLFormElement, FormProps>( export const Form: FC<FormProps> = ({ ref, children, className, onSubmit }) => {
({ children, className, onSubmit }, ref) => { return (
return ( <form
<form onSubmit={onSubmit}
onSubmit={onSubmit} className={classNames(styles.form, className)}
className={classNames(styles.form, className)} ref={ref}
ref={ref} >
> {children}
{children} </form>
</form> );
); };
},
);
Form.displayName = "Form"; Form.displayName = "Form";

View File

@@ -18,23 +18,22 @@ import {
type ComponentType, type ComponentType,
type Dispatch, type Dispatch,
type FC, type FC,
type LegacyRef,
type ReactNode, type ReactNode,
type Ref,
type SetStateAction, type SetStateAction,
createContext, createContext,
forwardRef,
memo, memo,
useContext, 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";
@@ -124,7 +123,7 @@ interface LayoutContext {
const LayoutContext = createContext<LayoutContext | null>(null); const LayoutContext = createContext<LayoutContext | null>(null);
function useLayoutContext(): LayoutContext { function useLayoutContext(): LayoutContext {
const context = useContext(LayoutContext); const context = use(LayoutContext);
if (context === null) if (context === null)
throw new Error("useUpdateLayout called outside a Grid layout context"); throw new Error("useUpdateLayout called outside a Grid layout context");
return context; return context;
@@ -156,13 +155,8 @@ 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: LegacyRef<R>; ref?: Ref<R>;
model: LayoutModel; model: LayoutModel;
/** /**
* Component creating an invisible "slot" for a tile to go in. * Component creating an invisible "slot" for a tile to go in.
@@ -171,7 +165,7 @@ export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
} }
export interface TileProps<Model, R extends HTMLElement> { export interface TileProps<Model, R extends HTMLElement> {
ref: LegacyRef<R>; ref?: Ref<R>;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
/** /**
@@ -262,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] =
@@ -297,14 +297,13 @@ export function Grid<
// render of Grid causes a re-render of Layout, which in turn re-renders Grid // render of Grid causes a re-render of Layout, which in turn re-renders Grid
const LayoutMemo = useMemo( const LayoutMemo = useMemo(
() => () =>
memo( memo(function LayoutMemo({
forwardRef< ref,
LayoutRef, Layout,
LayoutMemoProps<LayoutModel, TileModel, LayoutRef> ...props
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode { }: LayoutMemoProps<LayoutModel, TileModel, LayoutRef>): ReactNode {
return <Layout {...props} ref={ref} />; return <Layout {...props} ref={ref} />;
}), }),
),
[], [],
); );
@@ -532,14 +531,14 @@ export function Grid<
className={classNames(className, styles.grid)} className={classNames(className, styles.grid)}
style={style} style={style}
> >
<LayoutContext.Provider value={context}> <LayoutContext value={context}>
<LayoutMemo <LayoutMemo
ref={setLayoutRoot} ref={setLayoutRoot}
Layout={Layout} Layout={Layout}
model={model} model={model}
Slot={Slot} Slot={Slot}
/> />
</LayoutContext.Provider> </LayoutContext>
{tileTransitions((spring, { id, model, onDrag, width, height }) => ( {tileTransitions((spring, { id, model, onDrag, width, height }) => (
<TileWrapper <TileWrapper
key={id} key={id}

View File

@@ -5,7 +5,12 @@ 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 CSSProperties, forwardRef, useCallback, useMemo } from "react"; import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
} from "react";
import { distinctUntilChanged } from "rxjs"; import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
@@ -33,7 +38,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile // The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives // lives
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) { fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout(); useUpdateLayout();
const alignment = useObservableEagerState( const alignment = useObservableEagerState(
useInitial(() => useInitial(() =>
@@ -68,10 +73,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
)} )}
</div> </div>
); );
}), },
// The scrolling part of the layout is where all the grid tiles live // The scrolling part of the layout is where all the grid tiles live
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) { scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useObservableEagerState(minBounds$); const { width, height: minHeight } = useObservableEagerState(minBounds$);
@@ -98,5 +103,5 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
))} ))}
</div> </div>
); );
}), },
}); });

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { forwardRef, useCallback, useMemo } from "react"; import { type ReactNode, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
@@ -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
@@ -24,15 +25,15 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
}) => ({ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) { fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
useUpdateLayout(); useUpdateLayout();
return <div ref={ref} />; return <div ref={ref} />;
}), },
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) { 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],
@@ -66,5 +67,5 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
</Slot> </Slot>
</div> </div>
); );
}), },
}); });

View File

@@ -5,13 +5,13 @@ 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 { forwardRef, 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
@@ -22,10 +22,11 @@ export const makeSpotlightExpandedLayout: CallLayout<
> = ({ pipAlignment$ }) => ({ > = ({ pipAlignment$ }) => ({
scrollingOnTop: true, scrollingOnTop: true,
fixed: forwardRef(function SpotlightExpandedLayoutFixed( fixed: function SpotlightExpandedLayoutFixed({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
return ( return (
@@ -37,14 +38,15 @@ export const makeSpotlightExpandedLayout: CallLayout<
/> />
</div> </div>
); );
}), },
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling( scrolling: function SpotlightExpandedLayoutScrolling({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
const pipAlignmentValue = useObservableEagerState(pipAlignment$); const pipAlignmentValue = useBehavior(pipAlignment$);
const onDragPip: DragCallback = useCallback( const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) => ({ xRatio, yRatio }) =>
@@ -69,5 +71,5 @@ export const makeSpotlightExpandedLayout: CallLayout<
)} )}
</div> </div>
); );
}), },
}); });

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { forwardRef } from "react"; import { type ReactNode } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
@@ -24,10 +24,11 @@ export const makeSpotlightLandscapeLayout: CallLayout<
> = ({ minBounds$ }) => ({ > = ({ minBounds$ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
fixed: forwardRef(function SpotlightLandscapeLayoutFixed( fixed: function SpotlightLandscapeLayoutFixed({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
useObservableEagerState(minBounds$); useObservableEagerState(minBounds$);
@@ -43,12 +44,13 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<div className={styles.grid} /> <div className={styles.grid} />
</div> </div>
); );
}), },
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling( scrolling: function SpotlightLandscapeLayoutScrolling({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$); useObservableEagerState(minBounds$);
@@ -69,5 +71,5 @@ export const makeSpotlightLandscapeLayout: CallLayout<
</div> </div>
</div> </div>
); );
}), },
}); });

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type CSSProperties, forwardRef } from "react"; import { type ReactNode, type CSSProperties } from "react";
import { useObservableEagerState } from "observable-hooks"; import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames"; import classNames from "classnames";
@@ -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;
@@ -30,10 +31,11 @@ export const makeSpotlightPortraitLayout: CallLayout<
> = ({ minBounds$ }) => ({ > = ({ minBounds$ }) => ({
scrollingOnTop: false, scrollingOnTop: false,
fixed: forwardRef(function SpotlightPortraitLayoutFixed( fixed: function SpotlightPortraitLayoutFixed({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
return ( return (
@@ -47,12 +49,13 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div> </div>
</div> </div>
); );
}), },
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling( scrolling: function SpotlightPortraitLayoutScrolling({
{ model, Slot },
ref, ref,
) { model,
Slot,
}): ReactNode {
useUpdateLayout(); useUpdateLayout();
useVisibleTiles(model.setVisibleTiles); useVisibleTiles(model.setVisibleTiles);
const { width } = useObservableEagerState(minBounds$); const { width } = useObservableEagerState(minBounds$);
@@ -63,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
@@ -90,5 +92,5 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div> </div>
</div> </div>
); );
}), },
}); });

View File

@@ -37,12 +37,14 @@ import { Form } from "../form/Form";
import { AnalyticsNotice } from "../analytics/AnalyticsNotice"; import { AnalyticsNotice } from "../analytics/AnalyticsNotice";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useOptInAnalytics } from "../settings/settings"; import { useOptInAnalytics } from "../settings/settings";
import { useUrlParams } from "../UrlParams";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
} }
export const RegisteredView: FC<Props> = ({ client }) => { export const RegisteredView: FC<Props> = ({ client }) => {
const { header } = useUrlParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useOptInAnalytics();
@@ -114,14 +116,16 @@ export const RegisteredView: FC<Props> = ({ client }) => {
return ( return (
<> <>
<div className={commonStyles.container}> <div className={commonStyles.container}>
<Header> {header === "standard" && (
<LeftNav> <Header>
<HeaderLogo /> <LeftNav>
</LeftNav> <HeaderLogo />
<RightNav> </LeftNav>
<UserMenuContainer /> <RightNav>
</RightNav> <UserMenuContainer />
</Header> </RightNav>
</Header>
)}
<main className={commonStyles.main}> <main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} /> <HeaderLogo className={commonStyles.logo} />
<Heading size="lg" weight="semibold"> <Heading size="lg" weight="semibold">

View File

@@ -34,9 +34,11 @@ import { Config } from "../config/Config";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useOptInAnalytics } from "../settings/settings"; import { useOptInAnalytics } from "../settings/settings";
import { ExternalLink, Link } from "../button/Link"; import { ExternalLink, Link } from "../button/Link";
import { useUrlParams } from "../UrlParams";
export const UnauthenticatedView: FC = () => { export const UnauthenticatedView: FC = () => {
const { setClient } = useClient(); const { setClient } = useClient();
const { header } = useUrlParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
const [optInAnalytics] = useOptInAnalytics(); const [optInAnalytics] = useOptInAnalytics();
@@ -141,14 +143,16 @@ export const UnauthenticatedView: FC = () => {
return ( return (
<> <>
<div className={commonStyles.container}> <div className={commonStyles.container}>
<Header> {header === "standard" && (
<LeftNav> <Header>
<HeaderLogo /> <LeftNav>
</LeftNav> <HeaderLogo />
<RightNav hideMobile> </LeftNav>
<UserMenuContainer /> <RightNav hideMobile>
</RightNav> <UserMenuContainer />
</Header> </RightNav>
</Header>
)}
<main className={commonStyles.main}> <main className={commonStyles.main}>
<HeaderLogo className={commonStyles.logo} /> <HeaderLogo className={commonStyles.logo} />
<Heading size="lg" weight="semibold"> <Heading size="lg" weight="semibold">

View File

@@ -45,6 +45,9 @@ layer(compound);
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15); --small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
--subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05); --subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
--background-gradient: url("graphics/backgroundGradient.svg"); --background-gradient: url("graphics/backgroundGradient.svg");
--call-view-overlay-layer: 1;
--call-view-header-footer-layer: 2;
} }
:root, :root,
@@ -71,6 +74,13 @@ body {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
/* This prohibits the view to scroll for pages smaller than 122px in width
we use this for mobile pip webviews */
.no-scroll-body {
position: fixed;
width: 100%;
}
/* We use this to not render the page at all until we know the theme.*/ /* We use this to not render the page at all until we know the theme.*/
.no-theme { .no-theme {
opacity: 0; opacity: 0;

View File

@@ -9,10 +9,10 @@ import {
type ChangeEvent, type ChangeEvent,
type FC, type FC,
type ForwardedRef, type ForwardedRef,
forwardRef,
type ReactNode, type ReactNode,
useId, useId,
type JSX, type JSX,
type Ref,
} from "react"; } from "react";
import classNames from "classnames"; import classNames from "classnames";
@@ -54,6 +54,7 @@ function Field({ children, className }: FieldProps): JSX.Element {
} }
interface InputFieldProps { interface InputFieldProps {
ref?: Ref<HTMLInputElement | HTMLTextAreaElement>;
label?: string; label?: string;
type: string; type: string;
prefix?: string; prefix?: string;
@@ -78,88 +79,81 @@ interface InputFieldProps {
onChange?: (event: ChangeEvent<HTMLInputElement>) => void; onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
} }
export const InputField = forwardRef< export const InputField: FC<InputFieldProps> = ({
HTMLInputElement | HTMLTextAreaElement, ref,
InputFieldProps id,
>( label,
( className,
{ type,
id, checked,
label, prefix,
className, suffix,
type, description,
checked, disabled,
prefix, min,
suffix, ...rest
description, }) => {
disabled, const descriptionId = useId();
min,
...rest
},
ref,
) => {
const descriptionId = useId();
return ( return (
<Field <Field
className={classNames( className={classNames(
type === "checkbox" ? styles.checkboxField : styles.inputField, type === "checkbox" ? styles.checkboxField : styles.inputField,
{ {
[styles.prefix]: !!prefix, [styles.prefix]: !!prefix,
[styles.disabled]: disabled, [styles.disabled]: disabled,
}, },
className, className,
)} )}
> >
{prefix && <span>{prefix}</span>} {prefix && <span>{prefix}</span>}
{type === "textarea" ? ( {type === "textarea" ? (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
<textarea <textarea
id={id} id={id}
ref={ref as ForwardedRef<HTMLTextAreaElement>} ref={ref as ForwardedRef<HTMLTextAreaElement>}
disabled={disabled} disabled={disabled}
aria-describedby={descriptionId} aria-describedby={descriptionId}
{...rest} {...rest}
/> />
) : ( ) : (
<input <input
id={id} id={id}
ref={ref as ForwardedRef<HTMLInputElement>} ref={ref as ForwardedRef<HTMLInputElement>}
type={type} type={type}
checked={checked} checked={checked}
disabled={disabled} disabled={disabled}
aria-describedby={descriptionId} aria-describedby={descriptionId}
min={min} min={min}
{...rest} {...rest}
/> />
)} )}
<label htmlFor={id}> <label htmlFor={id}>
{type === "checkbox" && ( {type === "checkbox" && (
<div className={styles.checkbox}> <div className={styles.checkbox}>
<CheckIcon /> <CheckIcon />
</div> </div>
)}
{label}
</label>
{suffix && <span>{suffix}</span>}
{description && (
<p
id={descriptionId}
className={
label
? styles.description
: classNames(styles.description, styles.noLabel)
}
>
{description}
</p>
)} )}
</Field> {label}
); </label>
}, {suffix && <span>{suffix}</span>}
); {description && (
<p
id={descriptionId}
className={
label
? styles.description
: classNames(styles.description, styles.noLabel)
}
>
{description}
</p>
)}
</Field>
);
};
InputField.displayName = "InputField"; InputField.displayName = "InputField";

View File

@@ -17,12 +17,14 @@ import { type ReactNode } from "react";
import { useTracks } from "@livekit/components-react"; import { useTracks } from "@livekit/components-react";
import { testAudioContext } from "../useAudioContext.test"; import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "./MediaDevicesContext"; import * as MediaDevicesContext from "../MediaDevicesContext";
import { MatrixAudioRenderer } from "./MatrixAudioRenderer"; import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
import { mockTrack } from "../utils/test"; import { mockMediaDevices, mockTrack } from "../utils/test";
export const TestAudioContextConstructor = vi.fn(() => testAudioContext); export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
beforeEach(() => { beforeEach(() => {
vi.stubGlobal("AudioContext", TestAudioContextConstructor); vi.stubGlobal("AudioContext", TestAudioContextConstructor);
}); });
@@ -51,9 +53,11 @@ vi.mocked(useTracks).mockReturnValue(tracks);
it("should render for member", () => { it("should render for member", () => {
const { container, queryAllByTestId } = render( const { container, queryAllByTestId } = render(
<MatrixAudioRenderer <MediaDevicesProvider value={mockMediaDevices({})}>
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]} <MatrixAudioRenderer
/>, members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
); );
expect(container).toBeTruthy(); expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(1); expect(queryAllByTestId("audio")).toHaveLength(1);
@@ -64,7 +68,9 @@ it("should not render without member", () => {
{ sender: "othermember", deviceId: "123" }, { sender: "othermember", deviceId: "123" },
] as CallMembership[]; ] as CallMembership[];
const { container, queryAllByTestId } = render( const { container, queryAllByTestId } = render(
<MatrixAudioRenderer members={memberships} />, <MediaDevicesProvider value={mockMediaDevices({})}>
<MatrixAudioRenderer members={memberships} />
</MediaDevicesProvider>,
); );
expect(container).toBeTruthy(); expect(container).toBeTruthy();
expect(queryAllByTestId("audio")).toHaveLength(0); expect(queryAllByTestId("audio")).toHaveLength(0);
@@ -72,9 +78,11 @@ it("should not render without member", () => {
it("should not setup audioContext gain and pan if there is no need to.", () => { it("should not setup audioContext gain and pan if there is no need to.", () => {
render( render(
<MatrixAudioRenderer <MediaDevicesProvider value={mockMediaDevices({})}>
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]} <MatrixAudioRenderer
/>, members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
); );
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack; const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
@@ -93,9 +101,11 @@ it("should setup audioContext gain and pan", () => {
volume: 0.1, volume: 0.1,
}); });
render( render(
<MatrixAudioRenderer <MediaDevicesProvider value={mockMediaDevices({})}>
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]} <MatrixAudioRenderer
/>, members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
/>
</MediaDevicesProvider>,
); );
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack; const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;

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

@@ -1,445 +0,0 @@
/*
Copyright 2023-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type FC,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type JSX,
} from "react";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { combineLatest, distinctUntilChanged, map, startWith } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import {
useSetting,
audioInput as audioInputSetting,
audioOutput as audioOutputSetting,
videoInput as videoInputSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
type Setting,
} from "../settings/settings";
import { outputDevice$, availableOutputDevices$ } from "../controls";
import { useUrlParams } from "../UrlParams";
// This hardcoded id is used in EX ios! It can only be changed in coordination with
// the ios swift team.
export const EARPIECE_CONFIG_ID = "earpiece-id";
export type DeviceLabel =
| { type: "name"; name: string }
| { type: "number"; number: number }
| { type: "earpiece" }
| { type: "default"; name: string | null };
export interface MediaDeviceHandle {
/**
* A map from available device IDs to labels.
*/
available: Map<string, DeviceLabel>;
selectedId: string | undefined;
/**
* An additional device configuration that makes us use only one channel of the
* output device and a reduced volume.
*/
useAsEarpiece: boolean | undefined;
/**
* The group ID of the selected device.
*/
// This is exposed sort of ad-hoc because it's only needed for knowing when to
// restart the tracks of default input devices, and ideally this behavior
// would be encapsulated somehow…
selectedGroupId: string | undefined;
select: (deviceId: string) => void;
}
interface InputDevices {
audioInput: MediaDeviceHandle;
videoInput: MediaDeviceHandle;
startUsingDeviceNames: () => void;
stopUsingDeviceNames: () => void;
usingNames: boolean;
}
export interface MediaDevices extends Omit<InputDevices, "usingNames"> {
audioOutput: MediaDeviceHandle;
}
/**
* An observable that represents if we should display the devices menu for iOS.
* This implies the following
* - hide any input devices (they do not work anyhow on ios)
* - Show a button to show the native output picker instead.
* - Only show the earpiece toggle option if the earpiece is available:
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/
export const iosDeviceMenu$ = alwaysShowIphoneEarpieceSetting.value$.pipe(
map((v) => v || navigator.userAgent.includes("iPhone")),
);
function useSelectedId(
available: Map<string, DeviceLabel>,
preferredId: string | undefined,
): string | undefined {
return useMemo(() => {
if (available.size) {
// If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream.
// Otherwise, select the first available device.
return (preferredId !== undefined && available.has(preferredId)) ||
(available.size === 1 && available.has(""))
? preferredId
: available.keys().next().value;
}
return undefined;
}, [available, preferredId]);
}
/**
* Hook to get access to a mediaDevice handle for a kind. This allows to list
* the available devices, read and set the selected device.
* @param kind Audio input, output or video output.
* @param setting The setting this handle's selection should be synced with.
* @param usingNames If the hook should query device names for the associated
* list.
* @returns A handle for the chosen kind.
*/
function useMediaDeviceHandle(
kind: MediaDeviceKind,
setting: Setting<string | undefined>,
usingNames: boolean,
): MediaDeviceHandle {
const hasRequestedPermissions = useRef(false);
const requestPermissions = usingNames || hasRequestedPermissions.current;
// Make sure we don't needlessly reset to a device observer without names,
// once permissions are already given
hasRequestedPermissions.current ||= usingNames;
// We use a bare device observer here rather than one of the fancy device
// selection hooks from @livekit/components-react, because
// useMediaDeviceSelect expects a room or track, which we don't have here, and
// useMediaDevices provides no way to request device names.
// Tragically, the only way to get device names out of LiveKit is to specify a
// kind, which then results in multiple permissions requests.
const deviceObserver$ = useMemo(
() =>
createMediaDeviceObserver(
kind,
() => logger.error("Error creating MediaDeviceObserver"),
requestPermissions,
).pipe(
startWith([]),
// This Observable emits new values whenever the browser fires a
// MediaDevices 'devicechange' event. One would think, innocently, that
// a 'devicechange' event means the devices have changed. But as of the
// time of writing, we are seeing mobile Safari firing spurious
// 'devicechange' events (where no change has actually occurred) when
// we call MediaDevices.getUserMedia. So, filter by deep equality.
distinctUntilChanged<MediaDeviceInfo[]>(deepCompare),
),
[kind, requestPermissions],
);
const available = useObservableEagerState(
useMemo(
() =>
deviceObserver$.pipe(
map((availableRaw) => {
// Sometimes browsers (particularly Firefox) can return multiple device
// entries for the exact same device ID; using a map deduplicates them
let available = new Map<string, DeviceLabel>(
availableRaw.map((d, i) => [
d.deviceId,
d.label
? { type: "name", name: d.label }
: { type: "number", number: i + 1 },
]),
);
// Create a virtual default audio output for browsers that don't have one.
// Its device ID must be the empty string because that's what setSinkId
// recognizes.
// We also create this if we do not have any available devices, so that
// we can use the default or the earpiece.
if (
kind === "audiooutput" &&
!available.has("") &&
!available.has("default") &&
available.size
)
available = new Map([
["", { type: "default", name: availableRaw[0]?.label || null }],
...available,
]);
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
return available;
}),
),
[deviceObserver$, kind],
),
);
const [preferredId, select] = useSetting(setting);
const selectedId = useSelectedId(available, preferredId);
const selectedGroupId = useObservableEagerState(
useMemo(
() =>
deviceObserver$.pipe(
map(
(availableRaw) =>
availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
),
),
[deviceObserver$, selectedId],
),
);
return useMemo(
() => ({
available,
selectedId,
useAsEarpiece: false,
selectedGroupId,
select,
}),
[available, selectedId, selectedGroupId, select],
);
}
export const deviceStub: MediaDeviceHandle = {
available: new Map(),
selectedId: undefined,
selectedGroupId: undefined,
select: () => {},
useAsEarpiece: false,
};
export const devicesStub: MediaDevices = {
audioInput: deviceStub,
audioOutput: deviceStub,
videoInput: deviceStub,
startUsingDeviceNames: () => {},
stopUsingDeviceNames: () => {},
};
export const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
function useInputDevices(): InputDevices {
// Counts the number of callers currently using device names.
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
const usingNames = numCallersUsingNames > 0;
const audioInput = useMediaDeviceHandle(
"audioinput",
audioInputSetting,
usingNames,
);
const videoInput = useMediaDeviceHandle(
"videoinput",
videoInputSetting,
usingNames,
);
const startUsingDeviceNames = useCallback(
() => setNumCallersUsingNames((n) => n + 1),
[setNumCallersUsingNames],
);
const stopUsingDeviceNames = useCallback(
() => setNumCallersUsingNames((n) => n - 1),
[setNumCallersUsingNames],
);
return {
audioInput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
usingNames,
};
}
interface Props {
children: JSX.Element;
}
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
const {
audioInput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
usingNames,
} = useInputDevices();
const { controlledAudioDevices } = useUrlParams();
const webViewAudioOutput = useMediaDeviceHandle(
"audiooutput",
audioOutputSetting,
usingNames,
);
const controlledAudioOutput = useControlledOutput();
const context: MediaDevices = useMemo(
() => ({
audioInput,
audioOutput: controlledAudioDevices
? controlledAudioOutput
: webViewAudioOutput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
}),
[
audioInput,
controlledAudioDevices,
controlledAudioOutput,
webViewAudioOutput,
videoInput,
startUsingDeviceNames,
stopUsingDeviceNames,
],
);
return (
<MediaDevicesContext.Provider value={context}>
{children}
</MediaDevicesContext.Provider>
);
};
function useControlledOutput(): MediaDeviceHandle {
const { available } = useObservableEagerState(
useObservable(() => {
const outputDeviceData$ = availableOutputDevices$.pipe(
map((devices) => {
const deviceForEarpiece = devices.find((d) => d.forEarpiece);
const deviceMapTuple: [string, DeviceLabel][] = devices.map(
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
let deviceLabel: DeviceLabel = { type: "name", name };
// if (isExternalHeadset) // Do we want this?
if (isEarpiece) deviceLabel = { type: "earpiece" };
if (isSpeaker) deviceLabel = { type: "default", name };
return [id, deviceLabel];
},
);
return {
devicesMap: new Map<string, DeviceLabel>(deviceMapTuple),
deviceForEarpiece,
};
}),
);
return combineLatest(
[outputDeviceData$, iosDeviceMenu$],
({ devicesMap, deviceForEarpiece }, iosShowEarpiece) => {
let available = devicesMap;
if (iosShowEarpiece && !!deviceForEarpiece) {
available = new Map([
...devicesMap.entries(),
[EARPIECE_CONFIG_ID, { type: "earpiece" }],
]);
}
return { available, deviceForEarpiece };
},
);
}),
);
const [preferredId, setPreferredId] = useSetting(audioOutputSetting);
useEffect(() => {
const subscription = outputDevice$.subscribe((id) => {
if (id) setPreferredId(id);
});
return (): void => subscription.unsubscribe();
}, [setPreferredId]);
const selectedId = useSelectedId(available, preferredId);
const [asEarpiece, setAsEarpiece] = useState(false);
useEffect(() => {
// Let the hosting application know which output device has been selected.
// This information is probably only of interest if the earpiece mode has been
// selected - for example, Element X iOS listens to this to determine whether it
// should enable the proximity sensor.
if (selectedId) {
window.controls.onAudioDeviceSelect?.(selectedId);
// Call deprecated method for backwards compatibility.
window.controls.onOutputDeviceSelect?.(selectedId);
}
setAsEarpiece(selectedId === EARPIECE_CONFIG_ID);
}, [selectedId]);
return useMemo(
() => ({
available: available,
selectedId,
selectedGroupId: undefined,
select: setPreferredId,
useAsEarpiece: asEarpiece,
}),
[available, selectedId, setPreferredId, asEarpiece],
);
}
export const useMediaDevices = (): MediaDevices =>
useContext(MediaDevicesContext);
/**
* React hook that requests for the media devices context to be populated with
* real device names while this component is mounted. This is not done by
* default because it may involve requesting additional permissions from the
* user.
*/
export const useMediaDeviceNames = (
context: MediaDevices,
enabled = true,
): void =>
useEffect(() => {
if (enabled) {
context.startUsingDeviceNames();
return context.stopUsingDeviceNames;
}
}, [context, enabled]);
/**
* A convenience hook to get the audio node configuration for the earpiece.
* It will check the `useAsEarpiece` of the `audioOutput` device and return
* the appropriate pan and volume values.
*
* @returns pan and volume values for the earpiece audio node configuration.
*/
export const useEarpieceAudioConfig = (): {
pan: number;
volume: number;
} => {
const { audioOutput } = useMediaDevices();
// We use only the right speaker (pan = 1) for the earpiece.
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
const pan = useMemo(
() => (audioOutput.useAsEarpiece ? 1 : 0),
[audioOutput.useAsEarpiece],
);
// We also do lower the volume by a factor of 10 to optimize for the usecase where
// a user is holding the phone to their ear.
const volume = useMemo(
() => (audioOutput.useAsEarpiece ? 0.1 : 1),
[audioOutput.useAsEarpiece],
);
return { pan, volume };
};

View File

@@ -14,7 +14,7 @@ import {
createContext, createContext,
type FC, type FC,
type JSX, type JSX,
useContext, use,
useEffect, useEffect,
useMemo, useMemo,
} from "react"; } from "react";
@@ -34,7 +34,7 @@ type ProcessorState = {
const ProcessorContext = createContext<ProcessorState | undefined>(undefined); const ProcessorContext = createContext<ProcessorState | undefined>(undefined);
export function useTrackProcessor(): ProcessorState { export function useTrackProcessor(): ProcessorState {
const state = useContext(ProcessorContext); const state = use(ProcessorContext);
if (state === undefined) if (state === undefined)
throw new Error( throw new Error(
"useTrackProcessor must be used within a ProcessorProvider", "useTrackProcessor must be used within a ProcessorProvider",
@@ -83,9 +83,5 @@ export const ProcessorProvider: FC<Props> = ({ children }) => {
[supported, blurActivated, blur], [supported, blurActivated, blur],
); );
return ( return <ProcessorContext value={processorState}>{children}</ProcessorContext>;
<ProcessorContext.Provider value={processorState}>
{children}
</ProcessorContext.Provider>
);
}; };

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

@@ -19,12 +19,18 @@ import E2EEWorker from "livekit-client/e2ee-worker?worker";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc"; import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { useObservable, useObservableEagerState } from "observable-hooks"; import { useObservable, useObservableEagerState } from "observable-hooks";
import { map } from "rxjs"; import {
map,
NEVER,
type Observable,
type Subscription,
switchMap,
} from "rxjs";
import { defaultLiveKitOptions } from "./options"; import { defaultLiveKitOptions } from "./options";
import { type SFUConfig } from "./openIDSFU"; import { type SFUConfig } from "./openIDSFU";
import { type MuteStates } from "../room/MuteStates"; import { type MuteStates } from "../room/MuteStates";
import { type MediaDeviceHandle, useMediaDevices } from "./MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { import {
type ECConnectionState, type ECConnectionState,
useECConnectionState, useECConnectionState,
@@ -39,6 +45,8 @@ import {
import { observeTrackReference$ } from "../state/MediaViewModel"; import { observeTrackReference$ } from "../state/MediaViewModel";
import { useUrlParams } from "../UrlParams"; import { useUrlParams } from "../UrlParams";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { getValue } from "../utils/observable";
import { type SelectedDevice } from "../state/MediaDevices";
interface UseLivekitResult { interface UseLivekitResult {
livekitRoom?: Room; livekitRoom?: Room;
@@ -56,7 +64,9 @@ export function useLivekit(
const initialMuteStates = useInitial(() => muteStates); const initialMuteStates = useInitial(() => muteStates);
const devices = useMediaDevices(); const devices = useMediaDevices();
const initialDevices = useInitial(() => devices); const initialAudioInputId = useInitial(
() => getValue(devices.audioInput.selected$)?.id,
);
// Store if audio/video are currently updating. If to prohibit unnecessary calls // Store if audio/video are currently updating. If to prohibit unnecessary calls
// to setMicrophoneEnabled/setCameraEnabled // to setMicrophoneEnabled/setCameraEnabled
@@ -94,15 +104,20 @@ export function useLivekit(
...defaultLiveKitOptions, ...defaultLiveKitOptions,
videoCaptureDefaults: { videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults, ...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: initialDevices.videoInput.selectedId, deviceId: getValue(devices.videoInput.selected$)?.id,
processor, processor,
}, },
audioCaptureDefaults: { audioCaptureDefaults: {
...defaultLiveKitOptions.audioCaptureDefaults, ...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: initialDevices.audioInput.selectedId, deviceId: initialAudioInputId,
}, },
audioOutput: { audioOutput: {
deviceId: initialDevices.audioOutput.selectedId, // When using controlled audio devices, we don't want to set the
// deviceId here, because it will be set by the native app.
// (also the id does not need to match a browser device id)
deviceId: controlledAudioDevices
? undefined
: getValue(devices.audioOutput.selected$)?.id,
}, },
e2ee, e2ee,
}; };
@@ -142,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;
@@ -157,7 +175,7 @@ export function useLivekit(
); );
const connectionState = useECConnectionState( const connectionState = useECConnectionState(
initialDevices.audioInput.selectedId, initialAudioInputId,
initialMuteStates.audio.enabled, initialMuteStates.audio.enabled,
room, room,
sfuConfig, sfuConfig,
@@ -305,69 +323,76 @@ 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,
device: MediaDeviceHandle, selected$: Observable<SelectedDevice | undefined>,
): void => { ): Subscription =>
const id = device.selectedId; selected$.subscribe((device) => {
logger.info(
// Detect if we're trying to use chrome's default device, in which case "[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
// we need to to see if the default device has changed to a different device room.getActiveDevice(kind),
// by comparing the group ID of the device we're using against the group ID " !== ",
// of what the default device is *now*. device?.id,
// This is special-cased for only audio inputs because we need to dig around );
// in the LocalParticipant object for the track object and there's not a nice
// way to do that generically. There is usually no OS-level default video capture
// device anyway, and audio outputs work differently.
if (
id === "default" &&
kind === "audioinput" &&
room.options.audioCaptureDefaults?.deviceId === "default"
) {
const activeMicTrack = Array.from(
room.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track;
if ( if (
activeMicTrack && device !== undefined &&
// only restart if the stream is still running: LiveKit will detect room.getActiveDevice(kind) !== device.id
// when a track stops & restart appropriately, so this is not our job.
// Plus, we need to avoid restarting again if the track is already in
// the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended" &&
device.selectedGroupId !==
activeMicTrack.mediaStreamTrack.getSettings().groupId
) { ) {
// It's different, so restart the track, ie. cause Livekit to do another
// getUserMedia() call with deviceId: default to get the *new* default device.
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
// the deviceId hasn't changed (was & still is default).
room.localParticipant
.getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack()
.catch((e) => {
logger.error(`Failed to restart audio device track`, e);
});
}
} else {
if (id !== undefined && room.getActiveDevice(kind) !== id) {
room room
.switchActiveDevice(kind, id) .switchActiveDevice(kind, device.id)
.catch((e) => .catch((e) =>
logger.error(`Failed to sync ${kind} device with LiveKit`, e), logger.error(`Failed to sync ${kind} device with LiveKit`, e),
); );
} }
} });
};
syncDevice("audioinput", devices.audioInput); const subscriptions = [
syncDevice("audiooutput", devices.audioOutput); syncDevice("audioinput", devices.audioInput.selected$),
syncDevice("videoinput", devices.videoInput); !controlledAudioDevices
? syncDevice("audiooutput", devices.audioOutput.selected$)
: undefined,
syncDevice("videoinput", devices.videoInput.selected$),
// 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
// for the sake of Chrome, which provides a "default" device that is meant
// to match the system's default audio input, whatever that may be.
// This is special-cased for only audio inputs because we need to dig around
// in the LocalParticipant object for the track object and there's not a nice
// way to do that generically. There is usually no OS-level default video capture
// device anyway, and audio outputs work differently.
devices.audioInput.selected$
.pipe(switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER))
.subscribe(() => {
const activeMicTrack = Array.from(
room.localParticipant.audioTrackPublications.values(),
).find((d) => d.source === Track.Source.Microphone)?.track;
if (
activeMicTrack &&
// only restart if the stream is still running: LiveKit will detect
// when a track stops & restart appropriately, so this is not our job.
// Plus, we need to avoid restarting again if the track is already in
// the process of being restarted.
activeMicTrack.mediaStreamTrack.readyState !== "ended"
) {
// Restart the track, which will cause Livekit to do another
// getUserMedia() call with deviceId: default to get the *new* default device.
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
// the deviceId hasn't changed (was & still is default).
room.localParticipant
.getTrackPublication(Track.Source.Microphone)
?.audioTrack?.restartTrack()
.catch((e) => {
logger.error(`Failed to restart audio device track`, e);
});
}
}),
];
return (): void => {
for (const s of subscriptions) s?.unsubscribe();
};
} }
}, [room, devices, connectionState, controlledAudioDevices]); }, [room, devices, connectionState, controlledAudioDevices]);

View File

@@ -23,6 +23,7 @@ import {
import { App } from "./App"; import { App } from "./App";
import { init as initRageshake } from "./settings/rageshake"; import { init as initRageshake } from "./settings/rageshake";
import { Initializer } from "./initializer"; import { Initializer } from "./initializer";
import { AppViewModel } from "./state/AppViewModel";
window.setLKLogLevel = setLKLogLevel; window.setLKLogLevel = setLKLogLevel;
@@ -60,7 +61,7 @@ Initializer.initBeforeReact()
.then(() => { .then(() => {
root.render( root.render(
<StrictMode> <StrictMode>
<App /> <App vm={new AppViewModel()} />
</StrictMode>, </StrictMode>,
); );
}) })

View File

@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { EventType, RelationType } from "matrix-js-sdk"; import { EventType, RelationType } from "matrix-js-sdk";
import { import {
createContext, createContext,
useContext, use,
type ReactNode, type ReactNode,
useCallback, useCallback,
useMemo, useMemo,
@@ -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;
@@ -34,7 +34,7 @@ const ReactionsSenderContext = createContext<
>(undefined); >(undefined);
export const useReactionsSender = (): ReactionsSenderContextType => { export const useReactionsSender = (): ReactionsSenderContextType => {
const context = useContext(ReactionsSenderContext); const context = use(ReactionsSenderContext);
if (!context) { if (!context) {
throw new Error("useReactions must be used within a ReactionsProvider"); throw new Error("useReactions must be used within a ReactionsProvider");
} }
@@ -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
@@ -157,7 +157,7 @@ export const ReactionsSenderProvider = ({
); );
return ( return (
<ReactionsSenderContext.Provider <ReactionsSenderContext
value={{ value={{
supportsReactions, supportsReactions,
toggleRaisedHand, toggleRaisedHand,
@@ -165,6 +165,6 @@ export const ReactionsSenderProvider = ({
}} }}
> >
{children} {children}
</ReactionsSenderContext.Provider> </ReactionsSenderContext>
); );
}; };

View File

@@ -25,6 +25,7 @@ import { LinkButton } from "../button";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
isPasswordlessUser: boolean; isPasswordlessUser: boolean;
hideHeader: boolean;
confineToRoom: boolean; confineToRoom: boolean;
endedCallId: string; endedCallId: string;
} }
@@ -32,6 +33,7 @@ interface Props {
export const CallEndedView: FC<Props> = ({ export const CallEndedView: FC<Props> = ({
client, client,
isPasswordlessUser, isPasswordlessUser,
hideHeader,
confineToRoom, confineToRoom,
endedCallId, endedCallId,
}) => { }) => {
@@ -133,10 +135,12 @@ export const CallEndedView: FC<Props> = ({
return ( return (
<> <>
<Header> {!hideHeader && (
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav> <Header>
<RightNav /> <LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
</Header> <RightNav />
</Header>
)}
<div className={styles.container}> <div className={styles.container}>
<main className={styles.main}> <main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}> <Heading size="xl" weight="semibold" className={styles.headline}>

View File

@@ -0,0 +1,67 @@
.overlay {
position: fixed;
z-index: var(--call-view-overlay-layer);
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--cpd-space-2x);
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.overlay[data-show="true"] {
animation: fade-in 200ms;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
display: none;
}
}
.overlay[data-show="false"] {
animation: fade-out 130ms forwards;
content-visibility: hidden;
pointer-events: none;
}
.overlay::before {
content: "";
position: absolute;
z-index: -1;
inset: 0;
background: var(--cpd-color-bg-canvas-default);
opacity: 0.94;
}
.icon {
margin-block-end: var(--cpd-space-4x);
background: var(--cpd-color-alpha-gray-600);
color: var(--cpd-color-icon-primary);
}
.overlay > h2 {
text-align: center;
margin: 0;
}
.overlay > p {
text-align: center;
}
.spacer {
min-height: var(--cpd-space-32x);
}

View File

@@ -0,0 +1,44 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC } from "react";
import { BigIcon, Button, Heading, Text } from "@vector-im/compound-web";
import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import styles from "./EarpieceOverlay.module.css";
interface Props {
show: boolean;
onBackToVideoPressed?: (() => void) | null;
}
export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
const { t } = useTranslation();
return (
<div className={styles.overlay} data-show={show}>
<BigIcon className={styles.icon}>
<VoiceCallIcon aria-hidden />
</BigIcon>
<Heading as="h2" weight="semibold" size="md">
{t("handset.overlay_title")}
</Heading>
<Text>{t("handset.overlay_description")}</Text>
<Button
kind="primary"
size="sm"
onClick={() => {
onBackToVideoPressed?.();
}}
>
{t("handset.overlay_back_button")}
</Button>
{/* This spacer is used to give the overlay an offset to the top. */}
<div className={styles.spacer} />
</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";
@@ -32,6 +31,7 @@ import {
mockEmitter, mockEmitter,
mockMatrixRoom, mockMatrixRoom,
mockMatrixRoomMember, mockMatrixRoomMember,
mockMediaDevices,
mockRtcMembership, mockRtcMembership,
MockRTCSession, MockRTCSession,
} from "../utils/test"; } from "../utils/test";
@@ -40,6 +40,9 @@ import { type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter"; import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCFocusMissingError } from "../utils/errors"; import { MatrixRTCFocusMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";
import { constant } from "../state/Behavior";
vi.mock("../soundUtils"); vi.mock("../soundUtils");
vi.mock("../useAudioContext"); vi.mock("../useAudioContext");
@@ -138,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 },
@@ -147,20 +150,22 @@ function createGroupCallView(
const { getByText } = render( const { getByText } = render(
<BrowserRouter> <BrowserRouter>
<TooltipProvider> <TooltipProvider>
<ProcessorProvider> <MediaDevicesContext value={mockMediaDevices({})}>
<GroupCallView <ProcessorProvider>
client={client} <GroupCallView
isPasswordlessUser={false} client={client}
confineToRoom={false} isPasswordlessUser={false}
preload={false} confineToRoom={false}
skipLobby={false} preload={false}
hideHeader={true} skipLobby={false}
rtcSession={rtcSession as unknown as MatrixRTCSession} header={HeaderStyle.Standard}
isJoined={joined} rtcSession={rtcSession as unknown as MatrixRTCSession}
muteStates={muteState} isJoined={joined}
widget={widget} muteStates={muteState}
/> widget={widget}
</ProcessorProvider> />
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider> </TooltipProvider>
</BrowserRouter>, </BrowserRouter>,
); );

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 {
@@ -40,7 +39,7 @@ import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../utils/media"; import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView"; import { ActiveCall } from "./InCallView";
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates"; import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
import { useMediaDevices } from "../livekit/MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers"; import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
import { import {
@@ -51,17 +50,16 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName"; import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule"; import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal"; import { InviteModal } from "./InviteModal";
import { useUrlParams } from "../UrlParams"; import { HeaderStyle, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer"; import { callEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { import {
ConnectionLostError,
E2EENotSupportedError, E2EENotSupportedError,
ElementCallError, ElementCallError,
ErrorCode,
RTCSessionError,
UnknownCallError, UnknownCallError,
} from "../utils/errors.ts"; } from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
@@ -72,6 +70,8 @@ import {
} from "../settings/settings"; } from "../settings/settings";
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 { useBehavior } from "../useBehavior.ts";
declare global { declare global {
interface Window { interface Window {
@@ -85,7 +85,7 @@ interface Props {
confineToRoom: boolean; confineToRoom: boolean;
preload: boolean; preload: boolean;
skipLobby: boolean; skipLobby: boolean;
hideHeader: boolean; header: HeaderStyle;
rtcSession: MatrixRTCSession; rtcSession: MatrixRTCSession;
isJoined: boolean; isJoined: boolean;
muteStates: MuteStates; muteStates: MuteStates;
@@ -98,7 +98,7 @@ export const GroupCallView: FC<Props> = ({
confineToRoom, confineToRoom,
preload, preload,
skipLobby, skipLobby,
hideHeader, header,
rtcSession, rtcSession,
isJoined, isJoined,
muteStates, muteStates,
@@ -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,
@@ -132,6 +132,16 @@ export const GroupCallView: FC<Props> = ({
}; };
}, []); }, []);
// This CSS is the only way we could find to not make element call scroll for
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
// tested on different devices...)
useEffect(() => {
document.body.classList.add("no-scroll-body");
return (): void => {
document.body.classList.remove("no-scroll-body");
};
}, []);
useEffect(() => { useEffect(() => {
window.rtcSession = rtcSession; window.rtcSession = rtcSession;
return (): void => { return (): void => {
@@ -142,14 +152,7 @@ export const GroupCallView: FC<Props> = ({
useTypedEventEmitter( useTypedEventEmitter(
rtcSession, rtcSession,
MatrixRTCSessionEvent.MembershipManagerError, MatrixRTCSessionEvent.MembershipManagerError,
(error) => { (error) => setExternalError(new ConnectionLostError()),
setExternalError(
new RTCSessionError(
ErrorCode.MEMBERSHIP_MANAGER_UNRECOVERABLE,
error.message ?? error,
),
);
},
); );
useEffect(() => { useEffect(() => {
// Sanity check the room object // Sanity check the room object
@@ -177,6 +180,7 @@ export const GroupCallView: FC<Props> = ({
}, [passwordFromUrl, room.roomId]); }, [passwordFromUrl, room.roomId]);
usePageTitle(roomName); usePageTitle(roomName);
useAppBarTitle(roomName);
const matrixInfo = useMemo((): MatrixInfo => { const matrixInfo = useMemo((): MatrixInfo => {
return { return {
@@ -197,8 +201,7 @@ export const GroupCallView: FC<Props> = ({
[memberships], [memberships],
); );
const deviceContext = useMediaDevices(); const mediaDevices = useMediaDevices();
const latestDevices = useLatest(deviceContext);
const latestMuteStates = useLatest(muteStates); const latestMuteStates = useLatest(muteStates);
const enterRTCSessionOrError = useCallback( const enterRTCSessionOrError = useCallback(
@@ -250,7 +253,7 @@ export const GroupCallView: FC<Props> = ({
logger.debug( logger.debug(
`Found audio input ID ${deviceId} for name ${audioInput}`, `Found audio input ID ${deviceId} for name ${audioInput}`,
); );
latestDevices.current!.audioInput.select(deviceId); mediaDevices.audioInput.select(deviceId);
} }
} }
@@ -264,7 +267,7 @@ export const GroupCallView: FC<Props> = ({
logger.debug( logger.debug(
`Found video input ID ${deviceId} for name ${videoInput}`, `Found video input ID ${deviceId} for name ${videoInput}`,
); );
latestDevices.current!.videoInput.select(deviceId); mediaDevices.videoInput.select(deviceId);
} }
} }
}; };
@@ -306,7 +309,7 @@ export const GroupCallView: FC<Props> = ({
preload, preload,
skipLobby, skipLobby,
perParticipantE2EE, perParticipantE2EE,
latestDevices, mediaDevices,
latestMuteStates, latestMuteStates,
enterRTCSessionOrError, enterRTCSessionOrError,
useNewMembershipManager, useNewMembershipManager,
@@ -422,7 +425,7 @@ export const GroupCallView: FC<Props> = ({
muteStates={muteStates} muteStates={muteStates}
onEnter={() => void enterRTCSessionOrError(rtcSession)} onEnter={() => void enterRTCSessionOrError(rtcSession)}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={hideHeader} hideHeader={header === HeaderStyle.None}
participantCount={participantCount} participantCount={participantCount}
onShareClick={onShareClick} onShareClick={onShareClick}
/> />
@@ -448,7 +451,7 @@ export const GroupCallView: FC<Props> = ({
rtcSession={rtcSession as MatrixRTCSession} rtcSession={rtcSession as MatrixRTCSession}
participantCount={participantCount} participantCount={participantCount}
onLeave={onLeave} onLeave={onLeave}
hideHeader={hideHeader} header={header}
muteStates={muteStates} muteStates={muteStates}
e2eeSystem={e2eeSystem} e2eeSystem={e2eeSystem}
//otelGroupCallMembership={otelGroupCallMembership} //otelGroupCallMembership={otelGroupCallMembership}
@@ -474,6 +477,7 @@ export const GroupCallView: FC<Props> = ({
endedCallId={rtcSession.room.roomId} endedCallId={rtcSession.room.roomId}
client={client} client={client}
isPasswordlessUser={isPasswordlessUser} isPasswordlessUser={isPasswordlessUser}
hideHeader={header === HeaderStyle.None}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
/> />
); );

View File

@@ -18,7 +18,7 @@ Please see LICENSE in the repository root for full details.
position: sticky; position: sticky;
flex-shrink: 0; flex-shrink: 0;
inset-block-start: 0; inset-block-start: 0;
z-index: 1; z-index: var(--call-view-header-footer-layer);
background: linear-gradient( background: linear-gradient(
0deg, 0deg,
rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) 0%,
@@ -34,7 +34,7 @@ Please see LICENSE in the repository root for full details.
.footer { .footer {
position: sticky; position: sticky;
inset-block-end: 0; inset-block-end: 0;
z-index: 1; z-index: var(--call-view-header-footer-layer);
display: grid; display: grid;
grid-template-columns: grid-template-columns:
minmax(0, var(--inline-content-inset)) minmax(0, var(--inline-content-inset))

View File

@@ -31,6 +31,7 @@ import {
mockLocalParticipant, mockLocalParticipant,
mockMatrixRoom, mockMatrixRoom,
mockMatrixRoomMember, mockMatrixRoomMember,
mockMediaDevices,
mockRemoteParticipant, mockRemoteParticipant,
mockRtcMembership, mockRtcMembership,
type MockRTCSession, type MockRTCSession,
@@ -45,6 +46,8 @@ import {
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer"; import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { HeaderStyle } from "../UrlParams";
// vi.hoisted(() => { // vi.hoisted(() => {
// localStorage = {} as unknown as Storage; // localStorage = {} as unknown as Storage;
@@ -147,41 +150,43 @@ function createInCallView(): RenderResult & {
rtcSession.joined = true; rtcSession.joined = true;
const renderResult = render( const renderResult = render(
<BrowserRouter> <BrowserRouter>
<ReactionsSenderProvider <MediaDevicesContext value={mockMediaDevices({})}>
vm={vm} <ReactionsSenderProvider
rtcSession={rtcSession as unknown as MatrixRTCSession} vm={vm}
> rtcSession={rtcSession as unknown as MatrixRTCSession}
<TooltipProvider> >
<RoomContext.Provider value={livekitRoom}> <TooltipProvider>
<InCallView <RoomContext value={livekitRoom}>
client={client} <InCallView
hideHeader={true} client={client}
rtcSession={rtcSession as unknown as MatrixRTCSession} header={HeaderStyle.Standard}
muteStates={muteState} rtcSession={rtcSession as unknown as MatrixRTCSession}
vm={vm} muteStates={muteState}
matrixInfo={{ vm={vm}
userId: "", matrixInfo={{
displayName: "", userId: "",
avatarUrl: "", displayName: "",
roomId: "", avatarUrl: "",
roomName: "", roomId: "",
roomAlias: null, roomName: "",
roomAvatar: null, roomAlias: null,
e2eeSystem: { roomAvatar: null,
kind: E2eeType.NONE, e2eeSystem: {
}, kind: E2eeType.NONE,
}} },
livekitRoom={livekitRoom} }}
participantCount={0} livekitRoom={livekitRoom}
onLeave={function (): void { participantCount={0}
throw new Error("Function not implemented."); onLeave={function (): void {
}} throw new Error("Function not implemented.");
connState={ConnectionState.Connected} }}
onShareClick={null} connState={ConnectionState.Connected}
/> onShareClick={null}
</RoomContext.Provider> />
</TooltipProvider> </RoomContext>
</ReactionsSenderProvider> </TooltipProvider>
</ReactionsSenderProvider>
</MediaDevicesContext>
</BrowserRouter>, </BrowserRouter>,
); );
return { return {
@@ -239,6 +244,7 @@ describe("InCallView", () => {
queryByText("using to Device key transport"), queryByText("using to Device key transport"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("is not shown if setting is disabled", () => { it("is not shown if setting is disabled", () => {
useExperimentalToDeviceTransportSetting.setValue(false); useExperimentalToDeviceTransportSetting.setValue(false);
developerModeSetting.setValue(true); developerModeSetting.setValue(true);
@@ -250,6 +256,7 @@ describe("InCallView", () => {
queryByText("using to Device key transport"), queryByText("using to Device key transport"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
it("is not shown if developer mode is disabled", () => { it("is not shown if developer mode is disabled", () => {
useExperimentalToDeviceTransportSetting.setValue(true); useExperimentalToDeviceTransportSetting.setValue(true);
developerModeSetting.setValue(false); developerModeSetting.setValue(false);

View File

@@ -6,29 +6,33 @@ Please see LICENSE in the repository root for full details.
*/ */
import { RoomContext, useLocalParticipant } from "@livekit/components-react"; import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import { Text } from "@vector-im/compound-web"; import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
import { ConnectionState, type Room } from "livekit-client"; import { ConnectionState, type Room } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { import {
type FC, type FC,
type PointerEvent, type PointerEvent,
type PropsWithoutRef,
type TouchEvent, type TouchEvent,
forwardRef,
useCallback, useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
type JSX, type JSX,
type ReactNode,
} from "react"; } from "react";
import useMeasure from "react-use-measure"; 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 } 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 {
VoiceCallSolidIcon,
VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react"; import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react"; import LogoType from "../icons/LogoType.svg?react";
@@ -43,7 +47,7 @@ import {
SwitchCameraButton, SwitchCameraButton,
} from "../button"; } from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header"; import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { useUrlParams } from "../UrlParams"; import { type HeaderStyle, useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts"; import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { ElementWidgetActions, widget } from "../widget"; import { ElementWidgetActions, widget } from "../widget";
import styles from "./InCallView.module.css"; import styles from "./InCallView.module.css";
@@ -105,6 +109,10 @@ import { useTypedEventEmitter } from "../useEvents.ts";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx"; import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -116,6 +124,7 @@ export interface ActiveCallProps
} }
export const ActiveCall: FC<ActiveCallProps> = (props) => { export const ActiveCall: FC<ActiveCallProps> = (props) => {
const mediaDevices = useMediaDevices();
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession); const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
const { livekitRoom, connState } = useLivekit( const { livekitRoom, connState } = useLivekit(
props.rtcSession, props.rtcSession,
@@ -141,7 +150,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
?.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 +165,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const vm = new CallViewModel( const vm = new CallViewModel(
props.rtcSession, props.rtcSession,
livekitRoom, livekitRoom,
mediaDevices,
props.e2eeSystem, props.e2eeSystem,
connStateObservable$, connStateObservable$,
reactionsReader.raisedHands$, reactionsReader.raisedHands$,
@@ -167,12 +177,18 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
reactionsReader.destroy(); reactionsReader.destroy();
}; };
} }
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]); }, [
props.rtcSession,
livekitRoom,
mediaDevices,
props.e2eeSystem,
connStateObservable$,
]);
if (livekitRoom === undefined || vm === null) return null; if (livekitRoom === undefined || vm === null) return null;
return ( return (
<RoomContext.Provider value={livekitRoom}> <RoomContext value={livekitRoom}>
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}> <ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView <InCallView
{...props} {...props}
@@ -181,7 +197,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
connState={connState} connState={connState}
/> />
</ReactionsSenderProvider> </ReactionsSenderProvider>
</RoomContext.Provider> </RoomContext>
); );
}; };
@@ -195,7 +211,7 @@ export interface InCallViewProps {
participantCount: number; participantCount: number;
/** Function to call when the user explicitly ends the call */ /** Function to call when the user explicitly ends the call */
onLeave: () => void; onLeave: () => void;
hideHeader: boolean; header: HeaderStyle;
otelGroupCallMembership?: OTelGroupCallMembership; otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState; connState: ECConnectionState;
onShareClick: (() => void) | null; onShareClick: (() => void) | null;
@@ -210,10 +226,11 @@ export const InCallView: FC<InCallViewProps> = ({
muteStates, muteStates,
participantCount, participantCount,
onLeave, onLeave,
hideHeader, header: headerStyle,
connState, connState,
onShareClick, onShareClick,
}) => { }) => {
const { t } = useTranslation();
const { supportsReactions, sendReaction, toggleRaisedHand } = const { supportsReactions, sendReaction, toggleRaisedHand } =
useReactionsSender(); useReactionsSender();
@@ -235,7 +252,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);
@@ -286,13 +303,15 @@ 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 = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const switchCamera = useSwitchCamera(vm.localVideo$); const switchCamera = useSwitchCamera(vm.localVideo$);
// Ideally we could detect taps by listening for click events and checking // Ideally we could detect taps by listening for click events and checking
@@ -435,25 +454,86 @@ export const InCallView: FC<InCallViewProps> = ({
} }
}, [setGridMode]); }, [setGridMode]);
useAppBarSecondaryButton(
useMemo(() => {
if (audioOutputSwitcher === null) return null;
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
const label = isEarpieceTarget
? t("settings.devices.handset")
: t("settings.devices.loudspeaker");
return (
<Tooltip label={label}>
<IconButton
onClick={(e) => {
e.preventDefault();
audioOutputSwitcher.switch();
}}
>
<Icon />
</IconButton>
</Tooltip>
);
}, [t, audioOutputSwitcher]),
);
useAppBarHidden(!showHeader);
let header: ReactNode = null;
if (showHeader) {
switch (headerStyle) {
case "none":
// Cosmetic header to fill out space while still affecting the bounds of
// the grid
header = (
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
);
break;
case "standard":
header = (
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{showControls && onShareClick !== null && (
<InviteButton
className={styles.invite}
onClick={onShareClick}
/>
)}
</RightNav>
</Header>
);
}
}
const Tile = useMemo( const Tile = useMemo(
() => () =>
forwardRef< function Tile({
HTMLDivElement,
PropsWithoutRef<TileProps<TileViewModel, HTMLDivElement>>
>(function Tile(
{ className, style, targetWidth, targetHeight, model },
ref, ref,
) { className,
const spotlightExpanded = useObservableEagerState( style,
vm.spotlightExpanded$, targetWidth,
); targetHeight,
const onToggleExpanded = useObservableEagerState( model,
vm.toggleSpotlightExpanded$, }: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
); const spotlightExpanded = useBehavior(vm.spotlightExpanded$);
const showSpeakingIndicatorsValue = useObservableEagerState( const onToggleExpanded = useBehavior(vm.toggleSpotlightExpanded$);
const showSpeakingIndicatorsValue = useBehavior(
vm.showSpeakingIndicators$, vm.showSpeakingIndicators$,
); );
const showSpotlightIndicatorsValue = useObservableEagerState( const showSpotlightIndicatorsValue = useBehavior(
vm.showSpotlightIndicators$, vm.showSpotlightIndicators$,
); );
@@ -481,7 +561,7 @@ export const InCallView: FC<InCallViewProps> = ({
style={style} style={style}
/> />
); );
}), },
[vm, openProfile], [vm, openProfile],
); );
@@ -521,7 +601,8 @@ export const InCallView: FC<InCallViewProps> = ({
key="fixed" key="fixed"
className={styles.fixedGrid} className={styles.fixedGrid}
style={{ style={{
insetBlockStart: headerBounds.bottom, insetBlockStart:
headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
height: gridBounds.height, height: gridBounds.height,
}} }}
model={layout} model={layout}
@@ -644,10 +725,11 @@ export const InCallView: FC<InCallViewProps> = ({
ref={footerRef} ref={footerRef}
className={classNames(styles.footer, { className={classNames(styles.footer, {
[styles.overlay]: windowMode === "flat", [styles.overlay]: windowMode === "flat",
[styles.hidden]: !showFooter || (!showControls && hideHeader), [styles.hidden]:
!showFooter || (!showControls && headerStyle === "none"),
})} })}
> >
{!hideHeader && ( {headerStyle !== "none" && (
<div className={styles.logo}> <div className={styles.logo}>
<LogoMark width={24} height={24} aria-hidden /> <LogoMark width={24} height={24} aria-hidden />
<LogoType <LogoType
@@ -683,35 +765,7 @@ export const InCallView: FC<InCallViewProps> = ({
onPointerMove={onPointerMove} onPointerMove={onPointerMove}
onPointerOut={onPointerOut} onPointerOut={onPointerOut}
> >
{showHeader && {header}
(hideHeader ? (
// Cosmetic header to fill out space while still affecting the bounds
// of the grid
<div
className={classNames(styles.header, styles.filler)}
ref={headerRef}
/>
) : (
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
name={matrixInfo.roomName}
avatarUrl={matrixInfo.roomAvatar}
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
participantCount={participantCount}
/>
</LeftNav>
<RightNav>
{showControls && onShareClick !== null && (
<InviteButton
className={styles.invite}
onClick={onShareClick}
/>
)}
</RightNav>
</Header>
))}
{ {
// TODO: remove this once we remove the developer flag gets removed and we have shipped to // TODO: remove this once we remove the developer flag gets removed and we have shipped to
// device transport as the default. // device transport as the default.
@@ -728,6 +782,10 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()} {renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} /> <CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} /> <ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<EarpieceOverlay
show={earpieceMode}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
<ReactionsOverlay vm={vm} /> <ReactionsOverlay vm={vm} />
{footer} {footer}
{layout.type !== "pip" && ( {layout.type !== "pip" && (

View File

@@ -24,7 +24,7 @@ import {
type LocalVideoTrack, type LocalVideoTrack,
Track, Track,
} from "livekit-client"; } from "livekit-client";
import { useObservable } from "observable-hooks"; import { useObservable, useObservableEagerState } from "observable-hooks";
import { map } from "rxjs"; import { map } from "rxjs";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -45,7 +45,7 @@ import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
import { useMediaQuery } from "../useMediaQuery"; import { useMediaQuery } from "../useMediaQuery";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { Link } from "../button/Link"; import { Link } from "../button/Link";
import { useMediaDevices } from "../livekit/MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { useInitial } from "../useInitial"; import { useInitial } from "../useInitial";
import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera"; import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera";
import { import {
@@ -54,6 +54,7 @@ import {
} from "../livekit/TrackProcessorContext"; } from "../livekit/TrackProcessorContext";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
import { getValue } from "../utils/observable";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
@@ -126,13 +127,18 @@ export const LobbyView: FC<Props> = ({
); );
const devices = useMediaDevices(); const devices = useMediaDevices();
const videoInputId = useObservableEagerState(
devices.videoInput.selected$,
)?.id;
// Capture the audio options as they were when we first mounted, because // Capture the audio options as they were when we first mounted, because
// we're not doing anything with the audio anyway so we don't need to // we're not doing anything with the audio anyway so we don't need to
// re-open the devices when they change (see below). // re-open the devices when they change (see below).
const initialAudioOptions = useInitial( const initialAudioOptions = useInitial(
() => () =>
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId }, muteStates.audio.enabled && {
deviceId: getValue(devices.audioInput.selected$)?.id,
},
); );
const { processor } = useTrackProcessor(); const { processor } = useTrackProcessor();
@@ -148,14 +154,14 @@ export const LobbyView: FC<Props> = ({
// which would cause the devices to be re-opened on the next render. // which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions), audio: Object.assign({}, initialAudioOptions),
video: muteStates.video.enabled && { video: muteStates.video.enabled && {
deviceId: devices.videoInput.selectedId, deviceId: videoInputId,
processor: initialProcessor, processor: initialProcessor,
}, },
}), }),
[ [
initialAudioOptions, initialAudioOptions,
muteStates.video.enabled, muteStates.video.enabled,
devices.videoInput.selectedId, videoInputId,
initialProcessor, initialProcessor,
], ],
); );
@@ -178,6 +184,16 @@ export const LobbyView: FC<Props> = ({
null) as LocalVideoTrack | null, null) as LocalVideoTrack | null,
[tracks], [tracks],
); );
useEffect(() => {
if (videoTrack && videoInputId === undefined) {
// If we have a video track but no videoInputId,
// we have to update the available devices. So that we select the first
// available video input device as the default instead of the `""` id.
devices.requestDeviceNames();
}
}, [devices, videoInputId, videoTrack]);
useTrackProcessorSync(videoTrack); useTrackProcessorSync(videoTrack);
const showSwitchCamera = useShowSwitchCamera( const showSwitchCamera = useShowSwitchCamera(
useObservable( useObservable(

View File

@@ -5,20 +5,29 @@ 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 { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import {
afterAll,
afterEach,
describe,
expect,
it,
onTestFinished,
vi,
} from "vitest";
import { type FC, useCallback, useState } from "react"; import { type FC, useCallback, useState } from "react";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { of } from "rxjs";
import { useMuteStates } from "./MuteStates"; import { useMuteStates } from "./MuteStates";
import { import { MediaDevicesContext } from "../MediaDevicesContext";
type DeviceLabel,
type MediaDeviceHandle,
type MediaDevices,
MediaDevicesContext,
} from "../livekit/MediaDevicesContext";
import { mockConfig } from "../utils/test"; import { mockConfig } from "../utils/test";
import { MediaDevices } from "../state/MediaDevices";
import { ObservableScope } from "../state/ObservableScope";
vi.mock("@livekit/components-core");
interface TestComponentProps { interface TestComponentProps {
isJoined?: boolean; isJoined?: boolean;
@@ -73,16 +82,6 @@ const mockCamera: MediaDeviceInfo = {
}, },
}; };
function mockDevices(available: Map<string, DeviceLabel>): MediaDeviceHandle {
return {
available,
selectedId: "",
selectedGroupId: "",
select: (): void => {},
useAsEarpiece: false,
};
}
function mockMediaDevices( function mockMediaDevices(
{ {
microphone, microphone,
@@ -94,21 +93,21 @@ function mockMediaDevices(
camera?: boolean; camera?: boolean;
} = { microphone: true, speaker: true, camera: true }, } = { microphone: true, speaker: true, camera: true },
): MediaDevices { ): MediaDevices {
return { vi.mocked(createMediaDeviceObserver).mockImplementation((kind) => {
audioInput: mockDevices( switch (kind) {
microphone case "audioinput":
? new Map([[mockMicrophone.deviceId, mockMicrophone]]) return of(microphone ? [mockMicrophone] : []);
: new Map(), case "audiooutput":
), return of(speaker ? [mockSpeaker] : []);
audioOutput: mockDevices( case "videoinput":
speaker ? new Map([[mockSpeaker.deviceId, mockSpeaker]]) : new Map(), return of(camera ? [mockCamera] : []);
), case undefined:
videoInput: mockDevices( throw new Error("Unimplemented");
camera ? new Map([[mockCamera.deviceId, mockCamera]]) : new Map(), }
), });
startUsingDeviceNames: (): void => {}, const scope = new ObservableScope();
stopUsingDeviceNames: (): void => {}, onTestFinished(() => scope.end());
}; return new MediaDevices(scope);
} }
describe("useMuteStates", () => { describe("useMuteStates", () => {
@@ -125,14 +124,14 @@ describe("useMuteStates", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MediaDevicesContext.Provider <MediaDevicesContext
value={mockMediaDevices({ value={mockMediaDevices({
microphone: false, microphone: false,
camera: false, camera: false,
})} })}
> >
<TestComponent /> <TestComponent />
</MediaDevicesContext.Provider> </MediaDevicesContext>
</MemoryRouter>, </MemoryRouter>,
); );
expect(screen.getByTestId("audio-enabled").textContent).toBe("false"); expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
@@ -144,9 +143,9 @@ describe("useMuteStates", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MediaDevicesContext.Provider value={mockMediaDevices()}> <MediaDevicesContext value={mockMediaDevices()}>
<TestComponent /> <TestComponent />
</MediaDevicesContext.Provider> </MediaDevicesContext>
</MemoryRouter>, </MemoryRouter>,
); );
expect(screen.getByTestId("audio-enabled").textContent).toBe("true"); expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
@@ -160,9 +159,9 @@ describe("useMuteStates", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MediaDevicesContext.Provider value={mockMediaDevices()}> <MediaDevicesContext value={mockMediaDevices()}>
<TestComponent isJoined /> <TestComponent isJoined />
</MediaDevicesContext.Provider> </MediaDevicesContext>
</MemoryRouter>, </MemoryRouter>,
); );
expect(screen.getByTestId("audio-enabled").textContent).toBe("false"); expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
@@ -179,9 +178,9 @@ describe("useMuteStates", () => {
render( render(
<MemoryRouter> <MemoryRouter>
<MediaDevicesContext.Provider value={mockMediaDevices()}> <MediaDevicesContext value={mockMediaDevices()}>
<TestComponent /> <TestComponent />
</MediaDevicesContext.Provider> </MediaDevicesContext>
</MemoryRouter>, </MemoryRouter>,
); );
expect(screen.getByTestId("audio-enabled").textContent).toBe("false"); expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
@@ -192,10 +191,14 @@ describe("useMuteStates", () => {
mockConfig(); mockConfig();
render( render(
<MemoryRouter initialEntries={["/room/?skipLobby=true"]}> <MemoryRouter
<MediaDevicesContext.Provider value={mockMediaDevices()}> initialEntries={[
"/room/?skipLobby=true&widgetId=1234&parentUrl=www.parent.org",
]}
>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent /> <TestComponent />
</MediaDevicesContext.Provider> </MediaDevicesContext>
</MemoryRouter>, </MemoryRouter>,
); );
expect(screen.getByTestId("audio-enabled").textContent).toBe("false"); expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
@@ -206,7 +209,12 @@ describe("useMuteStates", () => {
const user = userEvent.setup(); const user = userEvent.setup();
mockConfig(); mockConfig();
const noDevices = mockMediaDevices({ microphone: false, camera: false }); const noDevices = mockMediaDevices({ microphone: false, camera: false });
// Warm up these Observables before making further changes to the
// createMediaDevicesObserver mock
noDevices.audioInput.available$.subscribe(() => {}).unsubscribe();
noDevices.videoInput.available$.subscribe(() => {}).unsubscribe();
const someDevices = mockMediaDevices(); const someDevices = mockMediaDevices();
const ReappearanceTest: FC = () => { const ReappearanceTest: FC = () => {
const [devices, setDevices] = useState(someDevices); const [devices, setDevices] = useState(someDevices);
const onConnectDevicesClick = useCallback( const onConnectDevicesClick = useCallback(
@@ -220,13 +228,13 @@ describe("useMuteStates", () => {
return ( return (
<MemoryRouter> <MemoryRouter>
<MediaDevicesContext.Provider value={devices}> <MediaDevicesContext value={devices}>
<TestComponent /> <TestComponent />
<button onClick={onConnectDevicesClick}>Connect devices</button> <button onClick={onConnectDevicesClick}>Connect devices</button>
<button onClick={onDisconnectDevicesClick}> <button onClick={onDisconnectDevicesClick}>
Disconnect devices Disconnect devices
</button> </button>
</MediaDevicesContext.Provider> </MediaDevicesContext>
</MemoryRouter> </MemoryRouter>
); );
}; };

View File

@@ -14,11 +14,14 @@ import {
} from "react"; } from "react";
import { type IWidgetApiRequest } from "matrix-widget-api"; import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import { import {
type MediaDeviceHandle, type DeviceLabel,
useMediaDevices, type SelectedDevice,
} from "../livekit/MediaDevicesContext"; type MediaDevice,
} from "../state/MediaDevices";
import { useIsEarpiece, useMediaDevices } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState"; import { useReactiveState } from "../useReactiveState";
import { ElementWidgetActions, widget } from "../widget"; import { ElementWidgetActions, widget } from "../widget";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
@@ -53,24 +56,25 @@ export interface MuteStates {
} }
function useMuteState( function useMuteState(
device: MediaDeviceHandle, device: MediaDevice<DeviceLabel, SelectedDevice>,
enabledByDefault: () => boolean, enabledByDefault: () => boolean,
forceUnavailable: boolean = false,
): MuteState { ): MuteState {
const available = useObservableEagerState(device.available$);
const [enabled, setEnabled] = useReactiveState<boolean | undefined>( const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
// Determine the default value once devices are actually connected // Determine the default value once devices are actually connected
(prev) => (prev) => prev ?? (available.size > 0 ? enabledByDefault() : undefined),
prev ?? (device.available.size > 0 ? enabledByDefault() : undefined), [available.size],
[device.available.size],
); );
return useMemo( return useMemo(
() => () =>
device.available.size === 0 available.size === 0 || forceUnavailable
? deviceUnavailable ? deviceUnavailable
: { : {
enabled: enabled ?? false, enabled: enabled ?? false,
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>, setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
}, },
[device.available.size, enabled, setEnabled], [available.size, enabled, forceUnavailable, setEnabled],
); );
} }
@@ -82,9 +86,19 @@ 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 video = useMuteState( const video = useMuteState(
devices.videoInput, devices.videoInput,
() => Config.get().media_devices.enable_video && !skipLobby && !isJoined, () => Config.get().media_devices.enable_video && !skipLobby && !isJoined,
isEarpiece, // Force video to be unavailable if using earpiece
); );
useEffect(() => { useEffect(() => {

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

@@ -19,8 +19,10 @@ import { UserMenuContainer } from "../UserMenuContainer";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser"; import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { Config } from "../config/Config"; import { Config } from "../config/Config";
import { ExternalLink, Link } from "../button/Link"; import { ExternalLink, Link } from "../button/Link";
import { useUrlParams } from "../UrlParams";
export const RoomAuthView: FC = () => { export const RoomAuthView: FC = () => {
const { header } = useUrlParams();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error>(); const [error, setError] = useState<Error>();
@@ -53,14 +55,16 @@ export const RoomAuthView: FC = () => {
return ( return (
<> <>
<Header> {header === "standard" && (
<LeftNav> <Header>
<HeaderLogo /> <LeftNav>
</LeftNav> <HeaderLogo />
<RightNav> </LeftNav>
<UserMenuContainer preventNavigation /> <RightNav>
</RightNav> <UserMenuContainer preventNavigation />
</Header> </RightNav>
</Header>
)}
<div className={styles.container}> <div className={styles.container}>
<main className={styles.main}> <main className={styles.main}>
<Heading size="xl" weight="semibold" className={styles.headline}> <Heading size="xl" weight="semibold" className={styles.headline}>

View File

@@ -43,14 +43,8 @@ import { ErrorView } from "../ErrorView";
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState"; import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
export const RoomPage: FC = () => { export const RoomPage: FC = () => {
const { const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } =
confineToRoom, useUrlParams();
appPrompt,
preload,
hideHeader,
displayName,
skipLobby,
} = useUrlParams();
const { t } = useTranslation(); const { t } = useTranslation();
const { roomAlias, roomId, viaServers } = useRoomIdentifier(); const { roomAlias, roomId, viaServers } = useRoomIdentifier();
@@ -120,7 +114,7 @@ export const RoomPage: FC = () => {
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
preload={preload} preload={preload}
skipLobby={skipLobby || wasInWaitForInviteState.current} skipLobby={skipLobby || wasInWaitForInviteState.current}
hideHeader={hideHeader} header={header}
muteStates={muteStates} muteStates={muteStates}
/> />
); );
@@ -161,7 +155,7 @@ export const RoomPage: FC = () => {
enterLabel={label} enterLabel={label}
waitingForInvite={groupCallState.kind === "waitForInvite"} waitingForInvite={groupCallState.kind === "waitForInvite"}
confineToRoom={confineToRoom} confineToRoom={confineToRoom}
hideHeader={hideHeader} hideHeader={header !== "standard"}
participantCount={null} participantCount={null}
muteStates={muteStates} muteStates={muteStates}
onShareClick={null} onShareClick={null}

View File

@@ -112,6 +112,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
data-size="large" data-size="large"
> >
<svg <svg
aria-hidden="true"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -131,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
@@ -262,6 +269,7 @@ exports[`should have a close button in widget mode 1`] = `
data-size="large" data-size="large"
> >
<svg <svg
aria-hidden="true"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -414,6 +422,7 @@ exports[`should render the error page with link back to home 1`] = `
data-size="large" data-size="large"
> >
<svg <svg
aria-hidden="true"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -566,6 +575,7 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
data-size="large" data-size="large"
> >
<svg <svg
aria-hidden="true"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -718,6 +728,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
data-size="large" data-size="large"
> >
<svg <svg
aria-hidden="true"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -737,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
@@ -868,6 +885,7 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
data-size="large" data-size="large"
> >
<svg <svg
aria-hidden="true"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -1015,6 +1033,7 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
data-size="large" data-size="large"
> >
<svg <svg
aria-hidden="true"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@@ -5,9 +5,56 @@ exports[`InCallView > rendering > renders 1`] = `
<div <div
class="inRoom" class="inRoom"
> >
<div <header
class="header filler" class="header header"
/> >
<div
class="nav leftNav"
>
<div
class="roomHeaderInfo"
data-size="lg"
>
<span
aria-label=""
class="_avatar_1qbcf_8 roomAvatar _avatar-imageless_1qbcf_52"
data-color="1"
data-type="round"
role="img"
style="--cpd-avatar-size: 56px;"
/>
<div
class="nameLine"
>
<h1
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
data-testid="roomHeader_roomName"
/>
<span
tabindex="0"
>
<svg
aria-labelledby="«r0»"
class="lock"
data-encrypted="false"
fill="currentColor"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6 22q-.825 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412a2 2 0 0 1 .702-.463L1.333 4.167a1 1 0 0 1 1.414-1.414L7 7.006v-.012l13 13v.012l1.247 1.247a1 1 0 1 1-1.414 1.414l-.896-.896A1.94 1.94 0 0 1 18 22zm14-4.834V10q0-.825-.587-1.412A1.93 1.93 0 0 0 18 8h-1V6q0-2.075-1.463-3.537Q14.075 1 12 1T8.463 2.463a4.9 4.9 0 0 0-1.22 1.946L9 6.166V6q0-1.25.875-2.125A2.9 2.9 0 0 1 12 3q1.25 0 2.125.875T15 6v2h-4.166z"
/>
</svg>
</span>
</div>
</div>
</div>
<div
class="nav rightNav"
/>
</header>
<div> <div>
mocked: MatrixAudioRenderer mocked: MatrixAudioRenderer
</div> </div>
@@ -35,18 +82,181 @@ exports[`InCallView > rendering > renders 1`] = `
> >
<div /> <div />
</div> </div>
<div
class="overlay"
data-show="false"
>
<div
class="_content_o77nw_8 icon"
data-size="large"
>
<svg
aria-hidden="true"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#a)"
>
<path
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>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Handset Mode
</h2>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
>
Only works while using app
</p>
<button
class="_button_vczzf_8"
data-kind="primary"
data-size="sm"
role="button"
tabindex="0"
>
Back to Speaker Mode
</button>
<div
class="spacer"
/>
</div>
<div <div
class="container" class="container"
/> />
<div <div
class="footer" class="footer"
> >
<div
class="logo"
>
<svg
aria-hidden="true"
fill="none"
height="24"
viewBox="0 0 48 48"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Logo Mark"
>
<rect
fill="#0DBD8B"
height="48"
rx="23.93"
width="47.86"
/>
<g
id="Union"
>
<path
d="M21.3075 9.42871C20.3396 9.42871 19.5549 10.214 19.5549 11.1828C19.5549 12.1516 20.3396 12.9369 21.3075 12.9369C25.9321 12.9369 29.6811 16.689 29.6811 21.3175C29.6811 22.2863 30.4657 23.0716 31.4337 23.0716C32.4016 23.0716 33.1863 22.2863 33.1863 21.3175C33.1863 14.7515 27.868 9.42871 21.3075 9.42871Z"
fill="white"
/>
<path
d="M38.4591 21.3174C38.4591 20.3486 37.6745 19.5633 36.7065 19.5633C35.7386 19.5633 34.9539 20.3486 34.9539 21.3174C34.9539 25.9459 31.2049 29.698 26.5804 29.698C25.6124 29.698 24.8277 30.4833 24.8277 31.4521C24.8277 32.4209 25.6124 33.2062 26.5804 33.2062C33.1408 33.2062 38.4591 27.8834 38.4591 21.3174Z"
fill="white"
/>
<path
d="M28.3329 36.8173C28.3329 37.786 27.5482 38.5714 26.5803 38.5714C20.0198 38.5714 14.7015 33.2486 14.7015 26.6826C14.7015 25.7138 15.4862 24.9285 16.4541 24.9285C17.4221 24.9285 18.2067 25.7138 18.2067 26.6826C18.2067 31.3111 21.9557 35.0632 26.5803 35.0632C27.5482 35.0632 28.3329 35.8485 28.3329 36.8173Z"
fill="white"
/>
<path
d="M9.40112 26.6827C9.40112 27.6514 10.1858 28.4368 11.1537 28.4368C12.1217 28.4368 12.9064 27.6514 12.9064 26.6827C12.9064 22.0542 16.6553 18.3021 21.2799 18.3021C22.2478 18.3021 23.0325 17.5167 23.0325 16.548C23.0325 15.5792 22.2478 14.7939 21.2799 14.7939C14.7194 14.7939 9.40112 20.1167 9.40112 26.6827Z"
fill="white"
/>
</g>
</g>
</svg>
<svg
aria-label="Element Call"
fill="none"
height="11"
viewBox="0 0 160 22"
width="80"
xmlns="http://www.w3.org/2000/svg"
>
<g
id="Logo Type"
>
<g
id="Vector"
>
<path
d="M14.8673 15.1575H3.39742C3.53293 16.3508 3.96849 17.3036 4.70411 18.0157C5.43974 18.7087 6.40766 19.0551 7.60789 19.0551C8.40159 19.0551 9.11785 18.8626 9.75668 18.4777C10.3955 18.0927 10.8504 17.5731 11.1215 16.9186H14.606C14.1414 18.4392 13.2702 19.671 11.9926 20.6142C10.7343 21.5381 9.24368 22 7.52078 22C5.27519 22 3.45549 21.259 2.06168 19.7769C0.687227 18.2948 0 16.4182 0 14.147C0 11.9335 0.696906 10.0761 2.09072 8.5748C3.48453 7.07349 5.28487 6.32283 7.49174 6.32283C9.69861 6.32283 11.4796 7.06387 12.8347 8.54593C14.2091 10.0087 14.8964 11.8565 14.8964 14.0892L14.8673 15.1575ZM7.49174 9.12336C6.40766 9.12336 5.50749 9.44095 4.79123 10.0761C4.07496 10.7113 3.62972 11.5582 3.45549 12.6168H11.4699C11.315 11.5582 10.8892 10.7113 10.1922 10.0761C9.49534 9.44095 8.59517 9.12336 7.49174 9.12336Z"
fill="currentColor"
/>
<path
d="M17.2743 17.1785V0H20.7298V17.2362C20.7298 18.0061 21.1557 18.3911 22.0074 18.3911L22.6172 18.3622V21.6247C22.2881 21.6824 21.9397 21.7113 21.5719 21.7113C20.0813 21.7113 18.9875 21.336 18.2906 20.5853C17.6131 19.8346 17.2743 18.699 17.2743 17.1785Z"
fill="currentColor"
/>
<path
d="M38.71 15.1575H27.2401C27.3756 16.3508 27.8112 17.3036 28.5468 18.0157C29.2824 18.7087 30.2504 19.0551 31.4506 19.0551C32.2443 19.0551 32.9606 18.8626 33.5994 18.4777C34.2382 18.0927 34.6931 17.5731 34.9642 16.9186H38.4487C37.9841 18.4392 37.113 19.671 35.8353 20.6142C34.577 21.5381 33.0864 22 31.3635 22C29.1179 22 27.2982 21.259 25.9044 19.7769C24.5299 18.2948 23.8427 16.4182 23.8427 14.147C23.8427 11.9335 24.5396 10.0761 25.9334 8.5748C27.3272 7.07349 29.1276 6.32283 31.3344 6.32283C33.5413 6.32283 35.3223 7.06387 36.6774 8.54593C38.0518 10.0087 38.7391 11.8565 38.7391 14.0892L38.71 15.1575ZM31.3344 9.12336C30.2504 9.12336 29.3502 9.44095 28.6339 10.0761C27.9177 10.7113 27.4724 11.5582 27.2982 12.6168H35.3126C35.1577 11.5582 34.7319 10.7113 34.035 10.0761C33.3381 9.44095 32.4379 9.12336 31.3344 9.12336Z"
fill="currentColor"
/>
<path
d="M54.3001 13.0499V21.6535H50.8446V12.6745C50.8446 10.4033 49.8961 9.26772 47.9989 9.26772C46.9729 9.26772 46.1502 9.59493 45.5307 10.2493C44.9306 10.9038 44.6306 11.7988 44.6306 12.9344V21.6535H41.1751V6.66929H44.3692V8.66142C44.737 7.98775 45.2984 7.42957 46.0534 6.98688C46.8084 6.54418 47.7473 6.32283 48.8701 6.32283C50.9608 6.32283 52.4707 7.11199 53.4 8.69029C54.6776 7.11199 56.3812 6.32283 58.5106 6.32283C60.2722 6.32283 61.6273 6.87139 62.5759 7.9685C63.5244 9.04637 63.9987 10.4707 63.9987 12.2415V21.6535H60.5432V12.6745C60.5432 10.4033 59.5947 9.26772 57.6975 9.26772C56.6522 9.26772 55.8198 9.60455 55.2003 10.2782C54.6002 10.9326 54.3001 11.8565 54.3001 13.0499Z"
fill="currentColor"
/>
<path
d="M81.1834 15.1575H69.7135C69.849 16.3508 70.2846 17.3036 71.0202 18.0157C71.7558 18.7087 72.7237 19.0551 73.924 19.0551C74.7177 19.0551 75.4339 18.8626 76.0728 18.4777C76.7116 18.0927 77.1665 17.5731 77.4375 16.9186H80.9221C80.4575 18.4392 79.5863 19.671 78.3087 20.6142C77.0504 21.5381 75.5598 22 73.8369 22C71.5913 22 69.7716 21.259 68.3778 19.7769C67.0033 18.2948 66.3161 16.4182 66.3161 14.147C66.3161 11.9335 67.013 10.0761 68.4068 8.5748C69.8006 7.07349 71.601 6.32283 73.8078 6.32283C76.0147 6.32283 77.7957 7.06387 79.1508 8.54593C80.5252 10.0087 81.2124 11.8565 81.2124 14.0892L81.1834 15.1575ZM73.8078 9.12336C72.7237 9.12336 71.8236 9.44095 71.1073 10.0761C70.391 10.7113 69.9458 11.5582 69.7716 12.6168H77.786C77.6311 11.5582 77.2052 10.7113 76.5083 10.0761C75.8114 9.44095 74.9113 9.12336 73.8078 9.12336Z"
fill="currentColor"
/>
<path
d="M86.8426 6.66929V8.66142C87.191 8.007 87.7621 7.45844 88.5558 7.01575C89.3689 6.55381 90.3465 6.32283 91.4886 6.32283C93.2696 6.32283 94.6441 6.86177 95.612 7.93963C96.5993 9.0175 97.0929 10.4514 97.0929 12.2415V21.6535H93.6374V12.6745C93.6374 11.6159 93.3858 10.7883 92.8824 10.1916C92.3985 9.57568 91.6532 9.26772 90.6465 9.26772C89.5431 9.26772 88.672 9.59493 88.0331 10.2493C87.4137 10.9038 87.1039 11.8084 87.1039 12.9633V21.6535H83.6484V6.66929H86.8426Z"
fill="currentColor"
/>
<path
d="M107.185 18.5932V21.5669C106.759 21.6824 106.159 21.7402 105.384 21.7402C102.442 21.7402 100.971 20.2677 100.971 17.3228V9.41208H98.6766V6.66929H100.971V2.77165H104.426V6.66929H107.243V9.41208H104.426V16.9764C104.426 18.1505 104.987 18.7375 106.11 18.7375L107.185 18.5932Z"
fill="currentColor"
/>
<path
d="M116.115 18.9881C114.474 17.2035 113.653 14.9429 113.653 12.2064C113.653 9.4699 114.474 7.21782 116.115 5.45015C117.773 3.66548 119.953 2.77314 122.654 2.77314C124.876 2.77314 126.756 3.38503 128.295 4.6088C129.833 5.83258 130.816 7.47277 131.244 9.52939H129.269C128.91 7.99967 128.132 6.7844 126.936 5.88357C125.739 4.98273 124.312 4.53232 122.654 4.53232C120.534 4.53232 118.824 5.23769 117.525 6.64842C116.243 8.05916 115.602 9.91182 115.602 12.2064C115.602 14.501 116.243 16.3536 117.525 17.7644C118.824 19.1751 120.534 19.8805 122.654 19.8805C124.312 19.8805 125.739 19.4301 126.936 18.5292C128.132 17.6284 128.91 16.4131 129.269 14.8834H131.244C130.816 16.94 129.833 18.5802 128.295 19.804C126.756 21.0278 124.876 21.6397 122.654 21.6397C119.953 21.6397 117.773 20.7558 116.115 18.9881Z"
fill="currentColor"
/>
<path
d="M143.174 15.0874C140.832 15.0874 139.233 15.1384 138.379 15.2403C137.541 15.3253 136.926 15.4698 136.532 15.6738C135.831 16.0647 135.481 16.6936 135.481 17.5604C135.481 19.2261 136.473 20.0589 138.456 20.0589C139.977 20.0589 141.139 19.719 141.943 19.0391C142.763 18.3593 143.174 17.4499 143.174 16.3111V15.0874ZM138.25 21.5632C136.763 21.5632 135.626 21.2062 134.84 20.4924C134.071 19.7615 133.686 18.8012 133.686 17.6114C133.686 16.8295 133.891 16.1327 134.301 15.5208C134.729 14.9089 135.31 14.4585 136.045 14.1695C136.661 13.9316 137.455 13.7786 138.43 13.7106C139.404 13.6256 140.986 13.5831 143.174 13.5831V12.7418C143.174 10.6002 141.943 9.52939 139.481 9.52939C137.361 9.52939 136.131 10.3877 135.789 12.1044H134.019C134.207 10.8466 134.746 9.84383 135.635 9.09597C136.541 8.34811 137.849 7.97418 139.558 7.97418C141.387 7.97418 142.746 8.3991 143.635 9.24894C144.541 10.0988 144.994 11.2716 144.994 12.7673V21.2572H143.251V19.3706C142.345 20.8323 140.678 21.5632 138.25 21.5632Z"
fill="currentColor"
/>
<path
d="M149.358 18.4018V2.13576H151.178V18.1978C151.178 18.7247 151.264 19.0901 151.435 19.2941C151.623 19.498 151.956 19.6 152.435 19.6L152.948 19.549V21.2062C152.657 21.2572 152.341 21.2827 151.999 21.2827C150.238 21.2827 149.358 20.3224 149.358 18.4018Z"
fill="currentColor"
/>
<path
d="M155.944 18.4018V2.13576H157.764V18.1978C157.764 18.7247 157.85 19.0901 158.021 19.2941C158.209 19.498 158.542 19.6 159.021 19.6L159.534 19.549V21.2062C159.243 21.2572 158.927 21.2827 158.585 21.2827C156.824 21.2827 155.944 20.3224 155.944 18.4018Z"
fill="currentColor"
/>
</g>
</g>
</svg>
</div>
<div <div
class="buttons" class="buttons"
> >
<button <button
aria-disabled="false" aria-disabled="false"
aria-labelledby="«r0»" aria-labelledby="«r5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50" class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -69,7 +279,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button> </button>
<button <button
aria-disabled="false" aria-disabled="false"
aria-labelledby="«r5»" aria-labelledby="«ra»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50" class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -91,7 +301,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-labelledby="«ra»" aria-labelledby="«rf»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50" class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="secondary" data-kind="secondary"
data-size="lg" data-size="lg"
@@ -112,7 +322,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg> </svg>
</button> </button>
<button <button
aria-labelledby="«rf»" aria-labelledby="«rk»"
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107" class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
data-kind="primary" data-kind="primary"
data-size="lg" data-size="lg"
@@ -138,7 +348,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="toggle layout" class="toggle layout"
> >
<input <input
aria-labelledby="«rk»" aria-labelledby="«rp»"
name="layout" name="layout"
type="radio" type="radio"
value="spotlight" value="spotlight"
@@ -156,7 +366,7 @@ exports[`InCallView > rendering > renders 1`] = `
/> />
</svg> </svg>
<input <input
aria-labelledby="«rp»" aria-labelledby="«ru»"
checked="" checked=""
name="layout" name="layout"
type="radio" type="radio"

View File

@@ -9,7 +9,7 @@ import {
type MatrixRTCSession, type MatrixRTCSession,
MatrixRTCSessionEvent, MatrixRTCSessionEvent,
} from "matrix-js-sdk/lib/matrixrtc"; } from "matrix-js-sdk/lib/matrixrtc";
import { useCallback, useEffect, useRef } from "react"; import { useCallback, useRef } from "react";
import { deepCompare } from "matrix-js-sdk/lib/utils"; import { deepCompare } from "matrix-js-sdk/lib/utils";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc"; import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
@@ -24,27 +24,22 @@ import { useTypedEventEmitterState } from "../useEvents";
export function useActiveLivekitFocus( export function useActiveLivekitFocus(
rtcSession: MatrixRTCSession, rtcSession: MatrixRTCSession,
): LivekitFocus | undefined { ): LivekitFocus | undefined {
const activeFocus = useTypedEventEmitterState( const prevActiveFocus = useRef<LivekitFocus | undefined>(undefined);
return useTypedEventEmitterState(
rtcSession, rtcSession,
MatrixRTCSessionEvent.MembershipsChanged, MatrixRTCSessionEvent.MembershipsChanged,
useCallback(() => { useCallback(() => {
const f = rtcSession.getActiveFocus(); const f = rtcSession.getActiveFocus();
// Only handle foci with type="livekit" for now. // Only handle foci with type="livekit" for now.
return !!f && isLivekitFocus(f) ? f : undefined; if (f && isLivekitFocus(f) && !deepCompare(f, prevActiveFocus.current)) {
const oldestMembership = rtcSession.getOldestMembership();
logger.info(
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(f)}`,
);
prevActiveFocus.current = f;
}
return prevActiveFocus.current;
}, [rtcSession]), }, [rtcSession]),
); );
const prevActiveFocus = useRef(activeFocus);
useEffect(() => {
if (!deepCompare(activeFocus, prevActiveFocus.current)) {
const oldestMembership = rtcSession.getOldestMembership();
logger.warn(
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(activeFocus)}`,
);
prevActiveFocus.current = activeFocus;
}
}, [activeFocus, rtcSession]);
return activeFocus;
} }

View File

@@ -22,7 +22,7 @@ import {
import { useObservable, useObservableEagerState } from "observable-hooks"; import { useObservable, useObservableEagerState } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { useMediaDevices } from "../livekit/MediaDevicesContext"; import { useMediaDevices } from "../MediaDevicesContext";
import { platform } from "../Platform"; import { platform } from "../Platform";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";

View File

@@ -18,8 +18,8 @@ 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";
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci"; const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
@@ -116,21 +116,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

@@ -21,15 +21,18 @@ import {
Separator, Separator,
} from "@vector-im/compound-web"; } from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { useObservableEagerState } from "observable-hooks";
import { import {
EARPIECE_CONFIG_ID, type AudioOutputDeviceLabel,
type MediaDeviceHandle, type DeviceLabel,
} from "../livekit/MediaDevicesContext"; type SelectedDevice,
type MediaDevice,
} from "../state/MediaDevices";
import styles from "./DeviceSelection.module.css"; import styles from "./DeviceSelection.module.css";
interface Props { interface Props {
device: MediaDeviceHandle; device: MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
title: string; title: string;
numberedLabel: (number: number) => string; numberedLabel: (number: number) => string;
} }
@@ -41,6 +44,8 @@ export const DeviceSelection: FC<Props> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const groupId = useId(); const groupId = useId();
const available = useObservableEagerState(device.available$);
const selectedId = useObservableEagerState(device.selected$)?.id;
const onChange = useCallback( const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => { (e: ChangeEvent<HTMLInputElement>) => {
device.select(e.target.value); device.select(e.target.value);
@@ -49,7 +54,7 @@ export const DeviceSelection: FC<Props> = ({
); );
// There is no need to show the menu if there is no choice that can be made. // There is no need to show the menu if there is no choice that can be made.
if (device.available.size <= 1) return null; if (available.size <= 1) return null;
return ( return (
<div className={styles.selection}> <div className={styles.selection}>
@@ -64,7 +69,7 @@ export const DeviceSelection: FC<Props> = ({
</Heading> </Heading>
<Separator className={styles.separator} /> <Separator className={styles.separator} />
<div className={styles.options}> <div className={styles.options}>
{[...device.available].map(([id, label]) => { {[...available].map(([id, label]) => {
let labelText: ReactNode; let labelText: ReactNode;
switch (label.type) { switch (label.type) {
case "name": case "name":
@@ -89,16 +94,12 @@ export const DeviceSelection: FC<Props> = ({
</Trans> </Trans>
); );
break; break;
case "earpiece": case "speaker":
labelText = t("settings.devices.earpiece"); labelText = t("settings.devices.loudspeaker");
break;
case "earpiece":
labelText = t("settings.devices.handset");
break; break;
}
let isSelected = false;
if (device.useAsEarpiece) {
isSelected = id === EARPIECE_CONFIG_ID;
} else {
isSelected = id === device.selectedId;
} }
return ( return (
@@ -107,7 +108,7 @@ export const DeviceSelection: FC<Props> = ({
name={groupId} name={groupId}
control={ control={
<RadioControl <RadioControl
checked={isSelected} checked={id === selectedId}
onChange={onChange} onChange={onChange}
value={id} value={id}
/> />

View File

@@ -5,23 +5,19 @@ 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 FC, type ReactNode, useState } from "react"; import { type FC, type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; 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";
import { type Tab, TabContainer } from "../tabs/Tabs"; import { type Tab, TabContainer } from "../tabs/Tabs";
import { ProfileSettingsTab } from "./ProfileSettingsTab"; import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab"; import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import { import { iosDeviceMenu$ } from "../state/MediaDevices";
useMediaDevices, import { useMediaDevices } from "../MediaDevicesContext";
useMediaDeviceNames,
iosDeviceMenu$,
} from "../livekit/MediaDevicesContext";
import { widget } from "../widget"; import { widget } from "../widget";
import { import {
useSetting, useSetting,
@@ -37,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"
@@ -98,7 +95,10 @@ export const SettingsModal: FC<Props> = ({
}; };
const devices = useMediaDevices(); const devices = useMediaDevices();
useMediaDeviceNames(devices, open); useEffect(() => {
if (open) devices.requestDeviceNames();
}, [open, devices]);
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting); const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume); const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
const [showDeveloperSettingsTab] = useSetting(developerMode); const [showDeveloperSettingsTab] = useSetting(developerMode);
@@ -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

19
src/state/AppViewModel.ts Normal file
View File

@@ -0,0 +1,19 @@
/*
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 { MediaDevices } from "./MediaDevices";
import { ViewModel } from "./ViewModel";
/**
* The top-level state holder for the application.
*/
export class AppViewModel extends ViewModel {
public readonly mediaDevices = new MediaDevices(this.scope);
// TODO: Move more application logic here. The CallViewModel, at the very
// least, ought to be accessible from this object.
}

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";
@@ -42,6 +42,7 @@ import {
withTestScheduler, withTestScheduler,
mockRtcMembership, mockRtcMembership,
MockRTCSession, MockRTCSession,
mockMediaDevices,
} from "../utils/test"; } from "../utils/test";
import { import {
ECAddonConnectionState, ECAddonConnectionState,
@@ -71,6 +72,20 @@ import {
localId, localId,
localRtcMember, localRtcMember,
} from "../utils/test-fixtures"; } from "../utils/test-fixtures";
import { ObservableScope } from "./ObservableScope";
import { MediaDevices } from "./MediaDevices";
import { getValue } from "../utils/observable";
import { type Behavior, constant } from "./Behavior";
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
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");
@@ -150,9 +165,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),
@@ -171,7 +187,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),
@@ -205,10 +222,11 @@ 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,
continuation: ( continuation: (
vm: CallViewModel, vm: CallViewModel,
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> }, subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
@@ -262,6 +280,7 @@ function withCallViewModel(
const vm = new CallViewModel( const vm = new CallViewModel(
rtcSession as unknown as MatrixRTCSession, rtcSession as unknown as MatrixRTCSession,
liveKitRoom, liveKitRoom,
mediaDevices,
{ {
kind: E2eeType.PER_PARTICIPANT, kind: E2eeType.PER_PARTICIPANT,
}, },
@@ -282,7 +301,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
@@ -291,16 +310,17 @@ 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,
}), }),
new Map(), new Map(),
mockMediaDevices({}),
(vm) => { (vm) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles, expectedLayoutMarbles,
@@ -318,7 +338,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";
@@ -331,15 +351,16 @@ 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({}),
(vm) => { (vm) => {
schedule(modeInputMarbles, { schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"), s: () => vm.setGridMode("spotlight"),
@@ -402,7 +423,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";
@@ -415,14 +436,24 @@ 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({}),
(vm) => { (vm) => {
schedule(visibilityInputMarbles, { schedule(visibilityInputMarbles, {
a: () => { a: () => {
@@ -460,7 +491,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
@@ -472,13 +503,20 @@ 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({}),
(vm) => { (vm) => {
let setVisibleTiles: ((value: number) => void) | null = null; let setVisibleTiles: ((value: number) => void) | null = null;
vm.layout$.subscribe((layout) => { vm.layout$.subscribe((layout) => {
@@ -510,7 +548,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
@@ -524,14 +562,24 @@ 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({}),
(vm) => { (vm) => {
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") }); schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
@@ -573,10 +621,11 @@ 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({}),
(vm) => { (vm) => {
schedule(pipControlInputMarbles, { schedule(pipControlInputMarbles, {
e: () => window.controls.enablePip(), e: () => window.controls.enablePip(),
@@ -614,10 +663,11 @@ 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({}),
(vm) => { (vm) => {
schedule(modeInputMarbles, { schedule(modeInputMarbles, {
s: () => vm.setGridMode("spotlight"), s: () => vm.setGridMode("spotlight"),
@@ -662,7 +712,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";
@@ -670,14 +720,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],
@@ -686,6 +736,7 @@ test("participants must have a MatrixRTCSession to be visible", () => {
}), }),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}),
(vm) => { (vm) => {
vm.setGridMode("grid"); vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -717,19 +768,20 @@ 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({}),
(vm) => { (vm) => {
vm.setGridMode("grid"); vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -761,15 +813,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],
@@ -777,6 +829,7 @@ it("should show at least one tile per MatrixRTCSession", () => {
}), }),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
mockMediaDevices({}),
(vm) => { (vm) => {
vm.setGridMode("grid"); vm.setGridMode("grid");
expectObservable(summarizeLayout$(vm.layout$)).toBe( expectObservable(summarizeLayout$(vm.layout$)).toBe(
@@ -810,13 +863,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],
@@ -825,109 +878,100 @@ test("should disambiguate users with the same displayname", () => {
}), }),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
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],
}), }),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
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],
}), }),
of(ConnectionState.Connected), of(ConnectionState.Connected),
new Map(), new Map(),
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})`],
]),
},
);
}, },
); );
}); });
@@ -939,10 +983,11 @@ it("should rank raised hands above video feeds and below speakers and presenters
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({}),
(vm, { raisedHands$ }) => { (vm, { raisedHands$ }) => {
schedule("ab", { schedule("ab", {
a: () => { a: () => {
@@ -991,3 +1036,47 @@ it("should rank raised hands above video feeds and below speakers and presenters
); );
}); });
}); });
test("audio output changes when toggling earpiece mode", () => {
withTestScheduler(({ schedule, expectObservable }) => {
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(of([]));
const scope = new ObservableScope();
onTestFinished(() => scope.end());
const devices = new MediaDevices(scope);
window.controls.setAvailableAudioDevices([
{ id: "speaker", name: "Speaker", isSpeaker: true },
{ id: "earpiece", name: "Handset", isEarpiece: true },
{ id: "headphones", name: "Headphones" },
]);
window.controls.setAudioDevice("headphones");
const toggleInputMarbles = " -aaa";
const expectedEarpieceModeMarbles = "n-yn";
const expectedTargetStateMarbles = " sese";
withCallViewModel(
constant([]),
constant([]),
of(ConnectionState.Connected),
new Map(),
devices,
(vm) => {
schedule(toggleInputMarbles, {
a: () => getValue(vm.audioOutputSwitcher$)?.switch(),
});
expectObservable(vm.earpieceMode$).toBe(expectedEarpieceModeMarbles, {
n: false,
y: true,
});
expectObservable(
vm.audioOutputSwitcher$.pipe(
map((switcher) => switcher?.targetOutput),
),
).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" });
},
);
});
});

File diff suppressed because it is too large Load Diff

442
src/state/MediaDevices.ts Normal file
View File

@@ -0,0 +1,442 @@
/*
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 {
combineLatest,
filter,
map,
merge,
pairwise,
startWith,
Subject,
switchMap,
type Observable,
} from "rxjs";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
audioInput as audioInputSetting,
audioOutput as audioOutputSetting,
videoInput as videoInputSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
} from "../settings/settings";
import { type ObservableScope } from "./ObservableScope";
import {
outputDevice$ as controlledOutputSelection$,
availableOutputDevices$ as controlledAvailableOutputDevices$,
} from "../controls";
import { getUrlParams } from "../UrlParams";
import { platform } from "../Platform";
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
// the ios swift team.
const EARPIECE_CONFIG_ID = "earpiece-id";
export type DeviceLabel =
| { type: "name"; name: string }
| { type: "number"; number: number };
export type AudioOutputDeviceLabel =
| DeviceLabel
| { type: "speaker" }
| { type: "earpiece" }
| { type: "default"; name: string | null };
export interface SelectedDevice {
id: string;
}
export interface SelectedAudioInputDevice extends SelectedDevice {
/**
* Emits whenever we think that this audio input device has logically changed
* to refer to a different hardware device.
*/
hardwareDeviceChange$: Observable<void>;
}
export interface SelectedAudioOutputDevice extends SelectedDevice {
/**
* Whether this device is a "virtual earpiece" device. If so, we should output
* on a single channel of the device at a reduced volume.
*/
virtualEarpiece: boolean;
}
export interface MediaDevice<Label, Selected> {
/**
* A map from available device IDs to labels.
*/
available$: Behavior<Map<string, Label>>;
/**
* The selected device.
*/
selected$: Behavior<Selected | undefined>;
/**
* Selects a new device.
*/
select(id: string): void;
}
/**
* An observable that represents if we should display the devices menu for iOS.
* This implies the following
* - hide any input devices (they do not work anyhow on ios)
* - Show a button to show the native output picker instead.
* - Only show the earpiece toggle option if the earpiece is available:
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
*/
export const iosDeviceMenu$ =
platform === "ios" ? constant(true) : alwaysShowIphoneEarpieceSetting.value$;
function availableRawDevices$(
kind: MediaDeviceKind,
usingNames$: Behavior<boolean>,
scope: ObservableScope,
logger: Logger,
): Behavior<MediaDeviceInfo[]> {
const logError = (e: Error): void =>
logger.error("Error creating MediaDeviceObserver", e);
const devices$ = createMediaDeviceObserver(kind, logError, false);
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
return scope.behavior(
usingNames$.pipe(
switchMap((withNames) =>
withNames
? // It might be that there is already a media stream running somewhere,
// and so we can do without requesting a second one. Only switch to the
// device observer that explicitly requests the names if we see that
// names are in fact missing from the initial device enumeration.
devices$.pipe(
switchWhen(
(devices, i) => i === 0 && devices.every((d) => !d.label),
devicesWithNames$,
),
)
: devices$,
),
),
[],
);
}
function buildDeviceMap(
availableRaw: MediaDeviceInfo[],
): Map<string, DeviceLabel> {
return new Map<string, DeviceLabel>(
availableRaw.map((d, i) => [
d.deviceId,
d.label
? { type: "name", name: d.label }
: { type: "number", number: i + 1 },
]),
);
}
function selectDevice$<Label>(
available$: Observable<Map<string, Label>>,
preferredId$: Observable<string | undefined>,
): Observable<string | undefined> {
return combineLatest([available$, preferredId$], (available, preferredId) => {
if (available.size) {
// If the preferred device is available, use it. Or if every available
// device ID is falsy, the browser is probably just being paranoid about
// fingerprinting and we should still try using the preferred device.
// Worst case it is not available and the browser will gracefully fall
// back to some other device for us when requesting the media stream.
// Otherwise, select the first available device.
return (preferredId !== undefined && available.has(preferredId)) ||
(available.size === 1 && available.has(""))
? preferredId
: available.keys().next().value;
}
return undefined;
});
}
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
private logger = rootLogger.getChild("[MediaDevices AudioInput]");
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
availableRawDevices$(
"audioinput",
this.usingNames$,
this.scope,
this.logger,
);
public readonly available$ = this.scope.behavior(
this.availableRaw$.pipe(map(buildDeviceMap)),
);
public readonly selected$ = this.scope.behavior(
selectDevice$(this.available$, audioInputSetting.value$).pipe(
map((id) =>
id === undefined
? undefined
: {
id,
// We can identify when the hardware device has changed by watching for
// changes in the group ID
hardwareDeviceChange$: this.availableRaw$.pipe(
map(
(devices) => devices.find((d) => d.deviceId === id)?.groupId,
),
pairwise(),
filter(([before, after]) => before !== after),
map(() => undefined),
),
},
),
),
);
public select(id: string): void {
audioInputSetting.setValue(id);
}
public constructor(
private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope,
) {
this.available$.subscribe((available) => {
this.logger.info("[audio-input] available devices:", available);
});
}
}
class AudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
private logger = rootLogger.getChild("[MediaDevices AudioOutput]");
public readonly available$ = this.scope.behavior(
availableRawDevices$(
"audiooutput",
this.usingNames$,
this.scope,
this.logger,
).pipe(
map((availableRaw) => {
let available: Map<string, AudioOutputDeviceLabel> =
buildDeviceMap(availableRaw);
// Create a virtual default audio output for browsers that don't have one.
// Its device ID must be the empty string because that's what setSinkId
// recognizes.
if (available.size && !available.has("") && !available.has("default"))
available.set("", {
type: "default",
name: availableRaw[0]?.label || null,
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
if (isSafari) {
// set to empty map if we are on Safari, because it does not support setSinkId
available = new Map();
}
// Note: creating virtual default input devices would be another problem
// entirely, because requesting a media stream from deviceId "" won't
// automatically track the default device.
return available;
}),
),
);
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 {
audioOutputSetting.setValue(id);
}
public constructor(
private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope,
) {
this.available$.subscribe((available) => {
this.logger.info("[audio-output] available devices:", available);
});
}
}
class ControlledAudioOutput
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
{
private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
// We need to subscribe to the raw devices so that the OS does update the input
// back to what it was before. otherwise we will switch back to the default
// whenever we allocate a new stream.
public readonly availableRaw$ = availableRawDevices$(
"audiooutput",
this.usingNames$,
this.scope,
this.logger,
);
public readonly available$ = this.scope.behavior(
combineLatest(
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
(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];
},
),
);
// Create a virtual earpiece device in case a non-earpiece device is
// designated for this purpose
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
return available;
},
),
);
private readonly deviceSelection$ = new Subject<string>();
public select(id: string): void {
this.deviceSelection$.next(id);
}
public readonly selected$ = this.scope.behavior(
combineLatest(
[
this.available$,
merge(
controlledOutputSelection$.pipe(startWith(undefined)),
this.deviceSelection$,
),
],
(available, preferredId) => {
const id = preferredId ?? available.keys().next().value;
return id === undefined
? undefined
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
},
),
);
public constructor(
private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope,
) {
this.selected$.subscribe((device) => {
// Let the hosting application know which output device has been selected.
// This information is probably only of interest if the earpiece mode has
// been selected - for example, Element X iOS listens to this to determine
// whether it should enable the proximity sensor.
if (device !== undefined) {
this.logger.info(
"[controlled-output] onAudioDeviceSelect called:",
device,
);
window.controls.onAudioDeviceSelect?.(device.id);
// Also invoke the deprecated callback for backward compatibility
window.controls.onOutputDeviceSelect?.(device.id);
}
});
this.available$.subscribe((available) => {
this.logger.info("[controlled-output] available devices:", available);
});
this.availableRaw$.subscribe((availableRaw) => {
this.logger.info(
"[controlled-output] available raw devices:",
availableRaw,
);
});
}
}
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
private logger = rootLogger.getChild("[MediaDevices VideoInput]");
public readonly available$ = this.scope.behavior(
availableRawDevices$(
"videoinput",
this.usingNames$,
this.scope,
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 {
videoInputSetting.setValue(id);
}
public constructor(
private readonly usingNames$: Behavior<boolean>,
private readonly scope: ObservableScope,
) {
// This also has the purpose of subscribing to the available devices
this.available$.subscribe((available) => {
this.logger.info("[video-input] available devices:", available);
});
}
}
export class MediaDevices {
private readonly deviceNamesRequest$ = new Subject<void>();
/**
* Requests that the media devices be populated with the names of each
* available device, rather than numbered identifiers. This may invoke a
* permissions pop-up, so it should only be called when there is a clear user
* intent to view the device list.
*/
public requestDeviceNames(): void {
this.deviceNamesRequest$.next();
}
// Start using device names as soon as requested. This will cause LiveKit to
// briefly request device permissions and acquire media streams for each
// device type while calling `enumerateDevices`, which is what browsers want
// 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
// the first time, the user won't be prompted again until reload of the page.
private readonly usingNames$ = this.scope.behavior(
this.deviceNamesRequest$.pipe(map(() => true)),
false,
);
public readonly audioInput: MediaDevice<
DeviceLabel,
SelectedAudioInputDevice
> = new AudioInput(this.usingNames$, this.scope);
public readonly audioOutput: MediaDevice<
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> = getUrlParams().controlledAudioDevices
? new ControlledAudioOutput(this.usingNames$, this.scope)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
new VideoInput(this.usingNames$, this.scope);
public constructor(private readonly scope: ObservableScope) {}
}

View File

@@ -51,26 +51,19 @@ import { accumulate } from "../utils/observable";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement"; import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { type ReactionOption } from "../reactions"; import { type ReactionOption } from "../reactions";
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);
}
}),
); );
} }
@@ -82,7 +75,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]) => {
@@ -223,19 +216,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.
@@ -257,84 +262,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),
);
}
}),
),
); );
} }
} }
@@ -354,31 +360,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,
@@ -386,9 +394,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,
@@ -398,18 +406,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)),
); );
} }
@@ -430,26 +439,25 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
} }
/** /**
},
},
* The local participant's user media. * The local participant's user media.
*/ */
export class LocalUserMediaViewModel extends BaseUserMediaViewModel { export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/** /**
* Whether the video should be mirrored. * Whether the video should be mirrored.
*/ */
public readonly mirror$ = this.video$.pipe( public readonly mirror$ = this.scope.behavior(
switchMap((v) => { this.video$.pipe(
const track = v?.publication?.track; switchMap((v) => {
if (!(track instanceof LocalTrack)) return of(false); const track = v?.publication?.track;
// Watch for track restarts, because they indicate a camera switch if (!(track instanceof LocalTrack)) return of(false);
return fromEvent(track, TrackEvent.Restarted).pipe( // Watch for track restarts, because they indicate a camera switch
startWith(null), return fromEvent(track, TrackEvent.Restarted).pipe(
// Mirror only front-facing cameras (those that face the user) startWith(null),
map(() => facingModeFromLocalTrack(track).facingMode === "user"), // Mirror only front-facing cameras (those that face the user)
); map(() => facingModeFromLocalTrack(track).facingMode === "user"),
}), );
this.scope.state(), }),
),
); );
/** /**
@@ -462,12 +470,12 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
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,
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,
@@ -475,7 +483,7 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
participant$, participant$,
encryptionSystem, encryptionSystem,
livekitRoom, livekitRoom,
displayname$, displayName$,
handRaised$, handRaised$,
reaction$, reaction$,
); );
@@ -514,42 +522,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(
@@ -558,9 +566,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,
@@ -623,7 +631,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

@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
.tile { .tile {
--media-view-border-radius: var(--cpd-space-4x); --media-view-border-radius: var(--cpd-space-4x);
--hover-space-margin: var(--cpd-space-1x);
transition: outline-color ease 0.15s; transition: outline-color ease 0.15s;
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0); outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
} }

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

@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
import { import {
type ComponentProps, type ComponentProps,
type FC,
type ReactNode, type ReactNode,
forwardRef, type Ref,
useCallback, useCallback,
useRef, useRef,
useState, useState,
@@ -34,7 +35,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 {
@@ -48,8 +49,10 @@ 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>;
className?: string; className?: string;
style?: ComponentProps<typeof animated.div>["style"]; style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number; targetWidth: number;
@@ -66,132 +69,128 @@ interface UserMediaTileProps extends TileProps {
menuEnd?: ReactNode; menuEnd?: ReactNode;
} }
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>( const UserMediaTile: FC<UserMediaTileProps> = ({
( ref,
{ vm,
vm, showSpeakingIndicators,
showSpeakingIndicators, locallyMuted,
locallyMuted, menuStart,
menuStart, menuEnd,
menuEnd, className,
className, displayName,
displayName, ...props
...props }) => {
const { toggleRaisedHand } = useReactionsSender();
const { t } = useTranslation();
const video = useBehavior(vm.video$);
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const encryptionStatus = useBehavior(vm.encryptionStatus$);
const audioStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.audioStreamStats$);
const videoStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.videoStreamStats$);
const audioEnabled = useBehavior(vm.audioEnabled$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const speaking = useBehavior(vm.speaking$);
const cropVideo = useBehavior(vm.cropVideo$);
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleFitContain();
}, },
ref, [vm],
) => { );
const { toggleRaisedHand } = useReactionsSender(); const handRaised = useBehavior(vm.handRaised$);
const { t } = useTranslation(); const reaction = useBehavior(vm.reaction$);
const video = useObservableEagerState(vm.video$);
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
const audioStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.audioStreamStats$);
const videoStreamStats = useObservableEagerState<
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>(vm.videoStreamStats$);
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
const speaking = useObservableEagerState(vm.speaking$);
const cropVideo = useObservableEagerState(vm.cropVideo$);
const onSelectFitContain = useCallback(
(e: Event) => {
e.preventDefault();
vm.toggleFitContain();
},
[vm],
);
const handRaised = useObservableState(vm.handRaised$);
const reaction = useObservableState(vm.reaction$);
const AudioIcon = locallyMuted const AudioIcon = locallyMuted
? VolumeOffSolidIcon ? VolumeOffSolidIcon
: audioEnabled : audioEnabled
? MicOnSolidIcon ? MicOnSolidIcon
: MicOffSolidIcon; : MicOffSolidIcon;
const audioIconLabel = locallyMuted const audioIconLabel = locallyMuted
? t("video_tile.muted_for_me") ? t("video_tile.muted_for_me")
: audioEnabled : audioEnabled
? t("microphone_on") ? t("microphone_on")
: t("microphone_off"); : t("microphone_off");
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
const menu = ( const menu = (
<> <>
{menuStart} {menuStart}
<ToggleMenuItem <ToggleMenuItem
Icon={ExpandIcon} Icon={ExpandIcon}
label={t("video_tile.change_fit_contain")} label={t("video_tile.change_fit_contain")}
checked={cropVideo} checked={cropVideo}
onSelect={onSelectFitContain} onSelect={onSelectFitContain}
/>
{menuEnd}
</>
);
const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking;
const tile = (
<MediaView
ref={ref}
video={video}
member={vm.member}
unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus}
videoEnabled={videoEnabled}
videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeaking,
[styles.handRaised]: !showSpeaking && handRaised,
})}
nameTagLeadingIcon={
<AudioIcon
width={20}
height={20}
aria-label={audioIconLabel}
data-muted={locallyMuted || !audioEnabled}
className={styles.muteIcon}
/>
}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local}
audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats}
{...props}
/> />
); {menuEnd}
</>
);
return ( const raisedHandOnClick = vm.local
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative> ? (): void => void toggleRaisedHand()
{menu} : undefined;
</ContextMenu>
); const showSpeaking = showSpeakingIndicators && speaking;
},
); const tile = (
<MediaView
ref={ref}
video={video}
member={vm.member}
unencryptedWarning={unencryptedWarning}
encryptionStatus={encryptionStatus}
videoEnabled={videoEnabled}
videoFit={cropVideo ? "cover" : "contain"}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeaking,
[styles.handRaised]: !showSpeaking && handRaised,
})}
nameTagLeadingIcon={
<AudioIcon
width={20}
height={20}
aria-label={audioIconLabel}
data-muted={locallyMuted || !audioEnabled}
className={styles.muteIcon}
/>
}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined}
raisedHandOnClick={raisedHandOnClick}
localParticipant={vm.local}
audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats}
{...props}
/>
);
return (
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
{menu}
</ContextMenu>
);
};
UserMediaTile.displayName = "UserMediaTile"; UserMediaTile.displayName = "UserMediaTile";
@@ -200,48 +199,51 @@ interface LocalUserMediaTileProps extends TileProps {
onOpenProfile: (() => void) | null; onOpenProfile: (() => void) | null;
} }
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>( const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
({ vm, onOpenProfile, ...props }, ref) => { ref,
const { t } = useTranslation(); vm,
const mirror = useObservableEagerState(vm.mirror$); onOpenProfile,
const alwaysShow = useObservableEagerState(vm.alwaysShow$); ...props
const latestAlwaysShow = useLatest(alwaysShow); }) => {
const onSelectAlwaysShow = useCallback( const { t } = useTranslation();
(e: Event) => { const mirror = useBehavior(vm.mirror$);
e.preventDefault(); const alwaysShow = useBehavior(vm.alwaysShow$);
vm.setAlwaysShow(!latestAlwaysShow.current); const latestAlwaysShow = useLatest(alwaysShow);
}, const onSelectAlwaysShow = useCallback(
[vm, latestAlwaysShow], (e: Event) => {
); e.preventDefault();
vm.setAlwaysShow(!latestAlwaysShow.current);
},
[vm, latestAlwaysShow],
);
return ( return (
<UserMediaTile <UserMediaTile
ref={ref} ref={ref}
vm={vm} vm={vm}
locallyMuted={false} locallyMuted={false}
mirror={mirror} mirror={mirror}
menuStart={ menuStart={
<ToggleMenuItem <ToggleMenuItem
Icon={VisibilityOnIcon} Icon={VisibilityOnIcon}
label={t("video_tile.always_show")} label={t("video_tile.always_show")}
checked={alwaysShow} checked={alwaysShow}
onSelect={onSelectAlwaysShow} onSelect={onSelectAlwaysShow}
/>
}
menuEnd={
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/> />
} )
menuEnd={ }
onOpenProfile && ( {...props}
<MenuItem />
Icon={UserProfileIcon} );
label={t("common.profile")} };
onSelect={onOpenProfile}
/>
)
}
{...props}
/>
);
},
);
LocalUserMediaTile.displayName = "LocalUserMediaTile"; LocalUserMediaTile.displayName = "LocalUserMediaTile";
@@ -249,13 +251,14 @@ interface RemoteUserMediaTileProps extends TileProps {
vm: RemoteUserMediaViewModel; vm: RemoteUserMediaViewModel;
} }
const RemoteUserMediaTile = forwardRef< const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
HTMLDivElement, ref,
RemoteUserMediaTileProps vm,
>(({ vm, ...props }, ref) => { ...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();
@@ -303,11 +306,12 @@ const RemoteUserMediaTile = forwardRef<
{...props} {...props}
/> />
); );
}); };
RemoteUserMediaTile.displayName = "RemoteUserMediaTile"; RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
interface GridTileProps { interface GridTileProps {
ref?: Ref<HTMLDivElement>;
vm: GridTileViewModel; vm: GridTileViewModel;
onOpenProfile: (() => void) | null; onOpenProfile: (() => void) | null;
targetWidth: number; targetWidth: number;
@@ -317,34 +321,37 @@ interface GridTileProps {
showSpeakingIndicators: boolean; showSpeakingIndicators: boolean;
} }
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>( export const GridTile: FC<GridTileProps> = ({
({ vm, onOpenProfile, ...props }, theirRef) => { ref: theirRef,
const ourRef = useRef<HTMLDivElement | null>(null); vm,
const ref = useMergedRefs(ourRef, theirRef); onOpenProfile,
const media = useObservableEagerState(vm.media$); ...props
const displayName = useObservableEagerState(media.displayname$); }) => {
const ourRef = useRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const media = useBehavior(vm.media$);
const displayName = useBehavior(media.displayName$);
if (media instanceof LocalUserMediaViewModel) { if (media instanceof LocalUserMediaViewModel) {
return ( return (
<LocalUserMediaTile <LocalUserMediaTile
ref={ref} ref={ref}
vm={media} vm={media}
onOpenProfile={onOpenProfile} onOpenProfile={onOpenProfile}
displayName={displayName} displayName={displayName}
{...props} {...props}
/> />
); );
} else { } else {
return ( return (
<RemoteUserMediaTile <RemoteUserMediaTile
ref={ref} ref={ref}
vm={media} vm={media}
displayName={displayName} displayName={displayName}
{...props} {...props}
/> />
); );
} }
}, };
);
GridTile.displayName = "GridTile"; GridTile.displayName = "GridTile";

View File

@@ -9,6 +9,8 @@ Please see LICENSE in the repository root for full details.
container-name: mediaView; container-name: mediaView;
container-type: size; container-type: size;
border-radius: var(--media-view-border-radius); border-radius: var(--media-view-border-radius);
/*Add spacing for the hover boarder. Otherwise it might get clipped.*/
margin: var(--hover-space-margin);
} }
.media video { .media video {

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