Compare commits

..

2 Commits

Author SHA1 Message Date
Robin
4df74c7134 Simplify our context providers
React 19 lets you omit the '.Provider' bit.
2025-06-23 23:03:42 -04:00
Robin
d4b38563e7 Replace useContext with use
The docs recommend the use hook because it is simpler and allows itself to be called conditionally.
2025-06-23 23:03:42 -04:00
170 changed files with 3766 additions and 8119 deletions

View File

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

View File

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

View File

@@ -44,8 +44,6 @@ jobs:
run: |
if [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "TAG=latest" >> "$GITHUB_OUTPUT"
elif [[ "${VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\-rc\.[0-9]+$ ]]; then
echo "TAG=rc" >> "$GITHUB_OUTPUT"
else
echo "TAG=other" >> "$GITHUB_OUTPUT"
fi
@@ -165,8 +163,6 @@ jobs:
run: |
if [[ "${{ needs.versioning.outputs.TAG }}" == "latest" ]]; then
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
elif [[ "${{ needs.versioning.outputs.TAG }}" == "rc" ]]; then
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}" >> "$GITHUB_ENV"
else
echo "ARTIFACT_VERSION=${{ needs.versioning.outputs.UNPREFIXED_VERSION }}-SNAPSHOT" >> "$GITHUB_ENV"
fi

4
.gitignore vendored
View File

@@ -8,9 +8,7 @@ dist-ssr
.idea/
public/config.json
backend/synapse_tmp/*
backend/synapse_tmp_othersite/*
/coverage
config.json
# Yarn
yarn-error.log
@@ -29,4 +27,4 @@ yarn-error.log
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.cache/

View File

@@ -143,8 +143,7 @@ via the `org.matrix.msc4143.rtc_foci` key, e.g.:
where the format for MatrixRTC using LiveKit backend is defined in
[MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
In the example above Matrix clients do discover a focus of type `livekit` which
points them to a [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
via `livekit_service_url`.
points them to a Matrix LiveKit JWT Auth Service via `livekit_service_url`.
### Backend Selection
@@ -155,9 +154,9 @@ via `livekit_service_url`.
the **first participant who joined the call** defines via the `foci_preferred`
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
will be used for this call.
- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)**
provides the client with the **LiveKit SFU WebSocket URL** and an
**access JWT token** in order to exchange media via WebRTC.
- During the actual call join flow, the **LiveKit JWT Auth Service** provides
the client with the **LiveKit SFU WebSocket URL** and an **access JWT token**
in order to exchange media via WebRTC.
The example below illustrates how backend selection works across **Matrix
federation**, using the setup from sites A, B, and C. It demonstrates backend
@@ -206,22 +205,22 @@ See also:
### Backend
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
including federation:
whole stack of components which is required for a local development environment:
- Minimum Synapse Setup (servernameis: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Minimum LiveKit SFU setup using dev defaults for config
- Minimum Synapse Setup (servername: `synapse.m.localhost`)
- LiveKit Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Minimum LiveKit SFU Setup using dev defaults for config
- Redis db for completeness
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
- Hostnames: `m.localhost`, `*.m.localhost`, `*.othersite.m.localhost`
- Hostnames: `m.localhost`, `*.m.localhost`
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted
certificates
- Minimum TLS reverse proxy for
- Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost`
- Synapse homeserver: `synapse.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost`
- Local Element Call development `call.m.localhost` via `yarn dev --host `
- Element Web `app.m.localhost` and `app.othersite.m.localhost`
- Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST
- Element Web `app.m.localhost`
- Note certificates will expire on Thu, 03 May 2035 10:32:02 GMT
These use a test 'secret' published in this repository, so this must be used
only for local development and **_never be exposed to the public Internet._**

View File

@@ -1,64 +0,0 @@
server_name: "synapse.othersite.m.localhost"
public_baseurl: https://synapse.othersite.m.localhost/
pid_file: /data/homeserver.pid
listeners:
- port: 18008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation, openid]
compress: false
database:
name: sqlite3
args:
database: /data/homeserver.db
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "synapse.m.localhost"
accept_keys_insecurely: true
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
# - per_second: number of requests a client can send per second.
# - burst_count: number of requests a client can send before being throttled.
rc_message:
# This needs to match at least the heart-beat frequency plus a bit of headroom
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5
burst_count: 30

View File

@@ -19,18 +19,8 @@ database:
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation.
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "synapse.othersite.m.localhost"
accept_keys_insecurely: true
- server_name: "matrix.org"
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
@@ -44,13 +34,6 @@ experimental_features:
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
@@ -62,3 +45,10 @@ rc_message:
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5
burst_count: 30
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true

View File

@@ -1,20 +0,0 @@
port: 17880
bind_addresses:
- "0.0.0.0"
rtc:
tcp_port: 17881
port_range_start: 50300
port_range_end: 50400
use_external_ip: false
turn:
enabled: false
domain: localhost
cert_file: ""
key_file: ""
tls_port: 5349
udp_port: 443
external_tls: true
keys:
devkey: secret
room:
auto_create: false

View File

@@ -6,6 +6,11 @@ rtc:
port_range_start: 50100
port_range_end: 50200
use_external_ip: false
#redis:
# address: redis:6379
# username: ""
# password: ""
# db: 0
turn:
enabled: false
domain: localhost
@@ -16,5 +21,3 @@ turn:
external_tls: true
keys:
devkey: secret
room:
auto_create: false

View File

@@ -1,5 +1,4 @@
# Synapse reverse proxy including .well-known/matrix/client
# domain synapse.m.localhost
server {
listen 80;
listen [::]:80;
@@ -27,53 +26,14 @@ server {
# This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token
# verification, which requires TLS
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass "http://homeserver:8008";
location / {
proxy_pass "http://homeserver:8008";
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
}
# Synapse reverse proxy including .well-known/matrix/client
# domain synapse.othersite.m.localhost
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen 8448 ssl;
listen [::]:443 ssl;
listen [::]:8448 ssl;
server_name synapse.othersite.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
# well-known config adding rtc_foci backend
# Note well-known is currently not effective due to:
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
# says it must be at https://$server_name/... (implied port 443) Hence, we
# currently rely for local development environment on deprecated config.json
# setting for livekit_service_url
location /.well-known/matrix/client {
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://synapse.othersite.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.othersite.m.localhost/livekit/jwt"}]}';
default_type application/json;
}
# Reverse proxy for Matrix Synapse Homeserver
# This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token
# verification, which requires TLS
location ~ ^(/_matrix|/_synapse/client) {
proxy_pass "http://homeserver-1:18008";
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $host;
}
error_page 500 502 503 504 /50x.html;
@@ -81,7 +41,6 @@ server {
}
# MatrixRTC reverse proxy
# domain matrix-rtc.m.localhost
# - MatrixRTC Authorization Service
# - LiveKit SFU websocket signaling connection
upstream jwt-auth-services {
@@ -90,14 +49,16 @@ upstream jwt-auth-services {
}
server {
listen 80;
listen [::]:80;
listen 443 ssl;
listen [::]:443 ssl;
listen 8448 ssl;
listen [::]:8448 ssl;
server_name matrix-rtc.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
http2 on;
location ^~ /livekit/jwt/ {
@@ -133,54 +94,6 @@ server {
}
# MatrixRTC reverse proxy
# domain matrix-rtc.othersite.m.localhost
# - MatrixRTC Authorization Service
# - LiveKit SFU websocket signaling connection
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name matrix-rtc.othersite.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
http2 on;
location ^~ /livekit/jwt/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# JWT Service running at port 16080
proxy_pass http://auth-service-1:16080/;
}
location ^~ /livekit/sfu/ {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_send_timeout 120;
proxy_read_timeout 120;
proxy_buffering off;
proxy_set_header Accept-Encoding gzip;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# LiveKit SFU websocket connection running at port 17880
proxy_pass http://livekit-sfu-1:17880/;
}
error_page 500 502 503 504 /50x.html;
}
# Convenience reverse proxy for the call.m.localhost domain to yarn dev --host
server {
listen 80;
@@ -246,36 +159,3 @@ server {
error_page 500 502 503 504 /50x.html;
}
# Convenience reverse proxy app.othersite.m.localhost for element web
server {
listen 80;
listen [::]:80;
server_name app.othersite.m.localhost;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name app.othersite.m.localhost;
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
location ^~ / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://element-web-1:18081;
proxy_ssl_verify off;
}
error_page 500 502 503 504 /50x.html;
}

View File

@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDGjCCAgKgAwIBAgIUbSbx+1UGptOTGefqEn7Zh3yoChIwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHODfkrFsOkqCnXnTb
QWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+1azC
SlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+I9O6
NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBhwLyC
DW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+Liyt
X/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPERYS2H
Ru/RAgMBAAGjUDBOMB0GA1UdDgQWBBTXNfLAKVayGQda/JZLPszrpz6LVzAfBgNV
HSMEGDAWgBTXNfLAKVayGQda/JZLPszrpz6LVzAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQCvGfyopHHgZB+horGH6i/Xg41V+r4d0o092F1Lfr4vh86e
XMakRw92vsyk/iWOnLPNPcpVWzPcvINaCs/bahgnGSOAnrA4jjcXqymyGIy/6xc5
1EeZAxehiL9E5q4LQ841HDX0gps4ZzUO1BRYQcjG9Rdts83JO2ekkfHkZdNj2eQr
KOrr92Na1/w+EQdo/T9Rs2ES623xKEOxPqb8d/rx5Z4DdeuGx1u+3AfS76Lpo4ni
EJ0g1ImqdSUtiOLzeCQh6pqqb+vuFbxAyeyYSAJ49847EtFBvZCmWmPL2JICg9uq
7rKW/qDfEK9GUs0GWCs3+mJkNvOOxBwtMuQrL7ZF
MIIDGjCCAgKgAwIBAgIUGdiFHhH4KL2pqBjMQHQ+PVIkSV8wDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA1MDUxMDMy
MDJaFw0zNTA1MDMxMDMyMDJaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDA2y0hjmNn1vRsVSdy
8IOfo8N1q9UgkhQWpGKXzPh+D5d1fnuJEmHIVwtDEtS/PwQ43LTmegChPtKH9jdT
tG0IihW9Ja5YNG+9xAwaoA/sB3CGCBYsz+2/XjVUpXoBJXIPoFBWsn+K0oeFw9fw
eRO1z9abM4cl+LjKzMNM8CCyu9uI1MaGjYez2YIWvG854VucLxX7HSlMJxZNWnie
Ui7fMakuJhB2+aiIQjdKxy4E5RHNhzYG/LXhvP+wBYBDPNRsP3rtzEaE9HAveL9K
FGqd3R4cBia6r1WIXmpAzyu5RGP5Eou0TZlGkal96/bF0I7q/pKlL23Jt1BLPiQU
KGKrAgMBAAGjUDBOMB0GA1UdDgQWBBQJqBjMu61c1p24txw/y+kv3D+V6DAfBgNV
HSMEGDAWgBQJqBjMu61c1p24txw/y+kv3D+V6DAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQB8m2YfFGLugNt5vAAOvNxVqDA8c72yCVYr3CBCpmTIEY5Z
d3qVGhG9//ux6+J8ntkSwd9nV5GJyYXHukCG1VavnAWolWdNF/WAllf0jhLuz7kD
/cJnuI1By4tBsBmSz851i6HJ4t5k99Be+6GQVzi0e7zzfxTHZE4xP2J6Ox8QbPsP
n0m76nIp/WbWaJqzvIIjJhmUUPPv+4wN+eOArgjiGLzptM2qTtGZtd0c9nS5gvep
+mEbSUN9zkhAroZf80wf+hEvy+fJ94VbZ9QjTzTg7odZLrsXGIe8DaG63EYRQ25b
W5iYBAreln5fGSt7qHsGfqwZibTEk/Lx3dydO1Kg
-----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHODfkrFsOkqCn
XnTbQWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+
1azCSlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+
I9O6NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBh
wLyCDW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+
LiytX/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPER
YS2HRu/RAgMBAAECggEABhB9CxYAE5p9D3s9nWsJcSDUdELRQSYlOoPFLmeMkF9c
dcvq7LmduMh1Q8TnoivOBxRIwbj7pZHEYfYJM0TmH82wrQzXu5KLVltm4gTkVt9b
DR8vjBgYdb8HVpM17Cl2xhW62XpJIiseFRUsHc/9sf2Egc3MIpPuIleGR0budbSW
ybBkqEokTYTSiAztcu3G+VN0U9MsJgLMa8HApya7M48ojdrhzngVHZRUOXul9o7u
zYJWSxPHIIYp5C4pYQBAx8OttThwKK1A9lwbQ2EJx0KnTbBC6O5Gna/jENpGd1h2
rzK/9MONtsjln7IejP+4mDlNupS6SF3zzHPBHjqKAQKBgQDtXUIKPiVTFS45yWtK
XD62s3j8jfIi+22b/C30fCPtppn0cm/0zY+vovgWVUBnQXkExafRthZCuxnE8ry7
E29S40+4z9yivAC9dz7vHZUbyIFP6VG9WyhUYo+/WqOIePyh+iBISQ9TA1DneIYz
+VZ8iU5GvdybUPl2C5WN8seaoQKBgQDW3EwVN2EEkChLRJbQYN2qpjn+0vYESMJ8
K0sgMRtgh4+/T2Xb9b8O/dd87Fi/4oaUqWZ2E2sdsXq8P/IEo0cv6SRfHMy7GyxL
RM7ztwUfMC4LVWi0ZIXMrm4gRDGN2XjGvhkX6fU2lSf6azWL1K3wI3amNV2b7P7d
ItpvdkH3MQKBgQCXf29YJEQkXB9t6J3fDzND3xb4cwy5wSo7ZeBa7CTuWOhoeeX1
JIJyAp0/e9goT0SThChRlFtu6gZPivJkoMnr6IOInLrg7we15fc4HPR/kCDgxTVT
m2wJOAMxigNYZogwRfn2yRLL1BD+PBHD+H936xcX1bSJOUyPSGOC/xLhIQKBgQCb
kCDd85ygyycBaAWxlZCor3WqFF/fNjbp5Aaepi9mMoBXSUs8eK7+UbelURHozEAY
fpYaw3B4rTlp9vppdTZjb+/PlXB9v+zQCl+0gTyKGj4cIpiOk4F0co51eipOw7f4
XUaZ0+CgxlmNq/W26iONjH+pU1YVQQA+Z6+zp/GW4QKBgQCrzYgeugxxqgJzyIRu
0njJkIg+T5gHvsQrtpzq7LVob+HBiBiT7eDOeGDXTK8F//sk969QVrDMQsTMvGW9
sG1oTqxciALTMqkJTf8+hT9Uogir0/iTbJUzTt5vPYpQOEQwQHIXMUTjZ9C6NDKT
QlmeMCxeWyPYqoMfwKmdtDP/Iw==
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDA2y0hjmNn1vRs
VSdy8IOfo8N1q9UgkhQWpGKXzPh+D5d1fnuJEmHIVwtDEtS/PwQ43LTmegChPtKH
9jdTtG0IihW9Ja5YNG+9xAwaoA/sB3CGCBYsz+2/XjVUpXoBJXIPoFBWsn+K0oeF
w9fweRO1z9abM4cl+LjKzMNM8CCyu9uI1MaGjYez2YIWvG854VucLxX7HSlMJxZN
WnieUi7fMakuJhB2+aiIQjdKxy4E5RHNhzYG/LXhvP+wBYBDPNRsP3rtzEaE9HAv
eL9KFGqd3R4cBia6r1WIXmpAzyu5RGP5Eou0TZlGkal96/bF0I7q/pKlL23Jt1BL
PiQUKGKrAgMBAAECggEAAPX2kxi5AQ7ul82SzT1KgpSXyDHLdYaUyAoYnaX9RO+B
8ylmpyeqygs4+KQS4EMJm9jpo85Oy37bIKdG3kljU6wQcKlL5Y+ZUOo1nzpV6fid
hGVs6ts8VXw8KshKQ9AyccZ8L/pirUfgOffgTwfjY7/90zceAL/s98GuZWc62nkX
55joQv/OikqYfAGP/U6Bp2Zyf23DwJB09Z3B6NnZj/ZyAbDrDEHuA15LhCOcCczp
IU/mFEywBPHT9Tg4w4Beq78PeAETvku2UalYRLhP3RLlXr2oEbwUtINRVt2QjZ85
Esps4uCqL/mgQluIebtudD9HL/YMlNPXue1mDXFxJQKBgQDgZZY4yJBcf488T1V6
HNm06b/LvVGj253pKgw14hpY1xQu3Ymgzv1GEqzhSYdzxhpmj0tMUNHxAp+YdGQu
SZ0wcPKhw0aYVkIjDRYDC3Wn5GJhyIEYHGYMo/n4l49UzHRBPOTDzp49DkHTKBgh
XgIIazYT3CkjTIMRrkUv+qfIPQKBgQDcBGu/mqbjxs4sN3zqPS4aB21o6t6W0sXs
ZP9w6RlTPQi5U2oRbftjZtYc0bbEgkMUImB1HwYPQT5pJ+MyC414xDvSc2exBr5d
To6yyPIy78Tf5PHM12fpKV92nSvoz/pSjYcGxxDtKfPqu+t8mOJfjCV1lLLA+xuB
DDaE4p8dBwKBgQCdAne6A5v/HMH8UQZeCxHJpESvKiiVnnU/UEx651nID7XvlNNX
0X0mKqsMd4ZvW43ddSYan/JF0LAa3FW8jYWO/3jF9vzOWoysOdvNBZetgf/Uq5ao
aDZ/YbzmVCXWD7jIbPMkjs3pqrAkL0mzDzQc7+dGviWKrV6IYIfIqnn7gQKBgDCz
vdIk/qpO+JZrFfiX4Fucp0hhLTJ/p5ZDaRPqVVPKn+K+Jy2ChfIj8mNgvK9VEloj
nexvGJ1J2PHYBX+vdPp1nbRhHWPfVUY8PHQw7QP/dToGaMvqJrNDGEGeWvjnCMc7
UtdaO1H0Rm0AegkTopB56lTTvJnhO95eALd7nrMDAoGAEPdzJtWoKafp49svhSj0
hiXQv2SPBwVUN4LZ4SOWiXUcmYYm80aNpYKLkBxYjrfqFWhE7NUHLGp8YorQWKY2
acD9AReHk/xku0ABy6jeYmSCmCxASxst5liKD+l12sk0gB0rk5MBxB4Uu1MIbQZ2
aCASX3AVD2/XyC2MKkzc8Eg=
-----END PRIVATE KEY-----

View File

@@ -1,21 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDgDCCAmigAwIBAgIUT9NYpZbrAKokSPSTE3zzsAMowvEwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDK1DNwTQWmyK71Ar56NvmSMQ8s
qUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgfJlZMd4lEvt6V
EEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+7Z22i6cGMOnQ
0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6WxJmFF+eV9P/ax
fxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiVG1jCrVcaKNGT
PYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrncneIJR24/AgMB
AAGjgbswgbgwHwYDVR0jBBgwFoAU1zXywClWshkHWvyWSz7M66c+i1cwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwSQYDVR0RBEIw
QIIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
cnNpdGUubS5sb2NhbGhvc3QwHQYDVR0OBBYEFIkGX+cEJ1ISKIwuT1zzp7uHJ90e
MA0GCSqGSIb3DQEBCwUAA4IBAQBnnnfB7KmyYo16ZYUCmoqGhbM4p8npeYTh5ySb
K01YwGCnMU1qGfJnKHaRwQ2+KtVGZnpBdjmsHcOUetA3V2BirPaYowMCMtaI36LD
LnxvboSZLX0mgEYuN7HmxW4a7fSelDecTYa7xti1sNhE/w8xW7Lky046/DousyUy
d9x3wJ183GGj1W2p6bR1E4sqTr/VbmoULQxnqA3GUNOxW3lRL5e8lQ6jJVRmMF4k
92BtMPrI/m7jwHj0f/WBLI8mdJ/O/W/NxQOG475FZePDfrg+MkeXPChPggf42/ou
AMm56FNB7e1l0b1Fots730RfpCPuXpiAxL4pisS0X1dMVeeM
MIIDZzCCAk+gAwIBAgIUXizLjwkdqepX0bh0K3abeJxj68IwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA1MDUxMzU5
MTFaFw0zNTA1MDMxMzU5MTFaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCrzGSScSgaQuZdELGFYiLiYRwr
LKyUdNr0rsPcOo0bvbeZ3zQMeUMRNlA69zGFdarumiDRXUoAmZI39WmH95aX3d+A
U7EFnWev7xpWSVhSYj8T0d4rke8HjGk3LpaffJ93tbJuagBIH1ouuN6AOdzWs8hp
RYIomWleEeeuVnnfaMwaXOdc+ihJJ6wzm2hwQSfdpjZPWBDd/DFft1ZXxIZOCjDs
rEIiI7uU8iZPLB3QEM/tgxSSAOxrcKvQvxZokk+FD7aMJFP71IfieLCEzMTP1VXa
tP7UTAKAqB2NyDJ8m3IHbOINiqcdFvFR3R1D9bXOYE4oRynNvYZrQUGnL2RtAgMB
AAGjgaIwgZ8wHwYDVR0jBBgwFoAUCagYzLutXNaduLccP8vpL9w/legwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwMAYDVR0RBCkw
J4IJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdDAdBgNVHQ4E
FgQUfdh1p52ZgWyZcBgBXGwKi4EnUE0wDQYJKoZIhvcNAQELBQADggEBAKrHEuB6
33j8+EwSHw3zrvt/DRXK2BDHI1Ir9JcztSunaKAjZXVvf/dvZp0Xs1dEdJIdnv6G
iZYhBbOqDqpQZbf2h/h0kuu5yZSBUdnQXnYNxlhp2UaC/UEgw5iZT/p1rm7RjVie
y4Dp2WytV5iZOLmLj6xDvd3DXazgJPWIRX8p8qJZbKTkwCjTr7nDIj8jjG1sVFf7
1RJBO5/6WSnImrpDmlLUrvjiKvbxcdseDJyBOhTwdRdSk4S2M+s5tR5j2I1gXLOq
J5ioN76+SCrTY0K0WKRy9oOXWO1/X3+VYcekp+0F3SGkd5w17jylCv1XIGHAdEsQ
v2z2/aMI/7sAD2Q=
-----END CERTIFICATE-----

View File

@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK1DNwTQWmyK71
Ar56NvmSMQ8sqUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgf
JlZMd4lEvt6VEEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+
7Z22i6cGMOnQ0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6Wx
JmFF+eV9P/axfxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiV
G1jCrVcaKNGTPYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrnc
neIJR24/AgMBAAECggEANRp6vzfDN4gKWoaV5TyYegCTNv+4rRl74cd9pjmx6Jam
uWaUXCx1etpNqPPWcG1Z9OKLLRnk+kjgKGOqq4mObGvGreNeBot7bHOJZADtwMMI
YG+Gp7StlclS1YoEHoDmezA/AcqDgTXa9KF0rdMBb1sGFJCLAuBNSJCxtVV6CQIz
X26uT0m+Wx8MQyQWA7Sqy6DQNJo++IZkvr7a3cidqBOUPs+QvnIV5JsUb2gp5tGn
zk+ObeRjoFFWYAN/NK7bneRenkP40m3MSL8ZfaEuuonui7CrxM1SiQyq2N1u/Aoy
OE1JtNaVPbLBo6kG5al7Sj4Z0zhRt+iv93S2lZMkBQKBgQD2+FpLTqyLO1NDOFkE
kxU+LdLOx0OV9wASC0ApPOu1dHMG6ksByr7TWeiu6GJDgajusPB7NVPOt2cm4iWU
xPxXPO5l87uiSvu80h5uG4Qdj8KEijHkdap2wbVkU/mm8lBKC36jyBQIlJKySyXY
zSEMfLK9jQPKz5cKKT3dVj/fAwKBgQDSPq9oks6K96MAB66o6cm214otQlnTQkPM
xgjtjddX+Lp9tgihGvtSfPbyy89oUDHCfKvW/AHG52e5dec5YUi6mVdHEWbk33Kt
BoQuxeK3XseIDlD/JD9Dd7KfUyO5w2jtYLfNdqez41O4qj2N52m1KwJYTwMsc8Kq
izVgkC5hFQKBgBFAc/5CtqbbNAvECePZ6mf3h3xOSxhUsrqP8qFu0gBQ7CAVibvM
T9wvsaNWNFcG3age0A2rQfl0sk3zCjEEOaRWa0jP59GEb2VXQCzs2yO9gRcFGEsf
NRMqoOMrQos47gbeGrCSL2QSDNVLjo9AdQiMRWgcS6GFMsXQ77NgbQHFAoGBAI4a
YGTGFWRITJvQlXUFz5kNxg8hMaVgvILDt3UY0dxb+XDOgLajjgsK+77Pkrhmu7tA
mMUOQAU4kxr/XfGil43H5v3Z/Tnk7ZWVOfKDPeHC5gpH4ucQkNIBLXISt6rvMRSA
srrk4CTuGcBPEJvBNemF0Gfvv61j8MdkoAdMbIyhAoGAfGR6yZLBmRMsW5PKmcpT
nq2oSeUpmtGZra6pWz/3XU7AgrCLcx1DmqEjm4w7y5NQJmxyMZqqdTJILCjr3Srt
+2F0NqQL6Li+xQGibAvDj0Jxyol38RvFC0J/w2vQmuF0hTuH95yknSd7FPXK+DPG
qYgXLjun9dht6kx9vGJ69wI=
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCrzGSScSgaQuZd
ELGFYiLiYRwrLKyUdNr0rsPcOo0bvbeZ3zQMeUMRNlA69zGFdarumiDRXUoAmZI3
9WmH95aX3d+AU7EFnWev7xpWSVhSYj8T0d4rke8HjGk3LpaffJ93tbJuagBIH1ou
uN6AOdzWs8hpRYIomWleEeeuVnnfaMwaXOdc+ihJJ6wzm2hwQSfdpjZPWBDd/DFf
t1ZXxIZOCjDsrEIiI7uU8iZPLB3QEM/tgxSSAOxrcKvQvxZokk+FD7aMJFP71Ifi
eLCEzMTP1VXatP7UTAKAqB2NyDJ8m3IHbOINiqcdFvFR3R1D9bXOYE4oRynNvYZr
QUGnL2RtAgMBAAECggEAJaFQii8U/KOYt9vXNoMnZvSkaeSQLLhn2V6Kciu1CtWE
aMTWLsFE6nk+G5xXkYcTmM3T0GghtH3u5CjyI6EcsEkeEorCZJt0wbmayDmqiekR
LfMzOdHuTHX5+edPgMGYYG1BFyRKyYFsjH1b5zRFZhXdGQnrl5760GsVlz9D1KZQ
iHcT+q1S2tmZeoUukQnADENKXUMCyTGM5FCddgNtsWnGDsTDayh7hUdvDkB+mW4G
lSp+BZuc3PCwpbD6qkXvfugWs6CUAAtXoV3ceWgxQ+TEnNlwxaG1AyugfgNUBolk
8xgeZt4r5QId03jsHDf7hpBAofcaCd5EMIIQYFvWoQKBgQDlbAvAzEFPTZZn2nRV
Xagw4xjqVc1LLEKLCWq0N5rEkwn0h90Dz5N7/3NuonP/sIDsDHCbyiOYBI1Ck6Xi
0WuB+OyKDh+xeF2mekN9G9ywPahdK5lT/TVsxXFyZlwtVv1x/6KBO4yv5URizxqU
gyAPDDxfD/KcNjkOBaodWEwQGQKBgQC/s2gPDBtQkjLwkHXchBomLww5eLlVrac1
WK4UX6uSdOgrjJ375OOgMTxe8NVZdOuAKytGXRWDwgH3nVWvuZhe7dGlX3JMuSer
e9VwDpBESrvqcR4ruL6wm8wej6BXyjH0wD3FHb0S5HfuBDxTn+4bDwrbRzOUMNgy
lSppuflxdQKBgQDiZcIfazFT8evn5nMAvuC4BZNTxIJHmZC9JfjPiUPIkpWzYtOe
7BvNtKOT3Op9uw8uYYRKqKqBXJSNy6ha8XCXHS9HeXKbLn20SFkLQBCDNwVLlDfF
40zyXtF6JDr4XyzSb4NM5pgKCER5AYloXxGm59s3sEQpFXUuOjbKqJS/GQKBgAoI
c7vF4HAZFr1sch62cz/oWnVvkhOf4Q5zs7ixQSOLJtOQqnwSgK9TpFs7s47ZBbJR
kBRAru2Ua9Hv1Bo8VnMxczV6h1roneDlvEf/GyHX33nnrbKQGrrXjJlU3wl5NaAf
p5v3cHvapUQ5yIZ/6lBUOzc6xMJOxCHxmKSr7Rg5AoGAbEE4lt6Xh2dnBPJ81eNI
IDrw/3ITY53qAY4Bx88CByIFuu8CEUdUZprh98jSl6ic1tMinZfUhRMwABLrUD51
DGst8iGLPD9u83iMcUHI/L+p7AbxrKLvWXZrF5UZm440c9mSWqfhPaTBosPtNDsG
LfETwH1flKXMTXd2xA9RTE4=
-----END PRIVATE KEY-----

View File

@@ -34,6 +34,5 @@ subjectAltName = @alt_names
DNS.1 = localhost
DNS.2 = m.localhost
DNS.3 = *.m.localhost
DNS.4 = *.othersite.m.localhost
EOF
)

View File

@@ -1,53 +0,0 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://synapse.othersite.m.localhost",
"server_name": "synapse.othersite.m.localhost"
}
},
"disable_custom_urls": false,
"disable_guests": false,
"disable_login_language_selector": false,
"disable_3pid_login": false,
"force_verification": false,
"brand": "Element",
"integrations_ui_url": "https://scalar.vector.im/",
"integrations_rest_url": "https://scalar.vector.im/api",
"integrations_widgets_urls": [
"https://scalar.vector.im/_matrix/integrations/v1",
"https://scalar.vector.im/api",
"https://scalar-staging.vector.im/_matrix/integrations/v1",
"https://scalar-staging.vector.im/api",
"https://scalar-staging.riot.im/scalar/api"
],
"default_widget_container_height": 280,
"default_country_code": "GB",
"show_labs_settings": false,
"features": {
"feature_element_call_video_rooms": true,
"feature_video_rooms": true,
"feature_group_calls": true,
"feature_release_announcement": false
},
"default_federate": true,
"default_theme": "light",
"room_directory": {
"servers": ["matrix.org"]
},
"enable_presence_by_hs_url": {
"https://matrix.org": false,
"https://matrix-client.matrix.org": false
},
"setting_defaults": {
"breadcrumbs": true,
"feature_group_calls": true
},
"jitsi": {
"preferred_domain": "meet.element.io"
},
"element_call": {
"participant_limit": 8,
"brand": "Element Call"
},
"map_style_url": "https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx"
}

View File

@@ -1,81 +0,0 @@
server_name: "synapse.othersite.m.localhost"
public_baseurl: https://synapse.othersite.m.localhost/
pid_file: /data/homeserver.pid
listeners:
- port: 18008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation, openid]
compress: false
database:
name: sqlite3
args:
database: /data/homeserver.db
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation.
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "synapse.m.localhost"
accept_keys_insecurely: true
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
# - per_second: number of requests a client can send per second.
# - burst_count: number of requests a client can send before being throttled.
rc_message:
per_second: 10000
burst_count: 10000
rc_delayed_event_mgmt:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
burst_count: 10000
account:
per_second: 10000
burst_count: 10000
failed_attempts:
per_second: 10000
burst_count: 10000
rc_registration:
per_second: 10000
burst_count: 10000

View File

@@ -19,18 +19,8 @@ database:
media_store_path: /data/media_store
signing_key_path: "/data/SERVERNAME.signing.key"
# Due to custom TLS certificate with domains
# - m.localhost, localhost
# - *.m.localhost
# - *.othersite.m.localhost
# we disable certificate verification to allow for federation.
# WARNING: DO NOT USE IN PRODUCTION!!!
federation_verify_certificates: false
ip_range_blacklist: []
trusted_key_servers:
- server_name: "synapse.othersite.m.localhost"
accept_keys_insecurely: true
- server_name: "matrix.org"
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
@@ -44,13 +34,6 @@ experimental_features:
# duration (null), which disallows sending delayed events.
max_event_delay_duration: 24h
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
# Each ratelimiting configuration is made of two parameters:
@@ -61,10 +44,6 @@ rc_message:
per_second: 10000
burst_count: 10000
rc_delayed_event_mgmt:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000
@@ -79,3 +58,10 @@ rc_login:
rc_registration:
per_second: 10000
burst_count: 10000
# Required for Element Call in Single Page Mode due to on-the-fly user registration
enable_registration: true
enable_registration_without_verification: true
report_stats: false
serve_server_wellknown: true

5
backend/redis.conf Normal file
View File

@@ -0,0 +1,5 @@
bind 0.0.0.0
protected-mode yes
port 6379
timeout 0
tcp-keepalive 300

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ services:
# If the configured homeserver runs on localhost, it'll probably be using
# a self-signed certificate
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
deploy:
restart_policy:
condition: on-failure
@@ -24,31 +23,8 @@ services:
networks:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
pull_policy: always
hostname: auth-server-1
environment:
- LIVEKIT_JWT_PORT=16080
- LIVEKIT_URL=wss://matrix-rtc.othersite.m.localhost/livekit/sfu
- LIVEKIT_KEY=devkey
- LIVEKIT_SECRET=secret
# 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
ports:
# HOST_PORT:CONTAINER_PORT
- 16080:16080
networks:
- ecbackend
livekit:
image: livekit/livekit-server:latest
pull_policy: always
hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml
restart: unless-stopped
@@ -66,30 +42,20 @@ services:
networks:
- ecbackend
livekit-1:
image: livekit/livekit-server:latest
pull_policy: always
hostname: livekit-sfu-1
command: --dev --config /etc/livekit.yaml
restart: unless-stopped
# The SFU seems to work far more reliably when we let it share the host
# network rather than opening specific ports (but why?? we're not missing
# any…)
redis:
image: redis:6-alpine
command: redis-server /etc/redis.conf
ports:
# HOST_PORT:CONTAINER_PORT
- 17880:17880/tcp
- 17881:17881/tcp
- 17882:17882/tcp
- 50300-50400:50300-50400/udp
- 6379:6379
volumes:
- ./backend/dev_livekit-othersite.yaml:/etc/livekit.yaml:Z
- ./backend/redis.conf:/etc/redis.conf:Z
networks:
- ecbackend
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
@@ -104,24 +70,6 @@ services:
networks:
- ecbackend
synapse-1:
hostname: homeserver-1
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
# fit local user uid. If the container runs as root (uid 0) it is fine as
# it actually maps to your non-root user on the host (e.g. 1000).
# Otherwise uid mapping will not match your non-root user.
- UID=0
- GID=0
volumes:
- ./backend/synapse_tmp_othersite:/data:Z
- ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
networks:
- ecbackend
element-web:
image: ghcr.io/element-hq/element-web:develop
pull_policy: always
@@ -134,24 +82,10 @@ services:
networks:
- ecbackend
element-web-1:
image: ghcr.io/element-hq/element-web:develop
pull_policy: always
volumes:
- ./backend/ew.test.othersite.config.json:/app/config.json:Z
environment:
ELEMENT_WEB_PORT: 18081
ports:
# HOST_PORT:CONTAINER_PORT
- "18081:18081"
networks:
- ecbackend
nginx:
# 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
@@ -167,9 +101,4 @@ services:
depends_on:
- synapse
networks:
ecbackend:
aliases:
- synapse.m.localhost
- synapse.othersite.m.localhost
- matrix-rtc.m.localhost
- matrix-rtc.othersite.m.localhost
- ecbackend

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 941 KiB

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 886 KiB

View File

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

View File

@@ -48,7 +48,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| Name | Values | Required for widget | Required for SPA | Description |
| ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesnt provide any. |
| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
@@ -57,9 +56,10 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
| `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`. |
| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. |
| `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. |
@@ -69,9 +69,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
### Widget-only parameters

View File

@@ -2,11 +2,11 @@
# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format
[versions]
android_gradle_plugin = "8.11.1"
android_gradle_plugin = "8.10.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.34.0" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.32.0" }

View File

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

View File

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

View File

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

11
knip.ts
View File

@@ -1,15 +1,8 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type KnipConfig } from "knip";
import { KnipConfig } from "knip";
export default {
vite: {
config: ["vite.config.ts", "vite-embedded.config.ts"],
config: ["vite.config.js", "vite-embedded.config.js"],
},
entry: ["src/main.tsx", "i18next-parser.config.ts"],
ignoreBinaries: [

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,6 @@
"profile": "Profile",
"reaction": "Reaction",
"reactions": "Reactions",
"reconnecting": "Reconnecting…",
"settings": "Settings",
"unencrypted": "Not encrypted",
"username": "Username",
@@ -83,7 +82,7 @@
"error": {
"call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one</1>.</0>",
"connection_lost": "Connection lost",
"connection_lost_description": "You were disconnected from the call.",
"e2ee_unsupported": "Incompatible browser",
@@ -95,8 +94,6 @@
"matrix_rtc_focus_missing": "The server is not configured to work with {{brand}}. Please contact your server admin (Domain: {{domain}}, Error Code: {{ errorCode }}).",
"open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"room_creation_restricted": "Failed to create call",
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
},
"group_call_loader": {
@@ -108,11 +105,6 @@
"knock_reject_heading": "Access denied",
"reason": "Reason: {{reason}}"
},
"handset": {
"overlay_back_button": "Back to Speaker Mode",
"overlay_description": "Only works while using app",
"overlay_title": "Handset Mode"
},
"hangup_button_label": "End call",
"header_label": "Element Call Home",
"header_participants_label": "Participants",
@@ -184,8 +176,7 @@
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"handset": "Handset",
"loudspeaker": "Loudspeaker",
"earpiece": "Earpiece",
"microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,3 @@ services:
synapse:
volumes:
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
synapse-1:
volumes:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z

View File

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

View File

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

View File

@@ -159,8 +159,8 @@ export const widgetTest = test.extend<MyFixtures>({
} = await registerUser(browser, userB);
// Invite the second user
await ewPage1.getByRole("button", { name: "Add", exact: true }).click();
await ewPage1.getByRole("menuitem", { name: "New Room" }).click();
await ewPage1.getByRole("button", { name: "Add room" }).click();
await ewPage1.getByText("New room").click();
await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room");
await ewPage1.getByRole("button", { name: "Create room" }).click();
await expect(ewPage1.getByText("You created this room.")).toBeVisible();
@@ -184,9 +184,9 @@ export const widgetTest = test.extend<MyFixtures>({
// Accept the invite
await expect(
ewPage2.getByRole("option", { name: "Welcome Room" }),
ewPage2.getByRole("treeitem", { name: "Welcome Room" }),
).toBeVisible();
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await ewPage2.getByRole("treeitem", { name: "Welcome Room" }).click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }),

View File

@@ -1,60 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
test.skip(
({ browserName }) => browserName === "firefox",
'This test is not working on firefox, page.keyboard.press("Tab") not reliable in headless mode',
);
test("can only interact with header and footer while reconnecting", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("home_callName").click();
await page.getByTestId("home_callName").fill("Test call");
await page.getByTestId("home_displayName").click();
await page.getByTestId("home_displayName").fill("Test user");
// If we do not call fastForward here, we end up with Date.now() returning an actual timestamp
// but once we call `await page.clock.fastForward(20000);` later this will reset Date.now() to 0
// and we will never get into probablyDisconnected state?
await page.clock.fastForward(10);
await page.getByTestId("home_go").click();
await expect(page.locator("video")).toBeVisible();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
// Join the call
await page.getByTestId("lobby_joinCall").click();
// The media tile for the local user should become visible
await new Promise((resolve) => setTimeout(resolve, 1500));
await expect(page.getByTestId("name_tag")).toContainText("Test user");
// Now disconnect from the internet
await page.route("https://synapse.m.localhost/**/*", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 10000));
await route.continue();
});
await page.clock.fastForward(20000);
await expect(
page.getByRole("dialog", { name: "Reconnecting…" }),
).toBeVisible();
// Tab order should jump directly from header to footer, skipping media tiles
await page.getByRole("button", { name: "Mute microphone" }).focus();
await expect(
page.getByRole("button", { name: "Mute microphone" }),
).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Stop video" })).toBeFocused();
// Most critically, we should be able to press the hangup button
await page.getByRole("button", { name: "End call" }).click();
});

