Compare commits
144 Commits
robin/noti
...
hs/error-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30a130ac70 | ||
|
|
82392bf582 | ||
|
|
f072fd2f9c | ||
|
|
c00298f4a5 | ||
|
|
0721d48c20 | ||
|
|
b16ba52e13 | ||
|
|
e201258af3 | ||
|
|
76465d0e63 | ||
|
|
a2cf4c8139 | ||
|
|
65d358df58 | ||
|
|
32cb8541f4 | ||
|
|
62cfe09c7b | ||
|
|
8e32deaba5 | ||
|
|
8311b1619c | ||
|
|
fc449bcfbf | ||
|
|
34a8977dd1 | ||
|
|
db04cbfbfc | ||
|
|
c8d3d58940 | ||
|
|
d85cf5f929 | ||
|
|
1e32b355ce | ||
|
|
1193a22658 | ||
|
|
2541f810fa | ||
|
|
7961bb35be | ||
|
|
c599d6eae9 | ||
|
|
075a4cbe0d | ||
|
|
cd845b69d2 | ||
|
|
779f792cbf | ||
|
|
e9c43856d0 | ||
|
|
1e8b87408c | ||
|
|
1428df8567 | ||
|
|
b8acdc3cec | ||
|
|
880e07c07f | ||
|
|
07522d6704 | ||
|
|
58ed7ef93e | ||
|
|
c1694fe706 | ||
|
|
a775bcd62a | ||
|
|
7724cbf9be | ||
|
|
fe65c1f4da | ||
|
|
a91c71a8e4 | ||
|
|
e30142a43b | ||
|
|
c15551c9f5 | ||
|
|
f410a11934 | ||
|
|
3122ccf4e4 | ||
|
|
03527e9d7a | ||
|
|
36be15436a | ||
|
|
04c03b1e72 | ||
|
|
38e60208ef | ||
|
|
fb8de061f3 | ||
|
|
acd26c61ef | ||
|
|
e475f56af5 | ||
|
|
9486ed5d77 | ||
|
|
7ba4df7781 | ||
|
|
db65a5308a | ||
|
|
482e2f8f6f | ||
|
|
cd5b7cdc47 | ||
|
|
e097e58212 | ||
|
|
31892f46cd | ||
|
|
c0dccf89cb | ||
|
|
44ddb40eea | ||
|
|
5b8839b577 | ||
|
|
33bf63d8d2 | ||
|
|
a7cc11ac81 | ||
|
|
25f04030fc | ||
|
|
5d0289f3c9 | ||
|
|
3da9013426 | ||
|
|
1a1e5a9db8 | ||
|
|
87574451a9 | ||
|
|
f8a051ee11 | ||
|
|
a0d778083d | ||
|
|
8de6ddceb0 | ||
|
|
8b3b72dfb4 | ||
|
|
db59679ad4 | ||
|
|
f08ae36f9e | ||
|
|
dc789e63f2 | ||
|
|
30024877fc | ||
|
|
94085bff00 | ||
|
|
aa0f3a1126 | ||
|
|
0ed47c2588 | ||
|
|
770346676c | ||
|
|
8c1fa3fd70 | ||
|
|
217fa40ddb | ||
|
|
a733461845 | ||
|
|
6667fc54c0 | ||
|
|
c0aab96968 | ||
|
|
610032ad1d | ||
|
|
e4d14b4892 | ||
|
|
8509efb48b | ||
|
|
6dcc44b631 | ||
|
|
fce7b6d456 | ||
|
|
ef7c9a166a | ||
|
|
dba8a43495 | ||
|
|
1a3e88c19a | ||
|
|
955c22c8dc | ||
|
|
02f9d0822f | ||
|
|
e133289a7f | ||
|
|
518039f7c3 | ||
|
|
f5094be194 | ||
|
|
fd426191af | ||
|
|
f9d87ea934 | ||
|
|
77ba88b76c | ||
|
|
8615d3adb0 | ||
|
|
7971ea76cd | ||
|
|
5d992a398d | ||
|
|
1aa76925f7 | ||
|
|
4888efe2f7 | ||
|
|
879816ac6d | ||
|
|
6de8820590 | ||
|
|
415f83ace5 | ||
|
|
c9fb80f702 | ||
|
|
9bf63cce25 | ||
|
|
16947e7c39 | ||
|
|
b84c8ab82b | ||
|
|
078c67bfb2 | ||
|
|
81555c8d51 | ||
|
|
d294be8bd4 | ||
|
|
51672599ba | ||
|
|
2ade9b5c1e | ||
|
|
b0503f41ef | ||
|
|
d59e794bdf | ||
|
|
b35fcf9076 | ||
|
|
029827488c | ||
|
|
4ee67fa4e5 | ||
|
|
446a2d4080 | ||
|
|
007a50d0f1 | ||
|
|
c7b0f9783d | ||
|
|
391ca63b7e | ||
|
|
5dfa420135 | ||
|
|
ff3f727c80 | ||
|
|
f7565ef104 | ||
|
|
2f55d8e30c | ||
|
|
3145bafd5e | ||
|
|
7dd45acd29 | ||
|
|
8f561c0319 | ||
|
|
4104db3e7a | ||
|
|
d530997725 | ||
|
|
fb0fefae10 | ||
|
|
8bb79e6f33 | ||
|
|
0e0fba6575 | ||
|
|
5ea0759427 | ||
|
|
735d63d393 | ||
|
|
31bb46485f | ||
|
|
f53558cb81 | ||
|
|
0c194617a3 | ||
|
|
7c5336fc40 |
@@ -28,8 +28,6 @@ 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",
|
||||
@@ -46,6 +44,15 @@ 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",
|
||||
|
||||
2
.github/workflows/changelog-label.yml
vendored
@@ -9,6 +9,6 @@ jobs:
|
||||
steps:
|
||||
- uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
|
||||
with:
|
||||
REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies"
|
||||
REQUIRED_LABELS_ANY: "PR-Bug-Fix,PR-Documentation,PR-Task,PR-Feature,PR-Improvement,PR-Developer-Experience,dependencies,PR-Breaking-Change"
|
||||
REQUIRED_LABELS_ANY_DESCRIPTION: "Select at least one 'PR-' label"
|
||||
BANNED_LABELS: "banned"
|
||||
|
||||
1
.gitignore
vendored
@@ -9,6 +9,7 @@ dist-ssr
|
||||
public/config.json
|
||||
backend/synapse_tmp/*
|
||||
/coverage
|
||||
config.json
|
||||
|
||||
# Yarn
|
||||
yarn-error.log
|
||||
|
||||
11
README.md
@@ -143,7 +143,8 @@ 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 Matrix LiveKit JWT Auth Service via `livekit_service_url`.
|
||||
points them to a [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||
via `livekit_service_url`.
|
||||
|
||||
### Backend Selection
|
||||
|
||||
@@ -154,9 +155,9 @@ points them to a Matrix LiveKit JWT Auth Service 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 **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.
|
||||
- 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.
|
||||
|
||||
The example below illustrates how backend selection works across **Matrix
|
||||
federation**, using the setup from sites A, B, and C. It demonstrates backend
|
||||
@@ -208,7 +209,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`)
|
||||
- LiveKit Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
|
||||
- MatrixRTC 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)
|
||||
|
||||
@@ -21,3 +21,5 @@ turn:
|
||||
external_tls: true
|
||||
keys:
|
||||
devkey: secret
|
||||
room:
|
||||
auto_create: false
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
@@ -25,6 +26,7 @@ services:
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
restart: unless-stopped
|
||||
@@ -44,6 +46,7 @@ services:
|
||||
|
||||
redis:
|
||||
image: redis:6-alpine
|
||||
pull_policy: always
|
||||
command: redis-server /etc/redis.conf
|
||||
ports:
|
||||
# HOST_PORT:CONTAINER_PORT
|
||||
@@ -56,6 +59,7 @@ 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
|
||||
@@ -86,6 +90,7 @@ 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
|
||||
@@ -101,4 +106,6 @@ services:
|
||||
depends_on:
|
||||
- synapse
|
||||
networks:
|
||||
- ecbackend
|
||||
ecbackend:
|
||||
aliases:
|
||||
- matrix-rtc.m.localhost
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 929 KiB After Width: | Height: | Size: 941 KiB |
|
Before Width: | Height: | Size: 886 KiB After Width: | Height: | Size: 908 KiB |
@@ -64,7 +64,7 @@ rc_delayed_event_mgmt:
|
||||
```
|
||||
|
||||
As a prerequisite for the
|
||||
[Matrix LiveKit JWT auth service](https://github.com/element-hq/lk-jwt-service)
|
||||
[MatrixRTC Authorization 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
|
||||
[Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service)
|
||||
[MatrixRTC Authorization 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` |
|
||||
| [Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
|
||||
| [MatrixRTC Authorization 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;
|
||||
|
||||
# JWT Service running at port 8080
|
||||
# MatrixRTC Authorization Service running at port 8080
|
||||
proxy_pass http://localhost:8080/;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,32 @@ 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]
|
||||
@@ -145,10 +171,6 @@ server {
|
||||
{
|
||||
"type": "livekit",
|
||||
"livekit_service_url": "https://matrix-rtc-2.example.com/livekit/jwt"
|
||||
},
|
||||
{
|
||||
"type": "nextgen_new_foci_type",
|
||||
"props_for_nextgen_foci": "val"
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -218,7 +240,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
|
||||
|
||||
@@ -48,6 +48,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
|
||||
|
||||
| Name | Values | Required for widget | Required for SPA | Description |
|
||||
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
|
||||
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. |
|
||||
| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
|
||||
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
|
||||
@@ -59,7 +60,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
|
||||
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
|
||||
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
|
||||
| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
|
||||
| `intent` | `start_call` or `join_existing` | No, defaults to `start_call` | No, defaults to `start_call` | The intent of the user with respect to the call. e.g. if they clicked a Start Call button, this would be `start_call`. If it was a Join Call button, it would be `join_existing`. |
|
||||
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
|
||||
| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
|
||||
| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
|
||||
@@ -70,6 +70,8 @@ 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 user’s 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
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
|
||||
|
||||
[versions]
|
||||
android_gradle_plugin = "8.11.0"
|
||||
android_gradle_plugin = "8.11.1"
|
||||
|
||||
[libraries]
|
||||
android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref = "android_gradle_plugin" }
|
||||
|
||||
[plugins]
|
||||
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.33.0" }
|
||||
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.34.0" }
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import com.vanniktech.maven.publish.SonatypeHost
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.maven.publish)
|
||||
@@ -27,7 +25,7 @@ android {
|
||||
}
|
||||
|
||||
mavenPublishing {
|
||||
publishToMavenCentral(SonatypeHost.CENTRAL_PORTAL, automaticRelease = true)
|
||||
publishToMavenCentral(automaticRelease = true)
|
||||
|
||||
signAllPublications()
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default {
|
||||
import type { UserConfig } from "i18next-parser";
|
||||
|
||||
const config: UserConfig = {
|
||||
keySeparator: ".",
|
||||
namespaceSeparator: false,
|
||||
contextSeparator: "|",
|
||||
@@ -26,3 +28,5 @@ export default {
|
||||
input: ["src/**/*.{ts,tsx}"],
|
||||
sort: true,
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
11
knip.ts
@@ -1,8 +1,15 @@
|
||||
import { KnipConfig } from "knip";
|
||||
/*
|
||||
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";
|
||||
|
||||
export default {
|
||||
vite: {
|
||||
config: ["vite.config.js", "vite-embedded.config.js"],
|
||||
config: ["vite.config.ts", "vite-embedded.config.ts"],
|
||||
},
|
||||
entry: ["src/main.tsx", "i18next-parser.config.ts"],
|
||||
ignoreBinaries: [
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Zobrazit možnost sluchátek pro iPhone na všech platformách",
|
||||
"crypto_version": "Kryptografická verze: {{version}}",
|
||||
"debug_tile_layout_label": "Ladění rozložení dlaždic",
|
||||
"device_id": "ID zařízení: {{id}}",
|
||||
@@ -81,7 +82,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 volání. Zkontrolujte, zda máte správný odkaz, nebo <1>vytvořte nový</1>.</0>",
|
||||
"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>",
|
||||
"connection_lost": "Spojení ztraceno",
|
||||
"connection_lost_description": "Hovor byl přerušen.",
|
||||
"e2ee_unsupported": "Nekompatibilní prohlížeč",
|
||||
@@ -104,6 +105,11 @@
|
||||
"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",
|
||||
@@ -173,8 +179,11 @@
|
||||
"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",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Vis mulighed for iPhone-høretelefon på alle platforme",
|
||||
"crypto_version": "Krypto-version: {{version}}",
|
||||
"debug_tile_layout_label": "Fejlfinding af fliselayout",
|
||||
"device_id": "Enheds-id: {{id}}",
|
||||
@@ -70,6 +71,7 @@
|
||||
"livekit_server_info": "LiveKit Serverinfo",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"mute_all_audio": "Slå al lyd fra (deltagere, reaktioner, deltagelseslyde)",
|
||||
"show_connection_stats": "Vis forbindelsesstatistik",
|
||||
"show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer",
|
||||
"url_params": "URL-parametre",
|
||||
@@ -80,7 +82,7 @@
|
||||
"error": {
|
||||
"call_is_not_supported": "Opkald er ikke understøttet",
|
||||
"call_not_found": "Opkald ikke fundet",
|
||||
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller<1> opret et nyt</1>.</0>",
|
||||
"call_not_found_description": "<0>Det link ser ikke ud til at høre til et eksisterende opkald. Tjek at du har det rigtige link, eller <2> opret et nyt</2>.</0>",
|
||||
"connection_lost": "Forbindelsen gik tabt",
|
||||
"connection_lost_description": "Du blev afbrudt fra opkaldet.",
|
||||
"e2ee_unsupported": "Inkompatibel browser",
|
||||
@@ -92,6 +94,8 @@
|
||||
"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": {
|
||||
@@ -103,6 +107,11 @@
|
||||
"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",
|
||||
@@ -164,12 +173,18 @@
|
||||
"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",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"profile": "Profil",
|
||||
"reaction": "Reaktion",
|
||||
"reactions": "Reaktionen",
|
||||
"reconnecting": "Verbindung wird wiederhergestellt...",
|
||||
"settings": "Einstellungen",
|
||||
"unencrypted": "Nicht verschlüsselt",
|
||||
"username": "Benutzername",
|
||||
@@ -82,7 +83,7 @@
|
||||
"error": {
|
||||
"call_is_not_supported": "Anrufe werden nicht unterstützt",
|
||||
"call_not_found": "Anruf nicht gefunden",
|
||||
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Vergewissern Sie sich, dass Sie den richtigen Link haben, oder <1> erstellen Sie einen neuen</1>. </0>",
|
||||
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Es sollte geprüft werden, ob der Link korrekt ist, oder <2>ein neuer erstellt werden</2>.</0>",
|
||||
"connection_lost": "Verbindung verloren",
|
||||
"connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.",
|
||||
"e2ee_unsupported": "Inkompatibler Browser",
|
||||
@@ -94,6 +95,8 @@
|
||||
"matrix_rtc_focus_missing": "Der Server ist nicht für die Verwendung mit {{brand}} konfiguriert. Bitte den Serveradministrator kontaktieren (Domain: {{domain}}, Fehlercode: {{ errorCode }}).",
|
||||
"open_elsewhere": "In einem anderen Tab geöffnet",
|
||||
"open_elsewhere_description": "{{brand}} wurde in einem anderen Tab geöffnet. Wenn das nicht richtig klingt, versuchen Sie, die Seite neu zu laden.",
|
||||
"room_creation_restricted": "Anruf konnte nicht erstellt werden",
|
||||
"room_creation_restricted_description": "Das Erstellen von Anrufen ist nur für autorisierte Nutzer möglich. Versuche es später erneut oder kontaktiere deinen Serveradministrator, falls das Problem weiterhin besteht.",
|
||||
"unexpected_ec_error": "Ein unerwarteter Fehler ist aufgetreten (<0>Fehlercode: </0> <1>{{ errorCode }}</1>). Bitte den Serveradministrator kontaktieren."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -105,6 +108,11 @@
|
||||
"knock_reject_heading": "Zugriff verweigert",
|
||||
"reason": "Grund: {{reason}}"
|
||||
},
|
||||
"handset": {
|
||||
"overlay_back_button": "Zurück zum Lautsprechermodus",
|
||||
"overlay_description": "Nur wenn App im Vordergrund nutzbar",
|
||||
"overlay_title": "Ohrhörer Modus"
|
||||
},
|
||||
"hangup_button_label": "Anruf beenden",
|
||||
"header_label": "Element Call-Startseite",
|
||||
"header_participants_label": "Teilnehmende",
|
||||
@@ -173,9 +181,11 @@
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"change_device_button": "Audiogerät wechseln",
|
||||
"default": "Standard",
|
||||
"default_named": "Standard<2> ({{name}} )</2>",
|
||||
"earpiece": "Ohrhörer",
|
||||
"handset": "Ohrhörer",
|
||||
"loudspeaker": "Lautsprecher",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon{{n}}",
|
||||
"speaker": "Lautsprecher",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"profile": "Profile",
|
||||
"reaction": "Reaction",
|
||||
"reactions": "Reactions",
|
||||
"reconnecting": "Reconnecting…",
|
||||
"settings": "Settings",
|
||||
"unencrypted": "Not encrypted",
|
||||
"username": "Username",
|
||||
@@ -79,11 +80,6 @@
|
||||
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
|
||||
},
|
||||
"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",
|
||||
@@ -99,6 +95,8 @@
|
||||
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
|
||||
"open_elsewhere": "Opened in another tab",
|
||||
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
|
||||
"room_creation_restricted": "Failed to create call",
|
||||
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
|
||||
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -110,6 +108,11 @@
|
||||
"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",
|
||||
@@ -181,7 +184,7 @@
|
||||
"change_device_button": "Change audio device",
|
||||
"default": "Default",
|
||||
"default_named": "Default <2>({{name}})</2>",
|
||||
"earpiece": "Earpiece",
|
||||
"handset": "Handset",
|
||||
"loudspeaker": "Loudspeaker",
|
||||
"microphone": "Microphone",
|
||||
"microphone_numbered": "Microphone {{n}}",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Näita iPhone'i kuulari valikut kõikidel platvormidel",
|
||||
"crypto_version": "Krüptoteekide versioon: {{version}}",
|
||||
"debug_tile_layout_label": "Meediapaanide paigutus",
|
||||
"device_id": "Seadme tunnus: {{id}}",
|
||||
@@ -81,7 +82,7 @@
|
||||
"error": {
|
||||
"call_is_not_supported": "Kõne pole toetatud",
|
||||
"call_not_found": "Kõnet ei leidu",
|
||||
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <1>loo uus</1>.</0>",
|
||||
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <2>loo uus</2>.</0>",
|
||||
"connection_lost": "Ühendus on katkenud",
|
||||
"connection_lost_description": "Sinu ühendus selle kõnega on katkenud.",
|
||||
"e2ee_unsupported": "Mitteühilduv brauser",
|
||||
@@ -93,6 +94,8 @@
|
||||
"matrix_rtc_focus_missing": "See server pole seadistatud töötama rakendusega {{brand}}. Palun võta ühendust serveri halduriga (domeen: {{domain}}, veakood: {{ errorCode }}).",
|
||||
"open_elsewhere": "Avatud teisel vahekaardil",
|
||||
"open_elsewhere_description": "{{brand}} on avatud teisel vahekaardil. Kui see ei tundu olema õige, proovi selle lehe uuesti laadimist.",
|
||||
"room_creation_restricted": "Kõne loomine ei õnnestunud",
|
||||
"room_creation_restricted_description": "Kõne loomine võib olla lubatud ainult volitatud kasutajatele. Proovi hiljem uuesti või probleemi püsimisel võta ühendust oma serveri haldajaga.",
|
||||
"unexpected_ec_error": "Tekkis ootamatu viga (<0>Veakood:</0> <1>{{ errorCode }}</1>). Palun võta ühendust serveri haldajaga."
|
||||
},
|
||||
"group_call_loader": {
|
||||
@@ -104,6 +107,10 @@
|
||||
"knock_reject_heading": "Liitumine pole lubatud",
|
||||
"reason": "Põhjus"
|
||||
},
|
||||
"handset": {
|
||||
"overlay_back_button": "Tagasi esineja vaatesse",
|
||||
"overlay_description": "See toimib vaid rakenduse kasutamise ajal"
|
||||
},
|
||||
"hangup_button_label": "Lõpeta kõne",
|
||||
"header_label": "Avaleht: Element Call",
|
||||
"header_participants_label": "Osalejad",
|
||||
@@ -172,8 +179,10 @@
|
||||
"devices": {
|
||||
"camera": "Kaamera",
|
||||
"camera_numbered": "Kaamera {{n}}",
|
||||
"change_device_button": "Muuda heliseadet",
|
||||
"default": "Vaikimisi",
|
||||
"default_named": "Vaikimisi <2>({{name}})</2>",
|
||||
"loudspeaker": "Valjuhääldi",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
"speaker": "Kõlar",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Видео"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Показать опцию наушников для iPhone на всех платформах",
|
||||
"crypto_version": "Версия криптографии: {{version}}",
|
||||
"debug_tile_layout_label": "Отладка расположения плиток",
|
||||
"device_id": "Идентификатор устройства: {{id}}",
|
||||
@@ -70,6 +71,7 @@
|
||||
"livekit_server_info": "Информация о сервере LiveKit",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"mute_all_audio": "Отключить все звуки (участников, реакции, звуки присоединения)",
|
||||
"show_connection_stats": "Показать статистику подключений",
|
||||
"show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками",
|
||||
"url_params": "Параметры URL-адреса",
|
||||
@@ -172,6 +174,7 @@
|
||||
"devices": {
|
||||
"camera": "Камера",
|
||||
"camera_numbered": "Камера {{n}}",
|
||||
"change_device_button": "Изменить аудиоустройство",
|
||||
"default": "По умолчанию",
|
||||
"default_named": "По умолчанию <2>({{name}})</2>",
|
||||
"microphone": "Микрофон",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Zobraziť možnosť slúchadla iPhone na všetkých platformách",
|
||||
"crypto_version": "Krypto verzia: {{version}}",
|
||||
"debug_tile_layout_label": "Ladenie rozloženia dlaždíc",
|
||||
"device_id": "ID zariadenia: {{id}}",
|
||||
@@ -81,7 +82,7 @@
|
||||
"error": {
|
||||
"call_is_not_supported": "Hovor nie je podporovaný",
|
||||
"call_not_found": "Hovor nebol nájdený",
|
||||
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <1> vytvorte nový</1>. </0>",
|
||||
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <2>vytvorte nový</2>.</0>",
|
||||
"connection_lost": "Strata spojenia",
|
||||
"connection_lost_description": "Boli ste odpojení od hovoru.",
|
||||
"e2ee_unsupported": "Nekompatibilný prehliadač",
|
||||
@@ -173,8 +174,10 @@
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"change_device_button": "Zmeniť zvukové zariadenie",
|
||||
"default": "Predvolené",
|
||||
"default_named": "Predvolené <2>({{name}})</2>",
|
||||
"loudspeaker": "Reproduktor",
|
||||
"microphone": "Mikrofón",
|
||||
"microphone_numbered": "Mikrofón {{n}}",
|
||||
"speaker": "Reproduktor",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Visa iPhone-hörsnäckealternativ på alla plattformar",
|
||||
"crypto_version": "Kryptoversion: {{version}}",
|
||||
"debug_tile_layout_label": "Felsök panelarrangemang",
|
||||
"device_id": "Enhets-ID: {{id}}",
|
||||
@@ -70,6 +71,7 @@
|
||||
"livekit_server_info": "LiveKit-serverinfo",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix-ID: {{id}}",
|
||||
"mute_all_audio": "Tysta allt ljud (deltagare, reaktioner, anslutningsljud)",
|
||||
"show_connection_stats": "Visa anslutningsstatistik",
|
||||
"show_non_member_tiles": "Visa paneler för media som inte är medlemmar",
|
||||
"url_params": "URL-parametrar",
|
||||
@@ -80,7 +82,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 <1>skapa en ny</1>.</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 <2>skapa en ny</2>.</0>",
|
||||
"connection_lost": "Anslutning förlorad",
|
||||
"connection_lost_description": "Du kopplades bort från samtalet.",
|
||||
"e2ee_unsupported": "Inkompatibel webbläsare",
|
||||
@@ -92,6 +94,8 @@
|
||||
"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": {
|
||||
@@ -103,6 +107,11 @@
|
||||
"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",
|
||||
@@ -164,12 +173,18 @@
|
||||
"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",
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"@mediapipe/tasks-vision": "^0.10.18",
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"@opentelemetry/core": "^2.0.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.203.0",
|
||||
"@opentelemetry/resources": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.0.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
||||
"@typescript-eslint/parser": "^8.31.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^4.0.0",
|
||||
"@vector-im/compound-design-tokens": "^6.0.0",
|
||||
"@vector-im/compound-web": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
@@ -129,7 +129,7 @@
|
||||
"typescript-eslint-language-service": "^5.0.5",
|
||||
"unique-names-generator": "^4.6.0",
|
||||
"vaul": "^1.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-generate-file": "^0.3.0",
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-svgr": "^4.0.0",
|
||||
|
||||
@@ -40,6 +40,11 @@ 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(
|
||||
|
||||
@@ -72,3 +72,56 @@ test("Should automatically retry non fatal JWT errors", async ({
|
||||
await hasRetriedPromise;
|
||||
await expect(page.getByTestId("video").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should show error screen if call creation is restricted", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// We need the socket connection to fail, but this cannot be done by using the websocket route.
|
||||
// Instead, we will trick the app by returning a bad URL for the SFU that will not be reachable an error out.
|
||||
await page.route(
|
||||
"**/matrix-rtc.m.localhost/livekit/jwt/sfu/get",
|
||||
async (route) =>
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
url: "wss://badurltotricktest/livekit/sfu",
|
||||
jwt: "FAKE",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Then if the socket connection fails, livekit will try to validate the token!
|
||||
// Livekit will not auto_create anymore and will return a 404 error.
|
||||
await page.route(
|
||||
"**/badurltotricktest/livekit/sfu/rtc/validate?**",
|
||||
async (route) =>
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: "text/plain",
|
||||
body: "requested room does not exist",
|
||||
}),
|
||||
);
|
||||
|
||||
await page.pause();
|
||||
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill("HelloCall");
|
||||
await page.getByTestId("home_displayName").click();
|
||||
await page.getByTestId("home_displayName").fill("John Doe");
|
||||
await page.getByTestId("home_go").click();
|
||||
|
||||
// Join the call
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
|
||||
await page.pause();
|
||||
// Should fail
|
||||
await expect(page.getByText("Failed to create call")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(
|
||||
/Call creation might be restricted to authorized users only/,
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
75
playwright/restricted-sfu.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { sleep } from "matrix-js-sdk/lib/utils.js";
|
||||
|
||||
test("Should request JWT token before starting the call", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
let sfGetTimestamp = 0;
|
||||
let sendStateEventTimestamp = 0;
|
||||
await page.route(
|
||||
"**/matrix-rtc.m.localhost/livekit/jwt/sfu/get",
|
||||
async (route) => {
|
||||
await sleep(2000); // Simulate very slow request
|
||||
await route.continue();
|
||||
sfGetTimestamp = Date.now();
|
||||
},
|
||||
);
|
||||
|
||||
await page.route(
|
||||
"**/state/org.matrix.msc3401.call.member/**",
|
||||
async (route) => {
|
||||
await route.continue();
|
||||
sendStateEventTimestamp = Date.now();
|
||||
},
|
||||
);
|
||||
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill("HelloCall");
|
||||
await page.getByTestId("home_displayName").click();
|
||||
await page.getByTestId("home_displayName").fill("John Doe");
|
||||
await page.getByTestId("home_go").click();
|
||||
|
||||
// Join the call
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
await page.waitForTimeout(4000);
|
||||
// Ensure that the call is connected
|
||||
await page
|
||||
.locator("div")
|
||||
.filter({ hasText: /^HelloCall$/ })
|
||||
.click();
|
||||
|
||||
expect(sfGetTimestamp).toBeGreaterThan(0);
|
||||
expect(sendStateEventTimestamp).toBeGreaterThan(0);
|
||||
expect(sfGetTimestamp).toBeLessThan(sendStateEventTimestamp);
|
||||
});
|
||||
|
||||
test("Error when pre-warming the focus are caught by the ErrorBoundary", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.route("**/openid/request_token", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 418, // Simulate an error not retryable
|
||||
});
|
||||
});
|
||||
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill("HelloCall");
|
||||
await page.getByTestId("home_displayName").click();
|
||||
await page.getByTestId("home_displayName").fill("John Doe");
|
||||
await page.getByTestId("home_go").click();
|
||||
|
||||
// Join the call
|
||||
await page.getByTestId("lobby_joinCall").click();
|
||||
|
||||
// Should fail
|
||||
await expect(page.getByText("Something went wrong")).toBeVisible();
|
||||
});
|
||||
@@ -100,5 +100,5 @@ test("When creator left, avoid reconnect to the same SFU", async ({
|
||||
// https://github.com/element-hq/element-call/issues/3344
|
||||
// The app used to request a new jwt token then to reconnect to the SFU
|
||||
expect(wsConnectionCount).toBe(1);
|
||||
expect(sfuGetCallCount).toBe(1);
|
||||
expect(sfuGetCallCount).toBe(2 /* the first one is for the warmup */);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ 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";
|
||||
@@ -86,7 +85,6 @@ 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 />} />
|
||||
|
||||
@@ -17,10 +17,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { Heading, IconButton, Tooltip } from "@vector-im/compound-web";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CollapseIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { CollapseIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Header, LeftNav, RightNav } from "./Header";
|
||||
@@ -45,7 +42,6 @@ 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?.();
|
||||
@@ -65,11 +61,15 @@ export const AppBar: FC<Props> = ({ children }) => {
|
||||
style={{ display: hidden ? "none" : "block" }}
|
||||
className={styles.bar}
|
||||
>
|
||||
<Header>
|
||||
<Header
|
||||
// App bar is mainly seen in the call view, which has its own
|
||||
// 'reconnecting' toast
|
||||
disconnectedBanner={false}
|
||||
>
|
||||
<LeftNav>
|
||||
<Tooltip label={t("common.back")}>
|
||||
<IconButton onClick={onBackClick}>
|
||||
<BackIcon />
|
||||
<CollapseIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</LeftNav>
|
||||
|
||||
@@ -17,27 +17,38 @@ 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>
|
||||
<>
|
||||
<header
|
||||
ref={ref}
|
||||
className={classNames(styles.header, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
{disconnectedBanner && <DisconnectedBanner />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +62,7 @@ export const Toast: FC<Props> = ({
|
||||
autoDismiss,
|
||||
children,
|
||||
Icon,
|
||||
portal = true,
|
||||
}) => {
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
@@ -71,29 +78,33 @@ 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}>
|
||||
<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>
|
||||
{portal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||
</DialogRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getRoomIdentifierFromUrl,
|
||||
getUrlParams,
|
||||
UserIntent,
|
||||
HeaderStyle,
|
||||
} from "../src/UrlParams";
|
||||
|
||||
const ROOM_NAME = "roomNameHere";
|
||||
@@ -82,6 +82,16 @@ 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", () => {
|
||||
@@ -201,24 +211,68 @@ describe("UrlParams", () => {
|
||||
});
|
||||
|
||||
describe("intent", () => {
|
||||
it("defaults to unknown", () => {
|
||||
expect(getUrlParams().intent).toBe(UserIntent.Unknown);
|
||||
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("ignores intent if it is not a valid value", () => {
|
||||
expect(getUrlParams("?intent=foo").intent).toBe(UserIntent.Unknown);
|
||||
expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
|
||||
});
|
||||
|
||||
it("accepts start_call", () => {
|
||||
expect(getUrlParams("?intent=start_call").intent).toBe(
|
||||
UserIntent.StartNewCall,
|
||||
);
|
||||
expect(
|
||||
getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"),
|
||||
).toMatchObject(startNewCallDefaults("desktop"));
|
||||
});
|
||||
|
||||
it("accepts join_existing", () => {
|
||||
expect(getUrlParams("?intent=join_existing").intent).toBe(
|
||||
UserIntent.JoinExistingCall,
|
||||
);
|
||||
expect(
|
||||
getUrlParams(
|
||||
"?intent=join_existing&widgetId=1234&parentUrl=parent.org",
|
||||
),
|
||||
).toMatchObject(joinExistingCallDefaults("desktop"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,9 +304,5 @@ describe("UrlParams", () => {
|
||||
);
|
||||
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
|
||||
});
|
||||
it("converts hideHeader to the correct header value", () => {
|
||||
expect(getUrlParams("?hideHeader=true").header).toBe("none");
|
||||
expect(getUrlParams("?hideHeader=false").header).toBe("standard");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
396
src/UrlParams.ts
@@ -9,10 +9,12 @@ 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;
|
||||
@@ -23,6 +25,8 @@ interface RoomIdentifier {
|
||||
export enum UserIntent {
|
||||
StartNewCall = "start_call",
|
||||
JoinExistingCall = "join_existing",
|
||||
StartNewCallDM = "start_call_dm",
|
||||
JoinExistingCallDM = "join_existing_dm",
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
@@ -32,12 +36,12 @@ export enum HeaderStyle {
|
||||
AppBar = "app_bar",
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||
// the 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 {
|
||||
/**
|
||||
* The UrlProperties are used to pass required data to the widget.
|
||||
* Those are different in different rooms, users, devices. They do not configure the behavior of the
|
||||
* widget but provide the required data to the widget.
|
||||
*/
|
||||
export interface UrlProperties {
|
||||
// Widget api related params
|
||||
widgetId: string | null;
|
||||
parentUrl: string | null;
|
||||
@@ -49,45 +53,11 @@ export interface UrlParams {
|
||||
* 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.
|
||||
*/
|
||||
@@ -125,14 +95,96 @@ export interface UrlParams {
|
||||
*/
|
||||
posthogApiKey: string | null;
|
||||
/**
|
||||
* Whether the app is allowed to use fallback STUN servers for ICE in case the
|
||||
* user's homeserver doesn't provide any.
|
||||
* Whether to use end-to-end encryption.
|
||||
*/
|
||||
allowIceFallback: boolean;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Whether the app should use per participant keys for E2EE.
|
||||
*/
|
||||
@@ -154,52 +206,36 @@ export interface UrlParams {
|
||||
* 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
|
||||
@@ -240,6 +276,17 @@ 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),
|
||||
@@ -247,10 +294,20 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,46 +324,98 @@ 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;
|
||||
|
||||
return {
|
||||
/**
|
||||
* The user's intent with respect to the call.
|
||||
* e.g. if they clicked a Start Call button, this would be `start_call`.
|
||||
* If it was a Join Call button, it would be `join_existing`.
|
||||
* This is a platform specific default set of parameters, that allows to minize the configuration
|
||||
* needed to start a call. And empowers the EC codebase to control the platform/intent behavior in
|
||||
* a central place.
|
||||
*
|
||||
* In short: either provide url query parameters of UrlConfiguration or set the intent
|
||||
* (or the global defaults will be used).
|
||||
*/
|
||||
const intent = !isWidget
|
||||
? UserIntent.Unknown
|
||||
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
|
||||
// Here we only use constants and `platform` to determine the intent preset.
|
||||
let intentPreset: UrlConfiguration;
|
||||
const inAppDefault = {
|
||||
confineToRoom: true,
|
||||
appPrompt: false,
|
||||
preload: 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 = {
|
||||
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,
|
||||
@@ -314,24 +423,9 @@ 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:
|
||||
@@ -339,7 +433,37 @@ export const getUrlParams = (
|
||||
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
|
||||
sentryDsn: parser.getParam("sentryDsn"),
|
||||
sentryEnvironment: parser.getParam("sentryEnvironment"),
|
||||
sendNotificationType,
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -398,10 +522,16 @@ export function getRoomIdentifierFromUrl(
|
||||
|
||||
// Make sure roomId is valid
|
||||
let roomId: string | null = parser.getParam("roomId");
|
||||
if (!roomId?.startsWith("!")) {
|
||||
roomId = null;
|
||||
} else if (!roomId.includes("")) {
|
||||
roomId = null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -32,7 +32,7 @@ exports[`AppBar > renders 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
|
||||
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
EndCallIcon,
|
||||
ShareScreenSolidIcon,
|
||||
SettingsSolidIcon,
|
||||
SwitchCameraSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import styles from "./Button.module.css";
|
||||
@@ -67,23 +66,6 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SwitchCameraButton: FC<ComponentPropsWithoutRef<"button">> = (
|
||||
props,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip label={t("switch_camera")}>
|
||||
<CpdButton
|
||||
iconOnly
|
||||
Icon={SwitchCameraSolidIcon}
|
||||
kind="secondary"
|
||||
{...props}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
interface ShareScreenButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@@ -114,24 +114,29 @@ export interface ConfigOptions {
|
||||
* when someone leaves a call.
|
||||
*/
|
||||
wait_for_key_rotation_ms?: number;
|
||||
/** @deprecated use wait_for_key_rotation_ms instead */
|
||||
key_rotation_on_leave_delay?: number;
|
||||
|
||||
/**
|
||||
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
|
||||
* that the server waits before sending the leave MatrixRTC membership event.
|
||||
*/
|
||||
delayed_leave_event_delay_ms?: number;
|
||||
/** @deprecated use delayed_leave_event_delay_ms instead */
|
||||
membership_server_side_expiry_timeout?: number;
|
||||
|
||||
/**
|
||||
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
|
||||
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
|
||||
*
|
||||
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
|
||||
* helps by keeping more delayed event reset candidates in flight,
|
||||
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
|
||||
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||
*/
|
||||
delayed_leave_event_restart_local_timeout_ms?: number;
|
||||
|
||||
/**
|
||||
* The time interval (in milliseconds) at which the client sends membership keep-alive
|
||||
* 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.
|
||||
|
||||
@@ -6,9 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { Subject } from "rxjs";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
const logger = rootLogger.getChild("[controlled-output]");
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
export interface Controls {
|
||||
canEnterPip(): boolean;
|
||||
|
||||
@@ -61,7 +61,12 @@ const TileWrapper_ = memo(
|
||||
useDrag((state) => onDrag?.current!(id, state), {
|
||||
target: ref,
|
||||
filterTaps: true,
|
||||
preventScroll: true,
|
||||
// Previous designs, which allowed tiles to be dragged and dropped around
|
||||
// the scrolling grid, required us to set preventScroll to true here. But
|
||||
// our designs no longer call for this, and meanwhile there's a bug in
|
||||
// use-gesture that causes filterTaps + preventScroll to break buttons
|
||||
// within tiles (like the 'switch camera' button) on mobile.
|
||||
// https://github.com/pmndrs/use-gesture/issues/593
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
6
src/icons/FullScreenMaximise.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 14C4.55228 14 5 14.4477 5 15V19H9C9.55228 19 10 19.4477 10 20C10 20.5523 9.55228 21 9 21H3V15C3 14.4477 3.44772 14 4 14Z"/>
|
||||
<path d="M20 14C20.5523 14 21 14.4477 21 15V21H15C14.4477 21 14 20.5523 14 20C14 19.4477 14.4477 19 15 19H19V15C19 14.4477 19.4477 14 20 14Z" />
|
||||
<path d="M9 3C9.55228 3 10 3.44772 10 4C10 4.55228 9.55228 5 9 5H5V9C5 9.55228 4.55228 10 4 10C3.44772 10 3 9.55228 3 9V3H9Z" />
|
||||
<path d="M21 9C21 9.55228 20.5523 10 20 10C19.4477 10 19 9.55228 19 9V5H15C14.4477 5 14 4.55228 14 4C14 3.44772 14.4477 3 15 3H21V9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 658 B |
6
src/icons/FullScreenMinimise.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 20C10 20.5523 9.55228 21 9 21C8.44772 21 8 20.5523 8 20V16H4C3.44772 16 3 15.5523 3 15C3 14.4477 3.44772 14 4 14H10V20Z" />
|
||||
<path d="M20 14C20.5523 14 21 14.4477 21 15C21 15.5523 20.5523 16 20 16H16V20C16 20.5523 15.5523 21 15 21C14.4477 21 14 20.5523 14 20V14H20Z" />
|
||||
<path d="M9 3C9.55228 3 10 3.44772 10 4V10H4C3.44772 10 3 9.55228 3 9C3 8.44772 3.44772 8 4 8H8V4C8 3.44772 8.44772 3 9 3Z" />
|
||||
<path d="M15 3C15.5523 3 16 3.44772 16 4V8H20C20.5523 8 21 8.44772 21 9C21 9.55228 20.5523 10 20 10H14V4C14 3.44772 14.4477 3 15 3Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 656 B |
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
|
||||
@@ -14,13 +14,12 @@ import {
|
||||
type AudioTrackProps,
|
||||
} from "@livekit/components-react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import * as controls from "../controls";
|
||||
|
||||
const logger = rootLogger.getChild("[MatrixAudioRenderer]");
|
||||
export interface MatrixAudioRendererProps {
|
||||
/**
|
||||
* The list of participants to render audio for.
|
||||
@@ -72,7 +71,7 @@ export function MatrixAudioRenderer({
|
||||
const logInvalid = (identity: string, validIdentities: Set<string>): void => {
|
||||
if (loggedInvalidIdentities.current.has(identity)) return;
|
||||
logger.warn(
|
||||
`Audio track ${identity} has no matching matrix call member`,
|
||||
`[MatrixAudioRenderer] Audio track ${identity} has no matching matrix call member`,
|
||||
`current members: ${Array.from(validIdentities.values())}`,
|
||||
`track will not get rendered`,
|
||||
);
|
||||
@@ -102,7 +101,7 @@ export function MatrixAudioRenderer({
|
||||
useEffect(() => {
|
||||
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
|
||||
logger.debug(
|
||||
`All audio tracks have a matching matrix call member identity.`,
|
||||
`[MatrixAudioRenderer] All audio tracks have a matching matrix call member identity.`,
|
||||
);
|
||||
loggedInvalidIdentities.current.clear();
|
||||
}
|
||||
@@ -182,7 +181,7 @@ interface StereoPanAudioTrackProps {
|
||||
/**
|
||||
* This wraps `livekit.AudioTrack` to allow adding audio nodes to a track.
|
||||
* It main purpose is to remount the AudioTrack component when switching from
|
||||
* audiooContext to normal audio playback.
|
||||
* audioContext to normal audio playback.
|
||||
* As of now the AudioTrack component does not support adding audio nodes while being mounted.
|
||||
* @param param0
|
||||
* @returns
|
||||
@@ -202,7 +201,7 @@ function AudioTrackWithAudioNodes({
|
||||
const [trackReady, setTrackReady] = useReactiveState(
|
||||
() => false,
|
||||
// We only want the track to reset once both (audioNodes and audioContext) are set.
|
||||
// for unsetting the audioContext its enough if one of the the is undefined.
|
||||
// for unsetting the audioContext its enough if one of the two is undefined.
|
||||
[audioContext && audioNodes],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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 { defer, sleep } from "matrix-js-sdk/lib/utils";
|
||||
import { 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 = defer<void>();
|
||||
// let pendingDisconnection = defer<void>()
|
||||
const pendingConnection = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = Promise.withResolvers<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 = defer<void>();
|
||||
// let pendingDisconnection = defer<void>()
|
||||
const pendingCreateTrack = Promise.withResolvers<void>();
|
||||
// let pendingDisconnection = Promise.withResolvers<void>()
|
||||
const disconnectMock = vi.fn();
|
||||
const connectMock = vi.fn();
|
||||
|
||||
|
||||
@@ -22,17 +22,11 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import {
|
||||
ElementCallError,
|
||||
InsufficientCapacityError,
|
||||
SFURoomCreationRestrictedError,
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { AbortHandle } from "../utils/abortHandle.ts";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
peerConnectionTimeout?: number;
|
||||
websocketTimeout?: number;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Additional values for states that a call can be in, beyond what livekit
|
||||
* provides in ConnectionState. Also reconnects the call if the SFU Config
|
||||
@@ -169,12 +163,7 @@ async function connectAndPublish(
|
||||
|
||||
try {
|
||||
logger.info(`[Lifecycle] Connecting to livekit room ${sfuConfig!.url} ...`);
|
||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt, {
|
||||
// Due to stability issues on Firefox we are testing the effect of different
|
||||
// timeouts, and allow these values to be set through the console
|
||||
peerConnectionTimeout: window.peerConnectionTimeout ?? 45000,
|
||||
websocketTimeout: window.websocketTimeout ?? 45000,
|
||||
});
|
||||
await livekitRoom!.connect(sfuConfig!.url, sfuConfig!.jwt);
|
||||
logger.info(`[Lifecycle] ... connected to livekit room`);
|
||||
} catch (e) {
|
||||
logger.error("[Lifecycle] Failed to connect", e);
|
||||
@@ -184,11 +173,19 @@ async function connectAndPublish(
|
||||
// participant limits.
|
||||
// LiveKit Cloud uses 429 for connection limits.
|
||||
// Either way, all these errors can be explained as "insufficient capacity".
|
||||
if (
|
||||
e instanceof ConnectionError &&
|
||||
(e.status === 503 || e.status === 200 || e.status === 429)
|
||||
)
|
||||
throw new InsufficientCapacityError();
|
||||
if (e instanceof ConnectionError) {
|
||||
if (e.status === 503 || e.status === 200 || e.status === 429) {
|
||||
throw new InsufficientCapacityError();
|
||||
}
|
||||
if (e.status === 404) {
|
||||
// error msg is "Could not establish signal connection: requested room does not exist"
|
||||
// The room does not exist. There are two different modes of operation for the SFU:
|
||||
// - the room is created on the fly when connecting (livekit `auto_create` option)
|
||||
// - Only authorized users can create rooms, so the room must exist before connecting (done by the auth jwt service)
|
||||
// In the first case there will not be a 404, so we are in the second case.
|
||||
throw new SFURoomCreationRestrictedError();
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,10 +19,7 @@ import { act } from "react";
|
||||
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { mockRtcMembership } from "../utils/test";
|
||||
import {
|
||||
CallEventAudioRenderer,
|
||||
MAX_PARTICIPANT_COUNT_FOR_SOUND,
|
||||
} from "./CallEventAudioRenderer";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
@@ -31,7 +28,9 @@ import {
|
||||
aliceRtcMember,
|
||||
bobRtcMember,
|
||||
local,
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { MAX_PARTICIPANT_COUNT_FOR_SOUND } from "../state/CallViewModel";
|
||||
|
||||
vitest.mock("../useAudioContext");
|
||||
vitest.mock("../soundUtils");
|
||||
@@ -55,6 +54,8 @@ beforeEach(() => {
|
||||
playSound = vitest.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
playSoundLooping: vitest.fn(),
|
||||
soundDuration: {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,7 +67,7 @@ beforeEach(() => {
|
||||
* a noise every time.
|
||||
*/
|
||||
test("plays one sound when entering a call", () => {
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
@@ -74,56 +75,72 @@ test("plays one sound when entering a call", () => {
|
||||
|
||||
// Joining a call usually means remote participants are added later.
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("plays a sound when a user joins", () => {
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([aliceRtcMember, bobRtcMember]);
|
||||
rtcMemberships$.next([localRtcMember, aliceRtcMember, bobRtcMember]);
|
||||
});
|
||||
// Play a sound when joining a call.
|
||||
expect(playSound).toBeCalledWith("join");
|
||||
});
|
||||
|
||||
test("plays a sound when a user leaves", () => {
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment([
|
||||
local,
|
||||
alice,
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
remoteRtcMemberships$.next([]);
|
||||
rtcMemberships$.next([localRtcMember]);
|
||||
});
|
||||
expect(playSound).toBeCalledWith("left");
|
||||
});
|
||||
|
||||
test("does not play a sound before the call is successful", () => {
|
||||
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
[localRtcMember],
|
||||
{ waitForCallPickup: true },
|
||||
);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
act(() => {
|
||||
rtcMemberships$.next([localRtcMember]);
|
||||
});
|
||||
expect(playSound).not.toBeCalledWith("left");
|
||||
});
|
||||
|
||||
test("plays no sound when the participant list is more than the maximum size", () => {
|
||||
const mockRtcMemberships: CallMembership[] = [];
|
||||
const mockRtcMemberships: CallMembership[] = [localRtcMember];
|
||||
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {
|
||||
mockRtcMemberships.push(
|
||||
mockRtcMembership(`@user${i}:example.org`, `DEVICE${i}`),
|
||||
);
|
||||
}
|
||||
|
||||
const { vm, remoteRtcMemberships$ } = getBasicCallViewModelEnvironment(
|
||||
const { vm, rtcMemberships$ } = 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(() => {
|
||||
remoteRtcMemberships$.next(
|
||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND - 1),
|
||||
rtcMemberships$.next(
|
||||
mockRtcMemberships.slice(0, MAX_PARTICIPANT_COUNT_FOR_SOUND),
|
||||
);
|
||||
});
|
||||
expect(playSound).toBeCalledWith("left");
|
||||
@@ -155,6 +172,7 @@ test("should not play a sound when a hand raise is retracted", () => {
|
||||
]);
|
||||
render(<CallEventAudioRenderer vm={vm} />);
|
||||
|
||||
playSound.mockClear();
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
["foo"]: {
|
||||
@@ -169,7 +187,7 @@ test("should not play a sound when a hand raise is retracted", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledTimes(2);
|
||||
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
|
||||
act(() => {
|
||||
handRaisedSubject$.next({
|
||||
["foo"]: {
|
||||
@@ -179,5 +197,5 @@ test("should not play a sound when a hand raise is retracted", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(playSound).toHaveBeenCalledTimes(2);
|
||||
expect(playSound).toHaveBeenCalledExactlyOnceWith("raiseHand");
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
@@ -17,15 +16,14 @@ import handSoundOgg from "../sound/raise_hand.ogg";
|
||||
import handSoundMp3 from "../sound/raise_hand.mp3";
|
||||
import screenShareStartedOgg from "../sound/screen_share_started.ogg";
|
||||
import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
|
||||
import declineMp3 from "../sound/call_declined.mp3?url";
|
||||
import declineOgg from "../sound/call_declined.ogg?url";
|
||||
import timeoutMp3 from "../sound/call_timeout.mp3?url";
|
||||
import timeoutOgg from "../sound/call_timeout.ogg?url";
|
||||
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,
|
||||
@@ -43,8 +41,18 @@ export const callEventAudioSounds = prefetchSounds({
|
||||
mp3: screenShareStartedMp3,
|
||||
ogg: screenShareStartedOgg,
|
||||
},
|
||||
decline: {
|
||||
mp3: declineMp3,
|
||||
ogg: declineOgg,
|
||||
},
|
||||
timeout: {
|
||||
mp3: timeoutMp3,
|
||||
ogg: timeoutOgg,
|
||||
},
|
||||
});
|
||||
|
||||
export type CallEventSounds = keyof Awaited<typeof callEventAudioSounds>;
|
||||
|
||||
export function CallEventAudioRenderer({
|
||||
vm,
|
||||
muted,
|
||||
@@ -60,37 +68,18 @@ export function CallEventAudioRenderer({
|
||||
const audioEngineRef = useLatest(audioEngineCtx);
|
||||
|
||||
useEffect(() => {
|
||||
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");
|
||||
});
|
||||
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"),
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
joinSub.unsubscribe();
|
||||
|
||||
@@ -61,3 +61,7 @@
|
||||
.overlay > p {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
min-height: var(--cpd-space-32x);
|
||||
}
|
||||
|
||||
@@ -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 { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { VoiceCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import 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}>
|
||||
<EarpieceIcon aria-hidden />
|
||||
<VoiceCallIcon aria-hidden />
|
||||
</BigIcon>
|
||||
<Heading as="h2" weight="semibold" size="md">
|
||||
{t("earpiece.overlay_title")}
|
||||
{t("handset.overlay_title")}
|
||||
</Heading>
|
||||
<Text>{t("earpiece.overlay_description")}</Text>
|
||||
<Text>{t("handset.overlay_description")}</Text>
|
||||
<Button
|
||||
kind="primary"
|
||||
size="sm"
|
||||
@@ -35,8 +35,10 @@ export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
|
||||
onBackToVideoPressed?.();
|
||||
}}
|
||||
>
|
||||
{t("earpiece.overlay_back_button")}
|
||||
{t("handset.overlay_back_button")}
|
||||
</Button>
|
||||
{/* This spacer is used to give the overlay an offset to the top. */}
|
||||
<div className={styles.spacer} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -132,9 +132,10 @@ test("ConnectionLostError: Action handling should reset error state", async () =
|
||||
const WrapComponent = (): ReactNode => {
|
||||
const [failState, setFailState] = useState(true);
|
||||
const reconnectCallback = useCallback(
|
||||
(action: CallErrorRecoveryAction) => {
|
||||
async (action: CallErrorRecoveryAction) => {
|
||||
reconnectCallbackSpy(action);
|
||||
setFailState(false);
|
||||
return Promise.resolve();
|
||||
},
|
||||
[setFailState],
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
OfflineIcon,
|
||||
WebBrowserIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
import {
|
||||
ConnectionLostError,
|
||||
@@ -35,7 +36,9 @@ import { type WidgetHelpers } from "../widget.ts";
|
||||
|
||||
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||
|
||||
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
|
||||
export type RecoveryActionHandler = (
|
||||
action: CallErrorRecoveryAction,
|
||||
) => Promise<void>;
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: ElementCallError;
|
||||
@@ -70,7 +73,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
if (error instanceof ConnectionLostError) {
|
||||
actions.push({
|
||||
label: t("call_ended_view.reconnect_button"),
|
||||
onClick: () => recoveryActionHandler("reconnect"),
|
||||
onClick: () => void recoveryActionHandler("reconnect"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -93,9 +96,13 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
</p>
|
||||
{actions &&
|
||||
actions.map((action, index) => (
|
||||
<button onClick={action.onClick} key={`action${index}`}>
|
||||
<Button
|
||||
kind="secondary"
|
||||
onClick={action.onClick}
|
||||
key={`action${index}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</ErrorView>
|
||||
</FullScreenView>
|
||||
@@ -126,9 +133,9 @@ export const GroupCallErrorBoundary = ({
|
||||
widget={widget ?? null}
|
||||
error={callError}
|
||||
resetError={resetError}
|
||||
recoveryActionHandler={(action: CallErrorRecoveryAction) => {
|
||||
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
|
||||
await recoveryActionHandler(action);
|
||||
resetError();
|
||||
recoveryActionHandler(action);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,10 +12,14 @@ import {
|
||||
onTestFinished,
|
||||
test,
|
||||
vi,
|
||||
vitest,
|
||||
} from "vitest";
|
||||
import { render, waitFor, screen } from "@testing-library/react";
|
||||
import { render, waitFor, screen, act } from "@testing-library/react";
|
||||
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
@@ -94,13 +98,15 @@ beforeEach(() => {
|
||||
playSound = vi.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
playSoundLooping: vi.fn(),
|
||||
soundDuration: {},
|
||||
});
|
||||
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
|
||||
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
|
||||
({ onLeave }) => {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => onLeave()}>Leave</button>
|
||||
<button onClick={() => onLeave("user")}>Leave</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -137,11 +143,9 @@ function createGroupCallView(
|
||||
getJoinRule: () => JoinRule.Invite,
|
||||
} as Partial<RoomState> as RoomState,
|
||||
});
|
||||
const rtcSession = new MockRTCSession(
|
||||
room,
|
||||
localRtcMember,
|
||||
[],
|
||||
).withMemberships(constant([]));
|
||||
const rtcSession = new MockRTCSession(room, []).withMemberships(
|
||||
constant([localRtcMember]),
|
||||
);
|
||||
rtcSession.joined = joined;
|
||||
const muteState = {
|
||||
audio: { enabled: false },
|
||||
@@ -208,6 +212,8 @@ test("GroupCallView plays a leave sound synchronously in widget mode", async ()
|
||||
);
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
playSoundLooping: vitest.fn(),
|
||||
soundDuration: {},
|
||||
});
|
||||
|
||||
const { getByText, rtcSession } = createGroupCallView(
|
||||
@@ -260,3 +266,19 @@ test("GroupCallView shows errors that occur during joining", async () => {
|
||||
await user.click(screen.getByRole("button", { name: "Join call" }));
|
||||
screen.getByText("Call is not supported");
|
||||
});
|
||||
|
||||
test("user can reconnect after a membership manager error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(null, true);
|
||||
await act(() =>
|
||||
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
||||
);
|
||||
// XXX: Wrapping the following click in act() shouldn't be necessary (the
|
||||
// async state update should be processed automatically by the waitFor call),
|
||||
// and yet here we are.
|
||||
await act(async () =>
|
||||
user.click(screen.getByRole("button", { name: "Reconnect" })),
|
||||
);
|
||||
// In-call controls should be visible again
|
||||
await waitFor(() => screen.getByRole("button", { name: "Leave" }));
|
||||
});
|
||||
|
||||
@@ -50,10 +50,13 @@ import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { callEventAudioSounds } from "./CallEventAudioRenderer";
|
||||
import {
|
||||
callEventAudioSounds,
|
||||
type CallEventSounds,
|
||||
} from "./CallEventAudioRenderer";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import {
|
||||
@@ -83,8 +86,8 @@ interface Props {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
confineToRoom: boolean;
|
||||
preload: boolean;
|
||||
skipLobby: boolean;
|
||||
preload: UrlParams["preload"];
|
||||
skipLobby: UrlParams["skipLobby"];
|
||||
header: HeaderStyle;
|
||||
rtcSession: MatrixRTCSession;
|
||||
isJoined: boolean;
|
||||
@@ -166,7 +169,11 @@ export const GroupCallView: FC<Props> = ({
|
||||
const { displayName, avatarUrl } = useProfile(client);
|
||||
const roomName = useRoomName(room);
|
||||
const roomAvatar = useRoomAvatar(room);
|
||||
const { perParticipantE2EE, returnToLobby } = useUrlParams();
|
||||
const {
|
||||
perParticipantE2EE,
|
||||
returnToLobby,
|
||||
password: passwordFromUrl,
|
||||
} = useUrlParams();
|
||||
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
|
||||
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
|
||||
const [useExperimentalToDeviceTransport] = useSetting(
|
||||
@@ -174,7 +181,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
);
|
||||
|
||||
// Save the password once we start the groupCallView
|
||||
const { password: passwordFromUrl } = useUrlParams();
|
||||
useEffect(() => {
|
||||
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
|
||||
}, [passwordFromUrl, room.roomId]);
|
||||
@@ -273,34 +279,28 @@ export const GroupCallView: FC<Props> = ({
|
||||
};
|
||||
|
||||
if (skipLobby) {
|
||||
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
|
||||
if (widget && 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);
|
||||
logger.error("Error joining RTC session on preload", e);
|
||||
});
|
||||
}
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
} else {
|
||||
void enterRTCSessionOrError(rtcSession);
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -320,8 +320,11 @@ export const GroupCallView: FC<Props> = ({
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onLeave = useCallback(
|
||||
(cause: "user" | "error" = "user"): void => {
|
||||
const audioPromise = leaveSoundContext.current?.playSound("left");
|
||||
(
|
||||
cause: "user" | "error" = "user",
|
||||
playSound: CallEventSounds = "left",
|
||||
): void => {
|
||||
const audioPromise = leaveSoundContext.current?.playSound(playSound);
|
||||
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
|
||||
// therefore we want the event to be sent instantly without getting queued/batched.
|
||||
const sendInstantly = !!widget;
|
||||
@@ -449,7 +452,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
client={client}
|
||||
matrixInfo={matrixInfo}
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
participantCount={participantCount}
|
||||
matrixRoom={room}
|
||||
onLeave={onLeave}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
@@ -491,6 +494,7 @@ 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;
|
||||
@@ -499,10 +503,11 @@ export const GroupCallView: FC<Props> = ({
|
||||
return (
|
||||
<GroupCallErrorBoundary
|
||||
widget={widget}
|
||||
recoveryActionHandler={(action) => {
|
||||
recoveryActionHandler={async (action) => {
|
||||
setExternalError(null);
|
||||
if (action == "reconnect") {
|
||||
setLeft(false);
|
||||
enterRTCSessionOrError(rtcSession).catch((e) => {
|
||||
await enterRTCSessionOrError(rtcSession).catch((e) => {
|
||||
logger.error("Error re-entering RTC session", e);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,14 +108,6 @@ 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;
|
||||
}
|
||||
@@ -127,6 +119,13 @@ 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);
|
||||
|
||||
@@ -17,7 +17,7 @@ import { act, render, type RenderResult } from "@testing-library/react";
|
||||
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
import { ConnectionState, type LocalParticipant } from "livekit-client";
|
||||
import { type LocalParticipant } from "livekit-client";
|
||||
import { of } from "rxjs";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
@@ -175,12 +175,11 @@ function createInCallView(): RenderResult & {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
matrixRoom={room}
|
||||
livekitRoom={livekitRoom}
|
||||
participantCount={0}
|
||||
onLeave={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
connState={ConnectionState.Connected}
|
||||
onShareClick={null}
|
||||
/>
|
||||
</RoomContext>
|
||||
|
||||
@@ -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 } from "livekit-client";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
|
||||
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
type PointerEvent,
|
||||
@@ -25,11 +25,15 @@ import useMeasure from "react-use-measure";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import classNames from "classnames";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import {
|
||||
useObservable,
|
||||
useObservableEagerState,
|
||||
useSubscription,
|
||||
} from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
||||
import {
|
||||
EarpieceIcon,
|
||||
VoiceCallSolidIcon,
|
||||
VolumeOnSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -44,13 +48,13 @@ import {
|
||||
ShareScreenButton,
|
||||
SettingsButton,
|
||||
ReactionToggleButton,
|
||||
SwitchCameraButton,
|
||||
} from "../button";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { type HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import styles from "./InCallView.module.css";
|
||||
import overlayStyles from "../Overlay.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
import { type OTelGroupCallMembership } from "../otel/OTelGroupCallMembership";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
@@ -63,7 +67,6 @@ import { type MuteStates } from "./MuteStates";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import { LayoutToggle } from "./LayoutToggle";
|
||||
import { type ECConnectionState } from "../livekit/useECConnectionState";
|
||||
import { useOpenIDSFU } from "../livekit/openIDSFU";
|
||||
import {
|
||||
CallViewModel,
|
||||
@@ -94,9 +97,11 @@ import {
|
||||
useReactionsSender,
|
||||
} from "../reactions/useReactionsSender";
|
||||
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
|
||||
import { useSwitchCamera } from "./useSwitchCamera";
|
||||
import { ReactionsOverlay } from "./ReactionsOverlay";
|
||||
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
|
||||
import {
|
||||
CallEventAudioRenderer,
|
||||
type CallEventSounds,
|
||||
} from "./CallEventAudioRenderer";
|
||||
import {
|
||||
debugTileLayout as debugTileLayoutSetting,
|
||||
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
|
||||
@@ -113,6 +118,13 @@ 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";
|
||||
import { Avatar, Size as AvatarSize } from "../Avatar";
|
||||
import waitingStyles from "./WaitingForJoin.module.css";
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import ringtoneMp3 from "../sound/ringtone.mp3?url";
|
||||
import ringtoneOgg from "../sound/ringtone.ogg?url";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -140,17 +152,17 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
logger.info(
|
||||
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
|
||||
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
|
||||
);
|
||||
return (): void => {
|
||||
logger.info(
|
||||
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
|
||||
`[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
|
||||
);
|
||||
livekitRoom
|
||||
?.disconnect()
|
||||
.then(() => {
|
||||
logger.info(
|
||||
`[Lifecycle] Disconnected from livekite room, state:${livekitRoom?.state}`,
|
||||
`[Lifecycle] Disconnected from livekit room, state:${livekitRoom?.state}`,
|
||||
);
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -159,14 +171,23 @@ 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,
|
||||
props.e2eeSystem,
|
||||
{
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
autoLeaveWhenOthersLeft,
|
||||
waitForCallPickup:
|
||||
waitForCallPickup && sendNotificationType === "ring",
|
||||
},
|
||||
connStateObservable$,
|
||||
reactionsReader.raisedHands$,
|
||||
reactionsReader.reactions$,
|
||||
@@ -179,10 +200,14 @@ 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;
|
||||
@@ -190,12 +215,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
return (
|
||||
<RoomContext value={livekitRoom}>
|
||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||
<InCallView
|
||||
{...props}
|
||||
vm={vm}
|
||||
livekitRoom={livekitRoom}
|
||||
connState={connState}
|
||||
/>
|
||||
<InCallView {...props} vm={vm} livekitRoom={livekitRoom} />
|
||||
</ReactionsSenderProvider>
|
||||
</RoomContext>
|
||||
);
|
||||
@@ -206,14 +226,13 @@ export interface InCallViewProps {
|
||||
vm: CallViewModel;
|
||||
matrixInfo: MatrixInfo;
|
||||
rtcSession: MatrixRTCSession;
|
||||
livekitRoom: Room;
|
||||
matrixRoom: MatrixRoom;
|
||||
livekitRoom: LivekitRoom;
|
||||
muteStates: MuteStates;
|
||||
participantCount: number;
|
||||
/** Function to call when the user explicitly ends the call */
|
||||
onLeave: () => void;
|
||||
onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
|
||||
header: HeaderStyle;
|
||||
otelGroupCallMembership?: OTelGroupCallMembership;
|
||||
connState: ECConnectionState;
|
||||
onShareClick: (() => void) | null;
|
||||
}
|
||||
|
||||
@@ -222,12 +241,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
vm,
|
||||
matrixInfo,
|
||||
rtcSession,
|
||||
matrixRoom,
|
||||
livekitRoom,
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
header: headerStyle,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -235,10 +253,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
useReactionsSender();
|
||||
|
||||
useWakeLock();
|
||||
const connectionState = useObservableEagerState(vm.livekitConnectionState$);
|
||||
|
||||
// annoyingly we don't get the disconnection reason this way,
|
||||
// only by listening for the emitted event
|
||||
if (connState === ConnectionState.Disconnected)
|
||||
if (connectionState === ConnectionState.Disconnected)
|
||||
throw new ConnectionLostError();
|
||||
|
||||
const containerRef1 = useRef<HTMLDivElement | null>(null);
|
||||
@@ -253,6 +272,21 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
});
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
// Call pickup state and display names are needed for waiting overlay/sounds
|
||||
const callPickupState = useBehavior(vm.callPickupState$);
|
||||
|
||||
// Preload a waiting and decline sounds
|
||||
const pickupPhaseSoundCache = useInitial(async () => {
|
||||
return prefetchSounds({
|
||||
waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
|
||||
});
|
||||
});
|
||||
|
||||
const pickupPhaseAudio = useAudioContext({
|
||||
sounds: pickupPhaseSoundCache,
|
||||
latencyHint: "interactive",
|
||||
muted: muteAllAudio,
|
||||
});
|
||||
|
||||
// This seems like it might be enough logic to use move it into the call view model?
|
||||
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
|
||||
@@ -266,7 +300,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const [useExperimentalToDeviceTransport] = useSetting(
|
||||
useExperimentalToDeviceTransportSetting,
|
||||
);
|
||||
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
|
||||
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
|
||||
const showToDeviceEncryption = useMemo(
|
||||
@@ -303,6 +337,8 @@ 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$);
|
||||
@@ -312,7 +348,90 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
|
||||
const switchCamera = useSwitchCamera(vm.localVideo$);
|
||||
useSubscription(vm.autoLeave$, () => onLeave("user"));
|
||||
|
||||
// We need to set the proper timings on the animation based upon the sound length.
|
||||
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
|
||||
useEffect((): (() => void) => {
|
||||
// The CSS animation includes the delay, so we must double the length of the sound.
|
||||
window.document.body.style.setProperty(
|
||||
"--call-ring-duration-s",
|
||||
`${ringDuration * 2}s`,
|
||||
);
|
||||
window.document.body.style.setProperty(
|
||||
"--call-ring-delay-s",
|
||||
`${ringDuration}s`,
|
||||
);
|
||||
// Remove properties when we unload.
|
||||
return () => {
|
||||
window.document.body.style.removeProperty("--call-ring-duration-s");
|
||||
window.document.body.style.removeProperty("--call-ring-delay-s");
|
||||
};
|
||||
}, [pickupPhaseAudio?.soundDuration, ringDuration]);
|
||||
|
||||
// When we enter timeout or decline we will leave the call.
|
||||
useEffect((): void | (() => void) => {
|
||||
if (callPickupState === "timeout") {
|
||||
onLeave("user", "timeout");
|
||||
}
|
||||
if (callPickupState === "decline") {
|
||||
onLeave("user", "decline");
|
||||
}
|
||||
}, [callPickupState, onLeave, pickupPhaseAudio]);
|
||||
|
||||
// When waiting for pickup, loop a waiting sound
|
||||
useEffect((): void | (() => void) => {
|
||||
if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
|
||||
const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration);
|
||||
return () => {
|
||||
void endSound().catch((e) => {
|
||||
logger.error("Failed to stop ringing sound", e);
|
||||
});
|
||||
};
|
||||
}, [callPickupState, pickupPhaseAudio, ringDuration]);
|
||||
|
||||
// Waiting UI overlay
|
||||
const waitingOverlay: JSX.Element | null = useMemo(() => {
|
||||
// No overlay if not in ringing state
|
||||
if (callPickupState !== "ringing") return null;
|
||||
|
||||
// Use room state for other participants data (the one that we likely want to reach)
|
||||
const roomOthers = [
|
||||
...matrixRoom.getMembersWithMembership("join"),
|
||||
...matrixRoom.getMembersWithMembership("invite"),
|
||||
].filter((m) => m.userId !== client.getUserId());
|
||||
// Yield if there are not other members in the room.
|
||||
if (roomOthers.length === 0) return null;
|
||||
|
||||
const otherMember = roomOthers.length > 0 ? roomOthers[0] : undefined;
|
||||
const isOneOnOne = roomOthers.length === 1 && otherMember;
|
||||
const text = isOneOnOne
|
||||
? `Waiting for ${otherMember.name ?? otherMember.userId} to join…`
|
||||
: "Waiting for other participants…";
|
||||
const avatarMxc = isOneOnOne
|
||||
? (otherMember.getMxcAvatarUrl?.() ?? undefined)
|
||||
: (matrixRoom.getMxcAvatarUrl() ?? undefined);
|
||||
|
||||
return (
|
||||
<div className={classNames(overlayStyles.bg, waitingStyles.overlay)}>
|
||||
<div
|
||||
className={classNames(overlayStyles.content, waitingStyles.content)}
|
||||
>
|
||||
<div className={waitingStyles.pulse}>
|
||||
<Avatar
|
||||
id={isOneOnOne ? otherMember.userId : matrixRoom.roomId}
|
||||
name={isOneOnOne ? otherMember.name : matrixRoom.name}
|
||||
src={avatarMxc}
|
||||
size={AvatarSize.XL}
|
||||
/>
|
||||
</div>
|
||||
<Text size="md" className={waitingStyles.text}>
|
||||
{text}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [callPickupState, client, matrixRoom]);
|
||||
|
||||
// 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
|
||||
@@ -458,9 +577,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
useMemo(() => {
|
||||
if (audioOutputSwitcher === null) return null;
|
||||
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
|
||||
const Icon = isEarpieceTarget ? EarpieceIcon : VolumeOnSolidIcon;
|
||||
const Icon = isEarpieceTarget ? VoiceCallSolidIcon : VolumeOnSolidIcon;
|
||||
const label = isEarpieceTarget
|
||||
? t("settings.devices.earpiece")
|
||||
? t("settings.devices.handset")
|
||||
: t("settings.devices.loudspeaker");
|
||||
|
||||
return (
|
||||
@@ -495,7 +614,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
break;
|
||||
case "standard":
|
||||
header = (
|
||||
<Header className={styles.header} ref={headerRef}>
|
||||
<Header
|
||||
className={styles.header}
|
||||
ref={headerRef}
|
||||
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
|
||||
>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
@@ -635,7 +758,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
};
|
||||
|
||||
const rageshakeRequestModalProps = useRageshakeRequestModal(
|
||||
rtcSession.room.roomId,
|
||||
matrixRoom.roomId,
|
||||
);
|
||||
|
||||
const toggleScreensharing = useCallback(() => {
|
||||
@@ -669,15 +792,6 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
data-testid="incall_videomute"
|
||||
/>,
|
||||
);
|
||||
if (switchCamera !== null)
|
||||
buttons.push(
|
||||
<SwitchCameraButton
|
||||
key="switch_camera"
|
||||
className={styles.switchCamera}
|
||||
onClick={switchCamera}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
/>,
|
||||
);
|
||||
if (canScreenshare && !hideScreensharing) {
|
||||
buttons.push(
|
||||
<ShareScreenButton
|
||||
@@ -714,7 +828,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
<EndCallButton
|
||||
key="end_call"
|
||||
onClick={function (): void {
|
||||
onLeave();
|
||||
onLeave("user");
|
||||
}}
|
||||
onTouchEnd={onControlsTouchEnd}
|
||||
data-testid="incall_leave"
|
||||
@@ -755,6 +869,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
// The reconnecting toast cannot be dismissed
|
||||
const onDismissReconnectingToast = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.inRoom}
|
||||
@@ -782,18 +899,26 @@ 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}
|
||||
show={earpieceMode && !reconnecting}
|
||||
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
||||
/>
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{waitingOverlay}
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
<>
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
<SettingsModal
|
||||
client={client}
|
||||
roomId={rtcSession.room.roomId}
|
||||
roomId={matrixRoom.roomId}
|
||||
open={settingsModalOpen}
|
||||
onDismiss={closeSettings}
|
||||
tab={settingsTab}
|
||||
|
||||
@@ -24,8 +24,7 @@ import {
|
||||
type LocalVideoTrack,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { map } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import inCallStyles from "./InCallView.module.css";
|
||||
@@ -38,7 +37,6 @@ import {
|
||||
EndCallButton,
|
||||
MicButton,
|
||||
SettingsButton,
|
||||
SwitchCameraButton,
|
||||
VideoButton,
|
||||
} from "../button/Button";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
@@ -47,7 +45,6 @@ import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { Link } from "../button/Link";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera";
|
||||
import {
|
||||
useTrackProcessor,
|
||||
useTrackProcessorSync,
|
||||
@@ -195,12 +192,6 @@ export const LobbyView: FC<Props> = ({
|
||||
}, [devices, videoInputId, videoTrack]);
|
||||
|
||||
useTrackProcessorSync(videoTrack);
|
||||
const showSwitchCamera = useShowSwitchCamera(
|
||||
useObservable(
|
||||
(inputs$) => inputs$.pipe(map(([video]) => video)),
|
||||
[videoTrack],
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||
// animations and don't have to feel bad about reusing its CSS
|
||||
@@ -257,9 +248,6 @@ export const LobbyView: FC<Props> = ({
|
||||
onClick={onVideoPress}
|
||||
disabled={muteStates.video.setEnabled === null}
|
||||
/>
|
||||
{showSwitchCamera && (
|
||||
<SwitchCameraButton onClick={showSwitchCamera} />
|
||||
)}
|
||||
<SettingsButton onClick={openSettings} />
|
||||
{!confineToRoom && <EndCallButton onClick={onLeaveClick} />}
|
||||
</div>
|
||||
|
||||
@@ -191,7 +191,11 @@ describe("useMuteStates", () => {
|
||||
mockConfig();
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/room/?skipLobby=true"]}>
|
||||
<MemoryRouter
|
||||
initialEntries={[
|
||||
"/room/?skipLobby=true&widgetId=1234&parentUrl=www.parent.org",
|
||||
]}
|
||||
>
|
||||
<MediaDevicesContext value={mockMediaDevices()}>
|
||||
<TestComponent />
|
||||
</MediaDevicesContext>
|
||||
|
||||
@@ -69,6 +69,8 @@ beforeEach(() => {
|
||||
playSound = vitest.fn();
|
||||
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
|
||||
playSound,
|
||||
playSoundLooping: vitest.fn(),
|
||||
soundDuration: {},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
61
src/room/WaitingForJoin.module.css
Normal file
@@ -0,0 +1,61 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
position: relative;
|
||||
height: 90px;
|
||||
}
|
||||
|
||||
.pulse::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
border-radius: 9999px;
|
||||
border: 12px solid rgba(255, 255, 255, 0.6);
|
||||
animation: pulse var(--call-ring-duration-s) ease-out infinite;
|
||||
animation-delay: 1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0.7;
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
35% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.15;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0;
|
||||
}
|
||||
50.01% {
|
||||
transform: scale(0);
|
||||
}
|
||||
85% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,13 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
||||
<p>
|
||||
You were disconnected from the call.
|
||||
</p>
|
||||
<button>
|
||||
<button
|
||||
class="_button_vczzf_8"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
@@ -742,7 +748,13 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
||||
<p>
|
||||
You were disconnected from the call.
|
||||
</p>
|
||||
<button>
|
||||
<button
|
||||
class="_button_vczzf_8"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -49,6 +49,34 @@ 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
|
||||
@@ -98,18 +126,30 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M8.929 15.1a13.6 13.6 0 0 0 4.654 3.066q2.62 1.036 5.492.923h.008l.003-.004.003-.002-.034-3.124-3.52-.483-1.791 1.792-.645-.322a13.5 13.5 0 0 1-3.496-2.52 13.4 13.4 0 0 1-2.52-3.496l-.322-.644 1.792-1.792-.483-3.519-3.123-.034-.003.002-.003.004v.002a13.65 13.65 0 0 0 .932 5.492A13.4 13.4 0 0 0 8.93 15.1m3.92 4.926a15.6 15.6 0 0 1-5.334-3.511 15.4 15.4 0 0 1-3.505-5.346 15.6 15.6 0 0 1-1.069-6.274 1.93 1.93 0 0 1 .589-1.366c.366-.366.84-.589 1.386-.589h.01l3.163.035a1.96 1.96 0 0 1 1.958 1.694v.005l.487 3.545v.003c.043.297.025.605-.076.907a2 2 0 0 1-.485.773l-.762.762a11.4 11.4 0 0 0 3.206 3.54q.457.33.948.614l.762-.761a2 2 0 0 1 .774-.486c.302-.1.61-.118.907-.076l3.553.487a1.96 1.96 0 0 1 1.694 1.958l.034 3.174c0 .546-.223 1.02-.588 1.386-.361.36-.827.582-1.363.588a15.3 15.3 0 0 1-6.29-1.062"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Earpiece Mode
|
||||
Handset Mode
|
||||
</h2>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
|
||||
@@ -125,6 +165,9 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
Back to Speaker Mode
|
||||
</button>
|
||||
<div
|
||||
class="spacer"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="container"
|
||||
@@ -241,7 +284,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«r5»"
|
||||
aria-labelledby="«r8»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -264,7 +307,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby="«ra»"
|
||||
aria-labelledby="«rd»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -286,7 +329,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«rf»"
|
||||
aria-labelledby="«ri»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -307,7 +350,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby="«rk»"
|
||||
aria-labelledby="«rn»"
|
||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -333,7 +376,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="toggle layout"
|
||||
>
|
||||
<input
|
||||
aria-labelledby="«rp»"
|
||||
aria-labelledby="«rs»"
|
||||
name="layout"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
@@ -351,7 +394,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby="«ru»"
|
||||
aria-labelledby="«r11»"
|
||||
checked=""
|
||||
name="layout"
|
||||
type="radio"
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
import {
|
||||
facingModeFromLocalTrack,
|
||||
type LocalVideoTrack,
|
||||
TrackEvent,
|
||||
} from "livekit-client";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { platform } from "../Platform";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
/**
|
||||
* Determines whether the user should be shown a button to switch their camera,
|
||||
* producing a callback if so.
|
||||
*/
|
||||
export function useSwitchCamera(
|
||||
video$: Observable<LocalVideoTrack | null>,
|
||||
): (() => void) | null {
|
||||
const mediaDevices = useMediaDevices();
|
||||
const setVideoInput = useLatest(mediaDevices.videoInput.select);
|
||||
|
||||
// Produce an observable like the input 'video' observable, except make it
|
||||
// emit whenever the track is muted or the device changes
|
||||
const videoTrack$: Observable<LocalVideoTrack | null> = useObservable(
|
||||
(inputs$) =>
|
||||
inputs$.pipe(
|
||||
switchMap(([video$]) => video$),
|
||||
switchMap((video) => {
|
||||
if (video === null) return of(null);
|
||||
return merge(
|
||||
fromEvent(video, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
map(() => video),
|
||||
),
|
||||
fromEvent(video, TrackEvent.Muted).pipe(map(() => null)),
|
||||
);
|
||||
}),
|
||||
),
|
||||
[video$],
|
||||
);
|
||||
|
||||
const switchCamera$: Observable<(() => void) | null> = useObservable(
|
||||
(inputs$) =>
|
||||
platform === "desktop"
|
||||
? of(null)
|
||||
: inputs$.pipe(
|
||||
switchMap(([track$]) => track$),
|
||||
map((track) => {
|
||||
if (track === null) return null;
|
||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
if (facingMode !== "user" && facingMode !== "environment")
|
||||
return null;
|
||||
// Restart the track with a camera facing the opposite direction
|
||||
return (): void =>
|
||||
void track
|
||||
.restartTrack({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
})
|
||||
.then(() => {
|
||||
// Inform the MediaDeviceContext which camera was chosen
|
||||
const deviceId =
|
||||
track.mediaStreamTrack.getSettings().deviceId;
|
||||
if (deviceId !== undefined) setVideoInput.current(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
);
|
||||
}),
|
||||
),
|
||||
[videoTrack$],
|
||||
);
|
||||
|
||||
return useObservableEagerState(switchCamera$);
|
||||
}
|
||||
@@ -70,6 +70,12 @@ test("It joins the correct Session", async () => {
|
||||
roomId: "roomId",
|
||||
client: {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||
access_token: "ACCCESS_TOKEN",
|
||||
token_type: "Bearer",
|
||||
matrix_server_name: "localhost",
|
||||
expires_in: 10000,
|
||||
}),
|
||||
},
|
||||
},
|
||||
memberships: [],
|
||||
@@ -195,6 +201,12 @@ test("It should not fail with configuration error if homeserver config has livek
|
||||
roomId: "roomId",
|
||||
client: {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||
access_token: "ACCCESS_TOKEN",
|
||||
token_type: "Bearer",
|
||||
matrix_server_name: "localhost",
|
||||
expires_in: 10000,
|
||||
}),
|
||||
},
|
||||
},
|
||||
memberships: [],
|
||||
|
||||
@@ -5,14 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
isLivekitFocus,
|
||||
isLivekitFocusConfig,
|
||||
type LivekitFocus,
|
||||
type LivekitFocusActive,
|
||||
type MatrixRTCSession,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
|
||||
|
||||
import { PosthogAnalytics } from "./analytics/PosthogAnalytics";
|
||||
@@ -20,6 +20,7 @@ import { Config } from "./config/Config";
|
||||
import { ElementWidgetActions, widget, type WidgetHelpers } from "./widget";
|
||||
import { MatrixRTCFocusMissingError } from "./utils/errors";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { getSFUConfigWithOpenID } from "./livekit/openIDSFU.ts";
|
||||
|
||||
const FOCI_WK_KEY = "org.matrix.msc4143.rtc_foci";
|
||||
|
||||
@@ -46,6 +47,9 @@ async function makePreferredLivekitFoci(
|
||||
preferredFoci.push(focusInUse);
|
||||
}
|
||||
|
||||
// Warm up the first focus we owned, to ensure livekit room is created before any state event sent.
|
||||
let toWarmUp: LivekitFocus | undefined;
|
||||
|
||||
// Prioritize the .well-known/matrix/client, if available, over the configured SFU
|
||||
const domain = rtcSession.room.client.getDomain();
|
||||
if (domain) {
|
||||
@@ -55,18 +59,17 @@ async function makePreferredLivekitFoci(
|
||||
FOCI_WK_KEY
|
||||
];
|
||||
if (Array.isArray(wellKnownFoci)) {
|
||||
preferredFoci.push(
|
||||
...wellKnownFoci
|
||||
.filter((f) => !!f)
|
||||
.filter(isLivekitFocusConfig)
|
||||
.map((wellKnownFocus) => {
|
||||
logger.log(
|
||||
"Adding livekit focus from well known: ",
|
||||
wellKnownFocus,
|
||||
);
|
||||
return { ...wellKnownFocus, livekit_alias: livekitAlias };
|
||||
}),
|
||||
);
|
||||
const validWellKnownFoci = wellKnownFoci
|
||||
.filter((f) => !!f)
|
||||
.filter(isLivekitFocusConfig)
|
||||
.map((wellKnownFocus) => {
|
||||
logger.log("Adding livekit focus from well known: ", wellKnownFocus);
|
||||
return { ...wellKnownFocus, livekit_alias: livekitAlias };
|
||||
});
|
||||
if (validWellKnownFoci.length > 0) {
|
||||
toWarmUp = validWellKnownFoci[0];
|
||||
}
|
||||
preferredFoci.push(...validWellKnownFoci);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +80,15 @@ async function makePreferredLivekitFoci(
|
||||
livekit_service_url: urlFromConf,
|
||||
livekit_alias: livekitAlias,
|
||||
};
|
||||
toWarmUp = toWarmUp ?? focusFormConf;
|
||||
logger.log("Adding livekit focus from config: ", focusFormConf);
|
||||
preferredFoci.push(focusFormConf);
|
||||
}
|
||||
|
||||
if (toWarmUp) {
|
||||
// this will call the jwt/sfu/get endpoint to pre create the livekit room.
|
||||
await getSFUConfigWithOpenID(rtcSession.room.client, toWarmUp);
|
||||
}
|
||||
if (preferredFoci.length === 0)
|
||||
throw new MatrixRTCFocusMissingError(domain ?? "");
|
||||
return Promise.resolve(preferredFoci);
|
||||
@@ -123,15 +131,13 @@ export async function enterRTCSession(
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
}),
|
||||
delayedLeaveEventRestartMs:
|
||||
matrixRtcSessionConfig?.delayed_leave_event_restart_ms ??
|
||||
matrixRtcSessionConfig?.membership_keep_alive_period,
|
||||
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
|
||||
delayedLeaveEventDelayMs:
|
||||
matrixRtcSessionConfig?.delayed_leave_event_delay_ms ??
|
||||
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
|
||||
matrixRtcSessionConfig?.delayed_leave_event_delay_ms,
|
||||
delayedLeaveEventRestartLocalTimeoutMs:
|
||||
matrixRtcSessionConfig?.delayed_leave_event_restart_local_timeout_ms,
|
||||
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
|
||||
makeKeyDelay:
|
||||
matrixRtcSessionConfig?.wait_for_key_rotation_ms ??
|
||||
matrixRtcSessionConfig?.key_rotation_on_leave_delay,
|
||||
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
|
||||
membershipEventExpiryMs:
|
||||
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
||||
useExperimentalToDeviceTransport,
|
||||
|
||||
@@ -98,7 +98,7 @@ export const DeviceSelection: FC<Props> = ({
|
||||
labelText = t("settings.devices.loudspeaker");
|
||||
break;
|
||||
case "earpiece":
|
||||
labelText = t("settings.devices.earpiece");
|
||||
labelText = t("settings.devices.handset");
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
BIN
src/sound/call_declined.mp3
Normal file
BIN
src/sound/call_declined.ogg
Normal file
BIN
src/sound/call_timeout.mp3
Normal file
BIN
src/sound/call_timeout.ogg
Normal file
BIN
src/sound/ringtone.mp3
Normal file
BIN
src/sound/ringtone.ogg
Normal file
@@ -11,37 +11,52 @@ import {
|
||||
observeParticipantMedia,
|
||||
} from "@livekit/components-core";
|
||||
import {
|
||||
ConnectionState,
|
||||
type Room as LivekitRoom,
|
||||
type LocalParticipant,
|
||||
LocalVideoTrack,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { RoomStateEvent, type Room, type RoomMember } from "matrix-js-sdk";
|
||||
import {
|
||||
ClientEvent,
|
||||
type EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
RoomEvent,
|
||||
RoomStateEvent,
|
||||
SyncState,
|
||||
type Room as MatrixRoom,
|
||||
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,
|
||||
skipWhile,
|
||||
startWith,
|
||||
switchAll,
|
||||
switchMap,
|
||||
switchScan,
|
||||
take,
|
||||
takeUntil,
|
||||
throttleTime,
|
||||
timer,
|
||||
withLatestFrom,
|
||||
} from "rxjs";
|
||||
@@ -50,6 +65,9 @@ import {
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
type MatrixRTCSessionEventHandlerMap,
|
||||
MembershipManagerEvent,
|
||||
Status,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { ViewModel } from "./ViewModel";
|
||||
@@ -60,12 +78,16 @@ import {
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
type MediaViewModel,
|
||||
observeTrackReference$,
|
||||
RemoteUserMediaViewModel,
|
||||
ScreenShareViewModel,
|
||||
type UserMediaViewModel,
|
||||
} from "./MediaViewModel";
|
||||
import { accumulate, finalizeValue } from "../utils/observable";
|
||||
import {
|
||||
accumulate,
|
||||
and$,
|
||||
finalizeValue,
|
||||
pauseWhen,
|
||||
} from "../utils/observable";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import {
|
||||
duplicateTiles,
|
||||
@@ -94,12 +116,27 @@ import { observeSpeaker$ } from "./observeSpeaker";
|
||||
import { shallowEquals } from "../utils/array";
|
||||
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type Behavior } from "./Behavior";
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -259,6 +296,8 @@ 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>,
|
||||
@@ -272,6 +311,7 @@ class UserMedia {
|
||||
this.participant$ as Behavior<LocalParticipant>,
|
||||
encryptionSystem,
|
||||
livekitRoom,
|
||||
mediaDevices,
|
||||
this.scope.behavior(displayname$),
|
||||
this.scope.behavior(handRaised$),
|
||||
this.scope.behavior(reaction$),
|
||||
@@ -285,6 +325,7 @@ class UserMedia {
|
||||
>,
|
||||
encryptionSystem,
|
||||
livekitRoom,
|
||||
pretendToBeDisconnected$,
|
||||
this.scope.behavior(displayname$),
|
||||
this.scope.behavior(handRaised$),
|
||||
this.scope.behavior(reaction$),
|
||||
@@ -338,7 +379,8 @@ class ScreenShare {
|
||||
member: RoomMember | undefined,
|
||||
participant: LocalParticipant | RemoteParticipant,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
liveKitRoom: LivekitRoom,
|
||||
livekitRoom: LivekitRoom,
|
||||
pretendToBeDisconnected$: Behavior<boolean>,
|
||||
displayName$: Observable<string>,
|
||||
) {
|
||||
this.participant$ = new BehaviorSubject(participant);
|
||||
@@ -348,7 +390,8 @@ class ScreenShare {
|
||||
member,
|
||||
this.participant$.asObservable(),
|
||||
encryptionSystem,
|
||||
liveKitRoom,
|
||||
livekitRoom,
|
||||
pretendToBeDisconnected$,
|
||||
this.scope.behavior(displayName$),
|
||||
participant.isLocal,
|
||||
);
|
||||
@@ -364,7 +407,7 @@ type MediaItem = UserMedia | ScreenShare;
|
||||
|
||||
function getRoomMemberFromRtcMember(
|
||||
rtcMember: CallMembership,
|
||||
room: Room,
|
||||
room: MatrixRoom,
|
||||
): { 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.
|
||||
@@ -386,18 +429,79 @@ function getRoomMemberFromRtcMember(
|
||||
|
||||
// TODO: Move wayyyy more business logic from the call and lobby views into here
|
||||
export class CallViewModel extends ViewModel {
|
||||
public readonly localVideo$ = this.scope.behavior<LocalVideoTrack | null>(
|
||||
observeTrackReference$(
|
||||
this.livekitRoom.localParticipant,
|
||||
Track.Source.Camera,
|
||||
).pipe(
|
||||
map((trackRef) => {
|
||||
const track = trackRef?.publication?.track;
|
||||
return track instanceof LocalVideoTrack ? track : null;
|
||||
}),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -412,7 +516,7 @@ export class CallViewModel extends ViewModel {
|
||||
private readonly remoteParticipantHolds$ = this.scope.behavior<
|
||||
RemoteParticipant[][]
|
||||
>(
|
||||
this.connectionState$.pipe(
|
||||
this.livekitConnectionState$.pipe(
|
||||
withLatestFrom(this.rawRemoteParticipants$),
|
||||
mergeMap(([s, ps]) => {
|
||||
// Whenever we switch focuses, we should retain all the previous
|
||||
@@ -425,7 +529,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.connectionState$.pipe(
|
||||
this.livekitConnectionState$.pipe(
|
||||
filter((s) => s !== ECAddonConnectionState.ECSwitchingFocus),
|
||||
take(1),
|
||||
),
|
||||
@@ -449,27 +553,38 @@ 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;
|
||||
},
|
||||
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),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -478,20 +593,23 @@ 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(
|
||||
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(() => {
|
||||
// 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) => {
|
||||
const displaynameMap = new Map<string, string>();
|
||||
const { room, memberships } = this.matrixRTCSession;
|
||||
const room = this.matrixRoom;
|
||||
|
||||
// We only consider RTC members for disambiguation as they are the only visible members.
|
||||
for (const rtcMember of memberships) {
|
||||
@@ -511,14 +629,13 @@ 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$);
|
||||
public readonly handsRaised$ = this.scope.behavior(
|
||||
this.handsRaisedSubject$.pipe(pauseWhen(this.pretendToBeDisconnected$)),
|
||||
);
|
||||
|
||||
public readonly reactions$ = this.scope.behavior(
|
||||
this.reactionsSubject$.pipe(
|
||||
@@ -530,6 +647,7 @@ export class CallViewModel extends ViewModel {
|
||||
]),
|
||||
),
|
||||
),
|
||||
pauseWhen(this.pretendToBeDisconnected$),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -541,13 +659,7 @@ export class CallViewModel extends ViewModel {
|
||||
this.remoteParticipants$,
|
||||
observeParticipantMedia(this.livekitRoom.localParticipant),
|
||||
duplicateTiles.value$,
|
||||
// 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)),
|
||||
this.memberships$,
|
||||
showNonMemberTiles.value$,
|
||||
]).pipe(
|
||||
scan(
|
||||
@@ -557,15 +669,15 @@ export class CallViewModel extends ViewModel {
|
||||
remoteParticipants,
|
||||
{ participant: localParticipant },
|
||||
duplicateTiles,
|
||||
_membershipsChanged,
|
||||
memberships,
|
||||
showNonMemberTiles,
|
||||
],
|
||||
) => {
|
||||
const newItems = new Map(
|
||||
function* (this: CallViewModel): Iterable<[string, MediaItem]> {
|
||||
const room = this.matrixRTCSession.room;
|
||||
const room = this.matrixRoom;
|
||||
// m.rtc.members are the basis for calculating what is visible in the call
|
||||
for (const rtcMember of this.matrixRTCSession.memberships) {
|
||||
for (const rtcMember of memberships) {
|
||||
const { member, id: livekitParticipantId } =
|
||||
getRoomMemberFromRtcMember(rtcMember, room);
|
||||
const matrixIdentifier = `${rtcMember.sender}:${rtcMember.deviceId}`;
|
||||
@@ -612,8 +724,10 @@ export class CallViewModel extends ViewModel {
|
||||
indexedMediaId,
|
||||
member,
|
||||
participant,
|
||||
this.encryptionSystem,
|
||||
this.options.encryptionSystem,
|
||||
this.livekitRoom,
|
||||
this.mediaDevices,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||
),
|
||||
@@ -635,8 +749,9 @@ export class CallViewModel extends ViewModel {
|
||||
screenShareId,
|
||||
member,
|
||||
participant,
|
||||
this.encryptionSystem,
|
||||
this.options.encryptionSystem,
|
||||
this.livekitRoom,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map((m) => m.get(matrixIdentifier) ?? "[👻]"),
|
||||
),
|
||||
@@ -676,8 +791,10 @@ export class CallViewModel extends ViewModel {
|
||||
nonMemberId,
|
||||
undefined,
|
||||
participant,
|
||||
this.encryptionSystem,
|
||||
this.options.encryptionSystem,
|
||||
this.livekitRoom,
|
||||
this.mediaDevices,
|
||||
this.pretendToBeDisconnected$,
|
||||
this.memberDisplaynames$.pipe(
|
||||
map(
|
||||
(m) => m.get(participant.identity) ?? "[👻]",
|
||||
@@ -726,18 +843,163 @@ export class CallViewModel extends ViewModel {
|
||||
),
|
||||
);
|
||||
|
||||
public readonly memberChanges$ = this.userMedia$
|
||||
.pipe(map((mediaItems) => mediaItems.map((m) => m.id)))
|
||||
.pipe(
|
||||
scan<string[], { ids: string[]; joined: string[]; left: string[] }>(
|
||||
(prev, ids) => {
|
||||
const left = prev.ids.filter((id) => !ids.includes(id));
|
||||
const joined = ids.filter((id) => !prev.ids.includes(id));
|
||||
return { ids, joined, left };
|
||||
},
|
||||
{ ids: [], joined: [], left: [] },
|
||||
),
|
||||
);
|
||||
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),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* This may also be set if we are disconnected.
|
||||
* - "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$: Behavior<
|
||||
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
|
||||
> = this.options.waitForCallPickup
|
||||
? this.scope.behavior<
|
||||
"unknown" | "ringing" | "timeout" | "decline" | "success"
|
||||
>(
|
||||
combineLatest([
|
||||
this.livekitConnectionState$,
|
||||
this.someoneElseJoined$,
|
||||
]).pipe(
|
||||
switchMap(([livekitConnectionState, someoneElseJoined]) => {
|
||||
if (livekitConnectionState === ConnectionState.Disconnected) {
|
||||
// Do not ring until we're connected.
|
||||
return of("unknown" as const);
|
||||
} else if (someoneElseJoined) {
|
||||
return of("success" as const);
|
||||
}
|
||||
// Show the ringing state of the most recent ringing attempt.
|
||||
return 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 leaveSoundEffect$ = combineLatest([
|
||||
this.callPickupState$,
|
||||
this.userMedia$,
|
||||
]).pipe(
|
||||
// Until the call is successful, do not play a leave sound.
|
||||
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
|
||||
skipWhile(([c]) => c !== null && c !== "success"),
|
||||
map(([, userMedia]) => userMedia),
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, current]) =>
|
||||
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
|
||||
current.length < prev.length,
|
||||
),
|
||||
map(() => {}),
|
||||
throttleTime(THROTTLE_SOUND_EFFECT_MS),
|
||||
);
|
||||
|
||||
/**
|
||||
* List of MediaItems that we want to display, that are of type ScreenShare
|
||||
@@ -839,7 +1101,7 @@ export class CallViewModel extends ViewModel {
|
||||
map((speaker) => (speaker ? [speaker] : [])),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1424,10 +1686,11 @@ 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 encryptionSystem: EncryptionSystem,
|
||||
private readonly connectionState$: Observable<ECConnectionState>,
|
||||
private readonly options: CallViewModelOptions,
|
||||
public readonly livekitConnectionState$: Observable<ECConnectionState>,
|
||||
private readonly handsRaisedSubject$: Observable<
|
||||
Record<string, RaisedHandInfo>
|
||||
>,
|
||||
@@ -1436,5 +1699,51 @@ 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
type Observable,
|
||||
} from "rxjs";
|
||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
audioInput as audioInputSetting,
|
||||
@@ -38,7 +38,6 @@ import { type Behavior, constant } from "./Behavior";
|
||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||
// the ios swift team.
|
||||
const EARPIECE_CONFIG_ID = "earpiece-id";
|
||||
const logger = rootLogger.getChild("[MediaDevices]");
|
||||
|
||||
export type DeviceLabel =
|
||||
| { type: "name"; name: string }
|
||||
@@ -100,6 +99,7 @@ function availableRawDevices$(
|
||||
kind: MediaDeviceKind,
|
||||
usingNames$: Behavior<boolean>,
|
||||
scope: ObservableScope,
|
||||
logger: Logger,
|
||||
): Behavior<MediaDeviceInfo[]> {
|
||||
const logError = (e: Error): void =>
|
||||
logger.error("Error creating MediaDeviceObserver", e);
|
||||
@@ -162,8 +162,15 @@ function selectDevice$<Label>(
|
||||
}
|
||||
|
||||
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
private logger = rootLogger.getChild("[MediaDevices AudioInput]");
|
||||
|
||||
private readonly availableRaw$: Behavior<MediaDeviceInfo[]> =
|
||||
availableRawDevices$("audioinput", this.usingNames$, this.scope);
|
||||
availableRawDevices$(
|
||||
"audioinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
public readonly available$ = this.scope.behavior(
|
||||
this.availableRaw$.pipe(map(buildDeviceMap)),
|
||||
@@ -200,7 +207,7 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[audio-input] available devices:", available);
|
||||
this.logger.info("[audio-input] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -208,10 +215,16 @@ class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
class AudioOutput
|
||||
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
|
||||
{
|
||||
private logger = rootLogger.getChild("[MediaDevices AudioOutput]");
|
||||
public readonly available$ = this.scope.behavior(
|
||||
availableRawDevices$("audiooutput", this.usingNames$, this.scope).pipe(
|
||||
availableRawDevices$(
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
).pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioOutputDeviceLabel> =
|
||||
let available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
@@ -221,6 +234,12 @@ class AudioOutput
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const isSafari = !!(window as any).GestureEvent; // non standard api only found on Safari. https://developer.mozilla.org/en-US/docs/Web/API/GestureEvent#browser_compatibility
|
||||
if (isSafari) {
|
||||
// set to empty map if we are on Safari, because it does not support setSinkId
|
||||
available = new Map();
|
||||
}
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
@@ -250,7 +269,7 @@ class AudioOutput
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[audio-output] available devices:", available);
|
||||
this.logger.info("[audio-output] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -258,6 +277,7 @@ class AudioOutput
|
||||
class ControlledAudioOutput
|
||||
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
|
||||
{
|
||||
private logger = rootLogger.getChild("[MediaDevices ControlledAudioOutput]");
|
||||
// We need to subscribe to the raw devices so that the OS does update the input
|
||||
// back to what it was before. otherwise we will switch back to the default
|
||||
// whenever we allocate a new stream.
|
||||
@@ -265,6 +285,7 @@ class ControlledAudioOutput
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
public readonly available$ = this.scope.behavior(
|
||||
@@ -328,26 +349,37 @@ class ControlledAudioOutput
|
||||
// been selected - for example, Element X iOS listens to this to determine
|
||||
// whether it should enable the proximity sensor.
|
||||
if (device !== undefined) {
|
||||
logger.info("[controlled-output] onAudioDeviceSelect called:", device);
|
||||
this.logger.info(
|
||||
"[controlled-output] onAudioDeviceSelect called:",
|
||||
device,
|
||||
);
|
||||
window.controls.onAudioDeviceSelect?.(device.id);
|
||||
// Also invoke the deprecated callback for backward compatibility
|
||||
window.controls.onOutputDeviceSelect?.(device.id);
|
||||
}
|
||||
});
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[controlled-output] available devices:", available);
|
||||
this.logger.info("[controlled-output] available devices:", available);
|
||||
});
|
||||
this.availableRaw$.subscribe((availableRaw) => {
|
||||
logger.info("[controlled-output] available raw devices:", availableRaw);
|
||||
this.logger.info(
|
||||
"[controlled-output] available raw devices:",
|
||||
availableRaw,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
||||
private logger = rootLogger.getChild("[MediaDevices VideoInput]");
|
||||
|
||||
public readonly available$ = this.scope.behavior(
|
||||
availableRawDevices$("videoinput", this.usingNames$, this.scope).pipe(
|
||||
map(buildDeviceMap),
|
||||
),
|
||||
availableRawDevices$(
|
||||
"videoinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
this.logger,
|
||||
).pipe(map(buildDeviceMap)),
|
||||
);
|
||||
public readonly selected$ = this.scope.behavior(
|
||||
selectDevice$(this.available$, videoInputSetting.value$).pipe(
|
||||
@@ -364,7 +396,7 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
||||
) {
|
||||
// This also has the purpose of subscribing to the available devices
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[video-input] available devices:", available);
|
||||
this.logger.info("[video-input] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,40 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, onTestFinished, test, vi } from "vitest";
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
LocalVideoTrack,
|
||||
TrackEvent,
|
||||
} from "livekit-client";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
|
||||
import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
withLocalMedia,
|
||||
withRemoteMedia,
|
||||
withTestScheduler,
|
||||
} from "../utils/test";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { constant } from "./Behavior";
|
||||
|
||||
global.MediaStreamTrack = class {} as unknown as {
|
||||
new (): MediaStreamTrack;
|
||||
prototype: MediaStreamTrack;
|
||||
};
|
||||
global.MediaStream = class {} as unknown as {
|
||||
new (): MediaStream;
|
||||
prototype: MediaStream;
|
||||
};
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
get platform(): string {
|
||||
return platformMock();
|
||||
},
|
||||
}));
|
||||
|
||||
const rtcMembership = mockRtcMembership("@alice:example.org", "AAAA");
|
||||
|
||||
@@ -79,17 +105,23 @@ test("toggle fit/contain for a participant's video", async () => {
|
||||
});
|
||||
|
||||
test("local media remembers whether it should always be shown", async () => {
|
||||
await withLocalMedia(rtcMembership, {}, (vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
||||
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
|
||||
}),
|
||||
await withLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
mockMediaDevices({}),
|
||||
(vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm.setAlwaysShow(false) });
|
||||
expectObservable(vm.alwaysShow$).toBe("ab", { a: true, b: false });
|
||||
}),
|
||||
);
|
||||
// Next local media should start out *not* always shown
|
||||
await withLocalMedia(
|
||||
rtcMembership,
|
||||
|
||||
{},
|
||||
mockLocalParticipant({}),
|
||||
mockMediaDevices({}),
|
||||
(vm) =>
|
||||
withTestScheduler(({ expectObservable, schedule }) => {
|
||||
schedule("-a|", { a: () => vm.setAlwaysShow(true) });
|
||||
@@ -97,3 +129,77 @@ test("local media remembers whether it should always be shown", async () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("switch cameras", async () => {
|
||||
// Camera switching is only available on mobile
|
||||
platformMock.mockReturnValue("android");
|
||||
onTestFinished(() => void platformMock.mockReset());
|
||||
|
||||
// Construct a mock video track which knows how to be restarted
|
||||
const track = new LocalVideoTrack({
|
||||
getConstraints() {},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
} as unknown as MediaStreamTrack);
|
||||
|
||||
let deviceId = "front camera";
|
||||
const restartTrack = vi.fn(async ({ facingMode }) => {
|
||||
deviceId = facingMode === "user" ? "front camera" : "back camera";
|
||||
track.emit(TrackEvent.Restarted);
|
||||
return Promise.resolve();
|
||||
});
|
||||
track.restartTrack = restartTrack;
|
||||
|
||||
Object.defineProperty(track, "mediaStreamTrack", {
|
||||
get() {
|
||||
return {
|
||||
label: "Video",
|
||||
getSettings: (): object => ({
|
||||
deviceId,
|
||||
facingMode: deviceId === "front camera" ? "user" : "environment",
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const selectVideoInput = vi.fn();
|
||||
|
||||
await withLocalMedia(
|
||||
rtcMembership,
|
||||
{},
|
||||
mockLocalParticipant({
|
||||
getTrackPublication() {
|
||||
return { track } as unknown as LocalTrackPublication;
|
||||
},
|
||||
}),
|
||||
mockMediaDevices({
|
||||
videoInput: {
|
||||
available$: constant(new Map()),
|
||||
selected$: constant(undefined),
|
||||
select: selectVideoInput,
|
||||
},
|
||||
}),
|
||||
async (vm) => {
|
||||
// Switch to back camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
expect(restartTrack).toHaveBeenCalledExactlyOnceWith({
|
||||
facingMode: "environment",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(selectVideoInput).toHaveBeenCalledTimes(1);
|
||||
expect(selectVideoInput).toHaveBeenCalledWith("back camera");
|
||||
});
|
||||
expect(deviceId).toBe("back camera");
|
||||
|
||||
// Switch to front camera
|
||||
getValue(vm.switchCamera$)!();
|
||||
expect(restartTrack).toHaveBeenCalledTimes(2);
|
||||
expect(restartTrack).toHaveBeenLastCalledWith({ facingMode: "user" });
|
||||
await waitFor(() => {
|
||||
expect(selectVideoInput).toHaveBeenCalledTimes(2);
|
||||
expect(selectVideoInput).toHaveBeenLastCalledWith("front camera");
|
||||
});
|
||||
expect(deviceId).toBe("front camera");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
type LocalParticipant,
|
||||
LocalTrack,
|
||||
LocalVideoTrack,
|
||||
type Participant,
|
||||
ParticipantEvent,
|
||||
type RemoteParticipant,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
RemoteTrack,
|
||||
} from "livekit-client";
|
||||
import { type RoomMember } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
type Observable,
|
||||
@@ -51,6 +53,8 @@ import { accumulate } from "../utils/observable";
|
||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { type ReactionOption } from "../reactions";
|
||||
import { platform } from "../Platform";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
import { type Behavior } from "./Behavior";
|
||||
|
||||
export function observeTrackReference$(
|
||||
@@ -357,10 +361,7 @@ export type UserMediaViewModel =
|
||||
* Some participant's user media.
|
||||
*/
|
||||
abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
/**
|
||||
* Whether the participant is speaking.
|
||||
*/
|
||||
public readonly speaking$ = this.scope.behavior(
|
||||
private readonly _speaking$ = this.scope.behavior(
|
||||
this.participant$.pipe(
|
||||
switchMap((p) =>
|
||||
p
|
||||
@@ -372,15 +373,27 @@ 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.
|
||||
*/
|
||||
public readonly videoEnabled$: Behavior<boolean>;
|
||||
// Getter backed by a private field so that subclasses can override it
|
||||
public get videoEnabled$(): Behavior<boolean> {
|
||||
return this._videoEnabled$;
|
||||
}
|
||||
|
||||
private readonly _cropVideo$ = new BehaviorSubject(true);
|
||||
/**
|
||||
@@ -417,7 +430,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)),
|
||||
);
|
||||
}
|
||||
@@ -443,20 +456,38 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
*/
|
||||
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
* The local video track as an observable that emits whenever the track
|
||||
* changes, the camera is switched, or the track is muted.
|
||||
*/
|
||||
public readonly mirror$ = this.scope.behavior(
|
||||
private readonly videoTrack$: Observable<LocalVideoTrack | null> =
|
||||
this.video$.pipe(
|
||||
switchMap((v) => {
|
||||
const track = v?.publication?.track;
|
||||
if (!(track instanceof LocalTrack)) return of(false);
|
||||
// Watch for track restarts, because they indicate a camera switch
|
||||
return fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(() => facingModeFromLocalTrack(track).facingMode === "user"),
|
||||
if (!(track instanceof LocalVideoTrack)) return of(null);
|
||||
return merge(
|
||||
// Watch for track restarts because they indicate a camera switch.
|
||||
// This event is also emitted when unmuting the track object.
|
||||
fromEvent(track, TrackEvent.Restarted).pipe(
|
||||
startWith(null),
|
||||
map(() => track),
|
||||
),
|
||||
// When the track object is muted, reset it to null.
|
||||
fromEvent(track, TrackEvent.Muted).pipe(map(() => null)),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether the video should be mirrored.
|
||||
*/
|
||||
public readonly mirror$ = this.scope.behavior(
|
||||
this.videoTrack$.pipe(
|
||||
// Mirror only front-facing cameras (those that face the user)
|
||||
map(
|
||||
(track) =>
|
||||
track !== null &&
|
||||
facingModeFromLocalTrack(track).facingMode === "user",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -467,12 +498,48 @@ export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
public readonly alwaysShow$ = alwaysShowSelf.value$;
|
||||
public readonly setAlwaysShow = alwaysShowSelf.setValue;
|
||||
|
||||
/**
|
||||
* Callback for switching between the front and back cameras.
|
||||
*/
|
||||
public readonly switchCamera$: Behavior<(() => void) | null> =
|
||||
this.scope.behavior(
|
||||
platform === "desktop"
|
||||
? of(null)
|
||||
: this.videoTrack$.pipe(
|
||||
map((track) => {
|
||||
if (track === null) return null;
|
||||
const facingMode = facingModeFromLocalTrack(track).facingMode;
|
||||
// If the camera isn't front or back-facing, don't provide a switch
|
||||
// camera shortcut at all
|
||||
if (facingMode !== "user" && facingMode !== "environment")
|
||||
return null;
|
||||
// Restart the track with a camera facing the opposite direction
|
||||
return (): void =>
|
||||
void track
|
||||
.restartTrack({
|
||||
facingMode: facingMode === "user" ? "environment" : "user",
|
||||
})
|
||||
.then(() => {
|
||||
// Inform the MediaDevices which camera was chosen
|
||||
const deviceId =
|
||||
track.mediaStreamTrack.getSettings().deviceId;
|
||||
if (deviceId !== undefined)
|
||||
this.mediaDevices.videoInput.select(deviceId);
|
||||
})
|
||||
.catch((e) =>
|
||||
logger.error("Failed to switch camera", facingMode, e),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
public constructor(
|
||||
id: string,
|
||||
member: RoomMember | undefined,
|
||||
participant$: Behavior<LocalParticipant | undefined>,
|
||||
encryptionSystem: EncryptionSystem,
|
||||
livekitRoom: LivekitRoom,
|
||||
private readonly mediaDevices: MediaDevices,
|
||||
displayName$: Behavior<string>,
|
||||
handRaised$: Behavior<Date | null>,
|
||||
reaction$: Behavior<ReactionOption | null>,
|
||||
@@ -514,6 +581,12 @@ 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>();
|
||||
@@ -553,6 +626,12 @@ 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.
|
||||
*/
|
||||
@@ -566,6 +645,7 @@ 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>,
|
||||
@@ -581,11 +661,33 @@ 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$,
|
||||
this.localVolume$.pipe(this.scope.bind()),
|
||||
]).subscribe(([p, volume]) => p && p.setVolume(volume));
|
||||
// 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));
|
||||
}
|
||||
|
||||
public toggleLocallyMuted(): void {
|
||||
@@ -625,12 +727,20 @@ 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,
|
||||
) {
|
||||
|
||||
@@ -83,3 +83,25 @@ borders don't support gradients */
|
||||
.volumeSlider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tile .switchCamera {
|
||||
opacity: 1;
|
||||
background: var(--cpd-color-bg-action-secondary-rest);
|
||||
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||
}
|
||||
|
||||
.tile .switchCamera > svg {
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.tile .switchCamera:hover {
|
||||
background: var(--cpd-color-bg-subtle-secondary);
|
||||
border-color: var(--cpd-color-border-interactive-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.tile .switchCamera:active {
|
||||
background: var(--cpd-color-bg-subtle-primary);
|
||||
border-color: var(--cpd-color-border-interactive-hovered);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
UserProfileIcon,
|
||||
ExpandIcon,
|
||||
VolumeOffSolidIcon,
|
||||
SwitchCameraSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -65,6 +66,7 @@ interface UserMediaTileProps extends TileProps {
|
||||
vm: UserMediaViewModel;
|
||||
mirror: boolean;
|
||||
locallyMuted: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
menuStart?: ReactNode;
|
||||
menuEnd?: ReactNode;
|
||||
}
|
||||
@@ -74,6 +76,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
primaryButton,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
className,
|
||||
@@ -160,20 +163,22 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
}
|
||||
displayName={displayName}
|
||||
primaryButton={
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={displayName}
|
||||
trigger={
|
||||
<button aria-label={t("common.options")}>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{menu}
|
||||
</Menu>
|
||||
primaryButton ?? (
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={displayName}
|
||||
trigger={
|
||||
<button aria-label={t("common.options")}>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{menu}
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
raisedHandTime={handRaised ?? undefined}
|
||||
currentReaction={reaction ?? undefined}
|
||||
@@ -208,6 +213,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const mirror = useBehavior(vm.mirror$);
|
||||
const alwaysShow = useBehavior(vm.alwaysShow$);
|
||||
const switchCamera = useBehavior(vm.switchCamera$);
|
||||
|
||||
const latestAlwaysShow = useLatest(alwaysShow);
|
||||
const onSelectAlwaysShow = useCallback(
|
||||
(e: Event) => {
|
||||
@@ -223,6 +230,17 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
vm={vm}
|
||||
locallyMuted={false}
|
||||
mirror={mirror}
|
||||
primaryButton={
|
||||
switchCamera === null ? undefined : (
|
||||
<button
|
||||
className={styles.switchCamera}
|
||||
aria-label={t("switch_camera")}
|
||||
onClick={switchCamera}
|
||||
>
|
||||
<SwitchCameraSolidIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
menuStart={
|
||||
<ToggleMenuItem
|
||||
Icon={VisibilityOnIcon}
|
||||
|
||||
@@ -85,6 +85,7 @@ unconditionally select the container so we can use cqmin units */
|
||||
|
||||
.nameTag {
|
||||
grid-area: nameTag;
|
||||
place-self: end start;
|
||||
padding: var(--cpd-space-1x);
|
||||
padding-block: var(--cpd-space-1x);
|
||||
color: var(--cpd-color-text-primary);
|
||||
@@ -173,7 +174,7 @@ unconditionally select the container so we can use cqmin units */
|
||||
}
|
||||
|
||||
.fg > button:active {
|
||||
background: var(--cpd-color-bg-action-primary-pressed) !important;
|
||||
background: var(--cpd-color-bg-action-primary-pressed);
|
||||
}
|
||||
|
||||
.fg > button[data-state="open"] {
|
||||
|
||||
@@ -88,40 +88,48 @@ Please see LICENSE in the repository root for full details.
|
||||
padding: var(--cpd-space-2x);
|
||||
border: none;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
background: var(--cpd-color-alpha-gray-1400);
|
||||
background: rgba(from var(--cpd-color-gray-100) r g b / 0.6);
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
transition:
|
||||
opacity 0.15s,
|
||||
background-color 0.1s;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
--inset: 6px;
|
||||
inset-block-end: var(--inset);
|
||||
inset-inline-end: var(--inset);
|
||||
}
|
||||
|
||||
.bottomRightButtons {
|
||||
display: flex;
|
||||
gap: var(--cpd-space-2x);
|
||||
position: absolute;
|
||||
inset-block-end: var(--cpd-space-1x);
|
||||
inset-inline-end: var(--cpd-space-1x);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.expand > svg {
|
||||
display: block;
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.expand:hover {
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
background: var(--cpd-color-gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
.expand:active {
|
||||
background: var(--cpd-color-bg-action-primary-pressed);
|
||||
background: var(--cpd-color-gray-100);
|
||||
}
|
||||
|
||||
@media (hover) {
|
||||
.tile:hover > button {
|
||||
.tile:hover > div > button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.tile:has(:focus-visible) > button {
|
||||
.tile:has(:focus-visible) > div > button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { SpotlightTile } from "./SpotlightTile";
|
||||
import {
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
withLocalMedia,
|
||||
withRemoteMedia,
|
||||
@@ -39,6 +41,8 @@ test("SpotlightTile is accessible", async () => {
|
||||
rawDisplayName: "Bob",
|
||||
getMxcAvatarUrl: () => "mxc://dlskf",
|
||||
},
|
||||
mockLocalParticipant({}),
|
||||
mockMediaDevices({}),
|
||||
async (vm2) => {
|
||||
const user = userEvent.setup();
|
||||
const toggleExpanded = vi.fn();
|
||||
|
||||
@@ -29,6 +29,8 @@ import classNames from "classnames";
|
||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
import { type RoomMember } from "matrix-js-sdk";
|
||||
|
||||
import FullScreenMaximiseIcon from "../icons/FullScreenMaximise.svg?react";
|
||||
import FullScreenMinimiseIcon from "../icons/FullScreenMinimise.svg?react";
|
||||
import { MediaView } from "./MediaView";
|
||||
import styles from "./SpotlightTile.module.css";
|
||||
import {
|
||||
@@ -52,6 +54,7 @@ interface SpotlightItemBaseProps {
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder | undefined;
|
||||
videoEnabled: boolean;
|
||||
member: RoomMember | undefined;
|
||||
unencryptedWarning: boolean;
|
||||
encryptionStatus: EncryptionStatus;
|
||||
@@ -61,7 +64,6 @@ interface SpotlightItemBaseProps {
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
videoEnabled: boolean;
|
||||
videoFit: "contain" | "cover";
|
||||
}
|
||||
|
||||
@@ -88,12 +90,10 @@ 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,6 +133,7 @@ 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$);
|
||||
|
||||
@@ -158,6 +159,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
videoEnabled,
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
@@ -167,7 +169,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
|
||||
<MediaView videoFit="contain" mirror={false} {...baseProps} />
|
||||
) : (
|
||||
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||
);
|
||||
@@ -210,6 +212,26 @@ export const SpotlightTile: FC<Props> = ({
|
||||
const canGoBack = visibleIndex > 0;
|
||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
|
||||
|
||||
const isFullscreen = useCallback((): boolean => {
|
||||
const rootElement = document.body;
|
||||
if (rootElement && document.fullscreenElement) return true;
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
const FullScreenIcon = isFullscreen()
|
||||
? FullScreenMinimiseIcon
|
||||
: FullScreenMaximiseIcon;
|
||||
|
||||
const onToggleFullscreen = useCallback(() => {
|
||||
const rootElement = document.body;
|
||||
if (!rootElement) return;
|
||||
if (isFullscreen()) {
|
||||
void document?.exitFullscreen();
|
||||
} else {
|
||||
void rootElement.requestFullscreen();
|
||||
}
|
||||
}, [isFullscreen]);
|
||||
|
||||
// To keep track of which item is visible, we need an intersection observer
|
||||
// hooked up to the root element and the items. Because the items will run
|
||||
// their effects before their parent does, we need to do this dance with an
|
||||
@@ -292,17 +314,28 @@ export const SpotlightTile: FC<Props> = ({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{onToggleExpanded && (
|
||||
<div className={styles.bottomRightButtons}>
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded ? t("video_tile.collapse") : t("video_tile.expand")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
aria-label={"maximise"}
|
||||
onClick={onToggleFullscreen}
|
||||
>
|
||||
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||
<FullScreenIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onToggleExpanded && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded ? t("video_tile.collapse") : t("video_tile.expand")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canGoToNext && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.next)}
|
||||
|
||||
@@ -32,6 +32,8 @@ async function playSound(
|
||||
buffer: AudioBuffer,
|
||||
volume: number,
|
||||
stereoPan: number,
|
||||
delayS = 0,
|
||||
abort?: AbortController,
|
||||
): Promise<void> {
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(volume, 0);
|
||||
@@ -39,13 +41,62 @@ async function playSound(
|
||||
pan.pan.setValueAtTime(stereoPan, 0);
|
||||
const src = ctx.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
src.connect(gain).connect(pan).connect(ctx.destination);
|
||||
abort?.signal.addEventListener("abort", () => {
|
||||
src.disconnect();
|
||||
});
|
||||
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
|
||||
src.connect(gain).connect(pan).connect(ctx.destination);
|
||||
controls.setPlaybackStarted();
|
||||
src.start();
|
||||
src.start(ctx.currentTime + delayS);
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sound though a given AudioContext, looping until stopped. Will take
|
||||
* care of connecting the correct buffer and gating
|
||||
* through gain.
|
||||
* @param volume The volume to play at.
|
||||
* @param ctx The context to play through.
|
||||
* @param buffer The buffer to play.
|
||||
* @returns A function used to end the sound. This function will return a promise when the sound has stopped.
|
||||
*/
|
||||
function playSoundLooping(
|
||||
ctx: AudioContext,
|
||||
buffer: AudioBuffer,
|
||||
volume: number,
|
||||
stereoPan: number,
|
||||
delayS?: number,
|
||||
): () => Promise<void> {
|
||||
if (delayS === 0) {
|
||||
throw Error("Looping sounds must have a delay");
|
||||
}
|
||||
|
||||
// Our audio loop
|
||||
let lastSoundPromise: Promise<void>;
|
||||
let nextSoundPromise: Promise<void>;
|
||||
let ac: AbortController | undefined;
|
||||
void (async (): Promise<void> => {
|
||||
ac = new AbortController();
|
||||
// Play a sound immediately
|
||||
lastSoundPromise = Promise.resolve();
|
||||
do {
|
||||
// Queue up the next sound.
|
||||
nextSoundPromise = playSound(ctx, buffer, volume, stereoPan, delayS, ac);
|
||||
// Await the previous sound.
|
||||
await lastSoundPromise;
|
||||
// Swap the promises over, and loop round to play the next sound.
|
||||
lastSoundPromise = nextSoundPromise;
|
||||
} while (!ac.signal.aborted);
|
||||
})();
|
||||
|
||||
return async () => {
|
||||
ac?.abort();
|
||||
// Wait for sounds to finish.
|
||||
await lastSoundPromise;
|
||||
await nextSoundPromise;
|
||||
};
|
||||
}
|
||||
|
||||
interface Props<S extends string> {
|
||||
/**
|
||||
* The sounds to play. If no sounds should be played then
|
||||
@@ -57,8 +108,13 @@ interface Props<S extends string> {
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
interface UseAudioContext<S> {
|
||||
interface UseAudioContext<S extends string> {
|
||||
playSound(soundName: S): Promise<void>;
|
||||
playSoundLooping(soundName: S, delayS?: number): () => Promise<void>;
|
||||
/**
|
||||
* Map of sound name to duration in seconds.
|
||||
*/
|
||||
soundDuration: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,5 +202,23 @@ export function useAudioContext<S extends string>(
|
||||
earpiecePan,
|
||||
);
|
||||
},
|
||||
playSoundLooping: (name, delayS: number): (() => Promise<void>) => {
|
||||
if (!audioBuffers[name]) {
|
||||
throw Error(`Tried to play a sound that wasn't buffered (${name})`);
|
||||
}
|
||||
return playSoundLooping(
|
||||
audioContext,
|
||||
audioBuffers[name],
|
||||
soundEffectVolume * earpieceVolume,
|
||||
earpiecePan,
|
||||
delayS,
|
||||
);
|
||||
},
|
||||
soundDuration: Object.fromEntries(
|
||||
Object.entries(audioBuffers).map(([k, v]) => [
|
||||
k,
|
||||
(v as AudioBuffer).duration,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum ErrorCode {
|
||||
INSUFFICIENT_CAPACITY_ERROR = "INSUFFICIENT_CAPACITY_ERROR",
|
||||
E2EE_NOT_SUPPORTED = "E2EE_NOT_SUPPORTED",
|
||||
OPEN_ID_ERROR = "OPEN_ID_ERROR",
|
||||
SFU_ERROR = "SFU_ERROR",
|
||||
UNKNOWN_ERROR = "UNKNOWN_ERROR",
|
||||
}
|
||||
|
||||
@@ -129,3 +130,14 @@ export class InsufficientCapacityError extends ElementCallError {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SFURoomCreationRestrictedError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
t("error.room_creation_restricted"),
|
||||
ErrorCode.SFU_ERROR,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
t("error.room_creation_restricted_description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
24
src/utils/observable.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -7,16 +7,23 @@ 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");
|
||||
|
||||
/**
|
||||
@@ -86,3 +93,27 @@ 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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
mockLocalParticipant,
|
||||
} from "./test";
|
||||
|
||||
export const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC");
|
||||
export const localRtcMember = mockRtcMembership("@carol:example.org", "1111");
|
||||
export const localRtcMemberDevice2 = mockRtcMembership(
|
||||
"@carol:example.org",
|
||||
"2222",
|
||||
);
|
||||
export const local = mockMatrixRoomMember(localRtcMember);
|
||||
export const localParticipant = mockLocalParticipant({ identity: "" });
|
||||
export const localId = `${local.userId}:${localRtcMember.deviceId}`;
|
||||
|
||||
@@ -14,10 +14,18 @@ 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 {
|
||||
CallViewModel,
|
||||
type CallViewModelOptions,
|
||||
} from "../state/CallViewModel";
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockMatrixRoom,
|
||||
@@ -34,10 +42,11 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
||||
|
||||
export function getBasicRTCSession(
|
||||
members: RoomMember[],
|
||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
||||
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||
): {
|
||||
rtcSession: MockRTCSession;
|
||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
matrixRoom: Room;
|
||||
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
} {
|
||||
const matrixRoomId = "!myRoomId:example.com";
|
||||
const matrixRoomMembers = new Map(members.map((p) => [p.userId, p]));
|
||||
@@ -51,6 +60,7 @@ 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),
|
||||
@@ -91,55 +101,58 @@ export function getBasicRTCSession(
|
||||
),
|
||||
});
|
||||
|
||||
const remoteRtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||
initialRemoteRtcMemberships,
|
||||
const rtcMemberships$ = new BehaviorSubject<CallMembership[]>(
|
||||
initialRtcMemberships,
|
||||
);
|
||||
|
||||
const rtcSession = new MockRTCSession(
|
||||
matrixRoom,
|
||||
localRtcMember,
|
||||
).withMemberships(remoteRtcMemberships$);
|
||||
const rtcSession = new MockRTCSession(matrixRoom).withMemberships(
|
||||
rtcMemberships$,
|
||||
);
|
||||
|
||||
return {
|
||||
rtcSession,
|
||||
remoteRtcMemberships$,
|
||||
matrixRoom,
|
||||
rtcMemberships$,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a basic CallViewModel to test components that make use of it.
|
||||
* @param members
|
||||
* @param initialRemoteRtcMemberships
|
||||
* @param initialRtcMemberships
|
||||
* @returns
|
||||
*/
|
||||
export function getBasicCallViewModelEnvironment(
|
||||
members: RoomMember[],
|
||||
initialRemoteRtcMemberships: CallMembership[] = [aliceRtcMember],
|
||||
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
|
||||
callViewModelOptions: Partial<CallViewModelOptions> = {},
|
||||
): {
|
||||
vm: CallViewModel;
|
||||
remoteRtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
rtcSession: MockRTCSession;
|
||||
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||
reactionsSubject$: BehaviorSubject<Record<string, ReactionInfo>>;
|
||||
} {
|
||||
const { rtcSession, remoteRtcMemberships$ } = getBasicRTCSession(
|
||||
const { rtcSession, matrixRoom, rtcMemberships$ } = getBasicRTCSession(
|
||||
members,
|
||||
initialRemoteRtcMemberships,
|
||||
initialRtcMemberships,
|
||||
);
|
||||
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,
|
||||
liveKitRoom,
|
||||
matrixRoom,
|
||||
livekitRoom,
|
||||
mockMediaDevices({}),
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
...callViewModelOptions,
|
||||
},
|
||||
of(ConnectionState.Connected),
|
||||
handRaisedSubject$,
|
||||
@@ -147,7 +160,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
);
|
||||
return {
|
||||
vm,
|
||||
remoteRtcMemberships$,
|
||||
rtcMemberships$,
|
||||
rtcSession,
|
||||
handRaisedSubject$: handRaisedSubject$,
|
||||
reactionsSubject$: reactionsSubject$,
|
||||
|
||||
@@ -19,8 +19,11 @@ 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,
|
||||
@@ -35,6 +38,7 @@ import {
|
||||
type RoomAndToDeviceEventsHandlerMap,
|
||||
} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
||||
import { type TrackReference } from "@livekit/components-core";
|
||||
import EventEmitter from "events";
|
||||
|
||||
import {
|
||||
LocalUserMediaViewModel,
|
||||
@@ -74,6 +78,7 @@ export interface OurRunHelpers extends RunHelpers {
|
||||
values?: { [marble: string]: T },
|
||||
error?: unknown,
|
||||
): Behavior<T>;
|
||||
scope: ObservableScope;
|
||||
}
|
||||
|
||||
interface TestRunnerGlobal {
|
||||
@@ -96,6 +101,7 @@ export function withTestScheduler(
|
||||
scheduler.run((helpers) =>
|
||||
continuation({
|
||||
...helpers,
|
||||
scope,
|
||||
schedule(marbles, actions) {
|
||||
const actionsObservable$ = helpers
|
||||
.cold(marbles)
|
||||
@@ -139,26 +145,25 @@ export function withTestScheduler(
|
||||
}
|
||||
|
||||
interface EmitterMock<T> {
|
||||
on: () => T;
|
||||
off: () => T;
|
||||
addListener: () => T;
|
||||
removeListener: () => T;
|
||||
on: (...args: unknown[]) => T;
|
||||
off: (...args: unknown[]) => T;
|
||||
addListener: (...args: unknown[]) => T;
|
||||
removeListener: (...args: unknown[]) => T;
|
||||
emit: (event: string | symbol, ...args: unknown[]) => boolean;
|
||||
}
|
||||
|
||||
export function mockEmitter<T>(): EmitterMock<T> {
|
||||
const ee = new EventEmitter();
|
||||
return {
|
||||
on(): T {
|
||||
return this as T;
|
||||
},
|
||||
off(): T {
|
||||
return this as T;
|
||||
},
|
||||
addListener(): T {
|
||||
return this as T;
|
||||
},
|
||||
removeListener(): T {
|
||||
return this as T;
|
||||
},
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,6 +236,7 @@ export function mockLocalParticipant(
|
||||
): LocalParticipant {
|
||||
return {
|
||||
isLocal: true,
|
||||
trackPublications: new Map(),
|
||||
getTrackPublication: () =>
|
||||
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
|
||||
...mockEmitter(),
|
||||
@@ -241,9 +247,10 @@ export function mockLocalParticipant(
|
||||
export async function withLocalMedia(
|
||||
localRtcMember: CallMembership,
|
||||
roomMember: Partial<RoomMember>,
|
||||
localParticipant: LocalParticipant,
|
||||
mediaDevices: MediaDevices,
|
||||
continuation: (vm: LocalUserMediaViewModel) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const localParticipant = mockLocalParticipant({});
|
||||
const vm = new LocalUserMediaViewModel(
|
||||
"local",
|
||||
mockMatrixRoomMember(localRtcMember, roomMember),
|
||||
@@ -252,6 +259,7 @@ export async function withLocalMedia(
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
mockLivekitRoom({ localParticipant }),
|
||||
mediaDevices,
|
||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
constant(null),
|
||||
constant(null),
|
||||
@@ -291,6 +299,7 @@ export async function withRemoteMedia(
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
mockLivekitRoom({}, { remoteParticipants$: of([remoteParticipant]) }),
|
||||
constant(false),
|
||||
constant(roomMember.rawDisplayName ?? "nodisplayname"),
|
||||
constant(null),
|
||||
constant(null),
|
||||
@@ -312,8 +321,10 @@ export function mockConfig(config: Partial<ResolvedConfigOptions> = {}): void {
|
||||
}
|
||||
|
||||
export class MockRTCSession extends TypedEventEmitter<
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
|
||||
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
|
||||
MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent,
|
||||
MatrixRTCSessionEventHandlerMap &
|
||||
RoomAndToDeviceEventsHandlerMap &
|
||||
MembershipManagerEventHandlerMap
|
||||
> {
|
||||
public readonly statistics = {
|
||||
counters: {},
|
||||
@@ -323,7 +334,6 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
|
||||
public constructor(
|
||||
public readonly room: Room,
|
||||
private localMembership: CallMembership,
|
||||
public memberships: CallMembership[] = [],
|
||||
) {
|
||||
super();
|
||||
@@ -339,14 +349,37 @@ export class MockRTCSession extends TypedEventEmitter<
|
||||
): MockRTCSession {
|
||||
rtcMembers$.subscribe((m) => {
|
||||
const old = this.memberships;
|
||||
// always prepend the local participant
|
||||
const updated = [this.localMembership, ...(m as CallMembership[])];
|
||||
this.memberships = updated;
|
||||
this.emit(MatrixRTCSessionEvent.MembershipsChanged, old, updated);
|
||||
this.memberships = m as CallMembership[];
|
||||
this.emit(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
old,
|
||||
this.memberships,
|
||||
);
|
||||
});
|
||||
|
||||
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 =>
|
||||
|
||||
@@ -106,18 +106,23 @@ 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,
|
||||
EventType.Reaction,
|
||||
EventType.RoomRedaction,
|
||||
ElementCallReactionEventType,
|
||||
EventType.RTCDecline,
|
||||
];
|
||||
|
||||
const sendState = [
|
||||
userId, // Legacy call membership events
|
||||
`_${userId}_${deviceId}`, // Session membership events
|
||||
`${userId}_${deviceId}`, // The above with no leading underscore, for room versions whose auth rules allow it
|
||||
`_${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
|
||||
].map((stateKey) => ({
|
||||
eventType: EventType.GroupCallMemberPrefix,
|
||||
stateKey,
|
||||
@@ -129,6 +134,7 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
{ eventType: EventType.RoomEncryption },
|
||||
{ eventType: EventType.GroupCallMemberPrefix },
|
||||
];
|
||||
|
||||
const sendRecvToDevice = [
|
||||
EventType.CallInvite,
|
||||
EventType.CallCandidates,
|
||||
@@ -146,7 +152,7 @@ export const widget = ((): WidgetHelpers | null => {
|
||||
const client = createRoomWidgetClient(
|
||||
api,
|
||||
{
|
||||
sendEvent: sendRecvEvent,
|
||||
sendEvent: [...sendEvent, ...sendRecvEvent],
|
||||
receiveEvent: sendRecvEvent,
|
||||
sendState,
|
||||
receiveState,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
/*
|
||||
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 fullConfig from "./vite.config";
|
||||
import generateFile from "vite-plugin-generate-file";
|
||||
|
||||
import fullConfig from "./vite.config";
|
||||
|
||||
const base = "./";
|
||||
|
||||
// Config for embedded deployments (possibly hosted under a non-root path)
|
||||
@@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { defineConfig, loadEnv, searchForWorkspaceRoot } from "vite";
|
||||
import {
|
||||
loadEnv,
|
||||
searchForWorkspaceRoot,
|
||||
type ConfigEnv,
|
||||
type UserConfig,
|
||||
} from "vite";
|
||||
import svgrPlugin from "vite-plugin-svgr";
|
||||
import { createHtmlPlugin } from "vite-plugin-html";
|
||||
import { codecovVitePlugin } from "@codecov/vite-plugin";
|
||||
@@ -15,10 +20,14 @@ import { realpathSync } from "fs";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode, packageType }) => {
|
||||
// Modified type helper from defineConfig to allow for packageType (see defineConfig from vite)
|
||||
export default ({
|
||||
mode,
|
||||
packageType,
|
||||
}: ConfigEnv & { packageType?: "full" | "embedded" }): UserConfig => {
|
||||
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 behaviour.
|
||||
// So, we set this to allow for build/package specific behavior.
|
||||
// In future we might be able to do what is needed via code splitting at
|
||||
// build time.
|
||||
process.env.VITE_PACKAGE = packageType ?? "full";
|
||||
@@ -93,7 +102,7 @@ export default defineConfig(({ mode, packageType }) => {
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: ({ originalFileNames }) => {
|
||||
assetFileNames: ({ originalFileNames }): string => {
|
||||
if (originalFileNames) {
|
||||
for (const name of originalFileNames) {
|
||||
// Custom asset name for locales to include the locale code in the filename
|
||||
@@ -143,4 +152,4 @@ export default defineConfig(({ mode, packageType }) => {
|
||||
exclude: ["@matrix-org/matrix-sdk-crypto-wasm"],
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
import viteConfig from "./vite.config.js";
|
||||
|
||||
import viteConfig from "./vite.config";
|
||||
|
||||
export default defineConfig((configEnv) =>
|
||||
mergeConfig(
|
||||