Compare commits

..

10 Commits

Author SHA1 Message Date
Timo
64aec47b50 format 2025-07-18 15:29:39 +02:00
Timo
f39dbf5e87 Merge branch 'livekit' into robin/notify 2025-07-18 15:06:06 +02:00
Timo
55923a8d5e back to head=develop and using js-sdk with send notification feature 2025-07-18 15:02:56 +02:00
Timo
a127f73155 fix url .md docs 2025-07-18 13:33:51 +02:00
Timo
5af3537454 use notification type in url params 2025-07-18 13:29:50 +02:00
Timo
46698055e6 remove everything decline related 2025-07-18 13:15:06 +02:00
Timo
7ffa18f9df bump to latest js-sdk 2025-07-18 13:14:43 +02:00
Timo
0ea737a34d add url param 2025-07-18 12:15:41 +02:00
Timo
d19d221fd0 use RTCNotification event 2025-07-18 12:00:09 +02:00
Robin
f1c2945eff Send notification events when starting a call
Previously this has been the responsibility of the hosting application (Element Web / Element X), but I would like to move this responsibility to Element Call itself to make it even more lightweight to integrate Element Call into a widget-capable client.
2025-07-18 11:50:46 +02:00
88 changed files with 1499 additions and 3262 deletions

View File