View File

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

View File

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

View File

@@ -49,10 +49,7 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
// Check the join indicator on the room list
await expect(
brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByRole("button", { name: "End call" }),
brooks.page.locator("div").filter({ hasText: /^Joined • 1$/ }),
).toBeVisible();
// Join from the other side
@@ -62,28 +59,26 @@ widgetTest("Start a new call as widget", async ({ asWidget, browserName }) => {
).toBeVisible();
await whistler.page.getByRole("button", { name: "Join" }).click();
// Currently disabled due to recent Element Web is bypassing Lobby
// await expect(
// whistler.page
// .locator('iframe[title="Element Call"]')
// .contentFrame()
// .getByTestId("lobby_joinCall"),
// ).toBeVisible();
//
// await whistler.page
// .locator('iframe[title="Element Call"]')
// .contentFrame()
// .getByTestId("lobby_joinCall")
// .click();
await expect(
whistler.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall"),
).toBeVisible();
// Currrenty disabled due to recent Element Web not indicating the number of participants
// await expect(
// whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
// ).toBeVisible();
await whistler.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall")
.click();
// await expect(
// brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
// ).toBeVisible();
await expect(
whistler.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
).toBeVisible();
await expect(
brooks.page.locator("div").filter({ hasText: /^Joined • 2$/ }),
).toBeVisible();
// Whistler leaves
await whistler.page.waitForTimeout(1000);

View File

@@ -5,14 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type FC,
type JSX,
Suspense,
useEffect,
useMemo,
useState,
} from "react";
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -24,14 +17,13 @@ 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";
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
import { getUrlParams, HeaderStyle } from "./UrlParams";
import { AppBar } from "./AppBar";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -75,42 +67,41 @@ export const App: FC<Props> = ({ vm }) => {
.catch(logger.error);
});
// Since we are outside the router component, we cannot use useUrlParams here
const { header } = useMemo(getUrlParams, []);
const content = loaded ? (
<ClientProvider>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} widget={widget} />}
>
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute path="/register" element={<RegisterPage />} />
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
) : (
<LoadingPage />
);
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
<BrowserRouter>
<BackgroundProvider>
<ThemeProvider>
<TooltipProvider>
<Suspense fallback={null}>
{header === HeaderStyle.AppBar ? (
<AppBar>{content}</AppBar>
) : (
content
)}
</Suspense>
{loaded ? (
<Suspense fallback={null}>
<ClientProvider>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => (
<ErrorPage error={error} widget={widget} />
)}
>
<DisconnectedBanner />
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute
path="/register"
element={<RegisterPage />}
/>
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</Suspense>
) : (
<LoadingPage />
)}
</TooltipProvider>
</ThemeProvider>
</BackgroundProvider>

View File

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

View File

@@ -1,25 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { AppBar } from "./AppBar";
describe("AppBar", () => {
it("renders", () => {
const { container } = render(
<TooltipProvider>
<AppBar>
<p>This is the content.</p>
</AppBar>
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
});
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,12 @@ Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react";
import {
type FC,
type HTMLAttributes,
type ReactNode,
forwardRef,
} from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Heading, Text } from "@vector-im/compound-web";
@@ -17,29 +22,15 @@ 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 (
<>
export const Header = forwardRef<HTMLElement, HeaderProps>(
({ children, className, ...rest }, ref) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
@@ -47,10 +38,9 @@ export const Header: FC<HeaderProps> = ({
>
{children}
</header>
{disconnectedBanner && <DisconnectedBanner />}
</>
);
};
);
},
);
Header.displayName = "Header";

View File

@@ -23,14 +23,6 @@ export function useMediaDevices(): MediaDevices {
return mediaDevices;
}
export const useIsEarpiece = (): boolean => {
const devices = useMediaDevices();
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
const available = useObservableEagerState(devices.audioOutput.available$);
if (!audioOutput?.id) return false;
return available.get(audioOutput.id)?.type === "earpiece";
};
/**
* A convenience hook to get the audio node configuration for the earpiece.
* It will check the `useAsEarpiece` of the `audioOutput` device and return
@@ -44,13 +36,17 @@ export const useEarpieceAudioConfig = (): {
} => {
const devices = useMediaDevices();
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
return {
// We use only the right speaker (pan = 1) for the earpiece.
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
pan: useMemo(() => (isVirtualEarpiece ? 1 : 0), [isVirtualEarpiece]),
// We also do lower the volume by a factor of 10 to optimize for the usecase where
// a user is holding the phone to their ear.
volume: useMemo(() => (isVirtualEarpiece ? 0.1 : 1), [isVirtualEarpiece]),
};
// We use only the right speaker (pan = 1) for the earpiece.
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
const pan = useMemo(
() => (audioOutput?.virtualEarpiece ? 1 : 0),
[audioOutput?.virtualEarpiece],
);
// We also do lower the volume by a factor of 10 to optimize for the usecase where
// a user is holding the phone to their ear.
const volume = useMemo(
() => (audioOutput?.virtualEarpiece ? 0.1 : 1),
[audioOutput?.virtualEarpiece],
);
return { pan, volume };
};

View File

@@ -35,8 +35,6 @@ Please see LICENSE in the repository root for full details.
.bg.animate[data-state="closed"] {
animation: fade-out 130ms;
opacity: 0;
pointer-events: none;
}
.overlay {

View File

@@ -45,12 +45,6 @@ interface Props {
* A supporting icon to display within the toast.
*/
Icon?: ComponentType<SVGAttributes<SVGElement>>;
/**
* Whether the toast should be modal, making it fill the screen (by portalling
* it into the root of the document) and trap focus until dismissed.
* @default true
*/
modal?: boolean;
}
/**
@@ -62,7 +56,6 @@ export const Toast: FC<Props> = ({
autoDismiss,
children,
Icon,
modal = true,
}) => {
const onOpenChange = useCallback(
(open: boolean) => {
@@ -78,33 +71,29 @@ export const Toast: FC<Props> = ({
}
}, [open, autoDismiss, onDismiss]);
const content = (
<>
<DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)}
/>
<DialogContent aria-describedby={undefined} asChild>
<DialogClose
className={classNames(
overlayStyles.overlay,
overlayStyles.animate,
styles.toast,
)}
>
<DialogTitle asChild>
<Text as="h3" size="sm" weight="semibold">
{children}
</Text>
</DialogTitle>
{Icon && <Icon width={20} height={20} aria-hidden />}
</DialogClose>
</DialogContent>
</>
);
return (
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
{modal ? <DialogPortal>{content}</DialogPortal> : content}
<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>
</DialogRoot>
);
};

View File

@@ -5,15 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { describe, expect, it } from "vitest";
import * as PlatformMod from "../src/Platform";
import {
getRoomIdentifierFromUrl,
computeUrlParams,
HeaderStyle,
getUrlParams,
UserIntent,
} from "../src/UrlParams";
const ROOM_NAME = "roomNameHere";
@@ -85,16 +82,6 @@ describe("UrlParams", () => {
getRoomIdentifierFromUrl("", `?roomId=${ROOM_ID}`, "").roomId,
).toBe(ROOM_ID);
});
it("(roomId with unprintable characters)", () => {
const invisibleChar = "\u2066";
expect(
getRoomIdentifierFromUrl(
"",
`?roomId=${invisibleChar}${ROOM_ID}${invisibleChar}`,
"",
).roomId,
).toBe(ROOM_ID);
});
});
it("ignores room alias", () => {
@@ -106,16 +93,16 @@ describe("UrlParams", () => {
describe("preload", () => {
it("defaults to false", () => {
expect(computeUrlParams().preload).toBe(false);
expect(getUrlParams().preload).toBe(false);
});
it("ignored in SPA mode", () => {
expect(computeUrlParams("?preload=true").preload).toBe(false);
expect(getUrlParams("?preload=true").preload).toBe(false);
});
it("respected in widget mode", () => {
expect(
computeUrlParams(
getUrlParams(
"?preload=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).preload,
).toBe(true);
@@ -124,20 +111,19 @@ describe("UrlParams", () => {
describe("returnToLobby", () => {
it("is false in SPA mode", () => {
expect(computeUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
expect(getUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
});
it("defaults to false in widget mode", () => {
expect(
computeUrlParams(
"?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby,
getUrlParams("?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo")
.returnToLobby,
).toBe(false);
});
it("respected in widget mode", () => {
expect(
computeUrlParams(
getUrlParams(
"?returnToLobby=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby,
).toBe(true);
@@ -146,12 +132,12 @@ describe("UrlParams", () => {
describe("userId", () => {
it("is ignored in SPA mode", () => {
expect(computeUrlParams("?userId=asd").userId).toBe(null);
expect(getUrlParams("?userId=asd").userId).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
computeUrlParams(
getUrlParams(
"?userId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).userId,
).toBe("asd");
@@ -160,12 +146,12 @@ describe("UrlParams", () => {
describe("deviceId", () => {
it("is ignored in SPA mode", () => {
expect(computeUrlParams("?deviceId=asd").deviceId).toBe(null);
expect(getUrlParams("?deviceId=asd").deviceId).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
computeUrlParams(
getUrlParams(
"?deviceId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).deviceId,
).toBe("asd");
@@ -174,12 +160,12 @@ describe("UrlParams", () => {
describe("baseUrl", () => {
it("is ignored in SPA mode", () => {
expect(computeUrlParams("?baseUrl=asd").baseUrl).toBe(null);
expect(getUrlParams("?baseUrl=asd").baseUrl).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
computeUrlParams(
getUrlParams(
"?baseUrl=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).baseUrl,
).toBe("asd");
@@ -189,179 +175,72 @@ describe("UrlParams", () => {
describe("viaServers", () => {
it("is ignored in widget mode", () => {
expect(
computeUrlParams(
getUrlParams(
"?viaServers=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).viaServers,
).toBe(null);
});
it("is parsed in SPA mode", () => {
expect(computeUrlParams("?viaServers=asd").viaServers).toBe("asd");
expect(getUrlParams("?viaServers=asd").viaServers).toBe("asd");
});
});
describe("homeserver", () => {
it("is ignored in widget mode", () => {
expect(
computeUrlParams(
getUrlParams(
"?homeserver=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).homeserver,
).toBe(null);
});
it("is parsed in SPA mode", () => {
expect(computeUrlParams("?homeserver=asd").homeserver).toBe("asd");
expect(getUrlParams("?homeserver=asd").homeserver).toBe("asd");
});
});
describe("intent", () => {
const noIntentDefaults = {
confineToRoom: false,
appPrompt: true,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
};
const startNewCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: platform === "desktop" ? "notification" : "ring",
});
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",
defaultAudioEnabled: true,
defaultVideoEnabled: true,
});
it("use no-intent-defaults with unknown intent", () => {
expect(computeUrlParams()).toMatchObject(noIntentDefaults);
it("defaults to unknown", () => {
expect(getUrlParams().intent).toBe(UserIntent.Unknown);
});
it("ignores intent if it is not a valid value", () => {
expect(computeUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
expect(getUrlParams("?intent=foo").intent).toBe(UserIntent.Unknown);
});
it("accepts start_call", () => {
expect(
computeUrlParams(
"?intent=start_call&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false });
});
it("accepts start_call_dm mobile", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(startNewCallDefaults("android"));
});
it("accepts start_call_dm mobile and prioritizes overwritten params", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org&sendNotificationType=notification",
),
).toMatchObject({
...startNewCallDefaults("android"),
sendNotificationType: "notification",
});
expect(getUrlParams("?intent=start_call").intent).toBe(
UserIntent.StartNewCall,
);
});
it("accepts join_existing", () => {
expect(
computeUrlParams(
"?intent=join_existing&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(joinExistingCallDefaults("desktop"));
expect(getUrlParams("?intent=join_existing").intent).toBe(
UserIntent.JoinExistingCall,
);
});
});
describe("skipLobby", () => {
it("defaults to false", () => {
expect(computeUrlParams().skipLobby).toBe(false);
expect(getUrlParams().skipLobby).toBe(false);
});
it("defaults to false if intent is start_call in SPA mode", () => {
expect(computeUrlParams("?intent=start_call").skipLobby).toBe(false);
expect(getUrlParams("?intent=start_call").skipLobby).toBe(false);
});
it("defaults to false if intent is start_call in widget mode", () => {
it("defaults to true if intent is start_call in widget mode", () => {
expect(
computeUrlParams(
getUrlParams(
"?intent=start_call&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).skipLobby,
).toBe(false);
).toBe(true);
});
it("default to false if intent is join_existing", () => {
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
});
});
describe("header", () => {
it("uses header if provided", () => {
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
"app_bar",
);
expect(computeUrlParams("?header=none&hideHeader=false").header).toBe(
"none",
);
});
});
describe("getUrlParams", () => {
it("uses cached values", () => {
const spy = vi.spyOn(logger, "info");
// call get once
const params = getUrlParams("?header=app_bar&hideHeader=true", "");
// call get twice
expect(getUrlParams("?header=app_bar&hideHeader=true", "")).toBe(params);
// expect compute to only be called once
// it will only log when it is computing the values
expect(spy).toHaveBeenCalledExactlyOnceWith(
"UrlParams: final set of url params\n",
"intent:",
"unknown",
"\nproperties:",
expect.any(Object),
"configuration:",
expect.any(Object),
"intentAndPlatformDerivedConfiguration:",
{},
);
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
});
});
});

View File

@@ -8,16 +8,10 @@ Please see LICENSE in the repository root for full details.
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type RTCCallIntent,
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;
@@ -28,25 +22,15 @@ interface RoomIdentifier {
export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm",
JoinExistingCallDMVoice = "join_existing_dm_voice",
Unknown = "unknown",
}
export enum HeaderStyle {
None = "none",
Standard = "standard",
AppBar = "app_bar",
}
/**
* The UrlProperties are used to pass required data to the widget.
* Those are different in different rooms, users, devices. They do not configure the behavior of the
* widget but provide the required data to the widget.
*/
export interface UrlProperties {
// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams {
// Widget api related params
widgetId: string | null;
parentUrl: string | null;
@@ -58,11 +42,42 @@ export interface UrlProperties {
* is also not validated, where it is in useRoomIdentifier().
*/
roomId: string | null;
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* The app prompt must also be enabled in the config for this to take effect.
*/
appPrompt: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* Whether to hide the room header when in a call.
*/
hideHeader: boolean;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether to use end-to-end encryption.
*/
e2eEnabled: boolean;
/**
* The user's ID (only used in matryoshka mode).
*/
userId: string | null;
/**
* The display name to use for auto-registration.
*/
@@ -99,97 +114,15 @@ export interface UrlProperties {
* The Posthog API key. This is only used in the embedded package of Element Call.
*/
posthogApiKey: string | null;
/**
* Whether to use end-to-end encryption.
*/
e2eEnabled: boolean;
/**
* E2EE password
*/
password: string | null;
/** This defines the homeserver that is going to be used when joining a room.
* It has to be set to a non default value for links to rooms
* that are not on the default homeserver,
* that is in use for the current user.
*/
viaServers: string | null;
/**
* This defines the homeserver that is going to be used when registering
* a new (guest) user.
* This can be user to configure a non default guest user server when
* creating a spa link.
*/
homeserver: string | null;
/**
* The rageshake submit URL. This is only used in the embedded package of Element Call.
*/
rageshakeSubmitUrl: string | null;
/**
* The Sentry DSN. This is only used in the embedded package of Element Call.
*/
sentryDsn: string | null;
/**
* The Sentry environment. This is only used in the embedded package of Element Call.
*/
sentryEnvironment: string | null;
/**
* The theme to use for element call.
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
*/
theme: string | null;
}
/**
* The configuration for the app, which can be set via URL parameters.
* Those property are different to the UrlProperties, since they are all optional
* and configure the behavior of the app. Their value is the same if EC is used in
* the same context but with different accounts/users.
*
* Their defaults can be controlled by the `intent` property.
*/
export interface UrlConfiguration {
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether upon entering a room, the user should be prompted to launch the
* native mobile app. (Affects only Android and iOS.)
*
* The app prompt must also be enabled in the config for this to take effect.
*/
appPrompt: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* The style of headers to show. "standard" is the default arrangement, "none"
* hides the header entirely, and "app_bar" produces a header with a back
* button like you might see in mobile apps. The callback for the back button
* is window.controls.onBackButtonPressed.
*/
header: HeaderStyle;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
*/
allowIceFallback: boolean;
/**
* E2EE password
*/
password: string | null;
/**
* Whether the app should use per participant keys for E2EE.
*/
@@ -212,47 +145,46 @@ export interface UrlConfiguration {
*/
returnToLobby: boolean;
/**
* Whether and what type of notification EC should send, when the user joins the call.
* The theme to use for element call.
* can be "light", "dark", "light-high-contrast" or "dark-high-contrast".
*/
sendNotificationType?: RTCNotificationType;
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;
/**
* 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.
* 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.
*/
autoLeaveWhenOthersLeft: boolean;
homeserver: string | null;
/**
* 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.
* 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`.
*/
waitForCallPickup: boolean;
intent: string | null;
callIntent?: RTCCallIntent;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
/**
* The rageshake submit URL. This is only used in the embedded package of Element Call.
*/
rageshakeSubmitUrl: string | null;
// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams
extends UrlProperties,
UrlConfiguration,
IntentAndPlatformDerivedConfiguration {}
/**
* 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;
}
// 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
@@ -294,17 +226,6 @@ class ParamParser {
return this.fragmentParams.get(name) ?? this.queryParams.get(name);
}
public getEnumParam<T extends string>(
name: string,
type: { [s: string]: T } | ArrayLike<T>,
): T | undefined {
const value = this.getParam(name);
if (value !== null && Object.values(type).includes(value as T)) {
return value as T;
}
return undefined;
}
public getAllParams(name: string): string[] {
return [
...this.fragmentParams.getAll(name),
@@ -312,30 +233,14 @@ 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;
}
}
let urlParamCache: {
search?: string;
hash?: string;
params?: UrlParams;
} = {};
/**
* Gets the url params and loads them from a cache if already computed.
* Gets the app parameters for the current URL.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
@@ -344,145 +249,36 @@ export const getUrlParams = (
search = window.location.search,
hash = window.location.hash,
): UrlParams => {
if (
urlParamCache.search === search &&
urlParamCache.hash === hash &&
urlParamCache.params
) {
return urlParamCache.params;
}
const params = computeUrlParams(search, hash);
urlParamCache = { search, hash, params };
return params;
};
/**
* Gets the app parameters for the current URL.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
*/
export const computeUrlParams = (search = "", hash = ""): UrlParams => {
const parser = new ParamParser(search, hash);
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
let intent = parser.getParam("intent");
if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) {
intent = UserIntent.Unknown;
}
const widgetId = parser.getParam("widgetId");
const parentUrl = parser.getParam("parentUrl");
const isWidget = !!widgetId && !!parentUrl;
/**
* The user's intent with respect to the call.
* e.g. if they clicked a Start Call button, this would be `start_call`.
* If it was a Join Call button, it would be `join_existing`.
* This is a platform specific default set of parameters, that allows to minize the configuration
* needed to start a call. And empowers the EC codebase to control the platform/intent behavior in
* a central place.
*
* In short: either provide url query parameters of UrlConfiguration or set the intent
* (or the global defaults will be used).
*/
const intent = !isWidget
? UserIntent.Unknown
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
// Here we only use constants and `platform` to determine the intent preset.
let intentPreset: UrlConfiguration = {
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",
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
switch (intent) {
case UserIntent.StartNewCall:
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.JoinExistingCall:
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.StartNewCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
intentPreset.skipLobby = true;
intentPreset.sendNotificationType = "ring";
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.waitForCallPickup = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
break;
case UserIntent.JoinExistingCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = true;
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
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 intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
{};
// Desktop also includes web. Its anything that is not mobile.
const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
switch (desktopMobile) {
case "desktop":
case "mobile":
switch (intent) {
case UserIntent.StartNewCall:
case UserIntent.JoinExistingCall:
case UserIntent.StartNewCallDM:
case UserIntent.JoinExistingCallDM:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
break;
case UserIntent.StartNewCallDMVoice:
case UserIntent.JoinExistingCallDMVoice:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = false;
break;
}
}
const properties: UrlProperties = {
return {
widgetId,
parentUrl,
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
// what would we do if it were invalid? If the widget API says that's what
// the room ID is, then that's what it is.
roomId: parser.getParam("roomId"),
password: parser.getParam("password"),
// This flag has 'embed' as an alias for historical reasons
confineToRoom:
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
appPrompt: parser.getFlagParam("appPrompt", true),
preload: isWidget ? parser.getFlagParam("preload") : false,
hideHeader: parser.getFlagParam("hideHeader"),
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,
@@ -490,9 +286,24 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
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:
@@ -500,52 +311,6 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
rageshakeSubmitUrl: parser.getParam("rageshakeSubmitUrl"),
sentryDsn: parser.getParam("sentryDsn"),
sentryEnvironment: parser.getParam("sentryEnvironment"),
e2eEnabled: parser.getFlagParam("enableE2EE", true),
};
const configuration: Partial<UrlConfiguration> = {
confineToRoom: parser.getFlag("confineToRoom"),
appPrompt: parser.getFlag("appPrompt"),
preload: isWidget ? parser.getFlag("preload") : undefined,
// Check hideHeader for backwards compatibility. If header is set, hideHeader
// is ignored.
header: parser.getEnumParam("header", HeaderStyle),
showControls: parser.getFlag("showControls"),
hideScreensharing: parser.getFlag("hideScreensharing"),
allowIceFallback: parser.getFlag("allowIceFallback"),
perParticipantE2EE: parser.getFlag("perParticipantE2EE"),
controlledAudioDevices: parser.getFlag("controlledAudioDevices"),
skipLobby: isWidget ? parser.getFlag("skipLobby") : false,
// In SPA mode the user should always exit to the home screen when hanging
// up, rather than being sent back to the lobby
returnToLobby: isWidget ? parser.getFlag("returnToLobby") : false,
sendNotificationType: parser.getEnumParam("sendNotificationType", [
"ring",
"notification",
]),
waitForCallPickup: parser.getFlag("waitForCallPickup"),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
};
// Log the final configuration for debugging purposes.
// This will only log when the cache is not yet set.
logger.info(
"UrlParams: final set of url params\n",
"intent:",
intent,
"\nproperties:",
properties,
"configuration:",
configuration,
"intentAndPlatformDerivedConfiguration:",
intentAndPlatformDerivedConfiguration,
);
return {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
...intentAndPlatformDerivedConfiguration,
};
};
@@ -604,16 +369,10 @@ export function getRoomIdentifierFromUrl(
// Make sure roomId is valid
let roomId: string | null = parser.getParam("roomId");
if (roomId !== null) {
// Replace any non-printable characters that another client may have inserted.
// For instance on iOS, some copied links end up with zero width characters on the end which get encoded into the URL.
// This isn't valid for a roomId, so we can freely strip the content.
roomId = roomId.replaceAll(/^[^ -~]+|[^ -~]+$/g, "");
if (!roomId.startsWith("!")) {
roomId = null;
} else if (!roomId.includes("")) {
roomId = null;
}
if (!roomId?.startsWith("!")) {
roomId = null;
} else if (!roomId.includes("")) {
roomId = null;
}
return {

View File

@@ -1,50 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`AppBar > renders 1`] = `
<div>
<div
class="bar"
style="display: block;"
>
<header
class="header"
>
<div
class="nav leftNav"
>
<button
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 11.034a1 1 0 0 0 .29.702l.005.005c.18.18.43.29.705.29h8a1 1 0 0 0 0-2h-5.586L22 3.445a1 1 0 0 0-1.414-1.414L14 8.617V3.031a1 1 0 1 0-2 0zm0 1.963a1 1 0 0 0-.29-.702l-.005-.004A1 1 0 0 0 11 12H3a1 1 0 1 0 0 2h5.586L2 20.586A1 1 0 1 0 3.414 22L10 15.414V21a1 1 0 0 0 2 0z"
/>
</svg>
</div>
</button>
</div>
<div
class="nav rightNav"
/>
</header>
</div>
<p>
This is the content.
</p>
</div>
`;

View File

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

View File

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

View File

@@ -5,22 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ComponentProps, type FC } from "react";
import { type ComponentPropsWithoutRef, forwardRef } from "react";
import { Button } from "@vector-im/compound-web";
import type { LinkProps } from "react-router-dom";
import { useLink } from "./Link";
type Props = Omit<ComponentProps<typeof Button<"a">>, "as" | "href"> & {
to: LinkProps["to"];
state?: unknown;
};
type Props = Omit<
ComponentPropsWithoutRef<typeof Button<"a">>,
"as" | "href"
> & { to: LinkProps["to"]; state?: unknown };
/**
* A version of Compound's button component that acts as a link and integrates
* with our router setup.
*/
export const LinkButton: FC<Props> = ({ ref, to, state, ...props }) => {
const [path, onClick] = useLink(to, state);
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
};
export const LinkButton = forwardRef<HTMLAnchorElement, Props>(
function LinkButton({ to, state, ...props }, ref) {
const [path, onClick] = useLink(to, state);
return <Button as="a" ref={ref} {...props} href={path} onClick={onClick} />;
},
);

View File

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

View File

@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-labelledby="«rbb»"
aria-labelledby="«rb5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"

View File

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

View File

@@ -1,36 +1,35 @@
/*
Copyright 2024-2025 New Vector Ltd.
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 { Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
const logger = rootLogger.getChild("[controlled-output]");
export interface Controls {
canEnterPip(): boolean;
enablePip(): void;
disablePip(): void;
setAvailableAudioDevices(devices: OutputDevice[]): void;
setAudioDevice(id: string): void;
onAudioDeviceSelect?: (id: string) => void;
onAudioPlaybackStarted?: () => void;
setAudioEnabled(enabled: boolean): void;
showNativeAudioDevicePicker?: () => void;
onBackButtonPressed?: () => void;
/** @deprecated use setAvailableAudioDevices instead*/
setAvailableOutputDevices(devices: OutputDevice[]): void;
setAvailableAudioDevices(devices: OutputDevice[]): void;
/** @deprecated use setAudioDevice instead*/
setOutputDevice(id: string): void;
setAudioDevice(id: string): void;
/** @deprecated use onAudioDeviceSelect instead*/
onOutputDeviceSelect?: (id: string) => void;
onAudioDeviceSelect?: (id: string) => void;
onAudioPlaybackStarted?: () => void;
/** @deprecated use setAudioEnabled instead*/
setOutputEnabled(enabled: boolean): void;
setAudioEnabled(enabled: boolean): void;
/** @deprecated use showNativeAudioDevicePicker instead*/
showNativeOutputDevicePicker?: () => void;
showNativeAudioDevicePicker?: () => void;
}
export interface OutputDevice {
@@ -49,7 +48,7 @@ export const setPipEnabled$ = new Subject<boolean>();
export const availableOutputDevices$ = new Subject<OutputDevice[]>();
export const outputDevice$ = new Subject<string>();
export const outputDevice$ = new Subject<string | undefined>();
/**
* This allows the os to mute the call if the user
@@ -58,7 +57,6 @@ export const outputDevice$ = new Subject<string>();
* This should also be used to display a darkened overlay screen letting the user know that audio is muted.
*/
export const setAudioEnabled$ = new Subject<boolean>();
let playbackStartedEmitted = false;
export const setPlaybackStarted = (): void => {
if (!playbackStartedEmitted) {
@@ -66,7 +64,6 @@ export const setPlaybackStarted = (): void => {
window.controls.onAudioPlaybackStarted?.();
}
};
window.controls = {
canEnterPip(): boolean {
return setPipEnabled$.observed;
@@ -79,7 +76,6 @@ window.controls = {
if (!setPipEnabled$.observed) throw new Error("No call is running");
setPipEnabled$.next(false);
},
setAvailableAudioDevices(devices: OutputDevice[]): void {
logger.info("setAvailableAudioDevices called from native:", devices);
availableOutputDevices$.next(devices);

View File

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

View File

@@ -18,22 +18,23 @@ import {
type ComponentType,
type Dispatch,
type FC,
type LegacyRef,
type ReactNode,
type Ref,
type SetStateAction,
createContext,
forwardRef,
memo,
use,
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import useMeasure from "react-use-measure";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger";
import { useObservableEagerState } from "observable-hooks";
import { fromEvent, map, startWith } from "rxjs";
import styles from "./Grid.module.css";
import { useMergedRefs } from "../useMergedRefs";
@@ -155,8 +156,13 @@ export function useVisibleTiles(callback: VisibleTilesCallback): void {
);
}
const windowHeightObservable$ = fromEvent(window, "resize").pipe(
startWith(null),
map(() => window.innerHeight),
);
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
ref?: Ref<R>;
ref: LegacyRef<R>;
model: LayoutModel;
/**
* Component creating an invisible "slot" for a tile to go in.
@@ -165,7 +171,7 @@ export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
}
export interface TileProps<Model, R extends HTMLElement> {
ref?: Ref<R>;
ref: LegacyRef<R>;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
/**
@@ -256,13 +262,7 @@ export function Grid<
const [gridRoot, gridRef2] = useState<HTMLElement | null>(null);
const gridRef = useMergedRefs<HTMLElement>(gridRef1, gridRef2);
const windowHeight = useSyncExternalStore(
useCallback((onChange) => {
window.addEventListener("resize", onChange);
return (): void => window.removeEventListener("resize", onChange);
}, []),
useCallback(() => window.innerHeight, []),
);
const windowHeight = useObservableEagerState(windowHeightObservable$);
const [layoutRoot, setLayoutRoot] = useState<HTMLElement | null>(null);
const [generation, setGeneration] = useState<number | null>(null);
const [visibleTilesCallback, setVisibleTilesCallback] =
@@ -297,13 +297,14 @@ export function Grid<
// render of Grid causes a re-render of Layout, which in turn re-renders Grid
const LayoutMemo = useMemo(
() =>
memo(function LayoutMemo({
ref,
Layout,
...props
}: LayoutMemoProps<LayoutModel, TileModel, LayoutRef>): ReactNode {
return <Layout {...props} ref={ref} />;
}),
memo(
forwardRef<
LayoutRef,
LayoutMemoProps<LayoutModel, TileModel, LayoutRef>
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode {
return <Layout {...props} ref={ref} />;
}),
),
[],
);

View File

@@ -5,12 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
} from "react";
import { type CSSProperties, forwardRef, useCallback, useMemo } from "react";
import { distinctUntilChanged } from "rxjs";
import { useObservableEagerState } from "observable-hooks";
@@ -38,7 +33,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
// lives
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
useUpdateLayout();
const alignment = useObservableEagerState(
useInitial(() =>
@@ -73,10 +68,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
)}
</div>
);
},
}),
// The scrolling part of the layout is where all the grid tiles live
scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width, height: minHeight } = useObservableEagerState(minBounds$);
@@ -103,5 +98,5 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
))}
</div>
);
},
}),
});

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback, useMemo } from "react";
import { forwardRef, useCallback, useMemo } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -13,7 +13,6 @@ import { type OneOnOneLayout as OneOnOneLayoutModel } from "../state/CallViewMod
import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
* An implementation of the "one-on-one" layout, in which the remote participant
@@ -25,15 +24,15 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
}) => ({
scrollingOnTop: false,
fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
useUpdateLayout();
return <div ref={ref} />;
},
}),
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
useUpdateLayout();
const { width, height } = useObservableEagerState(minBounds$);
const pipAlignmentValue = useBehavior(pipAlignment$);
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
@@ -67,5 +66,5 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
</Slot>
</div>
);
},
}),
});

View File

@@ -5,13 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { forwardRef, useCallback } from "react";
import { useObservableEagerState } from "observable-hooks";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior";
/**
* An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -22,11 +22,10 @@ export const makeSpotlightExpandedLayout: CallLayout<
> = ({ pipAlignment$ }) => ({
scrollingOnTop: true,
fixed: function SpotlightExpandedLayoutFixed({
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
{ model, Slot },
ref,
model,
Slot,
}): ReactNode {
) {
useUpdateLayout();
return (
@@ -38,15 +37,14 @@ export const makeSpotlightExpandedLayout: CallLayout<
/>
</div>
);
},
}),
scrolling: function SpotlightExpandedLayoutScrolling({
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
{ model, Slot },
ref,
model,
Slot,
}): ReactNode {
) {
useUpdateLayout();
const pipAlignmentValue = useBehavior(pipAlignment$);
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
@@ -71,5 +69,5 @@ export const makeSpotlightExpandedLayout: CallLayout<
)}
</div>
);
},
}),
});

View File

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

View File

@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, type CSSProperties } from "react";
import { type CSSProperties, forwardRef } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -13,7 +13,6 @@ import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type SpotlightPortraitLayout as SpotlightPortraitLayoutModel } from "../state/CallViewModel";
import styles from "./SpotlightPortraitLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";
interface GridCSSProperties extends CSSProperties {
"--grid-gap": string;
@@ -31,11 +30,10 @@ export const makeSpotlightPortraitLayout: CallLayout<
> = ({ minBounds$ }) => ({
scrollingOnTop: false,
fixed: function SpotlightPortraitLayoutFixed({
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
{ model, Slot },
ref,
model,
Slot,
}): ReactNode {
) {
useUpdateLayout();
return (
@@ -49,13 +47,12 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div>
</div>
);
},
}),
scrolling: function SpotlightPortraitLayoutScrolling({
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
{ model, Slot },
ref,
model,
Slot,
}): ReactNode {
) {
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
const { width } = useObservableEagerState(minBounds$);
@@ -66,7 +63,8 @@ export const makeSpotlightPortraitLayout: CallLayout<
width,
model.grid.length,
);
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
const withIndicators =
useObservableEagerState(model.spotlight.media$).length > 1;
return (
<div
@@ -92,5 +90,5 @@ export const makeSpotlightPortraitLayout: CallLayout<
</div>
</div>
);
},
}),
});

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 658 B

View File

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

Before

Width:  |  Height:  |  Size: 656 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import {
ConnectionState,
type E2EEManagerOptions,
ExternalE2EEKeyProvider,
type LocalTrackPublication,
LocalVideoTrack,
Room,
type RoomOptions,
@@ -158,13 +157,10 @@ export function useLivekit(
useObservableEagerState(
useObservable(
(room$) =>
room$.pipe(
switchMap(([room]) =>
observeTrackReference$(
room.localParticipant,
Track.Source.Camera,
),
),
observeTrackReference$(
room$.pipe(map(([room]) => room.localParticipant)),
Track.Source.Camera,
).pipe(
map((trackRef) => {
const track = trackRef?.publication?.track;
return track instanceof LocalVideoTrack ? track : null;
@@ -182,33 +178,6 @@ export function useLivekit(
sfuConfig,
);
// Log errors when local participant has issues publishing a track.
useEffect(() => {
const localTrackUnpublishedFn = (
publication: LocalTrackPublication,
): void => {
logger.info(
"Local track unpublished",
publication.trackName,
publication.trackInfo,
);
};
const mediaDevicesErrorFn = (error: Error): void => {
logger.warn("Media devices error when publishing a track", error);
};
room.localParticipant.on("localTrackUnpublished", localTrackUnpublishedFn);
room.localParticipant.on("mediaDevicesError", mediaDevicesErrorFn);
return (): void => {
room.localParticipant.off(
"localTrackUnpublished",
localTrackUnpublishedFn,
);
room.localParticipant.off("mediaDevicesError", mediaDevicesErrorFn);
};
}, [room.localParticipant]);
useEffect(() => {
// Sync the requested mute states with LiveKit's mute states. We do it this
// way around rather than using LiveKit as the source of truth, so that the
@@ -351,18 +320,16 @@ export function useLivekit(
useEffect(() => {
// Sync the requested devices with LiveKit's devices
if (room !== undefined && connectionState === ConnectionState.Connected) {
if (
room !== undefined &&
connectionState === ConnectionState.Connected &&
!controlledAudioDevices
) {
const syncDevice = (
kind: MediaDeviceKind,
selected$: Observable<SelectedDevice | undefined>,
): Subscription =>
selected$.subscribe((device) => {
logger.info(
"[LivekitRoom] syncDevice room.getActiveDevice(kind) !== d.id :",
room.getActiveDevice(kind),
" !== ",
device?.id,
);
if (
device !== undefined &&
room.getActiveDevice(kind) !== device.id
@@ -377,9 +344,7 @@ export function useLivekit(
const subscriptions = [
syncDevice("audioinput", devices.audioInput.selected$),
!controlledAudioDevices
? syncDevice("audiooutput", devices.audioOutput.selected$)
: undefined,
syncDevice("audiooutput", devices.audioOutput.selected$),
syncDevice("videoinput", devices.videoInput.selected$),
// Restart the audio input track whenever we detect that the active media
// device has changed to refer to a different hardware device. We do this
@@ -419,7 +384,7 @@ export function useLivekit(
];
return (): void => {
for (const s of subscriptions) s?.unsubscribe();
for (const s of subscriptions) s.unsubscribe();
};
}
}, [room, devices, connectionState, controlledAudioDevices]);

View File

@@ -22,13 +22,11 @@ export function RaisedHandIndicator({
miniature,
showTimer,
onClick,
tabIndex,
}: {
raisedHandTime?: Date;
miniature?: boolean;
showTimer?: boolean;
onClick?: () => void;
tabIndex?: number;
}): ReactNode {
const { t } = useTranslation();
const [raisedHandDuration, setRaisedHandDuration] = useState("");
@@ -96,7 +94,6 @@ export function RaisedHandIndicator({
background: "none",
}}
onClick={clickCallback}
tabIndex={tabIndex}
>
{content}
</button>

View File

@@ -27,8 +27,6 @@ import rockSoundOgg from "../sound/reactions/rock.ogg?url";
import rockSoundMp3 from "../sound/reactions/rock.mp3?url";
import waveSoundOgg from "../sound/reactions/wave.ogg?url";
import waveSoundMp3 from "../sound/reactions/wave.mp3?url";
import baduntssSoundOgg from "../sound/reactions/baduntss.ogg?url";
import baduntssSoundMp3 from "../sound/reactions/baduntss.mp3?url";
export const ElementCallReactionEventType = "io.element.call.reaction";
@@ -193,15 +191,6 @@ export const ReactionSet: ReactionOption[] = [
mp3: waveSoundMp3,
},
},
{
emoji: "🥁",
name: "drum",
alias: ["joke"],
sound: {
ogg: baduntssSoundOgg,
mp3: baduntssSoundMp3,
},
},
];
export interface RaisedHandInfo {

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useEffect } from "react";
import { filter, interval, throttle } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel";
import joinCallSoundMp3 from "../sound/join_call.mp3";
@@ -16,14 +17,15 @@ 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,
@@ -41,18 +43,8 @@ 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,
@@ -68,18 +60,37 @@ export function CallEventAudioRenderer({
const audioEngineRef = useLatest(audioEngineCtx);
useEffect(() => {
const joinSub = vm.joinSoundEffect$.subscribe(
() => void audioEngineRef.current?.playSound("join"),
);
const leftSub = vm.leaveSoundEffect$.subscribe(
() => void audioEngineRef.current?.playSound("left"),
);
const handRaisedSub = vm.newHandRaised$.subscribe(
() => void audioEngineRef.current?.playSound("raiseHand"),
);
const screenshareSub = vm.newScreenShare$.subscribe(
() => void audioEngineRef.current?.playSound("screenshareStarted"),
);
const joinSub = vm.memberChanges$
.pipe(
filter(
({ joined, ids }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
),
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
void audioEngineRef.current?.playSound("join");
});
const leftSub = vm.memberChanges$
.pipe(
filter(
({ ids, left }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
),
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
void audioEngineRef.current?.playSound("left");
});
const handRaisedSub = vm.newHandRaised$.subscribe(() => {
void audioEngineRef.current?.playSound("raiseHand");
});
const screenshareSub = vm.newScreenShare$.subscribe(() => {
void audioEngineRef.current?.playSound("screenshareStarted");
});
return (): void => {
joinSub.unsubscribe();

View File

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

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