@@ -28,6 +28,8 @@ module.exports = {
rules: {
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
"jsx-a11y/media-has-caption": "off",
// We should use the js-sdk logger, never console directly.
"no-console": ["error"],
"react/display-name": "error",
// Encourage proper usage of Promises:
"@typescript-eslint/no-floating-promises": "error",
@@ -44,15 +46,6 @@ module.exports = {
"rxjs/no-exposed-subjects": "error",
"rxjs/finnish": ["error", { names: { "^this$": false } }],
},
overrides: [
{
files: ["src/*/**"],
rules: {
// In application code we should use the js-sdk logger, never console directly.
"no-console": ["error"],
},
},
],
settings: {
react: {
version: "detect",

View File

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

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ services:
# If the configured homeserver runs on localhost, it'll probably be using
# a self-signed certificate
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
deploy:
restart_policy:
condition: on-failure
@@ -26,7 +25,6 @@ services:
livekit:
image: livekit/livekit-server:latest
pull_policy: always
hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml
restart: unless-stopped
@@ -46,7 +44,6 @@ services:
redis:
image: redis:6-alpine
pull_policy: always
command: redis-server /etc/redis.conf
ports:
# HOST_PORT:CONTAINER_PORT
@@ -59,7 +56,6 @@ services:
synapse:
hostname: homeserver
image: docker.io/matrixdotorg/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
# Needed for rootless podman-compose such that the uid/gid mapping does
@@ -90,7 +86,6 @@ services:
# see backend/dev_tls_setup for how to generate the tls certs
hostname: synapse.m.localhost
image: nginx:latest
pull_policy: always
volumes:
- ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z
- ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z
@@ -106,6 +101,4 @@ services:
depends_on:
- synapse
networks:
ecbackend:
aliases:
- matrix-rtc.m.localhost
- ecbackend

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 941 KiB

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 886 KiB

View File

@@ -64,7 +64,7 @@ rc_delayed_event_mgmt:
```
As a prerequisite for the
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
[Matrix LiveKit JWT auth service](https://github.com/element-hq/lk-jwt-service)
make sure that your Synapse server has either a `federation` or `openid`
[listener configured](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#listeners).
@@ -77,7 +77,7 @@ required for each site deployment.
As depicted above in the `example.com` site deployment, Element Call requires a
[Livekit SFU](https://github.com/livekit/livekit) alongside a
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
[Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service)
to implement
[MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
@@ -89,7 +89,7 @@ the example above, this results in:
| Service | Endpoint | Example |
| -------- | ------- | ------- |
| [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` |
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
| [Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
Using Nginx, you can achieve this by:
@@ -102,7 +102,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# MatrixRTC Authorization Service running at port 8080
# JWT Service running at port 8080
proxy_pass http://localhost:8080/;
}
@@ -126,32 +126,6 @@ server {
}
```
Or Using Caddy, you can achieve this by:
```caddy configuration file
# Route for lk-jwt-service with livekit/jwt prefix
@jwt_service path /livekit/jwt/sfu/get /livekit/jwt/healthz
handle @jwt_service {
uri strip_prefix /livekit/jwt
reverse_proxy http://[::1]:8080 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
# Default route for livekit
handle {
reverse_proxy http://localhost:7880 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
```
#### MatrixRTC backend announcement
> [!IMPORTANT]
@@ -171,6 +145,10 @@ handle {
{
"type": "livekit",
"livekit_service_url": "https://matrix-rtc-2.example.com/livekit/jwt"
},
{
"type": "nextgen_new_foci_type",
"props_for_nextgen_foci": "val"
}
]
```
@@ -240,7 +218,7 @@ server {
There are currently two different config files. `.env` holds variables that are
used at build time, while `public/config.json` holds variables that are used at
runtime. Documentation and default values for `public/config.json` can be found
in [ConfigOptions.ts](../src/config/ConfigOptions.ts).
in [ConfigOptions.ts](src/config/ConfigOptions.ts).
> [!CAUTION]
> Please note configuring MatrixRTC backend via `config.json` of

View File

@@ -48,7 +48,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| Name | Values | Required for widget | Required for SPA | Description |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesnt provide any. |
| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
@@ -60,6 +59,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
| `intent` | `start_call` or `join_existing` | No, defaults to `start_call` | No, defaults to `start_call` | The intent of the user with respect to the call. e.g. if they clicked a Start Call button, this would be `start_call`. If it was a Join Call button, it would be `join_existing`. |
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
@@ -70,8 +70,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
### Widget-only parameters

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
import type { UserConfig } from "i18next-parser";
const config: UserConfig = {
export default {
keySeparator: ".",
namespaceSeparator: false,
contextSeparator: "|",
@@ -28,5 +26,3 @@ const config: UserConfig = {
input: ["src/**/*.{ts,tsx}"],
sort: true,
};
export default config;

11
knip.ts
View File

@@ -1,15 +1,8 @@
/*
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 KnipConfig } from "knip";
import { KnipConfig } from "knip";
export default {
vite: {
config: ["vite.config.ts", "vite-embedded.config.ts"],
config: ["vite.config.js", "vite-embedded.config.js"],
},
entry: ["src/main.tsx", "i18next-parser.config.ts"],
ignoreBinaries: [

View File

@@ -61,7 +61,6 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Zobrazit možnost sluchátek pro iPhone na všech platformách",
"crypto_version": "Kryptografická verze: {{version}}",
"debug_tile_layout_label": "Ladění rozložení dlaždic",
"device_id": "ID zařízení: {{id}}",
@@ -82,7 +81,7 @@
"error": {
"call_is_not_supported": "Volání není podporováno",
"call_not_found": "Volání nebylo nalezeno",
"call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu hovoru. Zkontrolujte, zda máte správný odkaz, nebo<2> vytvořte nový</2>.</0>",
"call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu volání. Zkontrolujte, zda máte správný odkaz, nebo <1>vytvořte nový</1>.</0>",
"connection_lost": "Spojení ztraceno",
"connection_lost_description": "Hovor byl přerušen.",
"e2ee_unsupported": "Nekompatibilní prohlížeč",
@@ -105,11 +104,6 @@
"knock_reject_heading": "Přístup odepřen",
"reason": "Důvod"
},
"handset": {
"overlay_back_button": "Zpět do režimu reproduktoru",
"overlay_description": "Funguje pouze při používání aplikace",
"overlay_title": "Režim sluchátka"
},
"hangup_button_label": "Ukončit hovor",
"header_label": "Domov Element Call",
"header_participants_label": "Účastníci",
@@ -179,11 +173,8 @@
"devices": {
"camera": "Fotoaparát",
"camera_numbered": "Fotoaparát {{n}}",
"change_device_button": "Změnit zvukové zařízení",
"default": "Výchozí",
"default_named": "Výchozí <2> ({{name}}) </2>",
"handset": "Sluchátko",
"loudspeaker": "Reproduktor",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Reproduktor",

View File

@@ -61,7 +61,6 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Vis mulighed for iPhone-høretelefon på alle platforme",
"crypto_version": "Krypto-version: {{version}}",
"debug_tile_layout_label": "Fejlfinding af fliselayout",
"device_id": "Enheds-id: {{id}}",
@@ -71,7 +70,6 @@
"livekit_server_info": "LiveKit Serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix ID: {{id}}",
"mute_all_audio": "Slå al lyd fra (deltagere, reaktioner, deltagelseslyde)",
"show_connection_stats": "Vis forbindelsesstatistik",
"show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer",
"url_params": "URL-parametre",
@@ -82,7 +80,7 @@
"error": {
"call_is_not_supported": "Opkald er ikke understøttet",
"call_not_found": "Opkald ikke fundet",
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller <2> opret et nyt</2>.</0>",
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller<1> opret et nyt</1>.</0>",
"connection_lost": "Forbindelsen gik tabt",
"connection_lost_description": "Du blev afbrudt fra opkaldet.",
"e2ee_unsupported": "Inkompatibel browser",
@@ -94,8 +92,6 @@
"matrix_rtc_focus_missing": "Serveren er ikke konfigureret til at arbejde med {{brand}}{{domain}}. Kontakt venligst din serveradministrator (domæne:{{domain}}, fejlkode: {{ errorCode }}).",
"open_elsewhere": "Åbnet i en anden fane",
"open_elsewhere_description": "{{brand}} er blevet åbnet i en anden fane. Hvis det ikke lyder rigtigt, kan du prøve at genindlæse siden.",
"room_creation_restricted": "Kunne ikke oprette opkald",
"room_creation_restricted_description": "Oprettelse af opkald er muligvis begrænset til autoriserede brugere. Prøv igen senere, eller kontakt din serveradministrator, hvis problemet fortsætter.",
"unexpected_ec_error": "Der opstod en uventet fejl (<0>Fejlkode:</0> <1> {{ errorCode }}</1>). Kontakt venligst din serveradministrator."
},
"group_call_loader": {
@@ -107,11 +103,6 @@
"knock_reject_heading": "Adgang nægtet",
"reason": "Årsag: {{reason}}"
},
"handset": {
"overlay_back_button": "Tilbage til højttalertilstand",
"overlay_description": "Virker kun, når du bruger appen",
"overlay_title": "Telefon-højtaler"
},
"hangup_button_label": "Afslut opkald",
"header_label": "Element Ring hjem",
"header_participants_label": "Deltagere",
@@ -173,18 +164,12 @@
"effect_volume_description": "Juster den lydstyrke som reaktioner og håndsoprækninger afspilles med.",
"effect_volume_label": "Lydstyrke for lydeffekter"
},
"background_blur_header": "Baggrund",
"background_blur_label": "Gør videoens baggrund sløret",
"blur_not_supported_by_browser": "(Baggrundssløring understøttes ikke af denne enhed.)",
"developer_tab_title": "Udvikler",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Skift lydenhed",
"default": "Standard",
"default_named": "Standard <2>({{name}})</2>",
"handset": "Telefon",
"loudspeaker": "Højttaler",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Højttaler",

View File

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

View File

@@ -55,7 +55,6 @@
"profile": "Profile",
"reaction": "Reaction",
"reactions": "Reactions",
"reconnecting": "Reconnecting…",
"settings": "Settings",
"unencrypted": "Not encrypted",
"username": "Username",
@@ -80,6 +79,11 @@
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
},
"disconnected_banner": "Connectivity to the server has been lost.",
"earpiece": {
"overlay_back_button": "Back to Speaker Mode",
"overlay_description": "Only works while using app",
"overlay_title": "Earpiece Mode"
},
"error": {
"call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found",
@@ -95,8 +99,6 @@
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"room_creation_restricted": "Failed to create call",
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
},
"group_call_loader": {
@@ -108,11 +110,6 @@
"knock_reject_heading": "Access denied",
"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",
"header_label": "Element Call Home",
"header_participants_label": "Participants",
@@ -184,7 +181,7 @@
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset",
"earpiece": "Earpiece",
"loudspeaker": "Loudspeaker",
"microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}",

View File

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

View File

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

View File

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

View File

@@ -61,7 +61,6 @@
"video": "Video"
},
"developer_mode": {
"always_show_iphone_earpiece": "Visa iPhone-hörsnäckealternativ på alla plattformar",
"crypto_version": "Kryptoversion: {{version}}",
"debug_tile_layout_label": "Felsök panelarrangemang",
"device_id": "Enhets-ID: {{id}}",
@@ -71,7 +70,6 @@
"livekit_server_info": "LiveKit-serverinfo",
"livekit_sfu": "LiveKit SFU: {{url}}",
"matrix_id": "Matrix-ID: {{id}}",
"mute_all_audio": "Tysta allt ljud (deltagare, reaktioner, anslutningsljud)",
"show_connection_stats": "Visa anslutningsstatistik",
"show_non_member_tiles": "Visa paneler för media som inte är medlemmar",
"url_params": "URL-parametrar",
@@ -82,7 +80,7 @@
"error": {
"call_is_not_supported": "Call stöds inte",
"call_not_found": "Samtal hittades inte",
"call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <2>skapa en ny</2>.</0>",
"call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <1>skapa en ny</1>.</0>",
"connection_lost": "Anslutning förlorad",
"connection_lost_description": "Du kopplades bort från samtalet.",
"e2ee_unsupported": "Inkompatibel webbläsare",
@@ -94,8 +92,6 @@
"matrix_rtc_focus_missing": "Servern är inte konfigurerad för att fungera med {{brand}}. Vänligen kontakta serveradministratören (Domän: {{domain}}, Felkod: {{ errorCode }}).",
"open_elsewhere": "Öppnades i en annan flik",
"open_elsewhere_description": "{{brand}} har öppnats i en annan flik. Om det inte låter rätt, pröva att ladda om sidan.",
"room_creation_restricted": "Misslyckades att skapa samtal",
"room_creation_restricted_description": "Samtalsskapande kan vara begränsat till endast behöriga användare. Försök igen senare eller kontakta serveradministratören om problemet kvarstår.",
"unexpected_ec_error": "Ett oväntat fel inträffade (<0>Felkod:</0> <1>{{ errorCode }}</1>). Kontakta din serveradministratör."
},
"group_call_loader": {
@@ -107,11 +103,6 @@
"knock_reject_heading": "Inte tillåten att gå med",
"reason": "Anledning"
},
"handset": {
"overlay_back_button": "Tillbaka till högtalarläge",
"overlay_description": "Fungerar bara när appen används",
"overlay_title": "Telefonläge"
},
"hangup_button_label": "Avsluta samtal",
"header_label": "Element Call Hem",
"header_participants_label": "Deltagare",
@@ -173,18 +164,12 @@
"effect_volume_description": "Justera volymen vid vilken reaktioner och handuppräckningseffekter spelas",
"effect_volume_label": "Ljudeffektsvolym"
},
"background_blur_header": "Bakgrund",
"background_blur_label": "Gör bakgrunden i videon suddig",
"blur_not_supported_by_browser": "(Bakgrundssuddighet stöds inte av den här enheten.)",
"developer_tab_title": "Utvecklare",
"devices": {
"camera": "Kamera",
"camera_numbered": "Kamera {{n}}",
"change_device_button": "Byt ljudenhet",
"default": "Förval",
"default_named": "Förval <2>({{name}})</2>",
"handset": "Telefonlur",
"loudspeaker": "Högtalare",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Högtalare",

View File

@@ -49,7 +49,7 @@
"@mediapipe/tasks-vision": "^0.10.18",
"@opentelemetry/api": "^1.4.0",
"@opentelemetry/core": "^2.0.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
"@opentelemetry/resources": "^2.0.0",
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
@@ -81,7 +81,7 @@
"@typescript-eslint/eslint-plugin": "^8.31.0",
"@typescript-eslint/parser": "^8.31.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^8.0.0",
"@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^3.0.0",
@@ -129,7 +129,7 @@
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"vaul": "^1.0.0",
"vite": "^7.0.0",
"vite": "^6.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-svgr": "^4.0.0",

View File

@@ -40,11 +40,6 @@ test("Start a new call then leave and show the feedback screen", async ({
// The tooltip with the name should be visible
await expect(page.getByTestId("name_tag")).toContainText("John Doe");
// Resize the window to resemble a small mobile phone
await page.setViewportSize({ width: 350, height: 660 });
// We should still be able to send reactions at this screen size
await expect(page.getByRole("button", { name: "Reactions" })).toBeVisible();
// leave the call
await page.getByTestId("incall_leave").click();
await expect(page.getByRole("heading")).toContainText(

View File

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

View File

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

View File

@@ -100,5 +100,5 @@ test("When creator left, avoid reconnect to the same SFU", async ({
// https://github.com/element-hq/element-call/issues/3344
// The app used to request a new jwt token then to reconnect to the SFU
expect(wsConnectionCount).toBe(1);
expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
expect(sfuGetCallCount).toBe(1);
});

View File

@@ -24,6 +24,7 @@ import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { DisconnectedBanner } from "./DisconnectedBanner";
import { Initializer } from "./initializer";
import { widget } from "./widget";
import { useTheme } from "./useTheme";
@@ -85,6 +86,7 @@ export const App: FC<Props> = ({ vm }) => {
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} widget={widget} />}
>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />

View File

@@ -17,7 +17,10 @@ import {
useState,
} from "react";
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ArrowLeftIcon,
CollapseIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import { Header, LeftNav, RightNav } from "./Header";
@@ -42,6 +45,7 @@ interface Props {
*/
export const AppBar: FC<Props> = ({ children }) => {
const { t } = useTranslation();
const BackIcon = platform === "ios" ? CollapseIcon : ArrowLeftIcon;
const onBackClick = useCallback((e: MouseEvent) => {
e.preventDefault();
window.controls.onBackButtonPressed?.();
@@ -61,15 +65,11 @@ export const AppBar: FC<Props> = ({ children }) => {
style={{ display: hidden ? "none" : "block" }}
className={styles.bar}
>
<Header
// App bar is mainly seen in the call view, which has its own
// 'reconnecting' toast
disconnectedBanner={false}
>
<Header>
<LeftNav>
<Tooltip label={t("common.back")}>
<IconButton onClick={onBackClick}>
<CollapseIcon />
<BackIcon />
</IconButton>
</Tooltip>
</LeftNav>

View File

@@ -17,38 +17,27 @@ import Logo from "./icons/Logo.svg?react";
import { Avatar, Size } from "./Avatar";
import { EncryptionLock } from "./room/EncryptionLock";
import { useMediaQuery } from "./useMediaQuery";
import { DisconnectedBanner } from "./DisconnectedBanner";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
ref?: Ref<HTMLElement>;
children: ReactNode;
className?: string;
/**
* Whether the header should display an informational banner whenever the
* client is disconnected from the homeserver.
* @default true
*/
disconnectedBanner?: boolean;
}
export const Header: FC<HeaderProps> = ({
ref,
children,
className,
disconnectedBanner = true,
...rest
}) => {
return (
<>
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
{disconnectedBanner && <DisconnectedBanner />}
</>
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
);
};

View File

@@ -45,12 +45,6 @@ interface Props {
* A supporting icon to display within the toast.
*/
Icon?: ComponentType<SVGAttributes<SVGElement>>;
/**
* Whether the toast should be portaled into the root of the document (rather
* than rendered in-place within the component tree).
* @default true
*/
portal?: boolean;
}
/**
@@ -62,7 +56,6 @@ export const Toast: FC<Props> = ({
autoDismiss,
children,
Icon,
portal = true,
}) => {
const onOpenChange = useCallback(
(open: boolean) => {
@@ -78,33 +71,29 @@ export const Toast: FC<Props> = ({
}
}, [open, autoDismiss, onDismiss]);
const content = (
<>
<DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)}
/>
<DialogContent aria-describedby={undefined} asChild>
<DialogClose
className={classNames(
overlayStyles.overlay,
overlayStyles.animate,
styles.toast,
)}
>
<DialogTitle asChild>
<Text as="h3" size="sm" weight="semibold">
{children}
</Text>
</DialogTitle>
{Icon && <Icon width={20} height={20} aria-hidden />}
</DialogClose>
</DialogContent>
</>
);
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
{portal ? <DialogPortal>{content}</DialogPortal> : content}
<DialogPortal>
<DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)}
/>
<DialogContent aria-describedby={undefined} asChild>
<DialogClose
className={classNames(
overlayStyles.overlay,
overlayStyles.animate,
styles.toast,
)}
>
<DialogTitle asChild>
<Text as="h3" size="sm" weight="semibold">
{children}
</Text>
</DialogTitle>
{Icon && <Icon width={20} height={20} aria-hidden />}
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
);
};

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ exports[`AppBar > renders 1`] = `
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"
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
/>
</svg>
</div>

View File

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

View File

@@ -114,29 +114,24 @@ export interface ConfigOptions {
* when someone leaves a call.
*/
wait_for_key_rotation_ms?: number;
/** @deprecated use wait_for_key_rotation_ms instead */
key_rotation_on_leave_delay?: number;
/**
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
* that the server waits before sending the leave MatrixRTC membership event.
*/
delayed_leave_event_delay_ms?: number;
/**
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
*
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
* helps by keeping more delayed event reset candidates in flight,
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
*/
delayed_leave_event_restart_local_timeout_ms?: number;
/** @deprecated use delayed_leave_event_delay_ms instead */
membership_server_side_expiry_timeout?: number;
/**
* The time interval (in milliseconds) at which the client sends membership keep-alive
* messages to the server by restarting the timer for the delayed leave event.
*/
delayed_leave_event_restart_ms?: number;
/** @deprecated use delayed_leave_event_restart_ms instead */
membership_keep_alive_period?: number;
/**
* How long we wait before retrying after a network error on any of the requests.

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 658 B

View File

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

Before

Width:  |  Height:  |  Size: 656 B

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/

View File

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

View File

@@ -1,7 +1,7 @@
/*
Copyright 2024-2025 New Vector Ltd.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/

View File

@@ -15,7 +15,7 @@ import {
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { sleep } from "matrix-js-sdk/lib/utils";
import { defer, sleep } from "matrix-js-sdk/lib/utils";
import { useECConnectionState } from "./useECConnectionState";
import { type SFUConfig } from "./openIDSFU";
@@ -93,8 +93,8 @@ describe("Leaking connection prevention", () => {
test("Should cancel pending connections when the component is unmounted", async () => {
const connectCall = vi.fn();
const pendingConnection = Promise.withResolvers<void>();
// let pendingDisconnection = Promise.withResolvers<void>()
const pendingConnection = defer<void>();
// let pendingDisconnection = defer<void>()
const disconnectMock = vi.fn();
const mockRoom = {
@@ -141,8 +141,8 @@ describe("Leaking connection prevention", () => {
test("Should cancel about to open but not yet opened connection", async () => {
const createTracksCall = vi.fn();
const pendingCreateTrack = Promise.withResolvers<void>();
// let pendingDisconnection = Promise.withResolvers<void>()
const pendingCreateTrack = defer<void>();
// let pendingDisconnection = defer<void>()
const disconnectMock = vi.fn();
const connectMock = vi.fn();

View File

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

View File

@@ -19,7 +19,10 @@ import { act } from "react";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { mockRtcMembership } from "../utils/test";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
CallEventAudioRenderer,
MAX_PARTICIPANT_COUNT_FOR_SOUND,
} from "./CallEventAudioRenderer";
import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
@@ -28,9 +31,7 @@ import {
aliceRtcMember,
bobRtcMember,
local,
localRtcMember,
} from "../utils/test-fixtures";
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel";
vitest.mock("../useAudioContext");
vitest.mock("../soundUtils");
@@ -65,7 +66,7 @@ beforeEach(() => {
* a noise every time.
*/
test("plays one sound when entering a call", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
@@ -73,58 +74,56 @@ test("plays one sound when entering a call", () => {
// Joining a call usually means remote participants are added later.
act(() => {
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
});
expect(playSound).toHaveBeenCalledOnce();
});
test("plays a sound when a user joins", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
});
// Play a sound when joining a call.
expect(playSound).toBeCalledWith("join");
});
test("plays a sound when a user leaves", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
local,
alice,
]);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
rtcMemberships$.next([localRtcMember]);
remoteRtcMemberships$.next([]);
});
expect(playSound).toBeCalledWith("left");
});
test("plays no sound when the participant list is more than the maximum size", () => {
const mockRtcMemberships: CallMembership[] = [localRtcMember];
const mockRtcMemberships: CallMembership[] = [];
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
mockRtcMemberships.push(
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
);
}
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment(
[local, alice],
mockRtcMemberships,
);
render(<CallEventAudioRenderer vm={vm} />);
expect(playSound).not.toBeCalled();
// Remove the last membership in the array to test the leaving sound
// (The array has length MAX_PARTICIPANT_COUNT_FOR_SOUND + 1)
act(() => {
rtcMemberships$.next(
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND),
remoteRtcMemberships$.next(
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
);
});
expect(playSound).toBeCalledWith("left");
@@ -156,7 +155,6 @@ test("should not play a sound when a hand raise is retracted", () => {
]);
render(<CallEventAudioRenderer vm={vm} />);
playSound.mockClear();
act(() => {
handRaisedSubject$.next({
["foo"]: {
@@ -171,7 +169,7 @@ test("should not play a sound when a hand raise is retracted", () => {
},
});
});
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
expect(playSound).toHaveBeenCalledTimes(2);
act(() => {
handRaisedSubject$.next({
["foo"]: {
@@ -181,5 +179,5 @@ test("should not play a sound when a hand raise is retracted", () => {
},
});
});
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
expect(playSound).toHaveBeenCalledTimes(2);
});

View File

@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useEffect } from "react";
import { filter, interval, throttle } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel";
import joinCallSoundMp3 from "../sound/join_call.mp3";
@@ -20,6 +21,11 @@ import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest";
// Do not play any sounds if the participant count has exceeded this
// number.
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500;
export const callEventAudioSounds = prefetchSounds({
join: {
mp3: joinCallSoundMp3,
@@ -54,18 +60,37 @@ export function CallEventAudioRenderer({
const audioEngineRef = useLatest(audioEngineCtx);
useEffect(() => {
const joinSub = vm.joinSoundEffect$.subscribe(
() => void audioEngineRef.current?.playSound("join"),
);
const leftSub = vm.leaveSoundEffect$.subscribe(
() => void audioEngineRef.current?.playSound("left"),
);
const handRaisedSub = vm.newHandRaised$.subscribe(
() => void audioEngineRef.current?.playSound("raiseHand"),
);
const screenshareSub = vm.newScreenShare$.subscribe(
() => void audioEngineRef.current?.playSound("screenshareStarted"),
);
const joinSub = vm.memberChanges$
.pipe(
filter(
({ joined, ids }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
),
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
void audioEngineRef.current?.playSound("join");
});
const leftSub = vm.memberChanges$
.pipe(
filter(
({ ids, left }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
),
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
void audioEngineRef.current?.playSound("left");
});
const handRaisedSub = vm.newHandRaised$.subscribe(() => {
void audioEngineRef.current?.playSound("raiseHand");
});
const screenshareSub = vm.newScreenShare$.subscribe(() => {
void audioEngineRef.current?.playSound("screenshareStarted");
});
return (): void => {
joinSub.unsubscribe();

View File

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

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type FC } from "react";
import { BigIcon, Button, Heading, Text } from "@vector-im/compound-web";
import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
import styles from "./EarpieceOverlay.module.css";
@@ -22,12 +22,12 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
return (
<div className={styles.overlay} data-show={show}>
<BigIcon className={styles.icon}>
<VoiceCallIcon aria-hidden />
<EarpieceIcon aria-hidden />
</BigIcon>
<Heading as="h2" weight="semibold" size="md">
{t("handset.overlay_title")}
{t("earpiece.overlay_title")}
</Heading>
<Text>{t("handset.overlay_description")}</Text>
<Text>{t("earpiece.overlay_description")}</Text>
<Button
kind="primary"
size="sm"
@@ -35,10 +35,8 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
onBackToVideoPressed?.();
}}
>
{t("handset.overlay_back_button")}
{t("earpiece.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,7 +21,6 @@ import {
OfflineIcon,
WebBrowserIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { Button } from "@vector-im/compound-web";
import {
ConnectionLostError,
@@ -94,13 +93,9 @@ const ErrorPage: FC<ErrorPageProps> = ({
</p>
{actions &&
actions.map((action, index) => (
<Button
kind="secondary"
onClick={action.onClick}
key={`action${index}`}
>
<button onClick={action.onClick} key={`action${index}`}>
{action.label}
</Button>
</button>
))}
</ErrorView>
</FullScreenView>

View File

@@ -137,9 +137,11 @@ function createGroupCallView(
getJoinRule: () => JoinRule.Invite,
} as Partial<RoomState> as RoomState,
});
const rtcSession = new MockRTCSession(room, []).withMemberships(
constant([localRtcMember]),
);
const rtcSession = new MockRTCSession(
room,
localRtcMember,
[],
).withMemberships(constant([]));
rtcSession.joined = joined;
const muteState = {
audio: { enabled: false },

View File

@@ -50,7 +50,7 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
@@ -83,8 +83,8 @@ interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
confineToRoom: boolean;
preload: UrlParams["preload"];
skipLobby: UrlParams["skipLobby"];
preload: boolean;
skipLobby: boolean;
header: HeaderStyle;
rtcSession: MatrixRTCSession;
isJoined: boolean;
@@ -166,11 +166,7 @@ export const GroupCallView: FC<Props> = ({
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
} = useUrlParams();
const { perParticipantE2EE, returnToLobby } = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
const [useExperimentalToDeviceTransport] = useSetting(
@@ -178,6 +174,7 @@ export const GroupCallView: FC<Props> = ({
);
// Save the password once we start the groupCallView
const { password: passwordFromUrl } = useUrlParams();
useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]);
@@ -276,28 +273,34 @@ export const GroupCallView: FC<Props> = ({
};
if (skipLobby) {
if (widget && preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
if (widget) {
if (preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
(async (): Promise<void> => {
await defaultDeviceSetup(
ev.detail.data as unknown as JoinCallData,
);
await enterRTCSessionOrError(rtcSession);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
await enterRTCSessionOrError(rtcSession);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session on preload", e);
logger.error("Error joining RTC session", e);
});
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
}
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await enterRTCSessionOrError(rtcSession);
})().catch((e) => {
logger.error("Error joining RTC session immediately", e);
});
void enterRTCSessionOrError(rtcSession);
}
}
}, [
@@ -446,7 +449,7 @@ export const GroupCallView: FC<Props> = ({
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room}
participantCount={participantCount}
onLeave={onLeave}
header={header}
muteStates={muteStates}
@@ -488,7 +491,6 @@ export const GroupCallView: FC<Props> = ({
// Left in widget mode:
body = returnToLobby ? lobbyView : null;
} else if (preload || skipLobby) {
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
body = null;
} else {
body = lobbyView;

View File

@@ -108,6 +108,14 @@ Please see LICENSE in the repository root for full details.
}
@media (max-width: 370px) {
.raiseHand {
display: none;
}
}
@media (max-width: 340px) {
.invite,
.switchCamera,
.shareScreen {
display: none;
}
@@ -119,13 +127,6 @@ Please see LICENSE in the repository root for full details.
}
}
@media (max-width: 320px) {
.invite,
.raiseHand {
display: none;
}
}
@media (max-height: 400px) {
.footer {
padding-block: var(--cpd-space-4x);

View File

@@ -175,8 +175,8 @@ function createInCallView(): RenderResult & {
kind: E2eeType.NONE,
},
}}
matrixRoom={room}
livekitRoom={livekitRoom}
participantCount={0}
onLeave={function (): void {
throw new Error("Function not implemented.");
}}

View File

@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
import { ConnectionState, type Room } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk";
import {
type FC,
type PointerEvent,
@@ -25,11 +25,11 @@ import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { useObservable, useSubscription } from "observable-hooks";
import { useObservable } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import {
VoiceCallSolidIcon,
EarpieceIcon,
VolumeOnSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useTranslation } from "react-i18next";
@@ -44,6 +44,7 @@ import {
ShareScreenButton,
SettingsButton,
ReactionToggleButton,
SwitchCameraButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type HeaderStyle, useUrlParams } from "../UrlParams";
@@ -93,6 +94,7 @@ import {
useReactionsSender,
} from "../reactions/useReactionsSender";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { useSwitchCamera } from "./useSwitchCamera";
import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
@@ -111,7 +113,6 @@ import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { Toast } from "../Toast.tsx";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -139,17 +140,17 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useEffect(() => {
logger.info(
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
);
return (): void => {
logger.info(
`[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
);
livekitRoom
?.disconnect()
.then(() => {
logger.info(
`[Lifecycle] Disconnected from livekit room, state:${livekitRoom?.state}`,
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`,
);
})
.catch((e) => {
@@ -158,23 +159,14 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
}, [livekitRoom]);
const { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup } =
useUrlParams();
useEffect(() => {
if (livekitRoom !== undefined) {
const reactionsReader = new ReactionsReader(props.rtcSession);
const vm = new CallViewModel(
props.rtcSession,
props.matrixRoom,
livekitRoom,
mediaDevices,
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
waitForCallPickup:
waitForCallPickup && sendNotificationType === "ring",
},
props.e2eeSystem,
connStateObservable$,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
@@ -187,14 +179,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
}
}, [
props.rtcSession,
props.matrixRoom,
livekitRoom,
mediaDevices,
props.e2eeSystem,
connStateObservable$,
autoLeaveWhenOthersLeft,
sendNotificationType,
waitForCallPickup,
]);
if (livekitRoom === undefined || vm === null) return null;
@@ -218,9 +206,9 @@ export interface InCallViewProps {
vm: CallViewModel;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
livekitRoom: LivekitRoom;
livekitRoom: Room;
muteStates: MuteStates;
participantCount: number;
/** Function to call when the user explicitly ends the call */
onLeave: () => void;
header: HeaderStyle;
@@ -234,9 +222,9 @@ export const InCallView: FC<InCallViewProps> = ({
vm,
matrixInfo,
rtcSession,
matrixRoom,
livekitRoom,
muteStates,
participantCount,
onLeave,
header: headerStyle,
connState,
@@ -278,7 +266,7 @@ export const InCallView: FC<InCallViewProps> = ({
const [useExperimentalToDeviceTransport] = useSetting(
useExperimentalToDeviceTransportSetting,
);
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId);
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const showToDeviceEncryption = useMemo(
@@ -315,8 +303,6 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
@@ -326,7 +312,7 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
useSubscription(vm.autoLeave$, onLeave);
const switchCamera = useSwitchCamera(vm.localVideo$);
// Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported
@@ -472,9 +458,9 @@ export const InCallView: FC<InCallViewProps> = ({
useMemo(() => {
if (audioOutputSwitcher === null) return null;
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
const Icon = isEarpieceTarget ? EarpieceIcon : VolumeOnSolidIcon;
const label = isEarpieceTarget
? t("settings.devices.handset")
? t("settings.devices.earpiece")
: t("settings.devices.loudspeaker");
return (
@@ -509,11 +495,7 @@ export const InCallView: FC<InCallViewProps> = ({
break;
case "standard":
header = (
<Header
className={styles.header}
ref={headerRef}
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
>
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
@@ -653,7 +635,7 @@ export const InCallView: FC<InCallViewProps> = ({
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
matrixRoom.roomId,
rtcSession.room.roomId,
);
const toggleScreensharing = useCallback(() => {
@@ -687,6 +669,15 @@ export const InCallView: FC<InCallViewProps> = ({
data-testid="incall_videomute"
/>,
);
if (switchCamera !== null)
buttons.push(
<SwitchCameraButton
key="switch_camera"
className={styles.switchCamera}
onClick={switchCamera}
onTouchEnd={onControlsTouchEnd}
/>,
);
if (canScreenshare && !hideScreensharing) {
buttons.push(
<ShareScreenButton
@@ -764,9 +755,6 @@ export const InCallView: FC<InCallViewProps> = ({
</div>
);
// The reconnecting toast cannot be dismissed
const onDismissReconnectingToast = useCallback(() => {}, []);
return (
<div
className={styles.inRoom}
@@ -794,15 +782,8 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<Toast
onDismiss={onDismissReconnectingToast}
open={reconnecting}
portal={false}
>
{t("common.reconnecting")}
</Toast>
<EarpieceOverlay
show={earpieceMode && !reconnecting}
show={earpieceMode}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
<ReactionsOverlay vm={vm} />
@@ -812,7 +793,7 @@ export const InCallView: FC<InCallViewProps> = ({
<RageshakeRequestModal {...rageshakeRequestModalProps} />
<SettingsModal
client={client}
roomId={matrixRoom.roomId}
roomId={rtcSession.room.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}

View File

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

View File

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

View File

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

View File

@@ -49,34 +49,6 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</span>
</div>
<div
class="participantsLine"
>
<svg
aria-label="Participants"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.175 13.825Q10.35 15 12 15t2.825-1.175T16 11t-1.175-2.825T12 7 9.175 8.175 8 11t1.175 2.825m4.237-1.412A1.93 1.93 0 0 1 12 13q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 11q0-.825.588-1.412A1.93 1.93 0 0 1 12 9q.825 0 1.412.588Q14 10.175 14 11t-.588 1.412"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
/>
<path
d="M16.23 18.792a13 13 0 0 0-1.455-.455 11.6 11.6 0 0 0-5.55 0q-.73.18-1.455.455a8 8 0 0 1-1.729-1.454q1.336-.618 2.709-.95A13.8 13.8 0 0 1 12 16q1.65 0 3.25.387 1.373.333 2.709.95a8 8 0 0 1-1.73 1.455"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
data-testid="roomHeader_participants_count"
>
2
</span>
</div>
</div>
</div>
<div
@@ -126,30 +98,18 @@ exports[`InCallView > rendering > renders 1`] = `
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>
<path
d="M14 2c3.93 0 7 3.07 7 7a1 1 0 0 1-2 0c0-2.8-2.2-5-5-5S9 6.2 9 9c0 .93.29 1.98.82 2.94.71 1.29 1.53 1.92 2.32 2.53.92.71 1.88 1.44 2.39 3 .5 1.5 1 2.01 1.71 2.38.2.09.47.15.76.15 1.1 0 2-.9 2-2a1 1 0 1 1 2 0 4 4 0 0 1-5.64 3.65c-1.36-.71-2.13-1.73-2.73-3.55-.32-.98-.9-1.43-1.71-2.05-.87-.67-1.94-1.5-2.85-3.15C7.38 11.65 7 10.26 7 9c0-3.93 3.07-7 7-7"
/>
<path
d="M6.145 1.3a1 1 0 0 1 1.427 1.4A8.97 8.97 0 0 0 5 9c0 2.3.862 4.397 2.281 5.988l.291.312.069.077A1 1 0 0 1 6.22 16.77l-.075-.07-.356-.38A10.96 10.96 0 0 1 3 9c0-2.998 1.2-5.717 3.145-7.7M14 6.5a2.5 2.5 0 0 1 0 5 2.501 2.501 0 0 1 0-5"
/>
</svg>
</div>
<h2
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Handset Mode
Earpiece Mode
</h2>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
@@ -165,9 +125,6 @@ exports[`InCallView > rendering > renders 1`] = `
>
Back to Speaker Mode
</button>
<div
class="spacer"
/>
</div>
<div
class="container"
@@ -284,7 +241,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
aria-disabled="false"
aria-labelledby="«r8»"
aria-labelledby="«r5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -307,7 +264,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
aria-disabled="false"
aria-labelledby="«rd»"
aria-labelledby="«ra»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -329,7 +286,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="«ri»"
aria-labelledby="«rf»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="secondary"
data-size="lg"
@@ -350,7 +307,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="«rn»"
aria-labelledby="«rk»"
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
data-kind="primary"
data-size="lg"
@@ -376,7 +333,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="toggle layout"
>
<input
aria-labelledby="«rs»"
aria-labelledby="«rp»"
name="layout"
type="radio"
value="spotlight"
@@ -394,7 +351,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby="«r11»"
aria-labelledby="«ru»"
checked=""
name="layout"
type="radio"

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -11,41 +11,29 @@ import {
observeParticipantMedia,
} from "@livekit/components-core";
import {
ConnectionState,
type Room as LivekitRoom,
type LocalParticipant,
LocalVideoTrack,
ParticipantEvent,
type RemoteParticipant,
Track,
} from "livekit-client";
import {
ClientEvent,
type EventTimelineSetHandlerMap,
EventType,
RoomEvent,
RoomStateEvent,
SyncState,
type Room as MatrixRoom,
type RoomMember,
} from "matrix-js-sdk";
import { RoomStateEvent, type Room, type RoomMember } from "matrix-js-sdk";
import {
BehaviorSubject,
EMPTY,
NEVER,
type Observable,
Subject,
combineLatest,
concat,
distinctUntilChanged,
endWith,
filter,
forkJoin,
fromEvent,
ignoreElements,
map,
merge,
mergeMap,
of,
pairwise,
race,
scan,
skip,
@@ -54,8 +42,6 @@ import {
switchMap,
switchScan,
take,
takeUntil,
throttleTime,
timer,
withLatestFrom,
} from "rxjs";
@@ -64,9 +50,6 @@ import {
type CallMembership,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type MatrixRTCSessionEventHandlerMap,
MembershipManagerEvent,
Status,
} from "matrix-js-sdk/lib/matrixrtc";
import { ViewModel } from "./ViewModel";
@@ -77,16 +60,12 @@ import {
import {
LocalUserMediaViewModel,
type MediaViewModel,
observeTrackReference$,
RemoteUserMediaViewModel,
ScreenShareViewModel,
type UserMediaViewModel,
} from "./MediaViewModel";
import {
accumulate,
and$,
finalizeValue,
pauseWhen,
} from "../utils/observable";
import { accumulate, finalizeValue } from "../utils/observable";
import { ObservableScope } from "./ObservableScope";
import {
duplicateTiles,
@@ -115,27 +94,12 @@ import { observeSpeaker$ } from "./observeSpeaker";
import { shallowEquals } from "../utils/array";
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
import { type MediaDevices } from "./MediaDevices";
import { constant, type Behavior } from "./Behavior";
export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem;
autoLeaveWhenOthersLeft?: boolean;
/**
* If the call is started in a way where we want it to behave like a telephone usecase
* If we sent a notification event, we want the ui to show a ringing state
*/
waitForCallPickup?: boolean;
}
import { type Behavior } from "./Behavior";
// How long we wait after a focus switch before showing the real participant
// list again
const POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS = 3000;
// Do not play any sounds if the participant count has exceeded this
// number.
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500;
// This is the number of participants that we think constitutes a "small" call
// on mobile. No spotlight tile should be shown below this threshold.
const smallMobileCallThreshold = 3;
@@ -295,8 +259,6 @@ class UserMedia {
participant: LocalParticipant | RemoteParticipant | undefined,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
mediaDevices: MediaDevices,
pretendToBeDisconnected$: Behavior<boolean>,
displayname$: Observable<string>,
handRaised$: Observable<Date | null>,
reaction$: Observable<ReactionOption | null>,
@@ -310,7 +272,6 @@ class UserMedia {
this.participant$ as Behavior<LocalParticipant>,
encryptionSystem,
livekitRoom,
mediaDevices,
this.scope.behavior(displayname$),
this.scope.behavior(handRaised$),
this.scope.behavior(reaction$),
@@ -324,7 +285,6 @@ class UserMedia {
>,
encryptionSystem,
livekitRoom,
pretendToBeDisconnected$,
this.scope.behavior(displayname$),
this.scope.behavior(handRaised$),
this.scope.behavior(reaction$),
@@ -378,8 +338,7 @@ class ScreenShare {
member: RoomMember | undefined,
participant: LocalParticipant | RemoteParticipant,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
pretendToBeDisconnected$: Behavior<boolean>,
liveKitRoom: LivekitRoom,
displayName$: Observable<string>,
) {
this.participant$ = new BehaviorSubject(participant);
@@ -389,8 +348,7 @@ class ScreenShare {
member,
this.participant$.asObservable(),
encryptionSystem,
livekitRoom,
pretendToBeDisconnected$,
liveKitRoom,
this.scope.behavior(displayName$),
participant.isLocal,
);
@@ -406,7 +364,7 @@ type MediaItem = UserMedia | ScreenShare;
function getRoomMemberFromRtcMember(
rtcMember: CallMembership,
room: MatrixRoom,
room: Room,
): { id: string; member: RoomMember | undefined } {
// WARN! This is not exactly the sender but the user defined in the state key.
// This will be available once we change to the new "member as object" format in the MatrixRTC object.
@@ -428,79 +386,18 @@ function getRoomMemberFromRtcMember(
// TODO: Move wayyyy more business logic from the call and lobby views into here
export class CallViewModel extends ViewModel {
private readonly userId = this.matrixRoom.client.getUserId();
private readonly matrixConnected$ = this.scope.behavior(
// To consider ourselves connected to MatrixRTC, we check the following:
and$(
// The client is connected to the sync loop
(
fromEvent(this.matrixRoom.client, ClientEvent.Sync) as Observable<
[SyncState]
>
).pipe(
startWith([this.matrixRoom.client.getSyncState()]),
map(([state]) => state === SyncState.Syncing),
),
// Room state observed by session says we're connected
fromEvent(
this.matrixRTCSession,
MembershipManagerEvent.StatusChanged,
).pipe(
startWith(null),
map(() => this.matrixRTCSession.membershipStatus === Status.Connected),
),
// Also watch out for warnings that we've likely hit a timeout and our
// delayed leave event is being sent (this condition is here because it
// provides an earlier warning than the sync loop timeout, and we wouldn't
// see the actual leave event until we reconnect to the sync loop)
fromEvent(
this.matrixRTCSession,
MembershipManagerEvent.ProbablyLeft,
).pipe(
startWith(null),
map(() => this.matrixRTCSession.probablyLeft !== true),
),
public readonly localVideo$ = this.scope.behavior<LocalVideoTrack | null>(
observeTrackReference$(
this.livekitRoom.localParticipant,
Track.Source.Camera,
).pipe(
map((trackRef) => {
const track = trackRef?.publication?.track;
return track instanceof LocalVideoTrack ? track : null;
}),
),
);
private readonly connected$ = this.scope.behavior(
and$(
this.matrixConnected$,
this.livekitConnectionState$.pipe(
map((state) => state === ConnectionState.Connected),
),
),
);
/**
* Whether we should tell the user that we're reconnecting to the call.
*/
public readonly reconnecting$ = this.scope.behavior(
this.connected$.pipe(
// We are reconnecting if we previously had some successful initial
// connection but are now disconnected
scan(
({ connectedPreviously, reconnecting }, connectedNow) => ({
connectedPreviously: connectedPreviously || connectedNow,
reconnecting: connectedPreviously && !connectedNow,
}),
{ connectedPreviously: false, reconnecting: false },
),
map(({ reconnecting }) => reconnecting),
),
);
/**
* Whether various media/event sources should pretend to be disconnected from
* all network input, even if their connection still technically works.
*/
// We do this when the app is in the 'reconnecting' state, because it might be
// that the LiveKit connection is still functional while the homeserver is
// down, for example, and we want to avoid making people worry that the app is
// in a split-brained state.
private readonly pretendToBeDisconnected$ = this.reconnecting$;
/**
* The raw list of RemoteParticipants as reported by LiveKit
*/
@@ -515,7 +412,7 @@ export class CallViewModel extends ViewModel {
private readonly remoteParticipantHolds$ = this.scope.behavior<
RemoteParticipant[][]
>(
this.livekitConnectionState$.pipe(
this.connectionState$.pipe(
withLatestFrom(this.rawRemoteParticipants$),
mergeMap(([s, ps]) => {
// Whenever we switch focuses, we should retain all the previous
@@ -528,7 +425,7 @@ export class CallViewModel extends ViewModel {
// Wait for time to pass and the connection state to have changed
forkJoin([
timer(POST_FOCUS_PARTICIPANT_UPDATE_DELAY_MS),
this.livekitConnectionState$.pipe(
this.connectionState$.pipe(
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
take(1),
),
@@ -552,38 +449,27 @@ export class CallViewModel extends ViewModel {
/**
* The RemoteParticipants including those that are being "held" on the screen
*/
private readonly remoteParticipants$ = this.scope
.behavior<RemoteParticipant[]>(
combineLatest(
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
(raw, holds) => {
const result = [...raw];
const resultIds = new Set(result.map((p) => p.identity));
private readonly remoteParticipants$ = this.scope.behavior<
RemoteParticipant[]
>(
combineLatest(
[this.rawRemoteParticipants$, this.remoteParticipantHolds$],
(raw, holds) => {
const result = [...raw];
const resultIds = new Set(result.map((p) => p.identity));
// Incorporate the held participants into the list
for (const hold of holds) {
for (const p of hold) {
if (!resultIds.has(p.identity)) {
result.push(p);
resultIds.add(p.identity);
}
// Incorporate the held participants into the list
for (const hold of holds) {
for (const p of hold) {
if (!resultIds.has(p.identity)) {
result.push(p);
resultIds.add(p.identity);
}
}
}
return result;
},
),
)
.pipe(pauseWhen(this.pretendToBeDisconnected$));
private readonly memberships$ = this.scope.behavior(
fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
).pipe(
startWith(null),
pauseWhen(this.pretendToBeDisconnected$),
map(() => this.matrixRTCSession.memberships),
return result;
},
),
);
@@ -592,23 +478,20 @@ export class CallViewModel extends ViewModel {
* any displaynames that clashes with another member. Only members
* joined to the call are considered here.
*/
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
// don't do this work more times than we need to. This is achieved by converting to a behavior:
public readonly memberDisplaynames$ = this.scope.behavior(
// React to call memberships and also display name updates
// (calculateDisplayName implicitly depends on the room member data)
combineLatest(
[
this.memberships$,
fromEvent(this.matrixRoom, RoomStateEvent.Members).pipe(
startWith(null),
pauseWhen(this.pretendToBeDisconnected$),
),
],
(memberships, _members) => {
merge(
// Handle call membership changes.
fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
),
// Handle room membership changes (and displayname updates)
fromEvent(this.matrixRTCSession.room, RoomStateEvent.Members),
).pipe(
startWith(null),
map(() => {
const displaynameMap = new Map<string, string>();
const room = this.matrixRoom;
const { room, memberships } = this.matrixRTCSession;
// We only consider RTC members for disambiguation as they are the only visible members.
for (const rtcMember of memberships) {
@@ -628,13 +511,14 @@ export class CallViewModel extends ViewModel {
);
}
return displaynameMap;
},
}),
// It turns out that doing the disambiguation above is rather expensive on Safari (10x slower
// than on Chrome/Firefox). This means it is important that we multicast the result so that we
// don't do this work more times than we need to. This is achieved by converting to a behavior:
),
);
public readonly handsRaised$ = this.scope.behavior(
this.handsRaisedSubject$.pipe(pauseWhen(this.pretendToBeDisconnected$)),
);
public readonly handsRaised$ = this.scope.behavior(this.handsRaisedSubject$);
public readonly reactions$ = this.scope.behavior(
this.reactionsSubject$.pipe(
@@ -646,7 +530,6 @@ export class CallViewModel extends ViewModel {
]),
),
),
pauseWhen(this.pretendToBeDisconnected$),
),
);
@@ -658,7 +541,13 @@ export class CallViewModel extends ViewModel {
this.remoteParticipants$,
observeParticipantMedia(this.livekitRoom.localParticipant),
duplicateTiles.value$,
this.memberships$,
// Also react to changes in the MatrixRTC session list.
// The session list will also be update if a room membership changes.
// No additional RoomState event listener needs to be set up.
fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.MembershipsChanged,
).pipe(startWith(null)),
showNonMemberTiles.value$,
]).pipe(
scan(
@@ -668,15 +557,15 @@ export class CallViewModel extends ViewModel {
remoteParticipants,
{ participant: localParticipant },
duplicateTiles,
memberships,
_membershipsChanged,
showNonMemberTiles,
],
) => {
const newItems = new Map(
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
const room = this.matrixRoom;
const room = this.matrixRTCSession.room;
// m.rtc.members are the basis for calculating what is visible in the call
for (const rtcMember of memberships) {
for (const rtcMember of this.matrixRTCSession.memberships) {
const { member, id: livekitParticipantId } =
getRoomMemberFromRtcMember(rtcMember, room);
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
@@ -723,10 +612,8 @@ export class CallViewModel extends ViewModel {
indexedMediaId,
member,
participant,
this.options.encryptionSystem,
this.encryptionSystem,
this.livekitRoom,
this.mediaDevices,
this.pretendToBeDisconnected$,
this.memberDisplaynames$.pipe(
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
),
@@ -748,9 +635,8 @@ export class CallViewModel extends ViewModel {
screenShareId,
member,
participant,
this.options.encryptionSystem,
this.encryptionSystem,
this.livekitRoom,
this.pretendToBeDisconnected$,
this.memberDisplaynames$.pipe(
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
),
@@ -790,10 +676,8 @@ export class CallViewModel extends ViewModel {
nonMemberId,
undefined,
participant,
this.options.encryptionSystem,
this.encryptionSystem,
this.livekitRoom,
this.mediaDevices,
this.pretendToBeDisconnected$,
this.memberDisplaynames$.pipe(
map(
(m) => m.get(participant.identity) ?? "[👻]",
@@ -842,146 +726,18 @@ export class CallViewModel extends ViewModel {
),
);
public readonly joinSoundEffect$ = this.userMedia$.pipe(
pairwise(),
filter(
([prev, current]) =>
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current.length > prev.length,
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
public readonly leaveSoundEffect$ = this.userMedia$.pipe(
pairwise(),
filter(
([prev, current]) =>
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current.length < prev.length,
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
/**
* The number of participants currently in the call.
*
* - Each participant has a corresponding MatrixRTC membership state event
* - There can be multiple participants for one Matrix user if they join from
* multiple devices.
*/
public readonly participantCount$ = this.scope.behavior(
this.memberships$.pipe(map((ms) => ms.length)),
);
private readonly allOthersLeft$ = this.memberships$.pipe(
pairwise(),
filter(
([prev, current]) =>
current.every((m) => m.sender === this.userId) &&
prev.some((m) => m.sender !== this.userId),
),
map(() => {}),
take(1),
);
public readonly autoLeave$ = this.options.autoLeaveWhenOthersLeft
? this.allOthersLeft$
: NEVER;
/**
* Whenever the RTC session tells us that it intends to ring the remote
* participant's devices, this emits an Observable tracking the current state of
* that ringing process.
*/
private readonly ring$: Observable<
Observable<"ringing" | "timeout" | "decline">
> = (
fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.DidSendCallNotification,
) as Observable<
Parameters<
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
>
>
).pipe(
filter(
([notificationEvent]) => notificationEvent.notification_type === "ring",
),
map(([notificationEvent]) => {
const lifetimeMs = notificationEvent?.lifetime ?? 0;
return concat(
lifetimeMs === 0
? // If no lifetime, skip the ring state
EMPTY
: // Ring until lifetime ms have passed
timer(lifetimeMs).pipe(
ignoreElements(),
startWith("ringing" as const),
),
// The notification lifetime has timed out, meaning ringing has likely
// stopped on all receiving clients.
of("timeout" as const),
NEVER,
).pipe(
takeUntil(
(
fromEvent(this.matrixRoom, RoomEvent.Timeline) as Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>
).pipe(
filter(
([event]) =>
event.getType() === EventType.RTCDecline &&
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id === notificationEvent.event_id &&
event.getSender() !== this.userId,
),
),
),
endWith("decline" as const),
);
}),
);
/**
* Whether some Matrix user other than ourself is joined to the call.
*/
private readonly someoneElseJoined$ = this.memberships$.pipe(
map((ms) => ms.some((m) => m.sender !== this.userId)),
);
/**
* The current call pickup state of the call.
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
* Then we can conclude if we were the first one to join or not.
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
* The call failed. If desired this can be used as a trigger to exit the call.
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
* - null: EC is configured to never show any waiting for answer state.
*/
public readonly callPickupState$ = this.options.waitForCallPickup
? this.scope.behavior<
"unknown" | "ringing" | "timeout" | "decline" | "success"
>(
this.someoneElseJoined$.pipe(
switchMap((someoneElseJoined) =>
someoneElseJoined
? of("success" as const)
: // Show the ringing state of the most recent ringing attempt.
this.ring$.pipe(switchAll()),
),
// The state starts as 'unknown' because we don't know if the RTC
// session will actually send a notify event yet. It will only be
// known once we send our own membership and see that we were the
// first one to join.
startWith("unknown" as const),
),
)
: constant(null);
public readonly memberChanges$ = this.userMedia$
.pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
.pipe(
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
(prev, ids) => {
const left = prev.ids.filter((id) => !ids.includes(id));
const joined = ids.filter((id) => !prev.ids.includes(id));
return { ids, joined, left };
},
{ ids: [], joined: [], left: [] },
),
);
/**
* List of MediaItems that we want to display, that are of type ScreenShare
@@ -1083,7 +839,7 @@ export class CallViewModel extends ViewModel {
map((speaker) => (speaker ? [speaker] : [])),
);
}),
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
distinctUntilChanged(shallowEquals),
),
);
@@ -1668,11 +1424,10 @@ export class CallViewModel extends ViewModel {
public constructor(
// A call is permanently tied to a single Matrix room and LiveKit room
private readonly matrixRTCSession: MatrixRTCSession,
private readonly matrixRoom: MatrixRoom,
private readonly livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices,
private readonly options: CallViewModelOptions,
private readonly livekitConnectionState$: Observable<ECConnectionState>,
private readonly encryptionSystem: EncryptionSystem,
private readonly connectionState$: Observable<ECConnectionState>,
private readonly handsRaisedSubject$: Observable<
Record<string, RaisedHandInfo>
>,
@@ -1681,51 +1436,5 @@ export class CallViewModel extends ViewModel {
>,
) {
super();
// Pause upstream of all local media tracks when we're disconnected from
// MatrixRTC, because it can be an unpleasant surprise for the app to say
// 'reconnecting' and yet still be transmitting your media to others.
// We use matrixConnected$ rather than reconnecting$ because we want to
// pause tracks during the initial joining sequence too until we're sure
// that our own media is displayed on screen.
this.matrixConnected$.pipe(this.scope.bind()).subscribe((connected) => {
const publications =
this.livekitRoom.localParticipant.trackPublications.values();
if (connected) {
for (const p of publications) {
if (p.track?.isUpstreamPaused === true) {
const kind = p.track.kind;
logger.log(
`Resumming ${kind} track (MatrixRTC connection present)`,
);
p.track
.resumeUpstream()
.catch((e) =>
logger.error(
`Failed to resume ${kind} track after MatrixRTC reconnection`,
e,
),
);
}
}
} else {
for (const p of publications) {
if (p.track?.isUpstreamPaused === false) {
const kind = p.track.kind;
logger.log(
`Pausing ${kind} track (uncertain MatrixRTC connection)`,
);
p.track
.pauseUpstream()
.catch((e) =>
logger.error(
`Failed to pause ${kind} track after entering uncertain MatrixRTC connection`,
e,
),
);
}
}
}
});
}
}

View File

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

View File

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

View File

@@ -16,7 +16,6 @@ import {
import {
type LocalParticipant,
LocalTrack,
LocalVideoTrack,
type Participant,
ParticipantEvent,
type RemoteParticipant,
@@ -28,7 +27,6 @@ import {
RemoteTrack,
} from "livekit-client";
import { type RoomMember } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
type Observable,
@@ -53,8 +51,6 @@ import { accumulate } from "../utils/observable";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { E2eeType } from "../e2ee/e2eeType";
import { type ReactionOption } from "../reactions";
import { platform } from "../Platform";
import { type MediaDevices } from "./MediaDevices";
import { type Behavior } from "./Behavior";
export function observeTrackReference$(
@@ -361,7 +357,10 @@ export type UserMediaViewModel =
* Some participant's user media.
*/
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
private readonly _speaking$ = this.scope.behavior(
/**
* Whether the participant is speaking.
*/
public readonly speaking$ = this.scope.behavior(
this.participant$.pipe(
switchMap((p) =>
p
@@ -373,27 +372,15 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
),
),
);
/**
* Whether the participant is speaking.
*/
// Getter backed by a private field so that subclasses can override it
public get speaking$(): Behavior<boolean> {
return this._speaking$;
}
/**
* Whether this participant is sending audio (i.e. is unmuted on their side).
*/
public readonly audioEnabled$: Behavior<boolean>;
private readonly _videoEnabled$: Behavior<boolean>;
/**
* Whether this participant is sending video.
*/
// Getter backed by a private field so that subclasses can override it
public get videoEnabled$(): Behavior<boolean> {
return this._videoEnabled$;
}
public readonly videoEnabled$: Behavior<boolean>;
private readonly _cropVideo$ = new BehaviorSubject(true);
/**
@@ -430,7 +417,7 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
this.audioEnabled$ = this.scope.behavior(
media$.pipe(map((m) => m?.microphoneTrack?.isMuted === false)),
);
this._videoEnabled$ = this.scope.behavior(
this.videoEnabled$ = this.scope.behavior(
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
);
}
@@ -455,39 +442,21 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
* The local participant's user media.
*/
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
/**
* The local video track as an observable that emits whenever the track
* changes, the camera is switched, or the track is muted.
*/
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
this.video$.pipe(
switchMap((v) => {
const track = v?.publication?.track;
if (!(track instanceof LocalVideoTrack)) return of(null);
return merge(
// Watch for track restarts because they indicate a camera switch.
// This event is also emitted when unmuting the track object.
fromEvent(track, TrackEvent.Restarted).pipe(
startWith(null),
map(() => track),
),
// When the track object is muted, reset it to null.
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
);
}),
);
/**
* Whether the video should be mirrored.
*/
public readonly mirror$ = this.scope.behavior(
this.videoTrack$.pipe(
// Mirror only front-facing cameras (those that face the user)
map(
(track) =>
track !== null &&
facingModeFromLocalTrack(track).facingMode === "user",
),
this.video$.pipe(
switchMap((v) => {
const track = v?.publication?.track;
if (!(track instanceof LocalTrack)) return of(false);
// Watch for track restarts, because they indicate a camera switch
return fromEvent(track, TrackEvent.Restarted).pipe(
startWith(null),
// Mirror only front-facing cameras (those that face the user)
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
);
}),
),
);
@@ -498,48 +467,12 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
public readonly alwaysShow$ = alwaysShowSelf.value$;
public readonly setAlwaysShow = alwaysShowSelf.setValue;
/**
* Callback for switching between the front and back cameras.
*/
public readonly switchCamera$: Behavior<(() => void) | null> =
this.scope.behavior(
platform === "desktop"
? of(null)
: this.videoTrack$.pipe(
map((track) => {
if (track === null) return null;
const facingMode = facingModeFromLocalTrack(track).facingMode;
// If the camera isn't front or back-facing, don't provide a switch
// camera shortcut at all
if (facingMode !== "user" && facingMode !== "environment")
return null;
// Restart the track with a camera facing the opposite direction
return (): void =>
void track
.restartTrack({
facingMode: facingMode === "user" ? "environment" : "user",
})
.then(() => {
// Inform the MediaDevices which camera was chosen
const deviceId =
track.mediaStreamTrack.getSettings().deviceId;
if (deviceId !== undefined)
this.mediaDevices.videoInput.select(deviceId);
})
.catch((e) =>
logger.error("Failed to switch camera", facingMode, e),
);
}),
),
);
public constructor(
id: string,
member: RoomMember | undefined,
participant$: Behavior<LocalParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices,
displayName$: Behavior<string>,
handRaised$: Behavior<Date | null>,
reaction$: Behavior<ReactionOption | null>,
@@ -581,12 +514,6 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
* A remote participant's user media.
*/
export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
// This private field is used to override the value from the superclass
private __speaking$: Behavior<boolean>;
public get speaking$(): Behavior<boolean> {
return this.__speaking$;
}
private readonly locallyMutedToggle$ = new Subject<void>();
private readonly localVolumeAdjustment$ = new Subject<number>();
private readonly localVolumeCommit$ = new Subject<void>();
@@ -626,12 +553,6 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
),
);
// This private field is used to override the value from the superclass
private __videoEnabled$: Behavior<boolean>;
public get videoEnabled$(): Behavior<boolean> {
return this.__videoEnabled$;
}
/**
* Whether this participant's audio is disabled.
*/
@@ -645,7 +566,6 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
participant$: Observable<RemoteParticipant | undefined>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
private readonly pretendToBeDisconnected$: Behavior<boolean>,
displayname$: Behavior<string>,
handRaised$: Behavior<Date | null>,
reaction$: Behavior<ReactionOption | null>,
@@ -661,33 +581,11 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
reaction$,
);
this.__speaking$ = this.scope.behavior(
pretendToBeDisconnected$.pipe(
switchMap((disconnected) =>
disconnected ? of(false) : super.speaking$,
),
),
);
this.__videoEnabled$ = this.scope.behavior(
pretendToBeDisconnected$.pipe(
switchMap((disconnected) =>
disconnected ? of(false) : super.videoEnabled$,
),
),
);
// Sync the local volume with LiveKit
combineLatest([
participant$,
// The local volume, taking into account whether we're supposed to pretend
// that the audio stream is disconnected (since we don't necessarily want
// that to modify the UI state).
this.pretendToBeDisconnected$.pipe(
switchMap((disconnected) => (disconnected ? of(0) : this.localVolume$)),
this.scope.bind(),
),
]).subscribe(([p, volume]) => p?.setVolume(volume));
this.localVolume$.pipe(this.scope.bind()),
]).subscribe(([p, volume]) => p && p.setVolume(volume));
}
public toggleLocallyMuted(): void {
@@ -727,20 +625,12 @@ export class RemoteUserMediaViewModel extends BaseUserMediaViewModel {
* Some participant's screen share media.
*/
export class ScreenShareViewModel extends BaseMediaViewModel {
/**
* Whether this screen share's video should be displayed.
*/
public readonly videoEnabled$ = this.scope.behavior(
this.pretendToBeDisconnected$.pipe(map((disconnected) => !disconnected)),
);
public constructor(
id: string,
member: RoomMember | undefined,
participant$: Observable<LocalParticipant | RemoteParticipant>,
encryptionSystem: EncryptionSystem,
livekitRoom: LivekitRoom,
private readonly pretendToBeDisconnected$: Behavior<boolean>,
displayname$: Behavior<string>,
public readonly local: boolean,
) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,8 +29,6 @@ import classNames from "classnames";
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { type RoomMember } from "matrix-js-sdk";
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
import { MediaView } from "./MediaView";
import styles from "./SpotlightTile.module.css";
import {
@@ -54,7 +52,6 @@ interface SpotlightItemBaseProps {
targetWidth: number;
targetHeight: number;
video: TrackReferenceOrPlaceholder | undefined;
videoEnabled: boolean;
member: RoomMember | undefined;
unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus;
@@ -64,6 +61,7 @@ interface SpotlightItemBaseProps {
}
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
videoEnabled: boolean;
videoFit: "contain" | "cover";
}
@@ -90,10 +88,12 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
vm,
...props
}) => {
const videoEnabled = useBehavior(vm.videoEnabled$);
const cropVideo = useBehavior(vm.cropVideo$);
const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = {
videoEnabled,
videoFit: cropVideo ? "cover" : "contain",
...props,
};
@@ -133,7 +133,6 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
const ref = useMergedRefs(ourRef, theirRef);
const displayName = useBehavior(vm.displayName$);
const video = useBehavior(vm.video$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const encryptionStatus = useBehavior(vm.encryptionStatus$);
@@ -159,7 +158,6 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
targetWidth,
targetHeight,
video,
videoEnabled,
member: vm.member,
unencryptedWarning,
displayName,
@@ -169,7 +167,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
};
return vm instanceof ScreenShareViewModel ? (
<MediaView videoFit="contain" mirror={false} {...baseProps} />
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
) : (
<SpotlightUserMediaItem vm={vm} {...baseProps} />
);
@@ -212,26 +210,6 @@ export const SpotlightTile: FC<Props> = ({
const canGoBack = visibleIndex > 0;
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
const isFullscreen = useCallback((): boolean => {
const rootElement = document.body;
if (rootElement && document.fullscreenElement) return true;
return false;
}, []);
const FullScreenIcon = isFullscreen()
? FullScreenMinimiseIcon
: FullScreenMaximiseIcon;
const onToggleFullscreen = useCallback(() => {
const rootElement = document.body;
if (!rootElement) return;
if (isFullscreen()) {
void document?.exitFullscreen();
} else {
void rootElement.requestFullscreen();
}
}, [isFullscreen]);
// To keep track of which item is visible, we need an intersection observer
// hooked up to the root element and the items. Because the items will run
// their effects before their parent does, we need to do this dance with an
@@ -314,28 +292,17 @@ export const SpotlightTile: FC<Props> = ({
/>
))}
</div>
<div className={styles.bottomRightButtons}>
{onToggleExpanded && (
<button
className={classNames(styles.expand)}
aria-label={"maximise"}
onClick={onToggleFullscreen}
aria-label={
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
>
<FullScreenIcon aria-hidden width={20} height={20} />
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
{onToggleExpanded && (
<button
className={classNames(styles.expand)}
aria-label={
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
)}
</div>
)}
{canGoToNext && (
<button
className={classNames(styles.advance, styles.next)}

View File

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

View File

@@ -1,24 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test } from "vitest";
import { withTestScheduler } from "./test";
import { pauseWhen } from "./observable";
test("pauseWhen", () => {
withTestScheduler(({ behavior, expectObservable }) => {
const inputMarbles = " abcdefgh-i-jk-";
const pauseMarbles = " n-y--n-yn-y--n";
const outputMarbles = "abc--fgh-i---k";
expectObservable(
behavior(inputMarbles).pipe(
pauseWhen(behavior(pauseMarbles, { y: true, n: false })),
),
).toBe(outputMarbles);
});
});

View File

@@ -7,23 +7,16 @@ Please see LICENSE in the repository root for full details.
import {
type Observable,
audit,
combineLatest,
concat,
defer,
filter,
finalize,
map,
of,
scan,
startWith,
takeWhile,
tap,
withLatestFrom,
} from "rxjs";
import { type Behavior } from "../state/Behavior";
const nothing = Symbol("nothing");
/**
@@ -93,27 +86,3 @@ export function getValue<T>(state$: Observable<T>): T {
if (value === nothing) throw new Error("Not a state Observable");
return value;
}
/**
* Creates an Observable that has a value of true whenever all its inputs are
* true.
*/
export function and$(...inputs: Observable<boolean>[]): Observable<boolean> {
return combineLatest(inputs, (...flags) => flags.every((flag) => flag));
}
/**
* RxJS operator that pauses all changes in the input value whenever a Behavior
* is true. When the Behavior returns to being false, the most recently
* suppressed change is emitted as the most recent value.
*/
export function pauseWhen<T>(pause$: Behavior<boolean>) {
return (value$: Observable<T>): Observable<T> =>
value$.pipe(
withLatestFrom(pause$),
audit(([, pause]) =>
pause ? pause$.pipe(filter((pause) => !pause)) : of(null),
),
map(([value]) => value),
);
}

View File

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

View File

@@ -14,13 +14,8 @@ import { BehaviorSubject, of } from "rxjs";
import { vitest } from "vitest";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import EventEmitter from "events";
import {
type RoomMember,
type MatrixClient,
type Room,
SyncState,
} from "matrix-js-sdk";
import type { RoomMember, MatrixClient } from "matrix-js-sdk";
import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel";
import {
@@ -39,11 +34,10 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
export function getBasicRTCSession(
members: RoomMember[],
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
): {
rtcSession: MockRTCSession;
matrixRoom: Room;
rtcMemberships$: BehaviorSubject<CallMembership[]>;
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
} {
const matrixRoomId = "!myRoomId:example.com";
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
@@ -57,7 +51,6 @@ export function getBasicRTCSession(
client: {
getUserId: () => localRtcMember.sender,
getDeviceId: () => localRtcMember.deviceId,
getSyncState: () => SyncState.Syncing,
sendEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
redactEvent: vitest.fn().mockResolvedValue({ event_id: "$fake:event" }),
decryptEventIfNeeded: vitest.fn().mockResolvedValue(undefined),
@@ -98,56 +91,55 @@ export function getBasicRTCSession(
),
});
const rtcMemberships$ = new BehaviorSubject<CallMembership[]>(
initialRtcMemberships,
const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
initialRemoteRtcMemberships,
);
const rtcSession = new MockRTCSession(matrixRoom).withMemberships(
rtcMemberships$,
);
const rtcSession = new MockRTCSession(
matrixRoom,
localRtcMember,
).withMemberships(remoteRtcMemberships$);
return {
rtcSession,
matrixRoom,
rtcMemberships$,
remoteRtcMemberships$,
};
}
/**
* Construct a basic CallViewModel to test components that make use of it.
* @param members
* @param initialRtcMemberships
* @param initialRemoteRtcMemberships
* @returns
*/
export function getBasicCallViewModelEnvironment(
members: RoomMember[],
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
): {
vm: CallViewModel;
rtcMemberships$: BehaviorSubject<CallMembership[]>;
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
rtcSession: MockRTCSession;
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
reactionsSubject$: BehaviorSubject<Record<string, ReactionInfo>>;
} {
const { rtcSession, matrixRoom, rtcMemberships$ } = getBasicRTCSession(
const { rtcSession, remoteRtcMemberships$ } = getBasicRTCSession(
members,
initialRtcMemberships,
initialRemoteRtcMemberships,
);
const handRaisedSubject$ = new BehaviorSubject({});
const reactionsSubject$ = new BehaviorSubject({});
const remoteParticipants$ = of([aliceParticipant]);
const livekitRoom = mockLivekitRoom(
const liveKitRoom = mockLivekitRoom(
{ localParticipant },
{ remoteParticipants$ },
);
const vm = new CallViewModel(
rtcSession as unknown as MatrixRTCSession,
matrixRoom,
livekitRoom,
liveKitRoom,
mockMediaDevices({}),
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
kind: E2eeType.PER_PARTICIPANT,
},
of(ConnectionState.Connected),
handRaisedSubject$,
@@ -155,7 +147,7 @@ export function getBasicCallViewModelEnvironment(
);
return {
vm,
rtcMemberships$,
remoteRtcMemberships$,
rtcSession,
handRaisedSubject$: handRaisedSubject$,
reactionsSubject$: reactionsSubject$,

View File

@@ -19,11 +19,8 @@ import {
type Focus,
MatrixRTCSessionEvent,
type MatrixRTCSessionEventHandlerMap,
MembershipManagerEvent,
type SessionMembershipData,
Status,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MembershipManagerEventHandlerMap } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
import {
type LocalParticipant,
type LocalTrackPublication,
@@ -38,7 +35,6 @@ import {
type RoomAndToDeviceEventsHandlerMap,
} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { type TrackReference } from "@livekit/components-core";
import EventEmitter from "events";
import {
LocalUserMediaViewModel,
@@ -78,7 +74,6 @@ export interface OurRunHelpers extends RunHelpers {
values?: { [marble: string]: T },
error?: unknown,
): Behavior<T>;
scope: ObservableScope;
}
interface TestRunnerGlobal {
@@ -101,7 +96,6 @@ export function withTestScheduler(
scheduler.run((helpers) =>
continuation({
...helpers,
scope,
schedule(marbles, actions) {
const actionsObservable$ = helpers
.cold(marbles)
@@ -145,25 +139,26 @@ export function withTestScheduler(
}
interface EmitterMock<T> {
on: (...args: unknown[]) => T;
off: (...args: unknown[]) => T;
addListener: (...args: unknown[]) => T;
removeListener: (...args: unknown[]) => T;
emit: (event: string | symbol, ...args: unknown[]) => boolean;
on: () => T;
off: () => T;
addListener: () => T;
removeListener: () => T;
}
export function mockEmitter<T>(): EmitterMock<T> {
const ee = new EventEmitter();
return {
on: ee.on.bind(ee) as unknown as (...args: unknown[]) => T,
off: ee.off.bind(ee) as unknown as (...args: unknown[]) => T,
addListener: ee.addListener.bind(ee) as unknown as (
...args: unknown[]
) => T,
removeListener: ee.removeListener.bind(ee) as unknown as (
...args: unknown[]
) => T,
emit: ee.emit.bind(ee),
on(): T {
return this as T;
},
off(): T {
return this as T;
},
addListener(): T {
return this as T;
},
removeListener(): T {
return this as T;
},
};
}
@@ -236,7 +231,6 @@ export function mockLocalParticipant(
): LocalParticipant {
return {
isLocal: true,
trackPublications: new Map(),
getTrackPublication: () =>
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
...mockEmitter(),
@@ -247,10 +241,9 @@ export function mockLocalParticipant(
export async function withLocalMedia(
localRtcMember: CallMembership,
roomMember: Partial<RoomMember>,
localParticipant: LocalParticipant,
mediaDevices: MediaDevices,
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
): Promise<void> {
const localParticipant = mockLocalParticipant({});
const vm = new LocalUserMediaViewModel(
"local",
mockMatrixRoomMember(localRtcMember, roomMember),
@@ -259,7 +252,6 @@ export async function withLocalMedia(
kind: E2eeType.PER_PARTICIPANT,
},
mockLivekitRoom({ localParticipant }),
mediaDevices,
constant(roomMember.rawDisplayName ?? "nodisplayname"),
constant(null),
constant(null),
@@ -299,7 +291,6 @@ export async function withRemoteMedia(
kind: E2eeType.PER_PARTICIPANT,
},
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
constant(false),
constant(roomMember.rawDisplayName ?? "nodisplayname"),
constant(null),
constant(null),
@@ -321,10 +312,8 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
}
export class MockRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
MatrixRTCSessionEventHandlerMap &
RoomAndToDeviceEventsHandlerMap &
MembershipManagerEventHandlerMap
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
> {
public readonly statistics = {
counters: {},
@@ -334,6 +323,7 @@ export class MockRTCSession extends TypedEventEmitter<
public constructor(
public readonly room: Room,
private localMembership: CallMembership,
public memberships: CallMembership[] = [],
) {
super();
@@ -349,37 +339,14 @@ export class MockRTCSession extends TypedEventEmitter<
): MockRTCSession {
rtcMembers$.subscribe((m) => {
const old = this.memberships;
this.memberships = m as CallMembership[];
this.emit(
MatrixRTCSessionEvent.MembershipsChanged,
old,
this.memberships,
);
// always prepend the local participant
const updated = [this.localMembership, ...(m as CallMembership[])];
this.memberships = updated;
this.emit(MatrixRTCSessionEvent.MembershipsChanged, old, updated);
});
return this;
}
private _membershipStatus = Status.Connected;
public get membershipStatus(): Status {
return this._membershipStatus;
}
public set membershipStatus(value: Status) {
const prev = this._membershipStatus;
this._membershipStatus = value;
if (value !== prev)
this.emit(MembershipManagerEvent.StatusChanged, prev, value);
}
private _probablyLeft = false;
public get probablyLeft(): boolean {
return this._probablyLeft;
}
public set probablyLeft(value: boolean) {
const prev = this._probablyLeft;
this._probablyLeft = value;
if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value);
}
}
export const mockTrack = (identity: string): TrackReference =>

View File

@@ -106,10 +106,6 @@ export const widget = ((): WidgetHelpers | null => {
if (!baseUrl) throw new Error("Base URL must be supplied");
// These are all the event types the app uses
const sendEvent = [
EventType.CallNotify, // Sent as a deprecated fallback
EventType.RTCNotification,
];
const sendRecvEvent = [
"org.matrix.rageshake_request",
EventType.CallEncryptionKeysPrefix,
@@ -120,8 +116,8 @@ export const widget = ((): WidgetHelpers | null => {
const sendState = [
userId, // Legacy call membership events
`_${userId}_${deviceId}_m.call`, // Session membership events
`${userId}_${deviceId}_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it
`_${userId}_${deviceId}`, // Session membership events
`${userId}_${deviceId}`, // The above with no leading underscore, for room versions whose auth rules allow it
].map((stateKey) => ({
eventType: EventType.GroupCallMemberPrefix,
stateKey,
@@ -133,7 +129,6 @@ export const widget = ((): WidgetHelpers | null => {
{ eventType: EventType.RoomEncryption },
{ eventType: EventType.GroupCallMemberPrefix },
];
const sendRecvToDevice = [
EventType.CallInvite,
EventType.CallCandidates,
@@ -151,7 +146,7 @@ export const widget = ((): WidgetHelpers | null => {
const client = createRoomWidgetClient(
api,
{
sendEvent: [...sendEvent, ...sendRecvEvent],
sendEvent: sendRecvEvent,
receiveEvent: sendRecvEvent,
sendState,
receiveState,

View File

@@ -1,14 +1,6 @@
/*
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 { defineConfig, mergeConfig } from "vite";
import generateFile from "vite-plugin-generate-file";
import fullConfig from "./vite.config";
import generateFile from "vite-plugin-generate-file";
const base = "./";

View File

@@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
loadEnv,
searchForWorkspaceRoot,
type ConfigEnv,
type UserConfig,
} from "vite";
import { defineConfig, loadEnv, searchForWorkspaceRoot } from "vite";
import svgrPlugin from "vite-plugin-svgr";
import { createHtmlPlugin } from "vite-plugin-html";
import { codecovVitePlugin } from "@codecov/vite-plugin";
@@ -20,14 +15,10 @@ import { realpathSync } from "fs";
import * as fs from "node:fs";
// https://vitejs.dev/config/
// Modified type helper from defineConfig to allow for packageType (see defineConfig from vite)
export default ({
mode,
packageType,
}: ConfigEnv & { packageType?: "full" | "embedded" }): UserConfig => {
export default defineConfig(({ mode, packageType }) => {
const env = loadEnv(mode, process.cwd());
// Environment variables with the VITE_ prefix are accessible at runtime.
// So, we set this to allow for build/package specific behavior.
// So, we set this to allow for build/package specific behaviour.
// In future we might be able to do what is needed via code splitting at
// build time.
process.env.VITE_PACKAGE = packageType ?? "full";
@@ -102,7 +93,7 @@ export default ({
sourcemap: true,
rollupOptions: {
output: {
assetFileNames: ({ originalFileNames }): string => {
assetFileNames: ({ originalFileNames }) => {
if (originalFileNames) {
for (const name of originalFileNames) {
// Custom asset name for locales to include the locale code in the filename
@@ -152,4 +143,4 @@ export default ({
exclude: ["@matrix-org/matrix-sdk-crypto-wasm"],
},
};
};
});

View File

@@ -1,6 +1,5 @@
import { defineConfig, mergeConfig } from "vitest/config";
import viteConfig from "./vite.config";
import viteConfig from "./vite.config.js";
export default defineConfig((configEnv) =>
mergeConfig(

1087
yarn.lock

File diff suppressed because it is too large Load Diff