mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
75 Commits
robin/deep
...
v0.13.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca06aa82b2 | ||
|
|
20433e6935 | ||
|
|
0e5a79e896 | ||
|
|
a80c7aa0e2 | ||
|
|
42d905df7f | ||
|
|
f509c06cc6 | ||
|
|
c012aec909 | ||
|
|
d08a180c54 | ||
|
|
db948cb718 | ||
|
|
c03e2c4bde | ||
|
|
e47a743f1e | ||
|
|
73ccb38c11 | ||
|
|
ab4eadf58f | ||
|
|
93cb7955c9 | ||
|
|
8f841dfb59 | ||
|
|
f4d590c703 | ||
|
|
f3419f94c3 | ||
|
|
a53585c96d | ||
|
|
3b1ce22b71 | ||
|
|
9787ac3abc | ||
|
|
131bdc3522 | ||
|
|
daa931c3c4 | ||
|
|
0f8c4675cf | ||
|
|
337d09cd4d | ||
|
|
6b6b679774 | ||
|
|
3ffb118dc7 | ||
|
|
a507bcde90 | ||
|
|
0c27610119 | ||
|
|
f86c9fe0a0 | ||
|
|
3ae535bf31 | ||
|
|
5e2e94d794 | ||
|
|
5bf7361d01 | ||
|
|
0fe8d16740 | ||
|
|
f1a2002072 | ||
|
|
1af1a8ed0b | ||
|
|
0970db8297 | ||
|
|
7f4a5a8bab | ||
|
|
442fc1fc16 | ||
|
|
32b6250cc3 | ||
|
|
4a6dbf350f | ||
|
|
29968a3eba | ||
|
|
aa092c2195 | ||
|
|
0bf5c4a835 | ||
|
|
3d47609cff | ||
|
|
1425de8726 | ||
|
|
c1aa1888f0 | ||
|
|
58ebbfeed7 | ||
|
|
02f3f1b71a | ||
|
|
f7a6e20ae4 | ||
|
|
378d59e948 | ||
|
|
a07dbe645e | ||
|
|
a596eb71a9 | ||
|
|
c8a1cae18b | ||
|
|
4f1450aaf0 | ||
|
|
8704b44b78 | ||
|
|
0ee11af3c5 | ||
|
|
13fac57b01 | ||
|
|
e0ce58b2a2 | ||
|
|
6f9adfe3e8 | ||
|
|
d47e579994 | ||
|
|
61d8f9a412 | ||
|
|
31185ae365 | ||
|
|
e07c7c864b | ||
|
|
b88bce5220 | ||
|
|
775e110ca3 | ||
|
|
97d548ee3a | ||
|
|
34724b7a8c | ||
|
|
b005f36ac7 | ||
|
|
b0587fcfb3 | ||
|
|
b8eccc6d7a | ||
|
|
34df584366 | ||
|
|
a62b0be831 | ||
|
|
01240fb59d | ||
|
|
7851b9c289 | ||
|
|
789f37611b |
21
.github/workflows/build-element-call.yaml
vendored
21
.github/workflows/build-element-call.yaml
vendored
@@ -9,6 +9,11 @@ on:
|
||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||
description: The package type to be built. Must be one of 'full' or 'embedded'
|
||||
required: true
|
||||
build_mode:
|
||||
type: string # This would ideally be a `choice` type, but that isn't supported yet
|
||||
description: The build mode for vite. Must be either 'development' or 'production'
|
||||
required: false
|
||||
default: production
|
||||
secrets:
|
||||
SENTRY_ORG:
|
||||
required: true
|
||||
@@ -37,20 +42,8 @@ jobs:
|
||||
node-version-file: ".node-version"
|
||||
- name: Install dependencies
|
||||
run: "yarn install --immutable"
|
||||
- name: Build full version
|
||||
if: ${{ inputs.package == 'full' }}
|
||||
run: "yarn run build:full"
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_URL: ${{ secrets.SENTRY_URL }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
VITE_APP_VERSION: ${{ inputs.vite_app_version }}
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
- name: Build embedded
|
||||
if: ${{ inputs.package == 'embedded' }}
|
||||
run: "yarn run build:embedded"
|
||||
- name: Build Element Call
|
||||
run: ${{ format('yarn run build:{0}:{1}', inputs.package, inputs.build_mode) }}
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
|
||||
2
.github/workflows/build.yaml
vendored
2
.github/workflows/build.yaml
vendored
@@ -14,6 +14,7 @@ jobs:
|
||||
with:
|
||||
package: full
|
||||
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
@@ -61,6 +62,7 @@ jobs:
|
||||
with:
|
||||
package: embedded
|
||||
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
|
||||
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
|
||||
secrets:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
|
||||
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
fail_ci_if_error: true
|
||||
playwright:
|
||||
name: Run end-to-end tests
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
||||
@@ -49,9 +49,9 @@ jobs:
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
|
||||
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
|
||||
docker ps
|
||||
- name: Copy config file
|
||||
run: cp config/config.devenv.json public/config.json
|
||||
- name: Run Playwright tests
|
||||
env:
|
||||
USE_DOCKER: 1
|
||||
run: yarn playwright test
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
if: ${{ !cancelled() }}
|
||||
|
||||
@@ -6,7 +6,7 @@ COPY ./dist /dist
|
||||
WORKDIR /dist/assets
|
||||
RUN gzip -k ../index.html *.js *.map *.css *.wasm *-app-*.json
|
||||
|
||||
FROM nginxinc/nginx-unprivileged:alpine
|
||||
FROM nginxinc/nginx-unprivileged:alpine-slim
|
||||
|
||||
COPY --from=builder ./dist /app
|
||||
|
||||
|
||||
@@ -8,5 +8,12 @@
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 4000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,12 @@
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf"
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 4000
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
"server_name": "call-unstable.ems.host"
|
||||
}
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 4000
|
||||
},
|
||||
"posthog": {
|
||||
"api_key": "phc_rXGHx9vDmyEvyRxPziYtdVIv0ahEv8A9uLWFcCi1WcU",
|
||||
"api_host": "https://posthog-element-call.element.io"
|
||||
|
||||
@@ -4,6 +4,7 @@ networks:
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:latest-ci
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
- LIVEKIT_JWT_PORT=6080
|
||||
|
||||
@@ -17,5 +17,15 @@ On mobile platforms (iOS, Android), web views do not reliably support selecting
|
||||
- `controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
|
||||
- `controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example.
|
||||
- `controls.setAudioEnabled(enabled: boolean)` Enables/disables all audio output from the application. Output is enabled by default.
|
||||
- `controls.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. (`userAgent.includes("iPhone")`)
|
||||
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.
|
||||
|
||||
@@ -255,13 +255,17 @@ self-hosters and developers working with Element Call.
|
||||
|
||||
- [How to resolve stuck MatrixRTC calls](https://sspaeth.de/2025/02/how-to-resolve-stuck-matrixrtc-calls/)
|
||||
|
||||
## 🛠️ How-Tos & Tutorials
|
||||
## 📝 How-Tos & Tutorials
|
||||
|
||||
- [MatrixRTC aka Element-call setup (Geek warning)](https://sspaeth.de/2024/11/sfu/)
|
||||
- [MatrixRTC with Synology Container Manager (Docker)](https://ztfr.de/matrixrtc-with-synology-container-manager-docker/)
|
||||
- [Encrypted & Scalable Video Calls: How to deploy an Element Call backend with Synapse Using Docker-Compose](https://willlewis.co.uk/blog/posts/deploy-element-call-backend-with-synapse-and-docker-compose/)
|
||||
- [Element Call einrichten: Verschlüsselte Videoanrufe mit Element X und Matrix Synapse](https://www.cleveradmin.de/blog/2025/04/matrixrtc-element-call-backend-einrichten/)
|
||||
|
||||
## 🛠️ Tools
|
||||
|
||||
- [A Matrix server sanity tester including tests for proper MatrixRTC setup](https://codeberg.org/spaetz/testmatrix)
|
||||
|
||||
## 🤝 Want to Contribute?
|
||||
|
||||
Have a guide or blog post you'd like to share? Open a
|
||||
|
||||
@@ -56,7 +56,7 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
|
||||
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
|
||||
| `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...`. |
|
||||
| `hideHeader` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the room header when in a call. |
|
||||
| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
|
||||
| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
|
||||
| `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`. |
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Zobrazit možnost sluchátek pro iPhone na všech platformách",
|
||||
"crypto_version": "Kryptografická verze: {{version}}",
|
||||
"debug_tile_layout_label": "Ladění rozložení dlaždic",
|
||||
"device_id": "ID zařízení: {{id}}",
|
||||
@@ -173,8 +174,10 @@
|
||||
"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>",
|
||||
"earpiece": "Sluchátka",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
"speaker": "Reproduktor",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Vis mulighed for iPhone-høretelefon på alle platforme",
|
||||
"crypto_version": "Krypto-version: {{version}}",
|
||||
"debug_tile_layout_label": "Fejlfinding af fliselayout",
|
||||
"device_id": "Enheds-id: {{id}}",
|
||||
@@ -70,6 +71,7 @@
|
||||
"livekit_server_info": "LiveKit Serverinfo",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"mute_all_audio": "Slå al lyd fra (deltagere, reaktioner, deltagelseslyde)",
|
||||
"show_connection_stats": "Vis forbindelsesstatistik",
|
||||
"show_non_member_tiles": "Vis fliser for medier fra ikke-medlemmer",
|
||||
"url_params": "URL-parametre",
|
||||
@@ -164,12 +166,17 @@
|
||||
"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>",
|
||||
"earpiece": "Høretelefon",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
"speaker": "Højttaler",
|
||||
|
||||
@@ -79,10 +79,15 @@
|
||||
"use_to_device_key_transport": "To-Device media E2EE Schlüssel-Transport verwenden. Falls ein anderer Teilnehmer bereits den Raumschlüssel-Transport verwendet, wird automatisch auf Raumschlüssel-Transport zurückgegriffen."
|
||||
},
|
||||
"disconnected_banner": "Die Verbindung zum Server wurde getrennt.",
|
||||
"earpiece": {
|
||||
"overlay_back_button": "Zurück zum Lautsprechermodus",
|
||||
"overlay_description": "Nur bei App im Vordergrund nutzbar",
|
||||
"overlay_title": "Ohrhörer Modus"
|
||||
},
|
||||
"error": {
|
||||
"call_is_not_supported": "Anrufe werden nicht unterstützt",
|
||||
"call_not_found": "Anruf nicht gefunden",
|
||||
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Vergewissern Sie sich, dass Sie den richtigen Link haben, oder <1> erstellen Sie einen neuen</1>. </0>",
|
||||
"call_not_found_description": "<0>Dieser Link scheint zu keinem bestehenden Anruf zu gehören. Vergewissern Sie sich, dass Sie den richtigen Link haben, oder <2> erstellen Sie einen neuen</2>. </0>",
|
||||
"connection_lost": "Verbindung verloren",
|
||||
"connection_lost_description": "Ihre Verbindung zum Anruf wurde unterbrochen.",
|
||||
"e2ee_unsupported": "Inkompatibler Browser",
|
||||
@@ -173,9 +178,11 @@
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"change_device_button": "Audiogerät wechseln",
|
||||
"default": "Standard",
|
||||
"default_named": "Standard<2> ({{name}} )</2>",
|
||||
"earpiece": "Ohrhörer",
|
||||
"loudspeaker": "Lautsprecher",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon{{n}}",
|
||||
"speaker": "Lautsprecher",
|
||||
|
||||
@@ -79,10 +79,15 @@
|
||||
"use_to_device_key_transport": "Use to device key transport. This will fallback to room key transport when another call member sent a room key"
|
||||
},
|
||||
"disconnected_banner": "Connectivity to the server has been lost.",
|
||||
"earpiece": {
|
||||
"overlay_back_button": "Back to Speaker Mode",
|
||||
"overlay_description": "Only works while using app",
|
||||
"overlay_title": "Earpiece Mode"
|
||||
},
|
||||
"error": {
|
||||
"call_is_not_supported": "Call is not supported",
|
||||
"call_not_found": "Call not found",
|
||||
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <1>create a new one</1>.</0>",
|
||||
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
|
||||
"connection_lost": "Connection lost",
|
||||
"connection_lost_description": "You were disconnected from the call.",
|
||||
"e2ee_unsupported": "Incompatible browser",
|
||||
@@ -177,6 +182,7 @@
|
||||
"default": "Default",
|
||||
"default_named": "Default <2>({{name}})</2>",
|
||||
"earpiece": "Earpiece",
|
||||
"loudspeaker": "Loudspeaker",
|
||||
"microphone": "Microphone",
|
||||
"microphone_numbered": "Microphone {{n}}",
|
||||
"speaker": "Speaker",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Näita iPhone'i kuulari valikut kõikidel platvormidel",
|
||||
"crypto_version": "Krüptoteekide versioon: {{version}}",
|
||||
"debug_tile_layout_label": "Meediapaanide paigutus",
|
||||
"device_id": "Seadme tunnus: {{id}}",
|
||||
@@ -78,10 +79,15 @@
|
||||
"use_to_device_key_transport": "Kasuta seadmepõhist krüptovõtmete vahetust. Kui jututoa liige peaks saatma jututoakohase krüptovõtme, siis kasuta jututoakohast võtmevahetust"
|
||||
},
|
||||
"disconnected_banner": "Võrguühendus serveriga on katkenud.",
|
||||
"earpiece": {
|
||||
"overlay_back_button": "Tagasi videovaatesse",
|
||||
"overlay_description": "Toimib vaid rakenduse kasutamise ajal",
|
||||
"overlay_title": "Toimib vaid kuularitega"
|
||||
},
|
||||
"error": {
|
||||
"call_is_not_supported": "Kõne pole toetatud",
|
||||
"call_not_found": "Kõnet ei leidu",
|
||||
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <1>loo uus</1>.</0>",
|
||||
"call_not_found_description": "<0>See link ei tundu olema seotud ühegi olemasoleva kõnega. Kontrolli, et sul on õige link või <2>loo uus</2>.</0>",
|
||||
"connection_lost": "Ühendus on katkenud",
|
||||
"connection_lost_description": "Sinu ühendus selle kõnega on katkenud.",
|
||||
"e2ee_unsupported": "Mitteühilduv brauser",
|
||||
@@ -172,8 +178,11 @@
|
||||
"devices": {
|
||||
"camera": "Kaamera",
|
||||
"camera_numbered": "Kaamera {{n}}",
|
||||
"change_device_button": "Muuda heliseadet",
|
||||
"default": "Vaikimisi",
|
||||
"default_named": "Vaikimisi <2>({{name}})</2>",
|
||||
"earpiece": "Kuular",
|
||||
"loudspeaker": "Valjuhääldi",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
"speaker": "Kõlar",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Видео"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Показать опцию наушников для iPhone на всех платформах",
|
||||
"crypto_version": "Версия криптографии: {{version}}",
|
||||
"debug_tile_layout_label": "Отладка расположения плиток",
|
||||
"device_id": "Идентификатор устройства: {{id}}",
|
||||
@@ -70,6 +71,7 @@
|
||||
"livekit_server_info": "Информация о сервере LiveKit",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix ID: {{id}}",
|
||||
"mute_all_audio": "Отключить все звуки (участников, реакции, звуки присоединения)",
|
||||
"show_connection_stats": "Показать статистику подключений",
|
||||
"show_non_member_tiles": "Показать плитки для медиафайлов, не являющихся участниками",
|
||||
"url_params": "Параметры URL-адреса",
|
||||
@@ -172,8 +174,10 @@
|
||||
"devices": {
|
||||
"camera": "Камера",
|
||||
"camera_numbered": "Камера {{n}}",
|
||||
"change_device_button": "Изменить аудиоустройство",
|
||||
"default": "По умолчанию",
|
||||
"default_named": "По умолчанию <2>({{name}})</2>",
|
||||
"earpiece": "Наушник",
|
||||
"microphone": "Микрофон",
|
||||
"microphone_numbered": "Микрофон {{n}}",
|
||||
"speaker": "Динамик",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Zobraziť možnosť slúchadla iPhone na všetkých platformách",
|
||||
"crypto_version": "Krypto verzia: {{version}}",
|
||||
"debug_tile_layout_label": "Ladenie rozloženia dlaždíc",
|
||||
"device_id": "ID zariadenia: {{id}}",
|
||||
@@ -78,10 +79,15 @@
|
||||
"use_to_device_key_transport": "Používa sa na prenos kľúča zariadenia. Toto sa vráti k prenosu kľúča miestnosti, keď iný účastník hovoru poslal kľúč od miestnosti"
|
||||
},
|
||||
"disconnected_banner": "Spojenie so serverom sa stratilo.",
|
||||
"earpiece": {
|
||||
"overlay_back_button": "Späť na video",
|
||||
"overlay_description": "Funguje iba pri používaní aplikácie",
|
||||
"overlay_title": "Režim iba so slúchadlami"
|
||||
},
|
||||
"error": {
|
||||
"call_is_not_supported": "Hovor nie je podporovaný",
|
||||
"call_not_found": "Hovor nebol nájdený",
|
||||
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <1> vytvorte nový</1>. </0>",
|
||||
"call_not_found_description": "<0>Zdá sa, že tento odkaz nepatrí k žiadnemu existujúcemu hovoru. Skontrolujte, či máte správny odkaz, alebo <2>vytvorte nový</2>.</0>",
|
||||
"connection_lost": "Strata spojenia",
|
||||
"connection_lost_description": "Boli ste odpojení od hovoru.",
|
||||
"e2ee_unsupported": "Nekompatibilný prehliadač",
|
||||
@@ -173,8 +179,11 @@
|
||||
"devices": {
|
||||
"camera": "Kamera",
|
||||
"camera_numbered": "Kamera {{n}}",
|
||||
"change_device_button": "Zmeniť zvukové zariadenie",
|
||||
"default": "Predvolené",
|
||||
"default_named": "Predvolené <2>({{name}})</2>",
|
||||
"earpiece": "Slúchadlo",
|
||||
"loudspeaker": "Reproduktor",
|
||||
"microphone": "Mikrofón",
|
||||
"microphone_numbered": "Mikrofón {{n}}",
|
||||
"speaker": "Reproduktor",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"video": "Video"
|
||||
},
|
||||
"developer_mode": {
|
||||
"always_show_iphone_earpiece": "Visa iPhone-hörsnäckealternativ på alla plattformar",
|
||||
"crypto_version": "Kryptoversion: {{version}}",
|
||||
"debug_tile_layout_label": "Felsök panelarrangemang",
|
||||
"device_id": "Enhets-ID: {{id}}",
|
||||
@@ -70,6 +71,7 @@
|
||||
"livekit_server_info": "LiveKit-serverinfo",
|
||||
"livekit_sfu": "LiveKit SFU: {{url}}",
|
||||
"matrix_id": "Matrix-ID: {{id}}",
|
||||
"mute_all_audio": "Tysta allt ljud (deltagare, reaktioner, anslutningsljud)",
|
||||
"show_connection_stats": "Visa anslutningsstatistik",
|
||||
"show_non_member_tiles": "Visa paneler för media som inte är medlemmar",
|
||||
"url_params": "URL-parametrar",
|
||||
@@ -164,12 +166,17 @@
|
||||
"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>",
|
||||
"earpiece": "Hörsnäcka",
|
||||
"microphone": "Mikrofon",
|
||||
"microphone_numbered": "Mikrofon {{n}}",
|
||||
"speaker": "Högtalare",
|
||||
|
||||
18
package.json
18
package.json
@@ -8,7 +8,11 @@
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"build": "yarn build:full",
|
||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||
"build:full:production": "yarn build:full",
|
||||
"build:full:development": "yarn build:full --mode development",
|
||||
"build:embedded": "yarn build:full --config vite-embedded.config.js",
|
||||
"build:embedded:production": "yarn build:embedded",
|
||||
"build:embedded:development": "yarn build:embedded --mode development",
|
||||
"serve": "vite preview",
|
||||
"prettier:check": "prettier -c .",
|
||||
"prettier:format": "prettier -w .",
|
||||
@@ -54,7 +58,7 @@
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-visually-hidden": "^1.0.3",
|
||||
"@react-spring/web": "^9.4.4",
|
||||
"@react-spring/web": "^10.0.0",
|
||||
"@sentry/react": "^8.0.0",
|
||||
"@sentry/vite-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^3.0.0",
|
||||
@@ -70,19 +74,20 @@
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pako": "^2.0.3",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/sdp-transform": "^2.4.5",
|
||||
"@types/uuid": "10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
||||
"@typescript-eslint/parser": "^8.31.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^4.0.0",
|
||||
"@vector-im/compound-web": "^7.12.0",
|
||||
"@vector-im/compound-web": "^8.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.1",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"babel-plugin-transform-vite-meta-env": "^1.0.3",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.3",
|
||||
"eslint": "^8.14.0",
|
||||
"eslint-config-google": "^0.14.0",
|
||||
"eslint-config-prettier": "^10.0.0",
|
||||
@@ -113,11 +118,10 @@
|
||||
"posthog-js": "1.160.3",
|
||||
"prettier": "^3.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "18",
|
||||
"react-dom": "18",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
"react-i18next": "^15.0.0",
|
||||
"react-router-dom": "^7.0.0",
|
||||
"react-use-clipboard": "^1.0.7",
|
||||
"react-use-measure": "^2.1.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sass": "^1.42.1",
|
||||
|
||||
@@ -7,6 +7,10 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
: "https://localhost:3000";
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
@@ -25,7 +29,7 @@ export default defineConfig({
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "https://localhost:3000",
|
||||
baseURL,
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
@@ -73,9 +77,13 @@ export default defineConfig({
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "yarn dev",
|
||||
url: "https://localhost:3000",
|
||||
command: "./scripts/playwright-webserver-command.sh",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,16 +69,27 @@ const CONFIG_JSON = {
|
||||
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
|
||||
* @param page
|
||||
*/
|
||||
async function setDevToolElementCallDevUrl(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"https://localhost:3000/room",
|
||||
);
|
||||
});
|
||||
}
|
||||
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
|
||||
? async (page: Page): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"http://localhost:8080/room",
|
||||
);
|
||||
});
|
||||
}
|
||||
: async (page: Page): Promise<void> => {
|
||||
await page.evaluate(() => {
|
||||
window.mxSettingsStore.setValue(
|
||||
"Developer.elementCallUrl",
|
||||
null,
|
||||
"device",
|
||||
"https://localhost:3000/room",
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Registers a new user and returns page, clientHandle and mxId.
|
||||
|
||||
104
playwright/sfu-reconnect-bug.spec.ts
Normal file
104
playwright/sfu-reconnect-bug.spec.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("When creator left, avoid reconnect to the same SFU", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Use reduce motion to disable animations that are making the tests a bit flaky
|
||||
const creatorContext = await browser.newContext({ reducedMotion: "reduce" });
|
||||
const creatorPage = await creatorContext.newPage();
|
||||
|
||||
await creatorPage.goto("/");
|
||||
|
||||
// ========
|
||||
// ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link
|
||||
// ========
|
||||
await creatorPage.getByTestId("home_callName").click();
|
||||
await creatorPage.getByTestId("home_callName").fill("Welcome");
|
||||
await creatorPage.getByTestId("home_displayName").click();
|
||||
await creatorPage.getByTestId("home_displayName").fill("Inviter");
|
||||
await creatorPage.getByTestId("home_go").click();
|
||||
await expect(creatorPage.locator("video")).toBeVisible();
|
||||
|
||||
// join
|
||||
await creatorPage.getByTestId("lobby_joinCall").click();
|
||||
// Spotlight mode to make checking the test visually clearer
|
||||
await creatorPage.getByRole("radio", { name: "Spotlight" }).check();
|
||||
|
||||
// Get the invite link
|
||||
await creatorPage.getByRole("button", { name: "Invite" }).click();
|
||||
await expect(
|
||||
creatorPage.getByRole("heading", { name: "Invite to this call" }),
|
||||
).toBeVisible();
|
||||
await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible();
|
||||
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible();
|
||||
await creatorPage.getByTestId("modal_inviteLink").click();
|
||||
|
||||
const inviteLink = (await creatorPage.evaluate(
|
||||
"navigator.clipboard.readText()",
|
||||
)) as string;
|
||||
expect(inviteLink).toContain("room/#/");
|
||||
|
||||
// ========
|
||||
// ACT: The other user use the invite link to join the call as a guest
|
||||
// ========
|
||||
const guestB = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestBPage = await guestB.newPage();
|
||||
|
||||
await guestBPage.goto(inviteLink);
|
||||
await guestBPage.getByTestId("joincall_displayName").fill("Invitee");
|
||||
await expect(guestBPage.getByTestId("joincall_joincall")).toBeVisible();
|
||||
await guestBPage.getByTestId("joincall_joincall").click();
|
||||
await guestBPage.getByTestId("lobby_joinCall").click();
|
||||
await guestBPage.getByRole("radio", { name: "Spotlight" }).check();
|
||||
|
||||
// ========
|
||||
// ACT: add a third user to the call to reproduce the bug
|
||||
// ========
|
||||
const guestC = await browser.newContext({
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
const guestCPage = await guestC.newPage();
|
||||
let sfuGetCallCount = 0;
|
||||
await guestCPage.route("**/livekit/jwt/sfu/get", async (route) => {
|
||||
sfuGetCallCount++;
|
||||
await route.continue();
|
||||
});
|
||||
// Track WebSocket connections
|
||||
let wsConnectionCount = 0;
|
||||
await guestCPage.routeWebSocket("**", (ws) => {
|
||||
// For some reason the interception is not working with the **
|
||||
if (ws.url().includes("livekit/sfu/rtc")) {
|
||||
wsConnectionCount++;
|
||||
}
|
||||
ws.connectToServer();
|
||||
});
|
||||
|
||||
await guestCPage.goto(inviteLink);
|
||||
await guestCPage.getByTestId("joincall_displayName").fill("Invitee");
|
||||
await expect(guestCPage.getByTestId("joincall_joincall")).toBeVisible();
|
||||
await guestCPage.getByTestId("joincall_joincall").click();
|
||||
await guestCPage.getByTestId("lobby_joinCall").click();
|
||||
await guestCPage.getByRole("radio", { name: "Spotlight" }).check();
|
||||
|
||||
await guestCPage.waitForTimeout(1000);
|
||||
|
||||
// ========
|
||||
// the creator leaves the call
|
||||
await creatorPage.getByTestId("incall_leave").click();
|
||||
|
||||
await guestCPage.waitForTimeout(2000);
|
||||
// https://github.com/element-hq/element-call/issues/3344
|
||||
// The app used to request a new jwt token then to reconnect to the SFU
|
||||
expect(wsConnectionCount).toBe(1);
|
||||
expect(sfuGetCallCount).toBe(1);
|
||||
});
|
||||
10
scripts/playwright-webserver-command.sh
Executable file
10
scripts/playwright-webserver-command.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
if [ -n "$USE_DOCKER" ]; then
|
||||
set -ex
|
||||
yarn build
|
||||
docker build -t element-call:testing .
|
||||
exec docker run --rm --name element-call-testing -p 8080:8080 -v ./config/config.devenv.json:/app/config.json:ro,Z element-call:testing
|
||||
else
|
||||
cp config/config.devenv.json public/config.json
|
||||
exec yarn dev
|
||||
fi
|
||||
82
src/App.tsx
82
src/App.tsx
@@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type JSX,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
@@ -19,10 +26,13 @@ import { ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
import { Initializer } from "./initializer";
|
||||
import { MediaDevicesProvider } from "./livekit/MediaDevicesContext";
|
||||
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);
|
||||
|
||||
@@ -50,7 +60,11 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
return children;
|
||||
};
|
||||
|
||||
export const App: FC = () => {
|
||||
interface Props {
|
||||
vm: AppViewModel;
|
||||
}
|
||||
|
||||
export const App: FC<Props> = ({ vm }) => {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
Initializer.init()
|
||||
@@ -62,41 +76,43 @@ export const App: FC = () => {
|
||||
.catch(logger.error);
|
||||
});
|
||||
|
||||
// Since we are outside the router component, we cannot use useUrlParams here
|
||||
const { header } = useMemo(getUrlParams, []);
|
||||
|
||||
const content = loaded ? (
|
||||
<ClientProvider>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
||||
>
|
||||
<DisconnectedBanner />
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
<SentryRoute path="/register" element={<RegisterPage />} />
|
||||
<SentryRoute path="*" element={<RoomPage />} />
|
||||
</Routes>
|
||||
</Sentry.ErrorBoundary>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
<BrowserRouter>
|
||||
<BackgroundProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
{loaded ? (
|
||||
<Suspense fallback={null}>
|
||||
<ClientProvider>
|
||||
<MediaDevicesProvider>
|
||||
<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>
|
||||
</MediaDevicesProvider>
|
||||
</ClientProvider>
|
||||
</Suspense>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
)}
|
||||
<Suspense fallback={null}>
|
||||
{header === HeaderStyle.AppBar ? (
|
||||
<AppBar>{content}</AppBar>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BackgroundProvider>
|
||||
|
||||
23
src/AppBar.module.css
Normal file
23
src/AppBar.module.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.bar {
|
||||
block-size: 64px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bar > header {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
inset-block-start: 0;
|
||||
block-size: 64px;
|
||||
z-index: var(--call-view-header-footer-layer);
|
||||
}
|
||||
|
||||
.bar svg path {
|
||||
fill: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
.bar > header > h1 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
25
src/AppBar.test.tsx
Normal file
25
src/AppBar.test.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import { AppBar } from "./AppBar";
|
||||
|
||||
describe("AppBar", () => {
|
||||
it("renders", () => {
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<AppBar>
|
||||
<p>This is the content.</p>
|
||||
</AppBar>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
134
src/AppBar.tsx
Normal file
134
src/AppBar.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
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 {
|
||||
ArrowLeftIcon,
|
||||
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 BackIcon = platform === "ios" ? CollapseIcon : ArrowLeftIcon;
|
||||
const onBackClick = useCallback((e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
window.controls.onBackButtonPressed?.();
|
||||
}, []);
|
||||
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [hidden, setHidden] = useState<boolean>(false);
|
||||
const [secondaryButton, setSecondaryButton] = useState<ReactNode>(null);
|
||||
const context = useMemo(
|
||||
() => ({ setTitle, setSecondaryButton, setHidden }),
|
||||
[setTitle, setHidden, setSecondaryButton],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ display: hidden ? "none" : "block" }}
|
||||
className={styles.bar}
|
||||
>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<Tooltip label={t("common.back")}>
|
||||
<IconButton onClick={onBackClick}>
|
||||
<BackIcon />
|
||||
</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]);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
useEffect,
|
||||
useState,
|
||||
createContext,
|
||||
useContext,
|
||||
use,
|
||||
useRef,
|
||||
useMemo,
|
||||
type JSX,
|
||||
@@ -69,8 +69,7 @@ const ClientContext = createContext<ClientState | undefined>(undefined);
|
||||
|
||||
export const ClientContextProvider = ClientContext.Provider;
|
||||
|
||||
export const useClientState = (): ClientState | undefined =>
|
||||
useContext(ClientContext);
|
||||
export const useClientState = (): ClientState | undefined => use(ClientContext);
|
||||
|
||||
export function useClient(): {
|
||||
client?: MatrixClient;
|
||||
@@ -350,9 +349,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={state}>{children}</ClientContext.Provider>
|
||||
);
|
||||
return <ClientContext value={state}>{children}</ClientContext>;
|
||||
};
|
||||
|
||||
export type InitResult = {
|
||||
|
||||
@@ -28,10 +28,10 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
||||
className,
|
||||
children,
|
||||
}) => {
|
||||
const { hideHeader } = useUrlParams();
|
||||
const { header } = useUrlParams();
|
||||
return (
|
||||
<div className={classNames(styles.page, className)}>
|
||||
{!hideHeader && (
|
||||
{header === "standard" && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
|
||||
@@ -6,12 +6,7 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
@@ -24,23 +19,27 @@ import { EncryptionLock } from "./room/EncryptionLock";
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
|
||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
ref?: Ref<HTMLElement>;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Header = forwardRef<HTMLElement, HeaderProps>(
|
||||
({ children, className, ...rest }, ref) => {
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={classNames(styles.header, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const Header: FC<HeaderProps> = ({
|
||||
ref,
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<header
|
||||
ref={ref}
|
||||
className={classNames(styles.header, className)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
Header.displayName = "Header";
|
||||
|
||||
|
||||
56
src/MediaDevicesContext.ts
Normal file
56
src/MediaDevicesContext.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type MediaDevices } from "./state/MediaDevices";
|
||||
|
||||
export const MediaDevicesContext = createContext<MediaDevices | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export function useMediaDevices(): MediaDevices {
|
||||
const mediaDevices = use(MediaDevicesContext);
|
||||
if (mediaDevices === undefined)
|
||||
throw new Error(
|
||||
"useMediaDevices must be used within a MediaDevices context provider",
|
||||
);
|
||||
return mediaDevices;
|
||||
}
|
||||
|
||||
export const useIsEarpiece = (): boolean => {
|
||||
const devices = useMediaDevices();
|
||||
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
|
||||
const available = useObservableEagerState(devices.audioOutput.available$);
|
||||
if (!audioOutput?.id) return false;
|
||||
return available.get(audioOutput.id)?.type === "earpiece";
|
||||
};
|
||||
|
||||
/**
|
||||
* A convenience hook to get the audio node configuration for the earpiece.
|
||||
* It will check the `useAsEarpiece` of the `audioOutput` device and return
|
||||
* the appropriate pan and volume values.
|
||||
*
|
||||
* @returns pan and volume values for the earpiece audio node configuration.
|
||||
*/
|
||||
export const useEarpieceAudioConfig = (): {
|
||||
pan: number;
|
||||
volume: number;
|
||||
} => {
|
||||
const devices = useMediaDevices();
|
||||
const audioOutput = useObservableEagerState(devices.audioOutput.selected$);
|
||||
const isVirtualEarpiece = audioOutput?.virtualEarpiece ?? false;
|
||||
return {
|
||||
// We use only the right speaker (pan = 1) for the earpiece.
|
||||
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
|
||||
pan: useMemo(() => (isVirtualEarpiece ? 1 : 0), [isVirtualEarpiece]),
|
||||
// We also do lower the volume by a factor of 10 to optimize for the usecase where
|
||||
// a user is holding the phone to their ear.
|
||||
volume: useMemo(() => (isVirtualEarpiece ? 0.1 : 1), [isVirtualEarpiece]),
|
||||
};
|
||||
};
|
||||
@@ -243,4 +243,16 @@ describe("UrlParams", () => {
|
||||
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
|
||||
});
|
||||
});
|
||||
describe("header", () => {
|
||||
it("uses header if provided", () => {
|
||||
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe(
|
||||
"app_bar",
|
||||
);
|
||||
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
|
||||
});
|
||||
it("converts hideHeader to the correct header value", () => {
|
||||
expect(getUrlParams("?hideHeader=true").header).toBe("none");
|
||||
expect(getUrlParams("?hideHeader=false").header).toBe("standard");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,12 @@ export enum UserIntent {
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
export enum HeaderStyle {
|
||||
None = "none",
|
||||
Standard = "standard",
|
||||
AppBar = "app_bar",
|
||||
}
|
||||
|
||||
// If you need to add a new flag to this interface, prefer a name that describes
|
||||
// a specific behavior (such as 'confineToRoom'), rather than one that describes
|
||||
// the situations that call for this behavior ('isEmbedded'). This makes it
|
||||
@@ -59,9 +65,12 @@ export interface UrlParams {
|
||||
*/
|
||||
preload: boolean;
|
||||
/**
|
||||
* Whether to hide the room header when in a call.
|
||||
* 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: boolean;
|
||||
header: HeaderStyle;
|
||||
/**
|
||||
* Whether the controls should be shown. For screen recording no controls can be desired.
|
||||
*/
|
||||
@@ -257,6 +266,15 @@ export const getUrlParams = (
|
||||
if (!intent || !Object.values(UserIntent).includes(intent as UserIntent)) {
|
||||
intent = UserIntent.Unknown;
|
||||
}
|
||||
|
||||
// Check hideHeader for backwards compatibility. If header is set, hideHeader
|
||||
// is ignored.
|
||||
const header =
|
||||
parser.getParam("header") ??
|
||||
(parser.getFlagParam("hideHeader")
|
||||
? HeaderStyle.None
|
||||
: HeaderStyle.Standard);
|
||||
|
||||
const widgetId = parser.getParam("widgetId");
|
||||
const parentUrl = parser.getParam("parentUrl");
|
||||
const isWidget = !!widgetId && !!parentUrl;
|
||||
@@ -275,7 +293,7 @@ export const getUrlParams = (
|
||||
parser.getFlagParam("confineToRoom") || parser.getFlagParam("embed"),
|
||||
appPrompt: parser.getFlagParam("appPrompt", true),
|
||||
preload: isWidget ? parser.getFlagParam("preload") : false,
|
||||
hideHeader: parser.getFlagParam("hideHeader"),
|
||||
header: header as HeaderStyle,
|
||||
showControls: parser.getFlagParam("showControls", true),
|
||||
hideScreensharing: parser.getFlagParam("hideScreensharing"),
|
||||
e2eEnabled: parser.getFlagParam("enableE2EE", true),
|
||||
|
||||
50
src/__snapshots__/AppBar.test.tsx.snap
Normal file
50
src/__snapshots__/AppBar.test.tsx.snap
Normal file
@@ -0,0 +1,50 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AppBar > renders 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="bar"
|
||||
style="display: block;"
|
||||
>
|
||||
<header
|
||||
class="header"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="«r0»"
|
||||
class="_icon-button_1pz9o_8"
|
||||
data-kind="primary"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_zr2a0_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.207 5.293a1 1 0 0 1 0 1.414L7.914 11H18.5a1 1 0 1 1 0 2H7.914l4.293 4.293a1 1 0 0 1-1.414 1.414l-6-6a1 1 0 0 1 0-1.414l6-6a1 1 0 0 1 1.414 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
<p>
|
||||
This is the content.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
exports[`the content is rendered when the modal is open 1`] = `
|
||||
<div
|
||||
aria-labelledby="radix-:r4:"
|
||||
aria-labelledby="radix-«r4»"
|
||||
class="overlay animate modal dialog _glass_sepwu_8"
|
||||
data-state="open"
|
||||
id="radix-:r3:"
|
||||
id="radix-«r3»"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -18,7 +18,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
>
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
id="radix-:r4:"
|
||||
id="radix-«r4»"
|
||||
>
|
||||
My modal
|
||||
</h2>
|
||||
@@ -36,7 +36,7 @@ exports[`the content is rendered when the modal is open 1`] = `
|
||||
|
||||
exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
<div
|
||||
aria-labelledby="radix-:ra:"
|
||||
aria-labelledby="radix-«ra»"
|
||||
class="overlay modal drawer"
|
||||
data-state="open"
|
||||
data-vaul-animate="true"
|
||||
@@ -45,7 +45,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
data-vaul-drawer=""
|
||||
data-vaul-drawer-direction="bottom"
|
||||
data-vaul-snap-points="false"
|
||||
id="radix-:r9:"
|
||||
id="radix-«r9»"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -60,7 +60,7 @@ exports[`the modal renders as a drawer in mobile viewports 1`] = `
|
||||
class="handle"
|
||||
/>
|
||||
<h2
|
||||
id="radix-:ra:"
|
||||
id="radix-«ra»"
|
||||
style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); white-space: nowrap; word-wrap: normal;"
|
||||
>
|
||||
My modal
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
exports[`Toast > renders 1`] = `
|
||||
<button
|
||||
aria-labelledby="radix-:r4:"
|
||||
aria-labelledby="radix-«r4»"
|
||||
class="overlay animate toast"
|
||||
data-state="open"
|
||||
id="radix-:r3:"
|
||||
id="radix-«r3»"
|
||||
role="dialog"
|
||||
style="pointer-events: auto;"
|
||||
tabindex="-1"
|
||||
@@ -13,7 +13,7 @@ exports[`Toast > renders 1`] = `
|
||||
>
|
||||
<h3
|
||||
class="_typography_6v6n8_153 _font-body-sm-semibold_6v6n8_36"
|
||||
id="radix-:r4:"
|
||||
id="radix-«r4»"
|
||||
>
|
||||
Hello world!
|
||||
</h3>
|
||||
|
||||
@@ -5,11 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ComponentPropsWithoutRef,
|
||||
forwardRef,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { type ComponentProps, type FC, 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";
|
||||
@@ -26,31 +22,30 @@ export function useLink(
|
||||
return [href, onClick];
|
||||
}
|
||||
|
||||
type Props = Omit<
|
||||
ComponentPropsWithoutRef<typeof CpdLink>,
|
||||
"href" | "onClick"
|
||||
> & { to: LinkProps["to"]; state?: unknown };
|
||||
type Props = Omit<ComponentProps<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 = forwardRef<HTMLAnchorElement, Props>(function Link(
|
||||
{ to, state, ...props },
|
||||
ref,
|
||||
) {
|
||||
export const Link: FC<Props> = ({ ref, to, state, ...props }) => {
|
||||
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 = forwardRef<
|
||||
HTMLAnchorElement,
|
||||
ComponentPropsWithoutRef<"a">
|
||||
>(function ExternalLink({ className, children, ...props }, ref) {
|
||||
export const ExternalLink: FC<ComponentProps<"a">> = ({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<a
|
||||
ref={ref}
|
||||
@@ -62,4 +57,4 @@ export const ExternalLink = forwardRef<
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -5,24 +5,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ComponentPropsWithoutRef, forwardRef } from "react";
|
||||
import { type ComponentProps, type FC } from "react";
|
||||
import { Button } from "@vector-im/compound-web";
|
||||
|
||||
import type { LinkProps } from "react-router-dom";
|
||||
import { useLink } from "./Link";
|
||||
|
||||
type Props = Omit<
|
||||
ComponentPropsWithoutRef<typeof Button<"a">>,
|
||||
"as" | "href"
|
||||
> & { to: LinkProps["to"]; state?: unknown };
|
||||
type Props = Omit<ComponentProps<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 = 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} />;
|
||||
},
|
||||
);
|
||||
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} />;
|
||||
};
|
||||
|
||||
@@ -9,7 +9,7 @@ exports[`Can close reaction dialog 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":rb5:"
|
||||
aria-labelledby="«rb5»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -43,7 +43,7 @@ exports[`Can fully expand emoji picker 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":r7m:"
|
||||
aria-labelledby="«r7m»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -74,7 +74,7 @@ exports[`Can lower hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":r36:"
|
||||
aria-labelledby="«r36»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -108,7 +108,7 @@ exports[`Can open menu 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":r0:"
|
||||
aria-labelledby="«r0»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -139,7 +139,7 @@ exports[`Can raise hand 1`] = `
|
||||
aria-disabled="false"
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-labelledby=":r1j:"
|
||||
aria-labelledby="«r1j»"
|
||||
class="_button_vczzf_8 raisedButton _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ConfigOptions {
|
||||
api_key: string;
|
||||
api_host: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Sentry endpoint to which crash data will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
@@ -22,6 +23,7 @@ export interface ConfigOptions {
|
||||
DSN: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The rageshake server to which feedback and debug logs will be sent.
|
||||
* This is only used in the full package of Element Call.
|
||||
@@ -66,6 +68,7 @@ export interface ConfigOptions {
|
||||
* Allow to join group calls without audio and video.
|
||||
*/
|
||||
feature_group_calls_without_video_and_audio?: boolean;
|
||||
|
||||
/**
|
||||
* Send device-specific call session membership state events instead of
|
||||
* legacy user-specific call membership state events.
|
||||
@@ -86,6 +89,7 @@ export interface ConfigOptions {
|
||||
* Defines whether participants should start with audio enabled by default.
|
||||
*/
|
||||
enable_audio?: boolean;
|
||||
|
||||
/**
|
||||
* Defines whether participants should start with video enabled by default.
|
||||
*/
|
||||
@@ -109,19 +113,38 @@ export interface ConfigOptions {
|
||||
* How long (in milliseconds) to wait before rotating end-to-end media encryption keys
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* How often (in milliseconds) keep-alive messages should be sent to the server for
|
||||
* the MatrixRTC membership event.
|
||||
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
|
||||
* that the server waits before sending the leave MatrixRTC membership event.
|
||||
*/
|
||||
delayed_leave_event_delay_ms?: number;
|
||||
/** @deprecated use delayed_leave_event_delay_ms instead */
|
||||
membership_server_side_expiry_timeout?: number;
|
||||
|
||||
/**
|
||||
* The time 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 (in milliseconds) after the last keep-alive the server should expire the
|
||||
* MatrixRTC membership event.
|
||||
* How long we wait before retrying after a network error on any of the requests.
|
||||
*/
|
||||
membership_server_side_expiry_timeout?: number;
|
||||
network_error_retry_ms?: number;
|
||||
|
||||
/**
|
||||
* The timeout (in milliseconds) after we joined the call, that our membership should expire
|
||||
* unless we have explicitly updated it.
|
||||
*
|
||||
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
|
||||
*/
|
||||
membership_event_expiry_ms?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,31 +1,38 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
import { Subject } from "rxjs";
|
||||
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;
|
||||
/** @deprecated use setAudioEnabled instead*/
|
||||
setOutputEnabled(enabled: boolean): void;
|
||||
setAudioEnabled(enabled: boolean): void;
|
||||
/** @deprecated use showNativeAudioDevicePicker instead*/
|
||||
showNativeOutputDevicePicker?: () => void;
|
||||
showNativeAudioDevicePicker?: () => void;
|
||||
}
|
||||
|
||||
export interface OutputDevice {
|
||||
@@ -41,12 +48,11 @@ export interface OutputDevice {
|
||||
* If pipMode is enabled, EC will render a adapted call view layout.
|
||||
*/
|
||||
export const setPipEnabled$ = new Subject<boolean>();
|
||||
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
|
||||
// We want the devices that have been set during loading to be available immediately once loaded.
|
||||
export const availableOutputDevices$ = new BehaviorSubject<OutputDevice[]>([]);
|
||||
// BehaviorSubject since the client might set this before we have subscribed (GroupCallView still in "loading" state)
|
||||
// We want the device that has been set during loading to be available immediately once loaded.
|
||||
export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
|
||||
|
||||
export const availableOutputDevices$ = new Subject<OutputDevice[]>();
|
||||
|
||||
export const outputDevice$ = new Subject<string>();
|
||||
|
||||
/**
|
||||
* This allows the os to mute the call if the user
|
||||
* presses the volume down button when it is at the minimum volume.
|
||||
@@ -55,6 +61,14 @@ export const outputDevice$ = new BehaviorSubject<string | undefined>(undefined);
|
||||
*/
|
||||
export const setAudioEnabled$ = new Subject<boolean>();
|
||||
|
||||
let playbackStartedEmitted = false;
|
||||
export const setPlaybackStarted = (): void => {
|
||||
if (!playbackStartedEmitted) {
|
||||
playbackStartedEmitted = true;
|
||||
window.controls.onAudioPlaybackStarted?.();
|
||||
}
|
||||
};
|
||||
|
||||
window.controls = {
|
||||
canEnterPip(): boolean {
|
||||
return setPipEnabled$.observed;
|
||||
@@ -67,13 +81,17 @@ 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);
|
||||
},
|
||||
setAudioDevice(id: string): void {
|
||||
logger.info("setAudioDevice called from native", id);
|
||||
outputDevice$.next(id);
|
||||
},
|
||||
setAudioEnabled(enabled: boolean): void {
|
||||
logger.info("setAudioEnabled called from native:", enabled);
|
||||
if (!setAudioEnabled$.observed)
|
||||
throw new Error(
|
||||
"Output controls are disabled. No setAudioEnabled$ observer",
|
||||
|
||||
@@ -6,28 +6,32 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import { type FormEventHandler, forwardRef, type ReactNode } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type Ref,
|
||||
type FormEventHandler,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import styles from "./Form.module.css";
|
||||
|
||||
interface FormProps {
|
||||
ref?: Ref<HTMLFormElement>;
|
||||
className: string;
|
||||
onSubmit: FormEventHandler<HTMLFormElement>;
|
||||
children: ReactNode[];
|
||||
}
|
||||
|
||||
export const Form = forwardRef<HTMLFormElement, FormProps>(
|
||||
({ children, className, onSubmit }, ref) => {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className={classNames(styles.form, className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
},
|
||||
);
|
||||
export const Form: FC<FormProps> = ({ ref, children, className, onSubmit }) => {
|
||||
return (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className={classNames(styles.form, className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
Form.displayName = "Form";
|
||||
|
||||
@@ -18,13 +18,12 @@ import {
|
||||
type ComponentType,
|
||||
type Dispatch,
|
||||
type FC,
|
||||
type LegacyRef,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
type SetStateAction,
|
||||
createContext,
|
||||
forwardRef,
|
||||
memo,
|
||||
useContext,
|
||||
use,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -124,7 +123,7 @@ interface LayoutContext {
|
||||
const LayoutContext = createContext<LayoutContext | null>(null);
|
||||
|
||||
function useLayoutContext(): LayoutContext {
|
||||
const context = useContext(LayoutContext);
|
||||
const context = use(LayoutContext);
|
||||
if (context === null)
|
||||
throw new Error("useUpdateLayout called outside a Grid layout context");
|
||||
return context;
|
||||
@@ -162,7 +161,7 @@ const windowHeightObservable$ = fromEvent(window, "resize").pipe(
|
||||
);
|
||||
|
||||
export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
|
||||
ref: LegacyRef<R>;
|
||||
ref?: Ref<R>;
|
||||
model: LayoutModel;
|
||||
/**
|
||||
* Component creating an invisible "slot" for a tile to go in.
|
||||
@@ -171,7 +170,7 @@ export interface LayoutProps<LayoutModel, TileModel, R extends HTMLElement> {
|
||||
}
|
||||
|
||||
export interface TileProps<Model, R extends HTMLElement> {
|
||||
ref: LegacyRef<R>;
|
||||
ref?: Ref<R>;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
/**
|
||||
@@ -297,14 +296,13 @@ export function Grid<
|
||||
// render of Grid causes a re-render of Layout, which in turn re-renders Grid
|
||||
const LayoutMemo = useMemo(
|
||||
() =>
|
||||
memo(
|
||||
forwardRef<
|
||||
LayoutRef,
|
||||
LayoutMemoProps<LayoutModel, TileModel, LayoutRef>
|
||||
>(function LayoutMemo({ Layout, ...props }, ref): ReactNode {
|
||||
return <Layout {...props} ref={ref} />;
|
||||
}),
|
||||
),
|
||||
memo(function LayoutMemo({
|
||||
ref,
|
||||
Layout,
|
||||
...props
|
||||
}: LayoutMemoProps<LayoutModel, TileModel, LayoutRef>): ReactNode {
|
||||
return <Layout {...props} ref={ref} />;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -532,14 +530,14 @@ export function Grid<
|
||||
className={classNames(className, styles.grid)}
|
||||
style={style}
|
||||
>
|
||||
<LayoutContext.Provider value={context}>
|
||||
<LayoutContext value={context}>
|
||||
<LayoutMemo
|
||||
ref={setLayoutRoot}
|
||||
Layout={Layout}
|
||||
model={model}
|
||||
Slot={Slot}
|
||||
/>
|
||||
</LayoutContext.Provider>
|
||||
</LayoutContext>
|
||||
{tileTransitions((spring, { id, model, onDrag, width, height }) => (
|
||||
<TileWrapper
|
||||
key={id}
|
||||
|
||||
@@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type CSSProperties, forwardRef, useCallback, useMemo } from "react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { distinctUntilChanged } from "rxjs";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
@@ -33,7 +38,7 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
|
||||
// The "fixed" (non-scrolling) part of the layout is where the spotlight tile
|
||||
// lives
|
||||
fixed: forwardRef(function GridLayoutFixed({ model, Slot }, ref) {
|
||||
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
const alignment = useObservableEagerState(
|
||||
useInitial(() =>
|
||||
@@ -68,10 +73,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
},
|
||||
|
||||
// The scrolling part of the layout is where all the grid tiles live
|
||||
scrolling: forwardRef(function GridLayout({ model, Slot }, ref) {
|
||||
scrolling: function GridLayout({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
const { width, height: minHeight } = useObservableEagerState(minBounds$);
|
||||
@@ -98,5 +103,5 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 { forwardRef, useCallback, useMemo } from "react";
|
||||
import { type ReactNode, useCallback, useMemo } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
@@ -24,12 +24,12 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
}) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
fixed: forwardRef(function OneOnOneLayoutFixed(_props, ref) {
|
||||
fixed: function OneOnOneLayoutFixed({ ref }): ReactNode {
|
||||
useUpdateLayout();
|
||||
return <div ref={ref} />;
|
||||
}),
|
||||
},
|
||||
|
||||
scrolling: forwardRef(function OneOnOneLayoutScrolling({ model, Slot }, ref) {
|
||||
scrolling: function OneOnOneLayoutScrolling({ ref, model, Slot }): ReactNode {
|
||||
useUpdateLayout();
|
||||
const { width, height } = useObservableEagerState(minBounds$);
|
||||
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
|
||||
@@ -66,5 +66,5 @@ export const makeOneOnOneLayout: CallLayout<OneOnOneLayoutModel> = ({
|
||||
</Slot>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 { forwardRef, useCallback } from "react";
|
||||
import { type ReactNode, useCallback } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/CallViewModel";
|
||||
@@ -22,10 +22,11 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
> = ({ pipAlignment$ }) => ({
|
||||
scrollingOnTop: true,
|
||||
|
||||
fixed: forwardRef(function SpotlightExpandedLayoutFixed(
|
||||
{ model, Slot },
|
||||
fixed: function SpotlightExpandedLayoutFixed({
|
||||
ref,
|
||||
) {
|
||||
model,
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
|
||||
return (
|
||||
@@ -37,12 +38,13 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
},
|
||||
|
||||
scrolling: forwardRef(function SpotlightExpandedLayoutScrolling(
|
||||
{ model, Slot },
|
||||
scrolling: function SpotlightExpandedLayoutScrolling({
|
||||
ref,
|
||||
) {
|
||||
model,
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
const pipAlignmentValue = useObservableEagerState(pipAlignment$);
|
||||
|
||||
@@ -69,5 +71,5 @@ export const makeSpotlightExpandedLayout: CallLayout<
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 { forwardRef } from "react";
|
||||
import { type ReactNode } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
@@ -24,10 +24,11 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
> = ({ minBounds$ }) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
fixed: forwardRef(function SpotlightLandscapeLayoutFixed(
|
||||
{ model, Slot },
|
||||
fixed: function SpotlightLandscapeLayoutFixed({
|
||||
ref,
|
||||
) {
|
||||
model,
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useObservableEagerState(minBounds$);
|
||||
|
||||
@@ -43,12 +44,13 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
<div className={styles.grid} />
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
},
|
||||
|
||||
scrolling: forwardRef(function SpotlightLandscapeLayoutScrolling(
|
||||
{ model, Slot },
|
||||
scrolling: function SpotlightLandscapeLayoutScrolling({
|
||||
ref,
|
||||
) {
|
||||
model,
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
useObservableEagerState(minBounds$);
|
||||
@@ -69,5 +71,5 @@ export const makeSpotlightLandscapeLayout: CallLayout<
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 CSSProperties, forwardRef } from "react";
|
||||
import { type ReactNode, type CSSProperties } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
@@ -30,10 +30,11 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
> = ({ minBounds$ }) => ({
|
||||
scrollingOnTop: false,
|
||||
|
||||
fixed: forwardRef(function SpotlightPortraitLayoutFixed(
|
||||
{ model, Slot },
|
||||
fixed: function SpotlightPortraitLayoutFixed({
|
||||
ref,
|
||||
) {
|
||||
model,
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
|
||||
return (
|
||||
@@ -47,12 +48,13 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
},
|
||||
|
||||
scrolling: forwardRef(function SpotlightPortraitLayoutScrolling(
|
||||
{ model, Slot },
|
||||
scrolling: function SpotlightPortraitLayoutScrolling({
|
||||
ref,
|
||||
) {
|
||||
model,
|
||||
Slot,
|
||||
}): ReactNode {
|
||||
useUpdateLayout();
|
||||
useVisibleTiles(model.setVisibleTiles);
|
||||
const { width } = useObservableEagerState(minBounds$);
|
||||
@@ -90,5 +92,5 @@ export const makeSpotlightPortraitLayout: CallLayout<
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,12 +37,14 @@ 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();
|
||||
@@ -114,14 +116,16 @@ export const RegisteredView: FC<Props> = ({ client }) => {
|
||||
return (
|
||||
<>
|
||||
<div className={commonStyles.container}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
{header === "standard" && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
<main className={commonStyles.main}>
|
||||
<HeaderLogo className={commonStyles.logo} />
|
||||
<Heading size="lg" weight="semibold">
|
||||
|
||||
@@ -34,9 +34,11 @@ 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();
|
||||
@@ -141,14 +143,16 @@ export const UnauthenticatedView: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<div className={commonStyles.container}>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav hideMobile>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
{header === "standard" && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav hideMobile>
|
||||
<UserMenuContainer />
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
<main className={commonStyles.main}>
|
||||
<HeaderLogo className={commonStyles.logo} />
|
||||
<Heading size="lg" weight="semibold">
|
||||
|
||||
@@ -45,6 +45,9 @@ 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,
|
||||
@@ -71,6 +74,13 @@ 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;
|
||||
|
||||
@@ -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,6 +54,7 @@ function Field({ children, className }: FieldProps): JSX.Element {
|
||||
}
|
||||
|
||||
interface InputFieldProps {
|
||||
ref?: Ref<HTMLInputElement | HTMLTextAreaElement>;
|
||||
label?: string;
|
||||
type: string;
|
||||
prefix?: string;
|
||||
@@ -78,88 +79,81 @@ interface InputFieldProps {
|
||||
onChange?: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const InputField = forwardRef<
|
||||
HTMLInputElement | HTMLTextAreaElement,
|
||||
InputFieldProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
label,
|
||||
className,
|
||||
type,
|
||||
checked,
|
||||
prefix,
|
||||
suffix,
|
||||
description,
|
||||
disabled,
|
||||
min,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const descriptionId = useId();
|
||||
export const InputField: FC<InputFieldProps> = ({
|
||||
ref,
|
||||
id,
|
||||
label,
|
||||
className,
|
||||
type,
|
||||
checked,
|
||||
prefix,
|
||||
suffix,
|
||||
description,
|
||||
disabled,
|
||||
min,
|
||||
...rest
|
||||
}) => {
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
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>
|
||||
)}
|
||||
{label}
|
||||
</label>
|
||||
{suffix && <span>{suffix}</span>}
|
||||
{description && (
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={
|
||||
label
|
||||
? styles.description
|
||||
: classNames(styles.description, styles.noLabel)
|
||||
}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
<label htmlFor={id}>
|
||||
{type === "checkbox" && (
|
||||
<div className={styles.checkbox}>
|
||||
<CheckIcon />
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
},
|
||||
);
|
||||
{label}
|
||||
</label>
|
||||
{suffix && <span>{suffix}</span>}
|
||||
{description && (
|
||||
<p
|
||||
id={descriptionId}
|
||||
className={
|
||||
label
|
||||
? styles.description
|
||||
: classNames(styles.description, styles.noLabel)
|
||||
}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
InputField.displayName = "InputField";
|
||||
|
||||
|
||||
@@ -17,12 +17,14 @@ import { type ReactNode } from "react";
|
||||
import { useTracks } from "@livekit/components-react";
|
||||
|
||||
import { testAudioContext } from "../useAudioContext.test";
|
||||
import * as MediaDevicesContext from "./MediaDevicesContext";
|
||||
import * as MediaDevicesContext from "../MediaDevicesContext";
|
||||
import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
|
||||
import { mockTrack } from "../utils/test";
|
||||
import { mockMediaDevices, mockTrack } from "../utils/test";
|
||||
|
||||
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
|
||||
|
||||
const MediaDevicesProvider = MediaDevicesContext.MediaDevicesContext.Provider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("AudioContext", TestAudioContextConstructor);
|
||||
});
|
||||
@@ -51,18 +53,24 @@ vi.mocked(useTracks).mockReturnValue(tracks);
|
||||
|
||||
it("should render for member", () => {
|
||||
const { container, queryAllByTestId } = render(
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>,
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryAllByTestId("audio")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should not render without member", () => {
|
||||
const memberships = [
|
||||
{ sender: "othermember", deviceId: "123" },
|
||||
] as CallMembership[];
|
||||
const { container, queryAllByTestId } = render(
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "othermember", deviceId: "123" }] as CallMembership[]}
|
||||
/>,
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer members={memberships} />
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
expect(container).toBeTruthy();
|
||||
expect(queryAllByTestId("audio")).toHaveLength(0);
|
||||
@@ -70,9 +78,11 @@ it("should not render without member", () => {
|
||||
|
||||
it("should not setup audioContext gain and pan if there is no need to.", () => {
|
||||
render(
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>,
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
|
||||
@@ -84,15 +94,18 @@ it("should not setup audioContext gain and pan if there is no need to.", () => {
|
||||
expect(testAudioContext.gain.gain.value).toEqual(1);
|
||||
expect(testAudioContext.pan.pan.value).toEqual(0);
|
||||
});
|
||||
|
||||
it("should setup audioContext gain and pan", () => {
|
||||
vi.spyOn(MediaDevicesContext, "useEarpieceAudioConfig").mockReturnValue({
|
||||
pan: 1,
|
||||
volume: 0.1,
|
||||
});
|
||||
render(
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>,
|
||||
<MediaDevicesProvider value={mockMediaDevices({})}>
|
||||
<MatrixAudioRenderer
|
||||
members={[{ sender: "test", deviceId: "123" }] as CallMembership[]}
|
||||
/>
|
||||
</MediaDevicesProvider>,
|
||||
);
|
||||
|
||||
const audioTrack = tracks[0].publication.track! as RemoteAudioTrack;
|
||||
|
||||
@@ -14,11 +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 { 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.
|
||||
@@ -59,6 +61,7 @@ export function MatrixAudioRenderer({
|
||||
);
|
||||
|
||||
const loggedInvalidIdentities = useRef(new Set<string>());
|
||||
|
||||
/**
|
||||
* Log an invalid livekit track identity.
|
||||
* A invalid identity is one that does not match any of the matrix rtc members.
|
||||
@@ -96,6 +99,14 @@ export function MatrixAudioRenderer({
|
||||
isValid
|
||||
);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!tracks.some((t) => !validIdentities.has(t.participant.identity))) {
|
||||
logger.debug(
|
||||
`All audio tracks have a matching matrix call member identity.`,
|
||||
);
|
||||
loggedInvalidIdentities.current.clear();
|
||||
}
|
||||
}, [tracks, validIdentities]);
|
||||
|
||||
// This component is also (in addition to the "only play audio for connected members" logic above)
|
||||
// responsible for mimicking earpiece audio on iPhones.
|
||||
@@ -204,6 +215,7 @@ function AudioTrackWithAudioNodes({
|
||||
useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
|
||||
);
|
||||
setTrackReady(true);
|
||||
controls.setPlaybackStarted();
|
||||
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,445 +0,0 @@
|
||||
/*
|
||||
Copyright 2023-2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type FC,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||
import { combineLatest, distinctUntilChanged, map, startWith } from "rxjs";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
|
||||
import {
|
||||
useSetting,
|
||||
audioInput as audioInputSetting,
|
||||
audioOutput as audioOutputSetting,
|
||||
videoInput as videoInputSetting,
|
||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||
type Setting,
|
||||
} from "../settings/settings";
|
||||
import { outputDevice$, availableOutputDevices$ } from "../controls";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||
// the ios swift team.
|
||||
export const EARPIECE_CONFIG_ID = "earpiece-id";
|
||||
|
||||
export type DeviceLabel =
|
||||
| { type: "name"; name: string }
|
||||
| { type: "number"; number: number }
|
||||
| { type: "earpiece" }
|
||||
| { type: "default"; name: string | null };
|
||||
|
||||
export interface MediaDeviceHandle {
|
||||
/**
|
||||
* A map from available device IDs to labels.
|
||||
*/
|
||||
available: Map<string, DeviceLabel>;
|
||||
selectedId: string | undefined;
|
||||
/**
|
||||
* An additional device configuration that makes us use only one channel of the
|
||||
* output device and a reduced volume.
|
||||
*/
|
||||
useAsEarpiece: boolean | undefined;
|
||||
/**
|
||||
* The group ID of the selected device.
|
||||
*/
|
||||
// This is exposed sort of ad-hoc because it's only needed for knowing when to
|
||||
// restart the tracks of default input devices, and ideally this behavior
|
||||
// would be encapsulated somehow…
|
||||
selectedGroupId: string | undefined;
|
||||
select: (deviceId: string) => void;
|
||||
}
|
||||
|
||||
interface InputDevices {
|
||||
audioInput: MediaDeviceHandle;
|
||||
videoInput: MediaDeviceHandle;
|
||||
startUsingDeviceNames: () => void;
|
||||
stopUsingDeviceNames: () => void;
|
||||
usingNames: boolean;
|
||||
}
|
||||
|
||||
export interface MediaDevices extends Omit<InputDevices, "usingNames"> {
|
||||
audioOutput: MediaDeviceHandle;
|
||||
}
|
||||
|
||||
/**
|
||||
* An observable that represents if we should display the devices menu for iOS.
|
||||
* This implies the following
|
||||
* - hide any input devices (they do not work anyhow on ios)
|
||||
* - Show a button to show the native output picker instead.
|
||||
* - Only show the earpiece toggle option if the earpiece is available:
|
||||
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
|
||||
*/
|
||||
export const iosDeviceMenu$ = alwaysShowIphoneEarpieceSetting.value$.pipe(
|
||||
map((v) => v || navigator.userAgent.includes("iPhone")),
|
||||
);
|
||||
|
||||
function useSelectedId(
|
||||
available: Map<string, DeviceLabel>,
|
||||
preferredId: string | undefined,
|
||||
): string | undefined {
|
||||
return useMemo(() => {
|
||||
if (available.size) {
|
||||
// If the preferred device is available, use it. Or if every available
|
||||
// device ID is falsy, the browser is probably just being paranoid about
|
||||
// fingerprinting and we should still try using the preferred device.
|
||||
// Worst case it is not available and the browser will gracefully fall
|
||||
// back to some other device for us when requesting the media stream.
|
||||
// Otherwise, select the first available device.
|
||||
return (preferredId !== undefined && available.has(preferredId)) ||
|
||||
(available.size === 1 && available.has(""))
|
||||
? preferredId
|
||||
: available.keys().next().value;
|
||||
}
|
||||
return undefined;
|
||||
}, [available, preferredId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get access to a mediaDevice handle for a kind. This allows to list
|
||||
* the available devices, read and set the selected device.
|
||||
* @param kind Audio input, output or video output.
|
||||
* @param setting The setting this handle's selection should be synced with.
|
||||
* @param usingNames If the hook should query device names for the associated
|
||||
* list.
|
||||
* @returns A handle for the chosen kind.
|
||||
*/
|
||||
function useMediaDeviceHandle(
|
||||
kind: MediaDeviceKind,
|
||||
setting: Setting<string | undefined>,
|
||||
usingNames: boolean,
|
||||
): MediaDeviceHandle {
|
||||
const hasRequestedPermissions = useRef(false);
|
||||
const requestPermissions = usingNames || hasRequestedPermissions.current;
|
||||
// Make sure we don't needlessly reset to a device observer without names,
|
||||
// once permissions are already given
|
||||
hasRequestedPermissions.current ||= usingNames;
|
||||
|
||||
// We use a bare device observer here rather than one of the fancy device
|
||||
// selection hooks from @livekit/components-react, because
|
||||
// useMediaDeviceSelect expects a room or track, which we don't have here, and
|
||||
// useMediaDevices provides no way to request device names.
|
||||
// Tragically, the only way to get device names out of LiveKit is to specify a
|
||||
// kind, which then results in multiple permissions requests.
|
||||
const deviceObserver$ = useMemo(
|
||||
() =>
|
||||
createMediaDeviceObserver(
|
||||
kind,
|
||||
() => logger.error("Error creating MediaDeviceObserver"),
|
||||
requestPermissions,
|
||||
).pipe(
|
||||
startWith([]),
|
||||
// This Observable emits new values whenever the browser fires a
|
||||
// MediaDevices 'devicechange' event. One would think, innocently, that
|
||||
// a 'devicechange' event means the devices have changed. But as of the
|
||||
// time of writing, we are seeing mobile Safari firing spurious
|
||||
// 'devicechange' events (where no change has actually occurred) when
|
||||
// we call MediaDevices.getUserMedia. So, filter by deep equality.
|
||||
distinctUntilChanged<MediaDeviceInfo[]>(deepCompare),
|
||||
),
|
||||
[kind, requestPermissions],
|
||||
);
|
||||
const available = useObservableEagerState(
|
||||
useMemo(
|
||||
() =>
|
||||
deviceObserver$.pipe(
|
||||
map((availableRaw) => {
|
||||
// Sometimes browsers (particularly Firefox) can return multiple device
|
||||
// entries for the exact same device ID; using a map deduplicates them
|
||||
let available = new Map<string, DeviceLabel>(
|
||||
availableRaw.map((d, i) => [
|
||||
d.deviceId,
|
||||
d.label
|
||||
? { type: "name", name: d.label }
|
||||
: { type: "number", number: i + 1 },
|
||||
]),
|
||||
);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
// We also create this if we do not have any available devices, so that
|
||||
// we can use the default or the earpiece.
|
||||
if (
|
||||
kind === "audiooutput" &&
|
||||
!available.has("") &&
|
||||
!available.has("default") &&
|
||||
available.size
|
||||
)
|
||||
available = new Map([
|
||||
["", { type: "default", name: availableRaw[0]?.label || null }],
|
||||
...available,
|
||||
]);
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
),
|
||||
[deviceObserver$, kind],
|
||||
),
|
||||
);
|
||||
|
||||
const [preferredId, select] = useSetting(setting);
|
||||
const selectedId = useSelectedId(available, preferredId);
|
||||
|
||||
const selectedGroupId = useObservableEagerState(
|
||||
useMemo(
|
||||
() =>
|
||||
deviceObserver$.pipe(
|
||||
map(
|
||||
(availableRaw) =>
|
||||
availableRaw.find((d) => d.deviceId === selectedId)?.groupId,
|
||||
),
|
||||
),
|
||||
[deviceObserver$, selectedId],
|
||||
),
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
available,
|
||||
selectedId,
|
||||
useAsEarpiece: false,
|
||||
selectedGroupId,
|
||||
select,
|
||||
}),
|
||||
[available, selectedId, selectedGroupId, select],
|
||||
);
|
||||
}
|
||||
|
||||
export const deviceStub: MediaDeviceHandle = {
|
||||
available: new Map(),
|
||||
selectedId: undefined,
|
||||
selectedGroupId: undefined,
|
||||
select: () => {},
|
||||
useAsEarpiece: false,
|
||||
};
|
||||
|
||||
export const devicesStub: MediaDevices = {
|
||||
audioInput: deviceStub,
|
||||
audioOutput: deviceStub,
|
||||
videoInput: deviceStub,
|
||||
startUsingDeviceNames: () => {},
|
||||
stopUsingDeviceNames: () => {},
|
||||
};
|
||||
|
||||
export const MediaDevicesContext = createContext<MediaDevices>(devicesStub);
|
||||
|
||||
function useInputDevices(): InputDevices {
|
||||
// Counts the number of callers currently using device names.
|
||||
const [numCallersUsingNames, setNumCallersUsingNames] = useState(0);
|
||||
const usingNames = numCallersUsingNames > 0;
|
||||
|
||||
const audioInput = useMediaDeviceHandle(
|
||||
"audioinput",
|
||||
audioInputSetting,
|
||||
usingNames,
|
||||
);
|
||||
const videoInput = useMediaDeviceHandle(
|
||||
"videoinput",
|
||||
videoInputSetting,
|
||||
usingNames,
|
||||
);
|
||||
|
||||
const startUsingDeviceNames = useCallback(
|
||||
() => setNumCallersUsingNames((n) => n + 1),
|
||||
[setNumCallersUsingNames],
|
||||
);
|
||||
const stopUsingDeviceNames = useCallback(
|
||||
() => setNumCallersUsingNames((n) => n - 1),
|
||||
[setNumCallersUsingNames],
|
||||
);
|
||||
|
||||
return {
|
||||
audioInput,
|
||||
videoInput,
|
||||
startUsingDeviceNames,
|
||||
stopUsingDeviceNames,
|
||||
usingNames,
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
export const MediaDevicesProvider: FC<Props> = ({ children }) => {
|
||||
const {
|
||||
audioInput,
|
||||
videoInput,
|
||||
startUsingDeviceNames,
|
||||
stopUsingDeviceNames,
|
||||
usingNames,
|
||||
} = useInputDevices();
|
||||
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
|
||||
const webViewAudioOutput = useMediaDeviceHandle(
|
||||
"audiooutput",
|
||||
audioOutputSetting,
|
||||
usingNames,
|
||||
);
|
||||
const controlledAudioOutput = useControlledOutput();
|
||||
|
||||
const context: MediaDevices = useMemo(
|
||||
() => ({
|
||||
audioInput,
|
||||
audioOutput: controlledAudioDevices
|
||||
? controlledAudioOutput
|
||||
: webViewAudioOutput,
|
||||
videoInput,
|
||||
startUsingDeviceNames,
|
||||
stopUsingDeviceNames,
|
||||
}),
|
||||
[
|
||||
audioInput,
|
||||
controlledAudioDevices,
|
||||
controlledAudioOutput,
|
||||
webViewAudioOutput,
|
||||
videoInput,
|
||||
startUsingDeviceNames,
|
||||
stopUsingDeviceNames,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<MediaDevicesContext.Provider value={context}>
|
||||
{children}
|
||||
</MediaDevicesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
function useControlledOutput(): MediaDeviceHandle {
|
||||
const { available } = useObservableEagerState(
|
||||
useObservable(() => {
|
||||
const outputDeviceData$ = availableOutputDevices$.pipe(
|
||||
map((devices) => {
|
||||
const deviceForEarpiece = devices.find((d) => d.forEarpiece);
|
||||
const deviceMapTuple: [string, DeviceLabel][] = devices.map(
|
||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||
let deviceLabel: DeviceLabel = { type: "name", name };
|
||||
// if (isExternalHeadset) // Do we want this?
|
||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||
if (isSpeaker) deviceLabel = { type: "default", name };
|
||||
return [id, deviceLabel];
|
||||
},
|
||||
);
|
||||
return {
|
||||
devicesMap: new Map<string, DeviceLabel>(deviceMapTuple),
|
||||
deviceForEarpiece,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return combineLatest(
|
||||
[outputDeviceData$, iosDeviceMenu$],
|
||||
({ devicesMap, deviceForEarpiece }, iosShowEarpiece) => {
|
||||
let available = devicesMap;
|
||||
if (iosShowEarpiece && !!deviceForEarpiece) {
|
||||
available = new Map([
|
||||
...devicesMap.entries(),
|
||||
[EARPIECE_CONFIG_ID, { type: "earpiece" }],
|
||||
]);
|
||||
}
|
||||
return { available, deviceForEarpiece };
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
const [preferredId, setPreferredId] = useSetting(audioOutputSetting);
|
||||
useEffect(() => {
|
||||
const subscription = outputDevice$.subscribe((id) => {
|
||||
if (id) setPreferredId(id);
|
||||
});
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [setPreferredId]);
|
||||
|
||||
const selectedId = useSelectedId(available, preferredId);
|
||||
|
||||
const [asEarpiece, setAsEarpiece] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Let the hosting application know which output device has been selected.
|
||||
// This information is probably only of interest if the earpiece mode has been
|
||||
// selected - for example, Element X iOS listens to this to determine whether it
|
||||
// should enable the proximity sensor.
|
||||
if (selectedId) {
|
||||
window.controls.onAudioDeviceSelect?.(selectedId);
|
||||
// Call deprecated method for backwards compatibility.
|
||||
window.controls.onOutputDeviceSelect?.(selectedId);
|
||||
}
|
||||
setAsEarpiece(selectedId === EARPIECE_CONFIG_ID);
|
||||
}, [selectedId]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
available: available,
|
||||
selectedId,
|
||||
selectedGroupId: undefined,
|
||||
select: setPreferredId,
|
||||
useAsEarpiece: asEarpiece,
|
||||
}),
|
||||
[available, selectedId, setPreferredId, asEarpiece],
|
||||
);
|
||||
}
|
||||
|
||||
export const useMediaDevices = (): MediaDevices =>
|
||||
useContext(MediaDevicesContext);
|
||||
|
||||
/**
|
||||
* React hook that requests for the media devices context to be populated with
|
||||
* real device names while this component is mounted. This is not done by
|
||||
* default because it may involve requesting additional permissions from the
|
||||
* user.
|
||||
*/
|
||||
export const useMediaDeviceNames = (
|
||||
context: MediaDevices,
|
||||
enabled = true,
|
||||
): void =>
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
context.startUsingDeviceNames();
|
||||
return context.stopUsingDeviceNames;
|
||||
}
|
||||
}, [context, enabled]);
|
||||
|
||||
/**
|
||||
* A convenience hook to get the audio node configuration for the earpiece.
|
||||
* It will check the `useAsEarpiece` of the `audioOutput` device and return
|
||||
* the appropriate pan and volume values.
|
||||
*
|
||||
* @returns pan and volume values for the earpiece audio node configuration.
|
||||
*/
|
||||
export const useEarpieceAudioConfig = (): {
|
||||
pan: number;
|
||||
volume: number;
|
||||
} => {
|
||||
const { audioOutput } = useMediaDevices();
|
||||
// We use only the right speaker (pan = 1) for the earpiece.
|
||||
// This mimics the behavior of the native earpiece speaker (only the top speaker on an iPhone)
|
||||
const pan = useMemo(
|
||||
() => (audioOutput.useAsEarpiece ? 1 : 0),
|
||||
[audioOutput.useAsEarpiece],
|
||||
);
|
||||
// We also do lower the volume by a factor of 10 to optimize for the usecase where
|
||||
// a user is holding the phone to their ear.
|
||||
const volume = useMemo(
|
||||
() => (audioOutput.useAsEarpiece ? 0.1 : 1),
|
||||
[audioOutput.useAsEarpiece],
|
||||
);
|
||||
return { pan, volume };
|
||||
};
|
||||
@@ -10,7 +10,14 @@ import {
|
||||
supportsBackgroundProcessors,
|
||||
type BackgroundOptions,
|
||||
} from "@livekit/track-processors";
|
||||
import { createContext, type FC, useContext, useEffect, useMemo } from "react";
|
||||
import {
|
||||
createContext,
|
||||
type FC,
|
||||
type JSX,
|
||||
use,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { type LocalVideoTrack } from "livekit-client";
|
||||
|
||||
import {
|
||||
@@ -27,7 +34,7 @@ type ProcessorState = {
|
||||
const ProcessorContext = createContext<ProcessorState | undefined>(undefined);
|
||||
|
||||
export function useTrackProcessor(): ProcessorState {
|
||||
const state = useContext(ProcessorContext);
|
||||
const state = use(ProcessorContext);
|
||||
if (state === undefined)
|
||||
throw new Error(
|
||||
"useTrackProcessor must be used within a ProcessorProvider",
|
||||
@@ -76,9 +83,5 @@ export const ProcessorProvider: FC<Props> = ({ children }) => {
|
||||
[supported, blurActivated, blur],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProcessorContext.Provider value={processorState}>
|
||||
{children}
|
||||
</ProcessorContext.Provider>
|
||||
);
|
||||
return <ProcessorContext value={processorState}>{children}</ProcessorContext>;
|
||||
};
|
||||
|
||||
@@ -19,12 +19,18 @@ import E2EEWorker from "livekit-client/e2ee-worker?worker";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { map } from "rxjs";
|
||||
import {
|
||||
map,
|
||||
NEVER,
|
||||
type Observable,
|
||||
type Subscription,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { defaultLiveKitOptions } from "./options";
|
||||
import { type SFUConfig } from "./openIDSFU";
|
||||
import { type MuteStates } from "../room/MuteStates";
|
||||
import { type MediaDeviceHandle, useMediaDevices } from "./MediaDevicesContext";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import {
|
||||
type ECConnectionState,
|
||||
useECConnectionState,
|
||||
@@ -39,6 +45,8 @@ import {
|
||||
import { observeTrackReference$ } from "../state/MediaViewModel";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { getValue } from "../utils/observable";
|
||||
import { type SelectedDevice } from "../state/MediaDevices";
|
||||
|
||||
interface UseLivekitResult {
|
||||
livekitRoom?: Room;
|
||||
@@ -56,7 +64,9 @@ export function useLivekit(
|
||||
const initialMuteStates = useInitial(() => muteStates);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
const initialDevices = useInitial(() => devices);
|
||||
const initialAudioInputId = useInitial(
|
||||
() => getValue(devices.audioInput.selected$)?.id,
|
||||
);
|
||||
|
||||
// Store if audio/video are currently updating. If to prohibit unnecessary calls
|
||||
// to setMicrophoneEnabled/setCameraEnabled
|
||||
@@ -94,15 +104,20 @@ export function useLivekit(
|
||||
...defaultLiveKitOptions,
|
||||
videoCaptureDefaults: {
|
||||
...defaultLiveKitOptions.videoCaptureDefaults,
|
||||
deviceId: initialDevices.videoInput.selectedId,
|
||||
deviceId: getValue(devices.videoInput.selected$)?.id,
|
||||
processor,
|
||||
},
|
||||
audioCaptureDefaults: {
|
||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
||||
deviceId: initialDevices.audioInput.selectedId,
|
||||
deviceId: initialAudioInputId,
|
||||
},
|
||||
audioOutput: {
|
||||
deviceId: initialDevices.audioOutput.selectedId,
|
||||
// When using controlled audio devices, we don't want to set the
|
||||
// deviceId here, because it will be set by the native app.
|
||||
// (also the id does not need to match a browser device id)
|
||||
deviceId: controlledAudioDevices
|
||||
? undefined
|
||||
: getValue(devices.audioOutput.selected$)?.id,
|
||||
},
|
||||
e2ee,
|
||||
};
|
||||
@@ -157,7 +172,7 @@ export function useLivekit(
|
||||
);
|
||||
|
||||
const connectionState = useECConnectionState(
|
||||
initialDevices.audioInput.selectedId,
|
||||
initialAudioInputId,
|
||||
initialMuteStates.audio.enabled,
|
||||
room,
|
||||
sfuConfig,
|
||||
@@ -312,62 +327,65 @@ export function useLivekit(
|
||||
) {
|
||||
const syncDevice = (
|
||||
kind: MediaDeviceKind,
|
||||
device: MediaDeviceHandle,
|
||||
): void => {
|
||||
const id = device.selectedId;
|
||||
|
||||
// Detect if we're trying to use chrome's default device, in which case
|
||||
// we need to to see if the default device has changed to a different device
|
||||
// by comparing the group ID of the device we're using against the group ID
|
||||
// of what the default device is *now*.
|
||||
// This is special-cased for only audio inputs because we need to dig around
|
||||
// in the LocalParticipant object for the track object and there's not a nice
|
||||
// way to do that generically. There is usually no OS-level default video capture
|
||||
// device anyway, and audio outputs work differently.
|
||||
if (
|
||||
id === "default" &&
|
||||
kind === "audioinput" &&
|
||||
room.options.audioCaptureDefaults?.deviceId === "default"
|
||||
) {
|
||||
const activeMicTrack = Array.from(
|
||||
room.localParticipant.audioTrackPublications.values(),
|
||||
).find((d) => d.source === Track.Source.Microphone)?.track;
|
||||
|
||||
selected$: Observable<SelectedDevice | undefined>,
|
||||
): Subscription =>
|
||||
selected$.subscribe((device) => {
|
||||
if (
|
||||
activeMicTrack &&
|
||||
// only restart if the stream is still running: LiveKit will detect
|
||||
// when a track stops & restart appropriately, so this is not our job.
|
||||
// Plus, we need to avoid restarting again if the track is already in
|
||||
// the process of being restarted.
|
||||
activeMicTrack.mediaStreamTrack.readyState !== "ended" &&
|
||||
device.selectedGroupId !==
|
||||
activeMicTrack.mediaStreamTrack.getSettings().groupId
|
||||
device !== undefined &&
|
||||
room.getActiveDevice(kind) !== device.id
|
||||
) {
|
||||
// It's different, so restart the track, ie. cause Livekit to do another
|
||||
// getUserMedia() call with deviceId: default to get the *new* default device.
|
||||
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
|
||||
// the deviceId hasn't changed (was & still is default).
|
||||
room.localParticipant
|
||||
.getTrackPublication(Track.Source.Microphone)
|
||||
?.audioTrack?.restartTrack()
|
||||
.catch((e) => {
|
||||
logger.error(`Failed to restart audio device track`, e);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (id !== undefined && room.getActiveDevice(kind) !== id) {
|
||||
room
|
||||
.switchActiveDevice(kind, id)
|
||||
.switchActiveDevice(kind, device.id)
|
||||
.catch((e) =>
|
||||
logger.error(`Failed to sync ${kind} device with LiveKit`, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
syncDevice("audioinput", devices.audioInput);
|
||||
syncDevice("audiooutput", devices.audioOutput);
|
||||
syncDevice("videoinput", devices.videoInput);
|
||||
const subscriptions = [
|
||||
syncDevice("audioinput", devices.audioInput.selected$),
|
||||
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
|
||||
// for the sake of Chrome, which provides a "default" device that is meant
|
||||
// to match the system's default audio input, whatever that may be.
|
||||
// This is special-cased for only audio inputs because we need to dig around
|
||||
// in the LocalParticipant object for the track object and there's not a nice
|
||||
// way to do that generically. There is usually no OS-level default video capture
|
||||
// device anyway, and audio outputs work differently.
|
||||
devices.audioInput.selected$
|
||||
.pipe(switchMap((device) => device?.hardwareDeviceChange$ ?? NEVER))
|
||||
.subscribe(() => {
|
||||
const activeMicTrack = Array.from(
|
||||
room.localParticipant.audioTrackPublications.values(),
|
||||
).find((d) => d.source === Track.Source.Microphone)?.track;
|
||||
|
||||
if (
|
||||
activeMicTrack &&
|
||||
// only restart if the stream is still running: LiveKit will detect
|
||||
// when a track stops & restart appropriately, so this is not our job.
|
||||
// Plus, we need to avoid restarting again if the track is already in
|
||||
// the process of being restarted.
|
||||
activeMicTrack.mediaStreamTrack.readyState !== "ended"
|
||||
) {
|
||||
// Restart the track, which will cause Livekit to do another
|
||||
// getUserMedia() call with deviceId: default to get the *new* default device.
|
||||
// Note that room.switchActiveDevice() won't work: Livekit will ignore it because
|
||||
// the deviceId hasn't changed (was & still is default).
|
||||
room.localParticipant
|
||||
.getTrackPublication(Track.Source.Microphone)
|
||||
?.audioTrack?.restartTrack()
|
||||
.catch((e) => {
|
||||
logger.error(`Failed to restart audio device track`, e);
|
||||
});
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
return (): void => {
|
||||
for (const s of subscriptions) s.unsubscribe();
|
||||
};
|
||||
}
|
||||
}, [room, devices, connectionState, controlledAudioDevices]);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { App } from "./App";
|
||||
import { init as initRageshake } from "./settings/rageshake";
|
||||
import { Initializer } from "./initializer";
|
||||
import { AppViewModel } from "./state/AppViewModel";
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
@@ -60,7 +61,7 @@ Initializer.initBeforeReact()
|
||||
.then(() => {
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<App vm={new AppViewModel()} />
|
||||
</StrictMode>,
|
||||
);
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { EventType, RelationType } from "matrix-js-sdk";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
use,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -34,7 +34,7 @@ const ReactionsSenderContext = createContext<
|
||||
>(undefined);
|
||||
|
||||
export const useReactionsSender = (): ReactionsSenderContextType => {
|
||||
const context = useContext(ReactionsSenderContext);
|
||||
const context = use(ReactionsSenderContext);
|
||||
if (!context) {
|
||||
throw new Error("useReactions must be used within a ReactionsProvider");
|
||||
}
|
||||
@@ -157,7 +157,7 @@ export const ReactionsSenderProvider = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<ReactionsSenderContext.Provider
|
||||
<ReactionsSenderContext
|
||||
value={{
|
||||
supportsReactions,
|
||||
toggleRaisedHand,
|
||||
@@ -165,6 +165,6 @@ export const ReactionsSenderProvider = ({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactionsSenderContext.Provider>
|
||||
</ReactionsSenderContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import { LinkButton } from "../button";
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
isPasswordlessUser: boolean;
|
||||
hideHeader: boolean;
|
||||
confineToRoom: boolean;
|
||||
endedCallId: string;
|
||||
}
|
||||
@@ -32,6 +33,7 @@ interface Props {
|
||||
export const CallEndedView: FC<Props> = ({
|
||||
client,
|
||||
isPasswordlessUser,
|
||||
hideHeader,
|
||||
confineToRoom,
|
||||
endedCallId,
|
||||
}) => {
|
||||
@@ -133,10 +135,12 @@ export const CallEndedView: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
|
||||
<RightNav />
|
||||
</Header>
|
||||
{!hideHeader && (
|
||||
<Header>
|
||||
<LeftNav>{!confineToRoom && <HeaderLogo />}</LeftNav>
|
||||
<RightNav />
|
||||
</Header>
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
<main className={styles.main}>
|
||||
<Heading size="xl" weight="semibold" className={styles.headline}>
|
||||
|
||||
63
src/room/EarpieceOverlay.module.css
Normal file
63
src/room/EarpieceOverlay.module.css
Normal file
@@ -0,0 +1,63 @@
|
||||
.overlay {
|
||||
position: fixed;
|
||||
z-index: var(--call-view-overlay-layer);
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--cpd-space-2x);
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay[data-show="true"] {
|
||||
animation: fade-in 200ms;
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay[data-show="false"] {
|
||||
animation: fade-out 130ms forwards;
|
||||
content-visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.overlay::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
opacity: 0.94;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-block-end: var(--cpd-space-4x);
|
||||
background: var(--cpd-color-alpha-gray-600);
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
|
||||
.overlay > h2 {
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overlay > p {
|
||||
text-align: center;
|
||||
}
|
||||
42
src/room/EarpieceOverlay.tsx
Normal file
42
src/room/EarpieceOverlay.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type FC } from "react";
|
||||
import { BigIcon, Button, Heading, Text } from "@vector-im/compound-web";
|
||||
import { EarpieceIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./EarpieceOverlay.module.css";
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
onBackToVideoPressed?: (() => void) | null;
|
||||
}
|
||||
|
||||
export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={styles.overlay} data-show={show}>
|
||||
<BigIcon className={styles.icon}>
|
||||
<EarpieceIcon aria-hidden />
|
||||
</BigIcon>
|
||||
<Heading as="h2" weight="semibold" size="md">
|
||||
{t("earpiece.overlay_title")}
|
||||
</Heading>
|
||||
<Text>{t("earpiece.overlay_description")}</Text>
|
||||
<Button
|
||||
kind="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onBackToVideoPressed?.();
|
||||
}}
|
||||
>
|
||||
{t("earpiece.overlay_back_button")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -29,8 +29,10 @@ import { useAudioContext } from "../useAudioContext";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import {
|
||||
flushPromises,
|
||||
mockEmitter,
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockMediaDevices,
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
} from "../utils/test";
|
||||
@@ -39,6 +41,8 @@ import { type WidgetHelpers } from "../widget";
|
||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import { MatrixRTCFocusMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
|
||||
vi.mock("../soundUtils");
|
||||
vi.mock("../useAudioContext");
|
||||
@@ -129,6 +133,7 @@ function createGroupCallView(
|
||||
getMxcAvatarUrl: () => null,
|
||||
getCanonicalAlias: () => null,
|
||||
currentState: {
|
||||
...mockEmitter(),
|
||||
getJoinRule: () => JoinRule.Invite,
|
||||
} as Partial<RoomState> as RoomState,
|
||||
});
|
||||
@@ -145,20 +150,22 @@ function createGroupCallView(
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<ProcessorProvider>
|
||||
<GroupCallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
hideHeader={true}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
isJoined={joined}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
<GroupCallView
|
||||
client={client}
|
||||
isPasswordlessUser={false}
|
||||
confineToRoom={false}
|
||||
preload={false}
|
||||
skipLobby={false}
|
||||
header={HeaderStyle.Standard}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
isJoined={joined}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
/>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@@ -40,7 +40,7 @@ import { useProfile } from "../profile/useProfile";
|
||||
import { findDeviceByName } from "../utils/media";
|
||||
import { ActiveCall } from "./InCallView";
|
||||
import { MUTE_PARTICIPANT_COUNT, type MuteStates } from "./MuteStates";
|
||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
|
||||
import { enterRTCSession, leaveRTCSession } from "../rtcSessionHelpers";
|
||||
import {
|
||||
@@ -51,7 +51,7 @@ import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import { callEventAudioSounds } from "./CallEventAudioRenderer";
|
||||
@@ -72,6 +72,7 @@ import {
|
||||
} from "../settings/settings";
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useAppBarTitle } from "../AppBar.tsx";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -85,7 +86,7 @@ interface Props {
|
||||
confineToRoom: boolean;
|
||||
preload: boolean;
|
||||
skipLobby: boolean;
|
||||
hideHeader: boolean;
|
||||
header: HeaderStyle;
|
||||
rtcSession: MatrixRTCSession;
|
||||
isJoined: boolean;
|
||||
muteStates: MuteStates;
|
||||
@@ -98,7 +99,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
confineToRoom,
|
||||
preload,
|
||||
skipLobby,
|
||||
hideHeader,
|
||||
header,
|
||||
rtcSession,
|
||||
isJoined,
|
||||
muteStates,
|
||||
@@ -132,6 +133,16 @@ export const GroupCallView: FC<Props> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// This CSS is the only way we could find to not make element call scroll for
|
||||
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
|
||||
// tested on different devices...)
|
||||
useEffect(() => {
|
||||
document.body.classList.add("no-scroll-body");
|
||||
return (): void => {
|
||||
document.body.classList.remove("no-scroll-body");
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.rtcSession = rtcSession;
|
||||
return (): void => {
|
||||
@@ -177,6 +188,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
}, [passwordFromUrl, room.roomId]);
|
||||
|
||||
usePageTitle(roomName);
|
||||
useAppBarTitle(roomName);
|
||||
|
||||
const matrixInfo = useMemo((): MatrixInfo => {
|
||||
return {
|
||||
@@ -197,8 +209,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
[memberships],
|
||||
);
|
||||
|
||||
const deviceContext = useMediaDevices();
|
||||
const latestDevices = useLatest(deviceContext);
|
||||
const mediaDevices = useMediaDevices();
|
||||
const latestMuteStates = useLatest(muteStates);
|
||||
|
||||
const enterRTCSessionOrError = useCallback(
|
||||
@@ -250,7 +261,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
logger.debug(
|
||||
`Found audio input ID ${deviceId} for name ${audioInput}`,
|
||||
);
|
||||
latestDevices.current!.audioInput.select(deviceId);
|
||||
mediaDevices.audioInput.select(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +275,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
logger.debug(
|
||||
`Found video input ID ${deviceId} for name ${videoInput}`,
|
||||
);
|
||||
latestDevices.current!.videoInput.select(deviceId);
|
||||
mediaDevices.videoInput.select(deviceId);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -306,7 +317,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
preload,
|
||||
skipLobby,
|
||||
perParticipantE2EE,
|
||||
latestDevices,
|
||||
mediaDevices,
|
||||
latestMuteStates,
|
||||
enterRTCSessionOrError,
|
||||
useNewMembershipManager,
|
||||
@@ -422,7 +433,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
muteStates={muteStates}
|
||||
onEnter={() => void enterRTCSessionOrError(rtcSession)}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
hideHeader={header === HeaderStyle.None}
|
||||
participantCount={participantCount}
|
||||
onShareClick={onShareClick}
|
||||
/>
|
||||
@@ -448,7 +459,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
rtcSession={rtcSession as MatrixRTCSession}
|
||||
participantCount={participantCount}
|
||||
onLeave={onLeave}
|
||||
hideHeader={hideHeader}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
e2eeSystem={e2eeSystem}
|
||||
//otelGroupCallMembership={otelGroupCallMembership}
|
||||
@@ -474,6 +485,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
endedCallId={rtcSession.room.roomId}
|
||||
client={client}
|
||||
isPasswordlessUser={isPasswordlessUser}
|
||||
hideHeader={header === HeaderStyle.None}
|
||||
confineToRoom={confineToRoom}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ Please see LICENSE in the repository root for full details.
|
||||
position: sticky;
|
||||
flex-shrink: 0;
|
||||
inset-block-start: 0;
|
||||
z-index: 1;
|
||||
z-index: var(--call-view-header-footer-layer);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0) 0%,
|
||||
@@ -34,7 +34,7 @@ Please see LICENSE in the repository root for full details.
|
||||
.footer {
|
||||
position: sticky;
|
||||
inset-block-end: 0;
|
||||
z-index: 1;
|
||||
z-index: var(--call-view-header-footer-layer);
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
minmax(0, var(--inline-content-inset))
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
mockLocalParticipant,
|
||||
mockMatrixRoom,
|
||||
mockMatrixRoomMember,
|
||||
mockMediaDevices,
|
||||
mockRemoteParticipant,
|
||||
mockRtcMembership,
|
||||
type MockRTCSession,
|
||||
@@ -45,6 +46,8 @@ import {
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
|
||||
// vi.hoisted(() => {
|
||||
// localStorage = {} as unknown as Storage;
|
||||
@@ -147,41 +150,43 @@ function createInCallView(): RenderResult & {
|
||||
rtcSession.joined = true;
|
||||
const renderResult = render(
|
||||
<BrowserRouter>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<RoomContext.Provider value={livekitRoom}>
|
||||
<InCallView
|
||||
client={client}
|
||||
hideHeader={true}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
muteStates={muteState}
|
||||
vm={vm}
|
||||
matrixInfo={{
|
||||
userId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
livekitRoom={livekitRoom}
|
||||
participantCount={0}
|
||||
onLeave={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
connState={ConnectionState.Connected}
|
||||
onShareClick={null}
|
||||
/>
|
||||
</RoomContext.Provider>
|
||||
</TooltipProvider>
|
||||
</ReactionsSenderProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ReactionsSenderProvider
|
||||
vm={vm}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={livekitRoom}>
|
||||
<InCallView
|
||||
client={client}
|
||||
header={HeaderStyle.Standard}
|
||||
rtcSession={rtcSession as unknown as MatrixRTCSession}
|
||||
muteStates={muteState}
|
||||
vm={vm}
|
||||
matrixInfo={{
|
||||
userId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
livekitRoom={livekitRoom}
|
||||
participantCount={0}
|
||||
onLeave={function (): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
connState={ConnectionState.Connected}
|
||||
onShareClick={null}
|
||||
/>
|
||||
</RoomContext>
|
||||
</TooltipProvider>
|
||||
</ReactionsSenderProvider>
|
||||
</MediaDevicesContext>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
return {
|
||||
@@ -239,6 +244,7 @@ describe("InCallView", () => {
|
||||
queryByText("using to Device key transport"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not shown if setting is disabled", () => {
|
||||
useExperimentalToDeviceTransportSetting.setValue(false);
|
||||
developerModeSetting.setValue(true);
|
||||
@@ -250,6 +256,7 @@ describe("InCallView", () => {
|
||||
queryByText("using to Device key transport"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is not shown if developer mode is disabled", () => {
|
||||
useExperimentalToDeviceTransportSetting.setValue(true);
|
||||
developerModeSetting.setValue(false);
|
||||
|
||||
@@ -6,21 +6,20 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
|
||||
import { ConnectionState, type Room } from "livekit-client";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
type FC,
|
||||
type PointerEvent,
|
||||
type PropsWithoutRef,
|
||||
type TouchEvent,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type JSX,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
|
||||
@@ -29,6 +28,11 @@ import { BehaviorSubject, map } from "rxjs";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
|
||||
import {
|
||||
EarpieceIcon,
|
||||
VolumeOnSolidIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import LogoMark from "../icons/LogoMark.svg?react";
|
||||
import LogoType from "../icons/LogoType.svg?react";
|
||||
@@ -43,7 +47,7 @@ import {
|
||||
SwitchCameraButton,
|
||||
} from "../button";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { type HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import styles from "./InCallView.module.css";
|
||||
@@ -105,6 +109,9 @@ import { useTypedEventEmitter } from "../useEvents.ts";
|
||||
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer.tsx";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
|
||||
import { useMediaDevices } from "../MediaDevicesContext.ts";
|
||||
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
|
||||
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
|
||||
@@ -116,6 +123,7 @@ export interface ActiveCallProps
|
||||
}
|
||||
|
||||
export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const mediaDevices = useMediaDevices();
|
||||
const sfuConfig = useOpenIDSFU(props.client, props.rtcSession);
|
||||
const { livekitRoom, connState } = useLivekit(
|
||||
props.rtcSession,
|
||||
@@ -156,6 +164,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const vm = new CallViewModel(
|
||||
props.rtcSession,
|
||||
livekitRoom,
|
||||
mediaDevices,
|
||||
props.e2eeSystem,
|
||||
connStateObservable$,
|
||||
reactionsReader.raisedHands$,
|
||||
@@ -167,12 +176,18 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
reactionsReader.destroy();
|
||||
};
|
||||
}
|
||||
}, [props.rtcSession, livekitRoom, props.e2eeSystem, connStateObservable$]);
|
||||
}, [
|
||||
props.rtcSession,
|
||||
livekitRoom,
|
||||
mediaDevices,
|
||||
props.e2eeSystem,
|
||||
connStateObservable$,
|
||||
]);
|
||||
|
||||
if (livekitRoom === undefined || vm === null) return null;
|
||||
|
||||
return (
|
||||
<RoomContext.Provider value={livekitRoom}>
|
||||
<RoomContext value={livekitRoom}>
|
||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||
<InCallView
|
||||
{...props}
|
||||
@@ -181,7 +196,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
connState={connState}
|
||||
/>
|
||||
</ReactionsSenderProvider>
|
||||
</RoomContext.Provider>
|
||||
</RoomContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -195,7 +210,7 @@ export interface InCallViewProps {
|
||||
participantCount: number;
|
||||
/** Function to call when the user explicitly ends the call */
|
||||
onLeave: () => void;
|
||||
hideHeader: boolean;
|
||||
header: HeaderStyle;
|
||||
otelGroupCallMembership?: OTelGroupCallMembership;
|
||||
connState: ECConnectionState;
|
||||
onShareClick: (() => void) | null;
|
||||
@@ -210,10 +225,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
muteStates,
|
||||
participantCount,
|
||||
onLeave,
|
||||
hideHeader,
|
||||
header: headerStyle,
|
||||
connState,
|
||||
onShareClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { supportsReactions, sendReaction, toggleRaisedHand } =
|
||||
useReactionsSender();
|
||||
|
||||
@@ -293,6 +309,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const gridMode = useObservableEagerState(vm.gridMode$);
|
||||
const showHeader = useObservableEagerState(vm.showHeader$);
|
||||
const showFooter = useObservableEagerState(vm.showFooter$);
|
||||
const earpieceMode = useObservableEagerState(vm.earpieceMode$);
|
||||
const audioOutputSwitcher = useObservableEagerState(vm.audioOutputSwitcher$);
|
||||
const switchCamera = useSwitchCamera(vm.localVideo$);
|
||||
|
||||
// Ideally we could detect taps by listening for click events and checking
|
||||
@@ -435,15 +453,80 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
}
|
||||
}, [setGridMode]);
|
||||
|
||||
useAppBarSecondaryButton(
|
||||
useMemo(() => {
|
||||
if (audioOutputSwitcher === null) return null;
|
||||
const isEarpieceTarget = audioOutputSwitcher.targetOutput === "earpiece";
|
||||
const Icon = isEarpieceTarget ? EarpieceIcon : VolumeOnSolidIcon;
|
||||
const label = isEarpieceTarget
|
||||
? t("settings.devices.earpiece")
|
||||
: t("settings.devices.loudspeaker");
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<IconButton
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
audioOutputSwitcher.switch();
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
}, [t, audioOutputSwitcher]),
|
||||
);
|
||||
|
||||
useAppBarHidden(!showHeader);
|
||||
|
||||
let header: ReactNode = null;
|
||||
if (showHeader) {
|
||||
switch (headerStyle) {
|
||||
case "none":
|
||||
// Cosmetic header to fill out space while still affecting the bounds of
|
||||
// the grid
|
||||
header = (
|
||||
<div
|
||||
className={classNames(styles.header, styles.filler)}
|
||||
ref={headerRef}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "standard":
|
||||
header = (
|
||||
<Header className={styles.header} ref={headerRef}>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
{showControls && onShareClick !== null && (
|
||||
<InviteButton
|
||||
className={styles.invite}
|
||||
onClick={onShareClick}
|
||||
/>
|
||||
)}
|
||||
</RightNav>
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Tile = useMemo(
|
||||
() =>
|
||||
forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithoutRef<TileProps<TileViewModel, HTMLDivElement>>
|
||||
>(function Tile(
|
||||
{ className, style, targetWidth, targetHeight, model },
|
||||
function Tile({
|
||||
ref,
|
||||
) {
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
model,
|
||||
}: TileProps<TileViewModel, HTMLDivElement>): ReactNode {
|
||||
const spotlightExpanded = useObservableEagerState(
|
||||
vm.spotlightExpanded$,
|
||||
);
|
||||
@@ -481,7 +564,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
},
|
||||
[vm, openProfile],
|
||||
);
|
||||
|
||||
@@ -521,7 +604,8 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
key="fixed"
|
||||
className={styles.fixedGrid}
|
||||
style={{
|
||||
insetBlockStart: headerBounds.bottom,
|
||||
insetBlockStart:
|
||||
headerBounds.height > 0 ? headerBounds.bottom : bounds.top,
|
||||
height: gridBounds.height,
|
||||
}}
|
||||
model={layout}
|
||||
@@ -644,10 +728,11 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
ref={footerRef}
|
||||
className={classNames(styles.footer, {
|
||||
[styles.overlay]: windowMode === "flat",
|
||||
[styles.hidden]: !showFooter || (!showControls && hideHeader),
|
||||
[styles.hidden]:
|
||||
!showFooter || (!showControls && headerStyle === "none"),
|
||||
})}
|
||||
>
|
||||
{!hideHeader && (
|
||||
{headerStyle !== "none" && (
|
||||
<div className={styles.logo}>
|
||||
<LogoMark width={24} height={24} aria-hidden />
|
||||
<LogoType
|
||||
@@ -683,35 +768,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerOut={onPointerOut}
|
||||
>
|
||||
{showHeader &&
|
||||
(hideHeader ? (
|
||||
// Cosmetic header to fill out space while still affecting the bounds
|
||||
// of the grid
|
||||
<div
|
||||
className={classNames(styles.header, styles.filler)}
|
||||
ref={headerRef}
|
||||
/>
|
||||
) : (
|
||||
<Header className={styles.header} ref={headerRef}>
|
||||
<LeftNav>
|
||||
<RoomHeaderInfo
|
||||
id={matrixInfo.roomId}
|
||||
name={matrixInfo.roomName}
|
||||
avatarUrl={matrixInfo.roomAvatar}
|
||||
encrypted={matrixInfo.e2eeSystem.kind !== E2eeType.NONE}
|
||||
participantCount={participantCount}
|
||||
/>
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
{showControls && onShareClick !== null && (
|
||||
<InviteButton
|
||||
className={styles.invite}
|
||||
onClick={onShareClick}
|
||||
/>
|
||||
)}
|
||||
</RightNav>
|
||||
</Header>
|
||||
))}
|
||||
{header}
|
||||
{
|
||||
// TODO: remove this once we remove the developer flag gets removed and we have shipped to
|
||||
// device transport as the default.
|
||||
@@ -728,6 +785,10 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{renderContent()}
|
||||
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
|
||||
<EarpieceOverlay
|
||||
show={earpieceMode}
|
||||
onBackToVideoPressed={audioOutputSwitcher?.switch}
|
||||
/>
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
LinkIcon,
|
||||
CheckIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import useClipboard from "react-use-clipboard";
|
||||
import copy from "copy-to-clipboard";
|
||||
|
||||
import { Modal } from "../Modal";
|
||||
import { getAbsoluteRoomUrl } from "../utils/matrix";
|
||||
@@ -42,18 +42,17 @@ export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
|
||||
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
|
||||
[e2eeSystem, room.name, room.roomId],
|
||||
);
|
||||
const [, setCopied] = useClipboard(url);
|
||||
const [toastOpen, setToastOpen] = useState(false);
|
||||
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
|
||||
|
||||
const onButtonClick = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setCopied();
|
||||
copy(url);
|
||||
onDismiss();
|
||||
setToastOpen(true);
|
||||
},
|
||||
[setCopied, onDismiss],
|
||||
[url, onDismiss],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type LocalVideoTrack,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { useObservable } from "observable-hooks";
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { map } from "rxjs";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
@@ -45,7 +45,7 @@ import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
import { useMediaQuery } from "../useMediaQuery";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { Link } from "../button/Link";
|
||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useInitial } from "../useInitial";
|
||||
import { useSwitchCamera as useShowSwitchCamera } from "./useSwitchCamera";
|
||||
import {
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
} from "../livekit/TrackProcessorContext";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { getValue } from "../utils/observable";
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -126,13 +127,18 @@ export const LobbyView: FC<Props> = ({
|
||||
);
|
||||
|
||||
const devices = useMediaDevices();
|
||||
const videoInputId = useObservableEagerState(
|
||||
devices.videoInput.selected$,
|
||||
)?.id;
|
||||
|
||||
// Capture the audio options as they were when we first mounted, because
|
||||
// we're not doing anything with the audio anyway so we don't need to
|
||||
// re-open the devices when they change (see below).
|
||||
const initialAudioOptions = useInitial(
|
||||
() =>
|
||||
muteStates.audio.enabled && { deviceId: devices.audioInput.selectedId },
|
||||
muteStates.audio.enabled && {
|
||||
deviceId: getValue(devices.audioInput.selected$)?.id,
|
||||
},
|
||||
);
|
||||
|
||||
const { processor } = useTrackProcessor();
|
||||
@@ -148,14 +154,14 @@ export const LobbyView: FC<Props> = ({
|
||||
// which would cause the devices to be re-opened on the next render.
|
||||
audio: Object.assign({}, initialAudioOptions),
|
||||
video: muteStates.video.enabled && {
|
||||
deviceId: devices.videoInput.selectedId,
|
||||
deviceId: videoInputId,
|
||||
processor: initialProcessor,
|
||||
},
|
||||
}),
|
||||
[
|
||||
initialAudioOptions,
|
||||
muteStates.video.enabled,
|
||||
devices.videoInput.selectedId,
|
||||
videoInputId,
|
||||
initialProcessor,
|
||||
],
|
||||
);
|
||||
@@ -178,6 +184,16 @@ export const LobbyView: FC<Props> = ({
|
||||
null) as LocalVideoTrack | null,
|
||||
[tracks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoTrack && videoInputId === undefined) {
|
||||
// If we have a video track but no videoInputId,
|
||||
// we have to update the available devices. So that we select the first
|
||||
// available video input device as the default instead of the `""` id.
|
||||
devices.requestDeviceNames();
|
||||
}
|
||||
}, [devices, videoInputId, videoTrack]);
|
||||
|
||||
useTrackProcessorSync(videoTrack);
|
||||
const showSwitchCamera = useShowSwitchCamera(
|
||||
useObservable(
|
||||
|
||||
@@ -5,20 +5,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
onTestFinished,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { type FC, useCallback, useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { useMuteStates } from "./MuteStates";
|
||||
import {
|
||||
type DeviceLabel,
|
||||
type MediaDeviceHandle,
|
||||
type MediaDevices,
|
||||
MediaDevicesContext,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { mockConfig } from "../utils/test";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
|
||||
vi.mock("@livekit/components-core");
|
||||
|
||||
interface TestComponentProps {
|
||||
isJoined?: boolean;
|
||||
@@ -73,16 +82,6 @@ const mockCamera: MediaDeviceInfo = {
|
||||
},
|
||||
};
|
||||
|
||||
function mockDevices(available: Map<string, DeviceLabel>): MediaDeviceHandle {
|
||||
return {
|
||||
available,
|
||||
selectedId: "",
|
||||
selectedGroupId: "",
|
||||
select: (): void => {},
|
||||
useAsEarpiece: false,
|
||||
};
|
||||
}
|
||||
|
||||
function mockMediaDevices(
|
||||
{
|
||||
microphone,
|
||||
@@ -94,21 +93,21 @@ function mockMediaDevices(
|
||||
camera?: boolean;
|
||||
} = { microphone: true, speaker: true, camera: true },
|
||||
): MediaDevices {
|
||||
return {
|
||||
audioInput: mockDevices(
|
||||
microphone
|
||||
? new Map([[mockMicrophone.deviceId, mockMicrophone]])
|
||||
: new Map(),
|
||||
),
|
||||
audioOutput: mockDevices(
|
||||
speaker ? new Map([[mockSpeaker.deviceId, mockSpeaker]]) : new Map(),
|
||||
),
|
||||
videoInput: mockDevices(
|
||||
camera ? new Map([[mockCamera.deviceId, mockCamera]]) : new Map(),
|
||||
),
|
||||
startUsingDeviceNames: (): void => {},
|
||||
stopUsingDeviceNames: (): void => {},
|
||||
};
|
||||
vi.mocked(createMediaDeviceObserver).mockImplementation((kind) => {
|
||||
switch (kind) {
|
||||
case "audioinput":
|
||||
return of(microphone ? [mockMicrophone] : []);
|
||||
case "audiooutput":
|
||||
return of(speaker ? [mockSpeaker] : []);
|
||||
case "videoinput":
|
||||
return of(camera ? [mockCamera] : []);
|
||||
case undefined:
|
||||
throw new Error("Unimplemented");
|
||||
}
|
||||
});
|
||||
const scope = new ObservableScope();
|
||||
onTestFinished(() => scope.end());
|
||||
return new MediaDevices(scope);
|
||||
}
|
||||
|
||||
describe("useMuteStates", () => {
|
||||
@@ -125,14 +124,14 @@ describe("useMuteStates", () => {
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MediaDevicesContext.Provider
|
||||
<MediaDevicesContext
|
||||
value={mockMediaDevices({
|
||||
microphone: false,
|
||||
camera: false,
|
||||
})}
|
||||
>
|
||||
<TestComponent />
|
||||
</MediaDevicesContext.Provider>
|
||||
</MediaDevicesContext>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
|
||||
@@ -144,9 +143,9 @@ describe("useMuteStates", () => {
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MediaDevicesContext.Provider value={mockMediaDevices()}>
|
||||
<MediaDevicesContext value={mockMediaDevices()}>
|
||||
<TestComponent />
|
||||
</MediaDevicesContext.Provider>
|
||||
</MediaDevicesContext>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
|
||||
@@ -160,9 +159,9 @@ describe("useMuteStates", () => {
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MediaDevicesContext.Provider value={mockMediaDevices()}>
|
||||
<MediaDevicesContext value={mockMediaDevices()}>
|
||||
<TestComponent isJoined />
|
||||
</MediaDevicesContext.Provider>
|
||||
</MediaDevicesContext>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
|
||||
@@ -179,9 +178,9 @@ describe("useMuteStates", () => {
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MediaDevicesContext.Provider value={mockMediaDevices()}>
|
||||
<MediaDevicesContext value={mockMediaDevices()}>
|
||||
<TestComponent />
|
||||
</MediaDevicesContext.Provider>
|
||||
</MediaDevicesContext>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
|
||||
@@ -193,9 +192,9 @@ describe("useMuteStates", () => {
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/room/?skipLobby=true"]}>
|
||||
<MediaDevicesContext.Provider value={mockMediaDevices()}>
|
||||
<MediaDevicesContext value={mockMediaDevices()}>
|
||||
<TestComponent />
|
||||
</MediaDevicesContext.Provider>
|
||||
</MediaDevicesContext>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
|
||||
@@ -206,7 +205,12 @@ describe("useMuteStates", () => {
|
||||
const user = userEvent.setup();
|
||||
mockConfig();
|
||||
const noDevices = mockMediaDevices({ microphone: false, camera: false });
|
||||
// Warm up these Observables before making further changes to the
|
||||
// createMediaDevicesObserver mock
|
||||
noDevices.audioInput.available$.subscribe(() => {}).unsubscribe();
|
||||
noDevices.videoInput.available$.subscribe(() => {}).unsubscribe();
|
||||
const someDevices = mockMediaDevices();
|
||||
|
||||
const ReappearanceTest: FC = () => {
|
||||
const [devices, setDevices] = useState(someDevices);
|
||||
const onConnectDevicesClick = useCallback(
|
||||
@@ -220,13 +224,13 @@ describe("useMuteStates", () => {
|
||||
|
||||
return (
|
||||
<MemoryRouter>
|
||||
<MediaDevicesContext.Provider value={devices}>
|
||||
<MediaDevicesContext value={devices}>
|
||||
<TestComponent />
|
||||
<button onClick={onConnectDevicesClick}>Connect devices</button>
|
||||
<button onClick={onDisconnectDevicesClick}>
|
||||
Disconnect devices
|
||||
</button>
|
||||
</MediaDevicesContext.Provider>
|
||||
</MediaDevicesContext>
|
||||
</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,11 +14,14 @@ import {
|
||||
} from "react";
|
||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import {
|
||||
type MediaDeviceHandle,
|
||||
useMediaDevices,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
type DeviceLabel,
|
||||
type SelectedDevice,
|
||||
type MediaDevice,
|
||||
} from "../state/MediaDevices";
|
||||
import { useIsEarpiece, useMediaDevices } from "../MediaDevicesContext";
|
||||
import { useReactiveState } from "../useReactiveState";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import { Config } from "../config/Config";
|
||||
@@ -53,24 +56,25 @@ export interface MuteStates {
|
||||
}
|
||||
|
||||
function useMuteState(
|
||||
device: MediaDeviceHandle,
|
||||
device: MediaDevice<DeviceLabel, SelectedDevice>,
|
||||
enabledByDefault: () => boolean,
|
||||
forceUnavailable: boolean = false,
|
||||
): MuteState {
|
||||
const available = useObservableEagerState(device.available$);
|
||||
const [enabled, setEnabled] = useReactiveState<boolean | undefined>(
|
||||
// Determine the default value once devices are actually connected
|
||||
(prev) =>
|
||||
prev ?? (device.available.size > 0 ? enabledByDefault() : undefined),
|
||||
[device.available.size],
|
||||
(prev) => prev ?? (available.size > 0 ? enabledByDefault() : undefined),
|
||||
[available.size],
|
||||
);
|
||||
return useMemo(
|
||||
() =>
|
||||
device.available.size === 0
|
||||
available.size === 0 || forceUnavailable
|
||||
? deviceUnavailable
|
||||
: {
|
||||
enabled: enabled ?? false,
|
||||
setEnabled: setEnabled as Dispatch<SetStateAction<boolean>>,
|
||||
},
|
||||
[device.available.size, enabled, setEnabled],
|
||||
[available.size, enabled, forceUnavailable, setEnabled],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,9 +86,11 @@ export function useMuteStates(isJoined: boolean): MuteStates {
|
||||
const audio = useMuteState(devices.audioInput, () => {
|
||||
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
|
||||
});
|
||||
const isEarpiece = useIsEarpiece();
|
||||
const video = useMuteState(
|
||||
devices.videoInput,
|
||||
() => Config.get().media_devices.enable_video && !skipLobby && !isJoined,
|
||||
isEarpiece, // Force video to be unavailable if using earpiece
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -19,8 +19,10 @@ import { UserMenuContainer } from "../UserMenuContainer";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
import { Config } from "../config/Config";
|
||||
import { ExternalLink, Link } from "../button/Link";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const RoomAuthView: FC = () => {
|
||||
const { header } = useUrlParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
|
||||
@@ -53,14 +55,16 @@ export const RoomAuthView: FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenuContainer preventNavigation />
|
||||
</RightNav>
|
||||
</Header>
|
||||
{header === "standard" && (
|
||||
<Header>
|
||||
<LeftNav>
|
||||
<HeaderLogo />
|
||||
</LeftNav>
|
||||
<RightNav>
|
||||
<UserMenuContainer preventNavigation />
|
||||
</RightNav>
|
||||
</Header>
|
||||
)}
|
||||
<div className={styles.container}>
|
||||
<main className={styles.main}>
|
||||
<Heading size="xl" weight="semibold" className={styles.headline}>
|
||||
|
||||
@@ -43,14 +43,8 @@ import { ErrorView } from "../ErrorView";
|
||||
import { useMatrixRTCSessionJoinState } from "../useMatrixRTCSessionJoinState";
|
||||
|
||||
export const RoomPage: FC = () => {
|
||||
const {
|
||||
confineToRoom,
|
||||
appPrompt,
|
||||
preload,
|
||||
hideHeader,
|
||||
displayName,
|
||||
skipLobby,
|
||||
} = useUrlParams();
|
||||
const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } =
|
||||
useUrlParams();
|
||||
const { t } = useTranslation();
|
||||
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
||||
|
||||
@@ -120,7 +114,7 @@ export const RoomPage: FC = () => {
|
||||
confineToRoom={confineToRoom}
|
||||
preload={preload}
|
||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||
hideHeader={hideHeader}
|
||||
header={header}
|
||||
muteStates={muteStates}
|
||||
/>
|
||||
);
|
||||
@@ -161,7 +155,7 @@ export const RoomPage: FC = () => {
|
||||
enterLabel={label}
|
||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
||||
confineToRoom={confineToRoom}
|
||||
hideHeader={hideHeader}
|
||||
hideHeader={header !== "standard"}
|
||||
participantCount={null}
|
||||
muteStates={muteStates}
|
||||
onShareClick={null}
|
||||
|
||||
@@ -5,9 +5,56 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
<div
|
||||
class="inRoom"
|
||||
>
|
||||
<div
|
||||
class="header filler"
|
||||
/>
|
||||
<header
|
||||
class="header header"
|
||||
>
|
||||
<div
|
||||
class="nav leftNav"
|
||||
>
|
||||
<div
|
||||
class="roomHeaderInfo"
|
||||
data-size="lg"
|
||||
>
|
||||
<span
|
||||
aria-label=""
|
||||
class="_avatar_1qbcf_8 roomAvatar _avatar-imageless_1qbcf_52"
|
||||
data-color="1"
|
||||
data-type="round"
|
||||
role="img"
|
||||
style="--cpd-avatar-size: 56px;"
|
||||
/>
|
||||
<div
|
||||
class="nameLine"
|
||||
>
|
||||
<h1
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
data-testid="roomHeader_roomName"
|
||||
/>
|
||||
<span
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-labelledby="«r0»"
|
||||
class="lock"
|
||||
data-encrypted="false"
|
||||
fill="currentColor"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
width="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 22q-.825 0-1.412-.587A1.93 1.93 0 0 1 4 20V10q0-.825.588-1.412a2 2 0 0 1 .702-.463L1.333 4.167a1 1 0 0 1 1.414-1.414L7 7.006v-.012l13 13v.012l1.247 1.247a1 1 0 1 1-1.414 1.414l-.896-.896A1.94 1.94 0 0 1 18 22zm14-4.834V10q0-.825-.587-1.412A1.93 1.93 0 0 0 18 8h-1V6q0-2.075-1.463-3.537Q14.075 1 12 1T8.463 2.463a4.9 4.9 0 0 0-1.22 1.946L9 6.166V6q0-1.25.875-2.125A2.9 2.9 0 0 1 12 3q1.25 0 2.125.875T15 6v2h-4.166z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="nav rightNav"
|
||||
/>
|
||||
</header>
|
||||
<div>
|
||||
mocked: MatrixAudioRenderer
|
||||
</div>
|
||||
@@ -35,18 +82,166 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
>
|
||||
<div />
|
||||
</div>
|
||||
<div
|
||||
class="overlay"
|
||||
data-show="false"
|
||||
>
|
||||
<div
|
||||
class="_content_o77nw_8 icon"
|
||||
data-size="large"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M14 2c3.93 0 7 3.07 7 7a1 1 0 0 1-2 0c0-2.8-2.2-5-5-5S9 6.2 9 9c0 .93.29 1.98.82 2.94.71 1.29 1.53 1.92 2.32 2.53.92.71 1.88 1.44 2.39 3 .5 1.5 1 2.01 1.71 2.38.2.09.47.15.76.15 1.1 0 2-.9 2-2a1 1 0 1 1 2 0 4 4 0 0 1-5.64 3.65c-1.36-.71-2.13-1.73-2.73-3.55-.32-.98-.9-1.43-1.71-2.05-.87-.67-1.94-1.5-2.85-3.15C7.38 11.65 7 10.26 7 9c0-3.93 3.07-7 7-7"
|
||||
/>
|
||||
<path
|
||||
d="M6.145 1.3a1 1 0 0 1 1.427 1.4A8.97 8.97 0 0 0 5 9c0 2.3.862 4.397 2.281 5.988l.291.312.069.077A1 1 0 0 1 6.22 16.77l-.075-.07-.356-.38A10.96 10.96 0 0 1 3 9c0-2.998 1.2-5.717 3.145-7.7M14 6.5a2.5 2.5 0 0 1 0 5 2.501 2.501 0 0 1 0-5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2
|
||||
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
|
||||
>
|
||||
Earpiece Mode
|
||||
</h2>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50"
|
||||
>
|
||||
Only works while using app
|
||||
</p>
|
||||
<button
|
||||
class="_button_vczzf_8"
|
||||
data-kind="primary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
Back to Speaker Mode
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="container"
|
||||
/>
|
||||
<div
|
||||
class="footer"
|
||||
>
|
||||
<div
|
||||
class="logo"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
height="24"
|
||||
viewBox="0 0 48 48"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
id="Logo Mark"
|
||||
>
|
||||
<rect
|
||||
fill="#0DBD8B"
|
||||
height="48"
|
||||
rx="23.93"
|
||||
width="47.86"
|
||||
/>
|
||||
<g
|
||||
id="Union"
|
||||
>
|
||||
<path
|
||||
d="M21.3075 9.42871C20.3396 9.42871 19.5549 10.214 19.5549 11.1828C19.5549 12.1516 20.3396 12.9369 21.3075 12.9369C25.9321 12.9369 29.6811 16.689 29.6811 21.3175C29.6811 22.2863 30.4657 23.0716 31.4337 23.0716C32.4016 23.0716 33.1863 22.2863 33.1863 21.3175C33.1863 14.7515 27.868 9.42871 21.3075 9.42871Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M38.4591 21.3174C38.4591 20.3486 37.6745 19.5633 36.7065 19.5633C35.7386 19.5633 34.9539 20.3486 34.9539 21.3174C34.9539 25.9459 31.2049 29.698 26.5804 29.698C25.6124 29.698 24.8277 30.4833 24.8277 31.4521C24.8277 32.4209 25.6124 33.2062 26.5804 33.2062C33.1408 33.2062 38.4591 27.8834 38.4591 21.3174Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M28.3329 36.8173C28.3329 37.786 27.5482 38.5714 26.5803 38.5714C20.0198 38.5714 14.7015 33.2486 14.7015 26.6826C14.7015 25.7138 15.4862 24.9285 16.4541 24.9285C17.4221 24.9285 18.2067 25.7138 18.2067 26.6826C18.2067 31.3111 21.9557 35.0632 26.5803 35.0632C27.5482 35.0632 28.3329 35.8485 28.3329 36.8173Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M9.40112 26.6827C9.40112 27.6514 10.1858 28.4368 11.1537 28.4368C12.1217 28.4368 12.9064 27.6514 12.9064 26.6827C12.9064 22.0542 16.6553 18.3021 21.2799 18.3021C22.2478 18.3021 23.0325 17.5167 23.0325 16.548C23.0325 15.5792 22.2478 14.7939 21.2799 14.7939C14.7194 14.7939 9.40112 20.1167 9.40112 26.6827Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
aria-label="Element Call"
|
||||
fill="none"
|
||||
height="11"
|
||||
viewBox="0 0 160 22"
|
||||
width="80"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
id="Logo Type"
|
||||
>
|
||||
<g
|
||||
id="Vector"
|
||||
>
|
||||
<path
|
||||
d="M14.8673 15.1575H3.39742C3.53293 16.3508 3.96849 17.3036 4.70411 18.0157C5.43974 18.7087 6.40766 19.0551 7.60789 19.0551C8.40159 19.0551 9.11785 18.8626 9.75668 18.4777C10.3955 18.0927 10.8504 17.5731 11.1215 16.9186H14.606C14.1414 18.4392 13.2702 19.671 11.9926 20.6142C10.7343 21.5381 9.24368 22 7.52078 22C5.27519 22 3.45549 21.259 2.06168 19.7769C0.687227 18.2948 0 16.4182 0 14.147C0 11.9335 0.696906 10.0761 2.09072 8.5748C3.48453 7.07349 5.28487 6.32283 7.49174 6.32283C9.69861 6.32283 11.4796 7.06387 12.8347 8.54593C14.2091 10.0087 14.8964 11.8565 14.8964 14.0892L14.8673 15.1575ZM7.49174 9.12336C6.40766 9.12336 5.50749 9.44095 4.79123 10.0761C4.07496 10.7113 3.62972 11.5582 3.45549 12.6168H11.4699C11.315 11.5582 10.8892 10.7113 10.1922 10.0761C9.49534 9.44095 8.59517 9.12336 7.49174 9.12336Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.2743 17.1785V0H20.7298V17.2362C20.7298 18.0061 21.1557 18.3911 22.0074 18.3911L22.6172 18.3622V21.6247C22.2881 21.6824 21.9397 21.7113 21.5719 21.7113C20.0813 21.7113 18.9875 21.336 18.2906 20.5853C17.6131 19.8346 17.2743 18.699 17.2743 17.1785Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M38.71 15.1575H27.2401C27.3756 16.3508 27.8112 17.3036 28.5468 18.0157C29.2824 18.7087 30.2504 19.0551 31.4506 19.0551C32.2443 19.0551 32.9606 18.8626 33.5994 18.4777C34.2382 18.0927 34.6931 17.5731 34.9642 16.9186H38.4487C37.9841 18.4392 37.113 19.671 35.8353 20.6142C34.577 21.5381 33.0864 22 31.3635 22C29.1179 22 27.2982 21.259 25.9044 19.7769C24.5299 18.2948 23.8427 16.4182 23.8427 14.147C23.8427 11.9335 24.5396 10.0761 25.9334 8.5748C27.3272 7.07349 29.1276 6.32283 31.3344 6.32283C33.5413 6.32283 35.3223 7.06387 36.6774 8.54593C38.0518 10.0087 38.7391 11.8565 38.7391 14.0892L38.71 15.1575ZM31.3344 9.12336C30.2504 9.12336 29.3502 9.44095 28.6339 10.0761C27.9177 10.7113 27.4724 11.5582 27.2982 12.6168H35.3126C35.1577 11.5582 34.7319 10.7113 34.035 10.0761C33.3381 9.44095 32.4379 9.12336 31.3344 9.12336Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M54.3001 13.0499V21.6535H50.8446V12.6745C50.8446 10.4033 49.8961 9.26772 47.9989 9.26772C46.9729 9.26772 46.1502 9.59493 45.5307 10.2493C44.9306 10.9038 44.6306 11.7988 44.6306 12.9344V21.6535H41.1751V6.66929H44.3692V8.66142C44.737 7.98775 45.2984 7.42957 46.0534 6.98688C46.8084 6.54418 47.7473 6.32283 48.8701 6.32283C50.9608 6.32283 52.4707 7.11199 53.4 8.69029C54.6776 7.11199 56.3812 6.32283 58.5106 6.32283C60.2722 6.32283 61.6273 6.87139 62.5759 7.9685C63.5244 9.04637 63.9987 10.4707 63.9987 12.2415V21.6535H60.5432V12.6745C60.5432 10.4033 59.5947 9.26772 57.6975 9.26772C56.6522 9.26772 55.8198 9.60455 55.2003 10.2782C54.6002 10.9326 54.3001 11.8565 54.3001 13.0499Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M81.1834 15.1575H69.7135C69.849 16.3508 70.2846 17.3036 71.0202 18.0157C71.7558 18.7087 72.7237 19.0551 73.924 19.0551C74.7177 19.0551 75.4339 18.8626 76.0728 18.4777C76.7116 18.0927 77.1665 17.5731 77.4375 16.9186H80.9221C80.4575 18.4392 79.5863 19.671 78.3087 20.6142C77.0504 21.5381 75.5598 22 73.8369 22C71.5913 22 69.7716 21.259 68.3778 19.7769C67.0033 18.2948 66.3161 16.4182 66.3161 14.147C66.3161 11.9335 67.013 10.0761 68.4068 8.5748C69.8006 7.07349 71.601 6.32283 73.8078 6.32283C76.0147 6.32283 77.7957 7.06387 79.1508 8.54593C80.5252 10.0087 81.2124 11.8565 81.2124 14.0892L81.1834 15.1575ZM73.8078 9.12336C72.7237 9.12336 71.8236 9.44095 71.1073 10.0761C70.391 10.7113 69.9458 11.5582 69.7716 12.6168H77.786C77.6311 11.5582 77.2052 10.7113 76.5083 10.0761C75.8114 9.44095 74.9113 9.12336 73.8078 9.12336Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M86.8426 6.66929V8.66142C87.191 8.007 87.7621 7.45844 88.5558 7.01575C89.3689 6.55381 90.3465 6.32283 91.4886 6.32283C93.2696 6.32283 94.6441 6.86177 95.612 7.93963C96.5993 9.0175 97.0929 10.4514 97.0929 12.2415V21.6535H93.6374V12.6745C93.6374 11.6159 93.3858 10.7883 92.8824 10.1916C92.3985 9.57568 91.6532 9.26772 90.6465 9.26772C89.5431 9.26772 88.672 9.59493 88.0331 10.2493C87.4137 10.9038 87.1039 11.8084 87.1039 12.9633V21.6535H83.6484V6.66929H86.8426Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M107.185 18.5932V21.5669C106.759 21.6824 106.159 21.7402 105.384 21.7402C102.442 21.7402 100.971 20.2677 100.971 17.3228V9.41208H98.6766V6.66929H100.971V2.77165H104.426V6.66929H107.243V9.41208H104.426V16.9764C104.426 18.1505 104.987 18.7375 106.11 18.7375L107.185 18.5932Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M116.115 18.9881C114.474 17.2035 113.653 14.9429 113.653 12.2064C113.653 9.4699 114.474 7.21782 116.115 5.45015C117.773 3.66548 119.953 2.77314 122.654 2.77314C124.876 2.77314 126.756 3.38503 128.295 4.6088C129.833 5.83258 130.816 7.47277 131.244 9.52939H129.269C128.91 7.99967 128.132 6.7844 126.936 5.88357C125.739 4.98273 124.312 4.53232 122.654 4.53232C120.534 4.53232 118.824 5.23769 117.525 6.64842C116.243 8.05916 115.602 9.91182 115.602 12.2064C115.602 14.501 116.243 16.3536 117.525 17.7644C118.824 19.1751 120.534 19.8805 122.654 19.8805C124.312 19.8805 125.739 19.4301 126.936 18.5292C128.132 17.6284 128.91 16.4131 129.269 14.8834H131.244C130.816 16.94 129.833 18.5802 128.295 19.804C126.756 21.0278 124.876 21.6397 122.654 21.6397C119.953 21.6397 117.773 20.7558 116.115 18.9881Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M143.174 15.0874C140.832 15.0874 139.233 15.1384 138.379 15.2403C137.541 15.3253 136.926 15.4698 136.532 15.6738C135.831 16.0647 135.481 16.6936 135.481 17.5604C135.481 19.2261 136.473 20.0589 138.456 20.0589C139.977 20.0589 141.139 19.719 141.943 19.0391C142.763 18.3593 143.174 17.4499 143.174 16.3111V15.0874ZM138.25 21.5632C136.763 21.5632 135.626 21.2062 134.84 20.4924C134.071 19.7615 133.686 18.8012 133.686 17.6114C133.686 16.8295 133.891 16.1327 134.301 15.5208C134.729 14.9089 135.31 14.4585 136.045 14.1695C136.661 13.9316 137.455 13.7786 138.43 13.7106C139.404 13.6256 140.986 13.5831 143.174 13.5831V12.7418C143.174 10.6002 141.943 9.52939 139.481 9.52939C137.361 9.52939 136.131 10.3877 135.789 12.1044H134.019C134.207 10.8466 134.746 9.84383 135.635 9.09597C136.541 8.34811 137.849 7.97418 139.558 7.97418C141.387 7.97418 142.746 8.3991 143.635 9.24894C144.541 10.0988 144.994 11.2716 144.994 12.7673V21.2572H143.251V19.3706C142.345 20.8323 140.678 21.5632 138.25 21.5632Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M149.358 18.4018V2.13576H151.178V18.1978C151.178 18.7247 151.264 19.0901 151.435 19.2941C151.623 19.498 151.956 19.6 152.435 19.6L152.948 19.549V21.2062C152.657 21.2572 152.341 21.2827 151.999 21.2827C150.238 21.2827 149.358 20.3224 149.358 18.4018Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M155.944 18.4018V2.13576H157.764V18.1978C157.764 18.7247 157.85 19.0901 158.021 19.2941C158.209 19.498 158.542 19.6 159.021 19.6L159.534 19.549V21.2062C159.243 21.2572 158.927 21.2827 158.585 21.2827C156.824 21.2827 155.944 20.3224 155.944 18.4018Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="buttons"
|
||||
>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby=":r0:"
|
||||
aria-labelledby="«r5»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -69,7 +264,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-disabled="false"
|
||||
aria-labelledby=":r5:"
|
||||
aria-labelledby="«ra»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -91,7 +286,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby=":ra:"
|
||||
aria-labelledby="«rf»"
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
|
||||
data-kind="secondary"
|
||||
data-size="lg"
|
||||
@@ -112,7 +307,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
aria-labelledby=":rf:"
|
||||
aria-labelledby="«rk»"
|
||||
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
|
||||
data-kind="primary"
|
||||
data-size="lg"
|
||||
@@ -138,7 +333,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
class="toggle layout"
|
||||
>
|
||||
<input
|
||||
aria-labelledby=":rk:"
|
||||
aria-labelledby="«rp»"
|
||||
name="layout"
|
||||
type="radio"
|
||||
value="spotlight"
|
||||
@@ -156,7 +351,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
aria-labelledby=":rp:"
|
||||
aria-labelledby="«ru»"
|
||||
checked=""
|
||||
name="layout"
|
||||
type="radio"
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { deepCompare } from "matrix-js-sdk/lib/utils";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import { useTypedEventEmitterState } from "../useEvents";
|
||||
|
||||
/**
|
||||
* Gets the currently active (livekit) focus for a MatrixRTC session
|
||||
* This logic is specific to livekit foci where the whole call must use one
|
||||
@@ -22,38 +24,22 @@ import { type LivekitFocus, isLivekitFocus } from "matrix-js-sdk/lib/matrixrtc";
|
||||
export function useActiveLivekitFocus(
|
||||
rtcSession: MatrixRTCSession,
|
||||
): LivekitFocus | undefined {
|
||||
const [activeFocus, setActiveFocus] = useState(() => {
|
||||
const f = rtcSession.getActiveFocus();
|
||||
// Only handle foci with type="livekit" for now.
|
||||
return !!f && isLivekitFocus(f) ? f : undefined;
|
||||
});
|
||||
|
||||
const onMembershipsChanged = useCallback(() => {
|
||||
const newActiveFocus = rtcSession.getActiveFocus();
|
||||
if (!!newActiveFocus && !isLivekitFocus(newActiveFocus)) return;
|
||||
if (!deepCompare(activeFocus, newActiveFocus)) {
|
||||
const oldestMembership = rtcSession.getOldestMembership();
|
||||
logger.warn(
|
||||
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
|
||||
Updating focus (focus switch) from ${JSON.stringify(activeFocus)} to ${JSON.stringify(newActiveFocus)}`,
|
||||
);
|
||||
setActiveFocus(newActiveFocus);
|
||||
}
|
||||
}, [activeFocus, rtcSession]);
|
||||
|
||||
useEffect(() => {
|
||||
rtcSession.on(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
rtcSession.off(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
return activeFocus;
|
||||
const prevActiveFocus = useRef<LivekitFocus | undefined>(undefined);
|
||||
return useTypedEventEmitterState(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
useCallback(() => {
|
||||
const f = rtcSession.getActiveFocus();
|
||||
// Only handle foci with type="livekit" for now.
|
||||
if (f && isLivekitFocus(f) && !deepCompare(f, prevActiveFocus.current)) {
|
||||
const oldestMembership = rtcSession.getOldestMembership();
|
||||
logger.info(
|
||||
`Got new active focus from membership: ${oldestMembership?.sender}/${oldestMembership?.deviceId}.
|
||||
Updated focus (focus switch) from ${JSON.stringify(prevActiveFocus.current)} to ${JSON.stringify(f)}`,
|
||||
);
|
||||
prevActiveFocus.current = f;
|
||||
}
|
||||
return prevActiveFocus.current;
|
||||
}, [rtcSession]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/*
|
||||
Copyright 2023, 2024 New Vector Ltd.
|
||||
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 Room, RoomEvent } from "matrix-js-sdk";
|
||||
import { useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
import { useTypedEventEmitterState } from "../useEvents";
|
||||
|
||||
export function useRoomName(room: Room): string {
|
||||
const [, setNumUpdates] = useState(0);
|
||||
// Whenever the name changes, force an update
|
||||
useTypedEventEmitter(room, RoomEvent.Name, () => setNumUpdates((n) => n + 1));
|
||||
return room.name;
|
||||
return useTypedEventEmitterState(
|
||||
room,
|
||||
RoomEvent.Name,
|
||||
useCallback(() => room.name, [room]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,10 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { type RoomState, RoomStateEvent, type Room } from "matrix-js-sdk";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
type RoomState,
|
||||
RoomStateEvent,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
import { useTypedEventEmitter } from "../useEvents";
|
||||
import { useTypedEventEmitterState } from "../useEvents";
|
||||
|
||||
/**
|
||||
* A React hook for values computed from room state.
|
||||
@@ -16,14 +21,17 @@ import { useTypedEventEmitter } from "../useEvents";
|
||||
* @param f A mapping from the current room state to the computed value.
|
||||
* @returns The computed value.
|
||||
*/
|
||||
export const useRoomState = <T>(room: Room, f: (state: RoomState) => T): T => {
|
||||
const [numUpdates, setNumUpdates] = useState(0);
|
||||
useTypedEventEmitter(
|
||||
export function useRoomState<T>(room: Room, f: (state: RoomState) => T): T {
|
||||
// TODO: matrix-js-sdk says that Room.currentState is deprecated, but it's not
|
||||
// clear how to reactively track the current state of the room without it
|
||||
const currentState = useTypedEventEmitterState(
|
||||
room,
|
||||
RoomStateEvent.Update,
|
||||
useCallback(() => setNumUpdates((n) => n + 1), [setNumUpdates]),
|
||||
RoomEvent.CurrentStateUpdated,
|
||||
useCallback(() => room.currentState, [room]),
|
||||
);
|
||||
// We want any change to the update counter to trigger an update here
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return useMemo(() => f(room.currentState), [room, f, numUpdates]);
|
||||
};
|
||||
return useTypedEventEmitterState(
|
||||
currentState,
|
||||
RoomStateEvent.Update,
|
||||
useCallback(() => f(currentState), [f, currentState]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
import { useObservable, useObservableEagerState } from "observable-hooks";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { useMediaDevices } from "../livekit/MediaDevicesContext";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { platform } from "../Platform";
|
||||
import { useLatest } from "../useLatest";
|
||||
|
||||
|
||||
@@ -121,10 +121,18 @@ export async function enterRTCSession(
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
}),
|
||||
delayedLeaveEventRestartMs:
|
||||
matrixRtcSessionConfig?.delayed_leave_event_restart_ms ??
|
||||
matrixRtcSessionConfig?.membership_keep_alive_period,
|
||||
delayedLeaveEventDelayMs:
|
||||
matrixRtcSessionConfig?.delayed_leave_event_delay_ms ??
|
||||
matrixRtcSessionConfig?.membership_server_side_expiry_timeout,
|
||||
networkErrorRetryMs: matrixRtcSessionConfig?.membership_keep_alive_period,
|
||||
makeKeyDelay: matrixRtcSessionConfig?.key_rotation_on_leave_delay,
|
||||
networkErrorRetryMs: matrixRtcSessionConfig?.network_error_retry_ms,
|
||||
makeKeyDelay:
|
||||
matrixRtcSessionConfig?.wait_for_key_rotation_ms ??
|
||||
matrixRtcSessionConfig?.key_rotation_on_leave_delay,
|
||||
membershipEventExpiryMs:
|
||||
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
||||
useExperimentalToDeviceTransport,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,15 +21,18 @@ import {
|
||||
Separator,
|
||||
} from "@vector-im/compound-web";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import {
|
||||
EARPIECE_CONFIG_ID,
|
||||
type MediaDeviceHandle,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
type AudioOutputDeviceLabel,
|
||||
type DeviceLabel,
|
||||
type SelectedDevice,
|
||||
type MediaDevice,
|
||||
} from "../state/MediaDevices";
|
||||
import styles from "./DeviceSelection.module.css";
|
||||
|
||||
interface Props {
|
||||
device: MediaDeviceHandle;
|
||||
device: MediaDevice<DeviceLabel | AudioOutputDeviceLabel, SelectedDevice>;
|
||||
title: string;
|
||||
numberedLabel: (number: number) => string;
|
||||
}
|
||||
@@ -41,6 +44,8 @@ export const DeviceSelection: FC<Props> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const groupId = useId();
|
||||
const available = useObservableEagerState(device.available$);
|
||||
const selectedId = useObservableEagerState(device.selected$)?.id;
|
||||
const onChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
device.select(e.target.value);
|
||||
@@ -49,7 +54,7 @@ export const DeviceSelection: FC<Props> = ({
|
||||
);
|
||||
|
||||
// There is no need to show the menu if there is no choice that can be made.
|
||||
if (device.available.size <= 1) return null;
|
||||
if (available.size <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.selection}>
|
||||
@@ -64,7 +69,7 @@ export const DeviceSelection: FC<Props> = ({
|
||||
</Heading>
|
||||
<Separator className={styles.separator} />
|
||||
<div className={styles.options}>
|
||||
{[...device.available].map(([id, label]) => {
|
||||
{[...available].map(([id, label]) => {
|
||||
let labelText: ReactNode;
|
||||
switch (label.type) {
|
||||
case "name":
|
||||
@@ -89,25 +94,21 @@ export const DeviceSelection: FC<Props> = ({
|
||||
</Trans>
|
||||
);
|
||||
break;
|
||||
case "speaker":
|
||||
labelText = t("settings.devices.loudspeaker");
|
||||
break;
|
||||
case "earpiece":
|
||||
labelText = t("settings.devices.earpiece");
|
||||
break;
|
||||
}
|
||||
|
||||
let isSelected = false;
|
||||
if (device.useAsEarpiece) {
|
||||
isSelected = id === EARPIECE_CONFIG_ID;
|
||||
} else {
|
||||
isSelected = id === device.selectedId;
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineField
|
||||
key={id}
|
||||
name={groupId}
|
||||
control={
|
||||
<RadioControl
|
||||
checked={isSelected}
|
||||
checked={id === selectedId}
|
||||
onChange={onChange}
|
||||
value={id}
|
||||
/>
|
||||
|
||||
@@ -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 FC, type ReactNode, useState } from "react";
|
||||
import { type FC, type ReactNode, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { Button, Root as Form, Separator } from "@vector-im/compound-web";
|
||||
@@ -17,11 +17,8 @@ import styles from "./SettingsModal.module.css";
|
||||
import { type Tab, TabContainer } from "../tabs/Tabs";
|
||||
import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||
import {
|
||||
useMediaDevices,
|
||||
useMediaDeviceNames,
|
||||
iosDeviceMenu$,
|
||||
} from "../livekit/MediaDevicesContext";
|
||||
import { iosDeviceMenu$ } from "../state/MediaDevices";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { widget } from "../widget";
|
||||
import {
|
||||
useSetting,
|
||||
@@ -98,7 +95,10 @@ export const SettingsModal: FC<Props> = ({
|
||||
};
|
||||
|
||||
const devices = useMediaDevices();
|
||||
useMediaDeviceNames(devices, open);
|
||||
useEffect(() => {
|
||||
if (open) devices.requestDeviceNames();
|
||||
}, [open, devices]);
|
||||
|
||||
const [soundVolume, setSoundVolume] = useSetting(soundEffectVolumeSetting);
|
||||
const [soundVolumeRaw, setSoundVolumeRaw] = useState(soundVolume);
|
||||
const [showDeveloperSettingsTab] = useSetting(developerMode);
|
||||
|
||||
19
src/state/AppViewModel.ts
Normal file
19
src/state/AppViewModel.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { ViewModel } from "./ViewModel";
|
||||
|
||||
/**
|
||||
* The top-level state holder for the application.
|
||||
*/
|
||||
export class AppViewModel extends ViewModel {
|
||||
public readonly mediaDevices = new MediaDevices(this.scope);
|
||||
|
||||
// TODO: Move more application logic here. The CallViewModel, at the very
|
||||
// least, ought to be accessible from this object.
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
withTestScheduler,
|
||||
mockRtcMembership,
|
||||
MockRTCSession,
|
||||
mockMediaDevices,
|
||||
} from "../utils/test";
|
||||
import {
|
||||
ECAddonConnectionState,
|
||||
@@ -71,6 +72,12 @@ import {
|
||||
localId,
|
||||
localRtcMember,
|
||||
} from "../utils/test-fixtures";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { getValue } from "../utils/observable";
|
||||
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||
|
||||
vi.mock("@livekit/components-core");
|
||||
|
||||
@@ -209,6 +216,7 @@ function withCallViewModel(
|
||||
rtcMembers$: Observable<Partial<CallMembership>[]>,
|
||||
connectionState$: Observable<ECConnectionState>,
|
||||
speaking: Map<Participant, Observable<boolean>>,
|
||||
mediaDevices: MediaDevices,
|
||||
continuation: (
|
||||
vm: CallViewModel,
|
||||
subjects: { raisedHands$: BehaviorSubject<Record<string, RaisedHandInfo>> },
|
||||
@@ -262,6 +270,7 @@ function withCallViewModel(
|
||||
const vm = new CallViewModel(
|
||||
rtcSession as unknown as MatrixRTCSession,
|
||||
liveKitRoom,
|
||||
mediaDevices,
|
||||
{
|
||||
kind: E2eeType.PER_PARTICIPANT,
|
||||
},
|
||||
@@ -301,6 +310,7 @@ test("participants are retained during a focus switch", () => {
|
||||
s: ECAddonConnectionState.ECSwitchingFocus,
|
||||
}),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
expectedLayoutMarbles,
|
||||
@@ -340,6 +350,7 @@ test("screen sharing activates spotlight layout", () => {
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.setGridMode("spotlight"),
|
||||
@@ -423,6 +434,7 @@ test("participants stay in the same order unless to appear/disappear", () => {
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
schedule(visibilityInputMarbles, {
|
||||
a: () => {
|
||||
@@ -479,6 +491,7 @@ test("participants adjust order when space becomes constrained", () => {
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
let setVisibleTiles: ((value: number) => void) | null = null;
|
||||
vm.layout$.subscribe((layout) => {
|
||||
@@ -532,6 +545,7 @@ test("spotlight speakers swap places", () => {
|
||||
[bobParticipant, hot(bSpeakingInputMarbles, { y: true, n: false })],
|
||||
[daveParticipant, hot(dSpeakingInputMarbles, { y: true, n: false })],
|
||||
]),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
schedule(modeInputMarbles, { s: () => vm.setGridMode("spotlight") });
|
||||
|
||||
@@ -577,6 +591,7 @@ test("layout enters picture-in-picture mode when requested", () => {
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
schedule(pipControlInputMarbles, {
|
||||
e: () => window.controls.enablePip(),
|
||||
@@ -618,6 +633,7 @@ test("spotlight remembers whether it's expanded", () => {
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
schedule(modeInputMarbles, {
|
||||
s: () => vm.setGridMode("spotlight"),
|
||||
@@ -686,6 +702,7 @@ test("participants must have a MatrixRTCSession to be visible", () => {
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
vm.setGridMode("grid");
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
@@ -730,6 +747,7 @@ test("shows participants without MatrixRTCSession when enabled in settings", ()
|
||||
of([]), // No one joins the MatrixRTC session
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
vm.setGridMode("grid");
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
@@ -777,6 +795,7 @@ it("should show at least one tile per MatrixRTCSession", () => {
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
vm.setGridMode("grid");
|
||||
expectObservable(summarizeLayout$(vm.layout$)).toBe(
|
||||
@@ -825,6 +844,7 @@ test("should disambiguate users with the same displayname", () => {
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
@@ -875,6 +895,7 @@ test("should disambiguate users with invisible characters", () => {
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
@@ -911,6 +932,7 @@ test("should strip RTL characters from displayname", () => {
|
||||
}),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm) => {
|
||||
// Skip the null state.
|
||||
expectObservable(vm.memberDisplaynames$.pipe(skip(1))).toBe(
|
||||
@@ -943,6 +965,7 @@ it("should rank raised hands above video feeds and below speakers and presenters
|
||||
of([aliceRtcMember, bobRtcMember]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
mockMediaDevices({}),
|
||||
(vm, { raisedHands$ }) => {
|
||||
schedule("ab", {
|
||||
a: () => {
|
||||
@@ -991,3 +1014,47 @@ it("should rank raised hands above video feeds and below speakers and presenters
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("audio output changes when toggling earpiece mode", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(of([]));
|
||||
|
||||
const scope = new ObservableScope();
|
||||
onTestFinished(() => scope.end());
|
||||
const devices = new MediaDevices(scope);
|
||||
|
||||
window.controls.setAvailableAudioDevices([
|
||||
{ id: "speaker", name: "Speaker", isSpeaker: true },
|
||||
{ id: "earpiece", name: "Earpiece", isEarpiece: true },
|
||||
{ id: "headphones", name: "Headphones" },
|
||||
]);
|
||||
window.controls.setAudioDevice("headphones");
|
||||
|
||||
const toggleInputMarbles = " -aaa";
|
||||
const expectedEarpieceModeMarbles = "n-yn";
|
||||
const expectedTargetStateMarbles = " sese";
|
||||
|
||||
withCallViewModel(
|
||||
of([]),
|
||||
of([]),
|
||||
of(ConnectionState.Connected),
|
||||
new Map(),
|
||||
devices,
|
||||
(vm) => {
|
||||
schedule(toggleInputMarbles, {
|
||||
a: () => getValue(vm.audioOutputSwitcher$)?.switch(),
|
||||
});
|
||||
expectObservable(vm.earpieceMode$).toBe(expectedEarpieceModeMarbles, {
|
||||
n: false,
|
||||
y: true,
|
||||
});
|
||||
expectObservable(
|
||||
vm.audioOutputSwitcher$.pipe(
|
||||
map((switcher) => switcher?.targetOutput),
|
||||
),
|
||||
).toBe(expectedTargetStateMarbles, { s: "speaker", e: "earpiece" });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
import { observeSpeaker$ } from "./observeSpeaker";
|
||||
import { shallowEquals } from "../utils/array";
|
||||
import { calculateDisplayName, shouldDisambiguate } from "../utils/displayname";
|
||||
import { type MediaDevices } from "./MediaDevices";
|
||||
|
||||
// How long we wait after a focus switch before showing the real participant
|
||||
// list again
|
||||
@@ -1246,6 +1247,51 @@ export class CallViewModel extends ViewModel {
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether audio is currently being output through the earpiece.
|
||||
*/
|
||||
public readonly earpieceMode$: Observable<boolean> = combineLatest(
|
||||
[
|
||||
this.mediaDevices.audioOutput.available$,
|
||||
this.mediaDevices.audioOutput.selected$,
|
||||
],
|
||||
(available, selected) =>
|
||||
selected !== undefined && available.get(selected.id)?.type === "earpiece",
|
||||
).pipe(this.scope.state());
|
||||
|
||||
/**
|
||||
* Callback to toggle between the earpiece and the loudspeaker.
|
||||
*
|
||||
* This will be `null` in case the target does not exist in the list
|
||||
* of available audio outputs.
|
||||
*/
|
||||
public readonly audioOutputSwitcher$: Observable<{
|
||||
targetOutput: "earpiece" | "speaker";
|
||||
switch: () => void;
|
||||
} | null> = combineLatest(
|
||||
[
|
||||
this.mediaDevices.audioOutput.available$,
|
||||
this.mediaDevices.audioOutput.selected$,
|
||||
],
|
||||
(available, selected) => {
|
||||
const selectionType = selected && available.get(selected.id)?.type;
|
||||
|
||||
// If we are in any output mode other than spaeker switch to speaker.
|
||||
const newSelectionType =
|
||||
selectionType === "speaker" ? "earpiece" : "speaker";
|
||||
const newSelection = [...available].find(
|
||||
([, d]) => d.type === newSelectionType,
|
||||
);
|
||||
if (newSelection === undefined) return null;
|
||||
|
||||
const [id] = newSelection;
|
||||
return {
|
||||
targetOutput: newSelectionType,
|
||||
switch: () => this.mediaDevices.audioOutput.select(id),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
public readonly reactions$ = this.reactionsSubject$.pipe(
|
||||
map((v) =>
|
||||
Object.fromEntries(
|
||||
@@ -1336,6 +1382,7 @@ export class CallViewModel extends ViewModel {
|
||||
// A call is permanently tied to a single Matrix room and LiveKit room
|
||||
private readonly matrixRTCSession: MatrixRTCSession,
|
||||
private readonly livekitRoom: LivekitRoom,
|
||||
private readonly mediaDevices: MediaDevices,
|
||||
private readonly encryptionSystem: EncryptionSystem,
|
||||
private readonly connectionState$: Observable<ECConnectionState>,
|
||||
private readonly handsRaisedSubject$: Observable<
|
||||
|
||||
403
src/state/MediaDevices.ts
Normal file
403
src/state/MediaDevices.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
map,
|
||||
merge,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
type Observable,
|
||||
} from "rxjs";
|
||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
audioInput as audioInputSetting,
|
||||
audioOutput as audioOutputSetting,
|
||||
videoInput as videoInputSetting,
|
||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||
} from "../settings/settings";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
import {
|
||||
outputDevice$ as controlledOutputSelection$,
|
||||
availableOutputDevices$ as controlledAvailableOutputDevices$,
|
||||
} from "../controls";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { switchWhen } from "../utils/observable";
|
||||
|
||||
// This hardcoded id is used in EX ios! It can only be changed in coordination with
|
||||
// the ios swift team.
|
||||
const EARPIECE_CONFIG_ID = "earpiece-id";
|
||||
const logger = rootLogger.getChild("[MediaDevices]");
|
||||
|
||||
export type DeviceLabel =
|
||||
| { type: "name"; name: string }
|
||||
| { type: "number"; number: number };
|
||||
|
||||
export type AudioOutputDeviceLabel =
|
||||
| DeviceLabel
|
||||
| { type: "speaker" }
|
||||
| { type: "earpiece" }
|
||||
| { type: "default"; name: string | null };
|
||||
|
||||
export interface SelectedDevice {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SelectedAudioInputDevice extends SelectedDevice {
|
||||
/**
|
||||
* Emits whenever we think that this audio input device has logically changed
|
||||
* to refer to a different hardware device.
|
||||
*/
|
||||
hardwareDeviceChange$: Observable<void>;
|
||||
}
|
||||
|
||||
export interface SelectedAudioOutputDevice extends SelectedDevice {
|
||||
/**
|
||||
* Whether this device is a "virtual earpiece" device. If so, we should output
|
||||
* on a single channel of the device at a reduced volume.
|
||||
*/
|
||||
virtualEarpiece: boolean;
|
||||
}
|
||||
|
||||
export interface MediaDevice<Label, Selected> {
|
||||
/**
|
||||
* A map from available device IDs to labels.
|
||||
*/
|
||||
available$: Observable<Map<string, Label>>;
|
||||
/**
|
||||
* The selected device.
|
||||
*/
|
||||
selected$: Observable<Selected | undefined>;
|
||||
/**
|
||||
* Selects a new device.
|
||||
*/
|
||||
select(id: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An observable that represents if we should display the devices menu for iOS.
|
||||
* This implies the following
|
||||
* - hide any input devices (they do not work anyhow on ios)
|
||||
* - Show a button to show the native output picker instead.
|
||||
* - Only show the earpiece toggle option if the earpiece is available:
|
||||
* `availableOutputDevices$.includes((d)=>d.forEarpiece)`
|
||||
*/
|
||||
export const iosDeviceMenu$ =
|
||||
platform === "ios" ? of(true) : alwaysShowIphoneEarpieceSetting.value$;
|
||||
|
||||
function availableRawDevices$(
|
||||
kind: MediaDeviceKind,
|
||||
usingNames$: Observable<boolean>,
|
||||
scope: ObservableScope,
|
||||
): Observable<MediaDeviceInfo[]> {
|
||||
const logError = (e: Error): void =>
|
||||
logger.error("Error creating MediaDeviceObserver", e);
|
||||
const devices$ = createMediaDeviceObserver(kind, logError, false);
|
||||
const devicesWithNames$ = createMediaDeviceObserver(kind, logError, true);
|
||||
|
||||
return usingNames$.pipe(
|
||||
switchMap((withNames) =>
|
||||
withNames
|
||||
? // It might be that there is already a media stream running somewhere,
|
||||
// and so we can do without requesting a second one. Only switch to the
|
||||
// device observer that explicitly requests the names if we see that
|
||||
// names are in fact missing from the initial device enumeration.
|
||||
devices$.pipe(
|
||||
switchWhen(
|
||||
(devices, i) => i === 0 && devices.every((d) => !d.label),
|
||||
devicesWithNames$,
|
||||
),
|
||||
)
|
||||
: devices$,
|
||||
),
|
||||
startWith([]),
|
||||
scope.state(),
|
||||
);
|
||||
}
|
||||
|
||||
function buildDeviceMap(
|
||||
availableRaw: MediaDeviceInfo[],
|
||||
): Map<string, DeviceLabel> {
|
||||
return new Map<string, DeviceLabel>(
|
||||
availableRaw.map((d, i) => [
|
||||
d.deviceId,
|
||||
d.label
|
||||
? { type: "name", name: d.label }
|
||||
: { type: "number", number: i + 1 },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function selectDevice$<Label>(
|
||||
available$: Observable<Map<string, Label>>,
|
||||
preferredId$: Observable<string | undefined>,
|
||||
): Observable<string | undefined> {
|
||||
return combineLatest([available$, preferredId$], (available, preferredId) => {
|
||||
if (available.size) {
|
||||
// If the preferred device is available, use it. Or if every available
|
||||
// device ID is falsy, the browser is probably just being paranoid about
|
||||
// fingerprinting and we should still try using the preferred device.
|
||||
// Worst case it is not available and the browser will gracefully fall
|
||||
// back to some other device for us when requesting the media stream.
|
||||
// Otherwise, select the first available device.
|
||||
return (preferredId !== undefined && available.has(preferredId)) ||
|
||||
(available.size === 1 && available.has(""))
|
||||
? preferredId
|
||||
: available.keys().next().value;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
class AudioInput implements MediaDevice<DeviceLabel, SelectedAudioInputDevice> {
|
||||
private readonly availableRaw$: Observable<MediaDeviceInfo[]> =
|
||||
availableRawDevices$("audioinput", this.usingNames$, this.scope);
|
||||
|
||||
public readonly available$ = this.availableRaw$.pipe(
|
||||
map(buildDeviceMap),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
// We can identify when the hardware device has changed by watching for
|
||||
// changes in the group ID
|
||||
hardwareDeviceChange$: this.availableRaw$.pipe(
|
||||
map((devices) => devices.find((d) => d.deviceId === id)?.groupId),
|
||||
pairwise(),
|
||||
filter(([before, after]) => before !== after),
|
||||
map(() => undefined),
|
||||
),
|
||||
},
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
audioInputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[audio-input] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class AudioOutput
|
||||
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
|
||||
{
|
||||
public readonly available$ = availableRawDevices$(
|
||||
"audiooutput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(
|
||||
map((availableRaw) => {
|
||||
const available: Map<string, AudioOutputDeviceLabel> =
|
||||
buildDeviceMap(availableRaw);
|
||||
// Create a virtual default audio output for browsers that don't have one.
|
||||
// Its device ID must be the empty string because that's what setSinkId
|
||||
// recognizes.
|
||||
if (available.size && !available.has("") && !available.has("default"))
|
||||
available.set("", {
|
||||
type: "default",
|
||||
name: availableRaw[0]?.label || null,
|
||||
});
|
||||
// Note: creating virtual default input devices would be another problem
|
||||
// entirely, because requesting a media stream from deviceId "" won't
|
||||
// automatically track the default device.
|
||||
return available;
|
||||
}),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
audioOutputSetting.value$,
|
||||
).pipe(
|
||||
map((id) =>
|
||||
id === undefined
|
||||
? undefined
|
||||
: {
|
||||
id,
|
||||
virtualEarpiece: false,
|
||||
},
|
||||
),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
audioOutputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[audio-output] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class ControlledAudioOutput
|
||||
implements MediaDevice<AudioOutputDeviceLabel, SelectedAudioOutputDevice>
|
||||
{
|
||||
public readonly available$ = combineLatest(
|
||||
[controlledAvailableOutputDevices$.pipe(startWith([])), iosDeviceMenu$],
|
||||
(availableRaw, iosDeviceMenu) => {
|
||||
const available = new Map<string, AudioOutputDeviceLabel>(
|
||||
availableRaw.map(
|
||||
({ id, name, isEarpiece, isSpeaker /*,isExternalHeadset*/ }) => {
|
||||
let deviceLabel: AudioOutputDeviceLabel;
|
||||
// if (isExternalHeadset) // Do we want this?
|
||||
if (isEarpiece) deviceLabel = { type: "earpiece" };
|
||||
else if (isSpeaker) deviceLabel = { type: "speaker" };
|
||||
else deviceLabel = { type: "name", name };
|
||||
return [id, deviceLabel];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Create a virtual earpiece device in case a non-earpiece device is
|
||||
// designated for this purpose
|
||||
if (iosDeviceMenu && availableRaw.some((d) => d.forEarpiece))
|
||||
available.set(EARPIECE_CONFIG_ID, { type: "earpiece" });
|
||||
|
||||
return available;
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
|
||||
private readonly deviceSelection$ = new Subject<string>();
|
||||
|
||||
public select(id: string): void {
|
||||
this.deviceSelection$.next(id);
|
||||
}
|
||||
|
||||
public readonly selected$ = combineLatest(
|
||||
[
|
||||
this.available$,
|
||||
merge(
|
||||
controlledOutputSelection$.pipe(startWith(undefined)),
|
||||
this.deviceSelection$,
|
||||
),
|
||||
],
|
||||
(available, preferredId) => {
|
||||
const id = preferredId ?? available.keys().next().value;
|
||||
return id === undefined
|
||||
? undefined
|
||||
: { id, virtualEarpiece: id === EARPIECE_CONFIG_ID };
|
||||
},
|
||||
).pipe(this.scope.state());
|
||||
|
||||
public constructor(private readonly scope: ObservableScope) {
|
||||
this.selected$.subscribe((device) => {
|
||||
// Let the hosting application know which output device has been selected.
|
||||
// This information is probably only of interest if the earpiece mode has
|
||||
// been selected - for example, Element X iOS listens to this to determine
|
||||
// whether it should enable the proximity sensor.
|
||||
if (device !== undefined) {
|
||||
logger.info("[controlled-output] setAudioDeviceSelect called:", device);
|
||||
window.controls.onAudioDeviceSelect?.(device.id);
|
||||
// Also invoke the deprecated callback for backward compatibility
|
||||
window.controls.onOutputDeviceSelect?.(device.id);
|
||||
}
|
||||
});
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[controlled-output] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
||||
public readonly available$ = availableRawDevices$(
|
||||
"videoinput",
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
).pipe(map(buildDeviceMap));
|
||||
|
||||
public readonly selected$ = selectDevice$(
|
||||
this.available$,
|
||||
videoInputSetting.value$,
|
||||
).pipe(
|
||||
map((id) => (id === undefined ? undefined : { id })),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public select(id: string): void {
|
||||
videoInputSetting.setValue(id);
|
||||
}
|
||||
|
||||
public constructor(
|
||||
private readonly usingNames$: Observable<boolean>,
|
||||
private readonly scope: ObservableScope,
|
||||
) {
|
||||
// This also has the purpose of subscribing to the available devices
|
||||
this.available$.subscribe((available) => {
|
||||
logger.info("[video-input] available devices:", available);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MediaDevices {
|
||||
private readonly deviceNamesRequest$ = new Subject<void>();
|
||||
/**
|
||||
* Requests that the media devices be populated with the names of each
|
||||
* available device, rather than numbered identifiers. This may invoke a
|
||||
* permissions pop-up, so it should only be called when there is a clear user
|
||||
* intent to view the device list.
|
||||
*/
|
||||
public requestDeviceNames(): void {
|
||||
this.deviceNamesRequest$.next();
|
||||
}
|
||||
|
||||
// Start using device names as soon as requested. This will cause LiveKit to
|
||||
// briefly request device permissions and acquire media streams for each
|
||||
// device type while calling `enumerateDevices`, which is what browsers want
|
||||
// you to do to receive device names in lieu of a more explicit permissions
|
||||
// API. This flag never resets to false, because once permissions are granted
|
||||
// the first time, the user won't be prompted again until reload of the page.
|
||||
private readonly usingNames$ = this.deviceNamesRequest$.pipe(
|
||||
map(() => true),
|
||||
startWith(false),
|
||||
this.scope.state(),
|
||||
);
|
||||
|
||||
public readonly audioInput: MediaDevice<
|
||||
DeviceLabel,
|
||||
SelectedAudioInputDevice
|
||||
> = new AudioInput(this.usingNames$, this.scope);
|
||||
|
||||
public readonly audioOutput: MediaDevice<
|
||||
AudioOutputDeviceLabel,
|
||||
SelectedAudioOutputDevice
|
||||
> = getUrlParams().controlledAudioDevices
|
||||
? new ControlledAudioOutput(this.scope)
|
||||
: new AudioOutput(this.usingNames$, this.scope);
|
||||
|
||||
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
|
||||
new VideoInput(this.usingNames$, this.scope);
|
||||
|
||||
public constructor(private readonly scope: ObservableScope) {}
|
||||
}
|
||||
@@ -430,8 +430,6 @@ abstract class BaseUserMediaViewModel extends BaseMediaViewModel {
|
||||
}
|
||||
|
||||
/**
|
||||
},
|
||||
},
|
||||
* The local participant's user media.
|
||||
*/
|
||||
export class LocalUserMediaViewModel extends BaseUserMediaViewModel {
|
||||
|
||||
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
.tile {
|
||||
--media-view-border-radius: var(--cpd-space-4x);
|
||||
--hover-space-margin: var(--cpd-space-1x);
|
||||
transition: outline-color ease 0.15s;
|
||||
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
forwardRef,
|
||||
type Ref,
|
||||
useCallback,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -50,6 +51,7 @@ import { useMergedRefs } from "../useMergedRefs";
|
||||
import { useReactionsSender } from "../reactions/useReactionsSender";
|
||||
|
||||
interface TileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
className?: string;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
@@ -66,132 +68,128 @@ interface UserMediaTileProps extends TileProps {
|
||||
menuEnd?: ReactNode;
|
||||
}
|
||||
|
||||
const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
className,
|
||||
displayName,
|
||||
...props
|
||||
const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
ref,
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
locallyMuted,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
className,
|
||||
displayName,
|
||||
...props
|
||||
}) => {
|
||||
const { toggleRaisedHand } = useReactionsSender();
|
||||
const { t } = useTranslation();
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
const audioStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.audioStreamStats$);
|
||||
const videoStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.videoStreamStats$);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const speaking = useObservableEagerState(vm.speaking$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleFitContain();
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { toggleRaisedHand } = useReactionsSender();
|
||||
const { t } = useTranslation();
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
const audioStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.audioStreamStats$);
|
||||
const videoStreamStats = useObservableEagerState<
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>(vm.videoStreamStats$);
|
||||
const audioEnabled = useObservableEagerState(vm.audioEnabled$);
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const speaking = useObservableEagerState(vm.speaking$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
const onSelectFitContain = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.toggleFitContain();
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const handRaised = useObservableState(vm.handRaised$);
|
||||
const reaction = useObservableState(vm.reaction$);
|
||||
[vm],
|
||||
);
|
||||
const handRaised = useObservableState(vm.handRaised$);
|
||||
const reaction = useObservableState(vm.reaction$);
|
||||
|
||||
const AudioIcon = locallyMuted
|
||||
? VolumeOffSolidIcon
|
||||
: audioEnabled
|
||||
? MicOnSolidIcon
|
||||
: MicOffSolidIcon;
|
||||
const audioIconLabel = locallyMuted
|
||||
? t("video_tile.muted_for_me")
|
||||
: audioEnabled
|
||||
? t("microphone_on")
|
||||
: t("microphone_off");
|
||||
const AudioIcon = locallyMuted
|
||||
? VolumeOffSolidIcon
|
||||
: audioEnabled
|
||||
? MicOnSolidIcon
|
||||
: MicOffSolidIcon;
|
||||
const audioIconLabel = locallyMuted
|
||||
? t("video_tile.muted_for_me")
|
||||
: audioEnabled
|
||||
? t("microphone_on")
|
||||
: t("microphone_off");
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menu = (
|
||||
<>
|
||||
{menuStart}
|
||||
<ToggleMenuItem
|
||||
Icon={ExpandIcon}
|
||||
label={t("video_tile.change_fit_contain")}
|
||||
checked={cropVideo}
|
||||
onSelect={onSelectFitContain}
|
||||
/>
|
||||
{menuEnd}
|
||||
</>
|
||||
);
|
||||
|
||||
const raisedHandOnClick = vm.local
|
||||
? (): void => void toggleRaisedHand()
|
||||
: undefined;
|
||||
|
||||
const showSpeaking = showSpeakingIndicators && speaking;
|
||||
|
||||
const tile = (
|
||||
<MediaView
|
||||
ref={ref}
|
||||
video={video}
|
||||
member={vm.member}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
encryptionStatus={encryptionStatus}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeaking,
|
||||
[styles.handRaised]: !showSpeaking && handRaised,
|
||||
})}
|
||||
nameTagLeadingIcon={
|
||||
<AudioIcon
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioIconLabel}
|
||||
data-muted={locallyMuted || !audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
displayName={displayName}
|
||||
primaryButton={
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={displayName}
|
||||
trigger={
|
||||
<button aria-label={t("common.options")}>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{menu}
|
||||
</Menu>
|
||||
}
|
||||
raisedHandTime={handRaised ?? undefined}
|
||||
currentReaction={reaction ?? undefined}
|
||||
raisedHandOnClick={raisedHandOnClick}
|
||||
localParticipant={vm.local}
|
||||
audioStreamStats={audioStreamStats}
|
||||
videoStreamStats={videoStreamStats}
|
||||
{...props}
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const menu = (
|
||||
<>
|
||||
{menuStart}
|
||||
<ToggleMenuItem
|
||||
Icon={ExpandIcon}
|
||||
label={t("video_tile.change_fit_contain")}
|
||||
checked={cropVideo}
|
||||
onSelect={onSelectFitContain}
|
||||
/>
|
||||
);
|
||||
{menuEnd}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
|
||||
{menu}
|
||||
</ContextMenu>
|
||||
);
|
||||
},
|
||||
);
|
||||
const raisedHandOnClick = vm.local
|
||||
? (): void => void toggleRaisedHand()
|
||||
: undefined;
|
||||
|
||||
const showSpeaking = showSpeakingIndicators && speaking;
|
||||
|
||||
const tile = (
|
||||
<MediaView
|
||||
ref={ref}
|
||||
video={video}
|
||||
member={vm.member}
|
||||
unencryptedWarning={unencryptedWarning}
|
||||
encryptionStatus={encryptionStatus}
|
||||
videoEnabled={videoEnabled}
|
||||
videoFit={cropVideo ? "cover" : "contain"}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.speaking]: showSpeaking,
|
||||
[styles.handRaised]: !showSpeaking && handRaised,
|
||||
})}
|
||||
nameTagLeadingIcon={
|
||||
<AudioIcon
|
||||
width={20}
|
||||
height={20}
|
||||
aria-label={audioIconLabel}
|
||||
data-muted={locallyMuted || !audioEnabled}
|
||||
className={styles.muteIcon}
|
||||
/>
|
||||
}
|
||||
displayName={displayName}
|
||||
primaryButton={
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={displayName}
|
||||
trigger={
|
||||
<button aria-label={t("common.options")}>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
{menu}
|
||||
</Menu>
|
||||
}
|
||||
raisedHandTime={handRaised ?? undefined}
|
||||
currentReaction={reaction ?? undefined}
|
||||
raisedHandOnClick={raisedHandOnClick}
|
||||
localParticipant={vm.local}
|
||||
audioStreamStats={audioStreamStats}
|
||||
videoStreamStats={videoStreamStats}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
|
||||
{menu}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
UserMediaTile.displayName = "UserMediaTile";
|
||||
|
||||
@@ -200,48 +198,51 @@ interface LocalUserMediaTileProps extends TileProps {
|
||||
onOpenProfile: (() => void) | null;
|
||||
}
|
||||
|
||||
const LocalUserMediaTile = forwardRef<HTMLDivElement, LocalUserMediaTileProps>(
|
||||
({ vm, onOpenProfile, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
|
||||
const latestAlwaysShow = useLatest(alwaysShow);
|
||||
const onSelectAlwaysShow = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.setAlwaysShow(!latestAlwaysShow.current);
|
||||
},
|
||||
[vm, latestAlwaysShow],
|
||||
);
|
||||
const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
ref,
|
||||
vm,
|
||||
onOpenProfile,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
const alwaysShow = useObservableEagerState(vm.alwaysShow$);
|
||||
const latestAlwaysShow = useLatest(alwaysShow);
|
||||
const onSelectAlwaysShow = useCallback(
|
||||
(e: Event) => {
|
||||
e.preventDefault();
|
||||
vm.setAlwaysShow(!latestAlwaysShow.current);
|
||||
},
|
||||
[vm, latestAlwaysShow],
|
||||
);
|
||||
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={false}
|
||||
mirror={mirror}
|
||||
menuStart={
|
||||
<ToggleMenuItem
|
||||
Icon={VisibilityOnIcon}
|
||||
label={t("video_tile.always_show")}
|
||||
checked={alwaysShow}
|
||||
onSelect={onSelectAlwaysShow}
|
||||
return (
|
||||
<UserMediaTile
|
||||
ref={ref}
|
||||
vm={vm}
|
||||
locallyMuted={false}
|
||||
mirror={mirror}
|
||||
menuStart={
|
||||
<ToggleMenuItem
|
||||
Icon={VisibilityOnIcon}
|
||||
label={t("video_tile.always_show")}
|
||||
checked={alwaysShow}
|
||||
onSelect={onSelectAlwaysShow}
|
||||
/>
|
||||
}
|
||||
menuEnd={
|
||||
onOpenProfile && (
|
||||
<MenuItem
|
||||
Icon={UserProfileIcon}
|
||||
label={t("common.profile")}
|
||||
onSelect={onOpenProfile}
|
||||
/>
|
||||
}
|
||||
menuEnd={
|
||||
onOpenProfile && (
|
||||
<MenuItem
|
||||
Icon={UserProfileIcon}
|
||||
label={t("common.profile")}
|
||||
onSelect={onOpenProfile}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
LocalUserMediaTile.displayName = "LocalUserMediaTile";
|
||||
|
||||
@@ -249,10 +250,11 @@ interface RemoteUserMediaTileProps extends TileProps {
|
||||
vm: RemoteUserMediaViewModel;
|
||||
}
|
||||
|
||||
const RemoteUserMediaTile = forwardRef<
|
||||
HTMLDivElement,
|
||||
RemoteUserMediaTileProps
|
||||
>(({ vm, ...props }, ref) => {
|
||||
const RemoteUserMediaTile: FC<RemoteUserMediaTileProps> = ({
|
||||
ref,
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const locallyMuted = useObservableEagerState(vm.locallyMuted$);
|
||||
const localVolume = useObservableEagerState(vm.localVolume$);
|
||||
@@ -303,11 +305,12 @@ const RemoteUserMediaTile = forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
RemoteUserMediaTile.displayName = "RemoteUserMediaTile";
|
||||
|
||||
interface GridTileProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
vm: GridTileViewModel;
|
||||
onOpenProfile: (() => void) | null;
|
||||
targetWidth: number;
|
||||
@@ -317,34 +320,37 @@ interface GridTileProps {
|
||||
showSpeakingIndicators: boolean;
|
||||
}
|
||||
|
||||
export const GridTile = forwardRef<HTMLDivElement, GridTileProps>(
|
||||
({ vm, onOpenProfile, ...props }, theirRef) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const displayName = useObservableEagerState(media.displayname$);
|
||||
export const GridTile: FC<GridTileProps> = ({
|
||||
ref: theirRef,
|
||||
vm,
|
||||
onOpenProfile,
|
||||
...props
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const displayName = useObservableEagerState(media.displayname$);
|
||||
|
||||
if (media instanceof LocalUserMediaViewModel) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
onOpenProfile={onOpenProfile}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RemoteUserMediaTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (media instanceof LocalUserMediaViewModel) {
|
||||
return (
|
||||
<LocalUserMediaTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
onOpenProfile={onOpenProfile}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<RemoteUserMediaTile
|
||||
ref={ref}
|
||||
vm={media}
|
||||
displayName={displayName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
GridTile.displayName = "GridTile";
|
||||
|
||||
@@ -9,6 +9,8 @@ Please see LICENSE in the repository root for full details.
|
||||
container-name: mediaView;
|
||||
container-type: size;
|
||||
border-radius: var(--media-view-border-radius);
|
||||
/*Add spacing for the hover boarder. Otherwise it might get clipped.*/
|
||||
margin: var(--hover-space-margin);
|
||||
}
|
||||
|
||||
.media video {
|
||||
|
||||
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
||||
import { animated } from "@react-spring/web";
|
||||
import { type RoomMember } from "matrix-js-sdk";
|
||||
import { type ComponentProps, type ReactNode, forwardRef } from "react";
|
||||
import { type FC, type ComponentProps, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { VideoTrack } from "@livekit/components-react";
|
||||
@@ -47,97 +47,94 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
||||
}
|
||||
|
||||
export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
videoFit,
|
||||
mirror,
|
||||
member,
|
||||
videoEnabled,
|
||||
unencryptedWarning,
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
primaryButton,
|
||||
encryptionStatus,
|
||||
raisedHandTime,
|
||||
currentReaction,
|
||||
raisedHandOnClick,
|
||||
localParticipant,
|
||||
audioStreamStats,
|
||||
videoStreamStats,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
||||
export const MediaView: FC<Props> = ({
|
||||
ref,
|
||||
className,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
videoFit,
|
||||
mirror,
|
||||
member,
|
||||
videoEnabled,
|
||||
unencryptedWarning,
|
||||
nameTagLeadingIcon,
|
||||
displayName,
|
||||
primaryButton,
|
||||
encryptionStatus,
|
||||
raisedHandTime,
|
||||
currentReaction,
|
||||
raisedHandOnClick,
|
||||
localParticipant,
|
||||
audioStreamStats,
|
||||
videoStreamStats,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
||||
|
||||
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
||||
const avatarSize = Math.round(Math.min(targetWidth, targetHeight) / 2);
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className={classNames(styles.media, className, {
|
||||
[styles.mirror]: mirror,
|
||||
})}
|
||||
style={style}
|
||||
ref={ref}
|
||||
data-testid="videoTile"
|
||||
data-video-fit={videoFit}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<Avatar
|
||||
id={member?.userId ?? displayName}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
src={member?.getMxcAvatarUrl()}
|
||||
className={styles.avatar}
|
||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||
return (
|
||||
<animated.div
|
||||
className={classNames(styles.media, className, {
|
||||
[styles.mirror]: mirror,
|
||||
})}
|
||||
style={style}
|
||||
ref={ref}
|
||||
data-testid="videoTile"
|
||||
data-video-fit={videoFit}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.bg}>
|
||||
<Avatar
|
||||
id={member?.userId ?? displayName}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
src={member?.getMxcAvatarUrl()}
|
||||
className={styles.avatar}
|
||||
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
||||
/>
|
||||
{video?.publication !== undefined && (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
style={{ display: video && videoEnabled ? "block" : "none" }}
|
||||
data-testid="video"
|
||||
/>
|
||||
{video?.publication !== undefined && (
|
||||
<VideoTrack
|
||||
trackRef={video}
|
||||
// There's no reason for this to be focusable
|
||||
tabIndex={-1}
|
||||
disablePictureInPicture
|
||||
style={{ display: video && videoEnabled ? "block" : "none" }}
|
||||
data-testid="video"
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.fg}>
|
||||
<div className={styles.reactions}>
|
||||
<RaisedHandIndicator
|
||||
raisedHandTime={raisedHandTime}
|
||||
miniature={avatarSize < 96}
|
||||
showTimer={handRaiseTimerVisible}
|
||||
onClick={raisedHandOnClick}
|
||||
/>
|
||||
{currentReaction && (
|
||||
<ReactionIndicator
|
||||
miniature={avatarSize < 96}
|
||||
emoji={currentReaction.emoji}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.fg}>
|
||||
<div className={styles.reactions}>
|
||||
<RaisedHandIndicator
|
||||
raisedHandTime={raisedHandTime}
|
||||
miniature={avatarSize < 96}
|
||||
showTimer={handRaiseTimerVisible}
|
||||
onClick={raisedHandOnClick}
|
||||
/>
|
||||
{currentReaction && (
|
||||
<ReactionIndicator
|
||||
miniature={avatarSize < 96}
|
||||
emoji={currentReaction.emoji}
|
||||
/>
|
||||
)}
|
||||
{!video && !localParticipant && (
|
||||
<div className={styles.status}>
|
||||
{t("video_tile.waiting_for_media")}
|
||||
</div>
|
||||
{!video && !localParticipant && (
|
||||
<div className={styles.status}>
|
||||
{t("video_tile.waiting_for_media")}
|
||||
</div>
|
||||
)}
|
||||
{(audioStreamStats || videoStreamStats) && (
|
||||
<RTCConnectionStats
|
||||
audio={audioStreamStats}
|
||||
video={videoStreamStats}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Bring this back once encryption status is less broken */}
|
||||
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
||||
)}
|
||||
{(audioStreamStats || videoStreamStats) && (
|
||||
<RTCConnectionStats
|
||||
audio={audioStreamStats}
|
||||
video={videoStreamStats}
|
||||
/>
|
||||
)}
|
||||
{/* TODO: Bring this back once encryption status is less broken */}
|
||||
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
||||
<div className={styles.status}>
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
{encryptionStatus === EncryptionStatus.Connecting &&
|
||||
@@ -151,38 +148,37 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
</Text>
|
||||
</div>
|
||||
)*/}
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
className={styles.name}
|
||||
data-testid="name_tag"
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text
|
||||
as="span"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
className={styles.name}
|
||||
data-testid="name_tag"
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
label={t("common.unencrypted")}
|
||||
placement="bottom"
|
||||
isTriggerInteractive={false}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
label={t("common.unencrypted")}
|
||||
placement="bottom"
|
||||
isTriggerInteractive={false}
|
||||
>
|
||||
<ErrorSolidIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
role="img"
|
||||
aria-label={t("common.unencrypted")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{primaryButton}
|
||||
<ErrorSolidIcon
|
||||
width={20}
|
||||
height={20}
|
||||
className={styles.errorIcon}
|
||||
role="img"
|
||||
aria-label={t("common.unencrypted")}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
{primaryButton}
|
||||
</div>
|
||||
</animated.div>
|
||||
);
|
||||
};
|
||||
|
||||
MediaView.displayName = "MediaView";
|
||||
|
||||
@@ -7,8 +7,9 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
type Ref,
|
||||
type RefAttributes,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -44,6 +45,7 @@ import { useLatest } from "../useLatest";
|
||||
import { type SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
className?: string;
|
||||
"data-id": string;
|
||||
targetWidth: number;
|
||||
@@ -67,13 +69,13 @@ interface SpotlightLocalUserMediaItemProps
|
||||
vm: LocalUserMediaViewModel;
|
||||
}
|
||||
|
||||
const SpotlightLocalUserMediaItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
SpotlightLocalUserMediaItemProps
|
||||
>(({ vm, ...props }, ref) => {
|
||||
const SpotlightLocalUserMediaItem: FC<SpotlightLocalUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const mirror = useObservableEagerState(vm.mirror$);
|
||||
return <MediaView ref={ref} mirror={mirror} {...props} />;
|
||||
});
|
||||
return <MediaView mirror={mirror} {...props} />;
|
||||
};
|
||||
|
||||
SpotlightLocalUserMediaItem.displayName = "SpotlightLocalUserMediaItem";
|
||||
|
||||
@@ -81,16 +83,15 @@ interface SpotlightUserMediaItemProps extends SpotlightItemBaseProps {
|
||||
vm: UserMediaViewModel;
|
||||
}
|
||||
|
||||
const SpotlightUserMediaItem = forwardRef<
|
||||
HTMLDivElement,
|
||||
SpotlightUserMediaItemProps
|
||||
>(({ vm, ...props }, ref) => {
|
||||
const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
|
||||
vm,
|
||||
...props
|
||||
}) => {
|
||||
const videoEnabled = useObservableEagerState(vm.videoEnabled$);
|
||||
const cropVideo = useObservableEagerState(vm.cropVideo$);
|
||||
|
||||
const baseProps: SpotlightUserMediaItemBaseProps &
|
||||
RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
videoEnabled,
|
||||
videoFit: cropVideo ? "cover" : "contain",
|
||||
...props,
|
||||
@@ -101,11 +102,12 @@ const SpotlightUserMediaItem = forwardRef<
|
||||
) : (
|
||||
<MediaView mirror={false} {...baseProps} />
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
SpotlightUserMediaItem.displayName = "SpotlightUserMediaItem";
|
||||
|
||||
interface SpotlightItemProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
vm: MediaViewModel;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
@@ -117,71 +119,63 @@ interface SpotlightItemProps {
|
||||
"aria-hidden"?: boolean;
|
||||
}
|
||||
|
||||
const SpotlightItem = forwardRef<HTMLDivElement, SpotlightItemProps>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
intersectionObserver$,
|
||||
snap,
|
||||
"aria-hidden": ariaHidden,
|
||||
},
|
||||
theirRef,
|
||||
) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useObservableEagerState(vm.displayname$);
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
const SpotlightItem: FC<SpotlightItemProps> = ({
|
||||
ref: theirRef,
|
||||
vm,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
intersectionObserver$,
|
||||
snap,
|
||||
"aria-hidden": ariaHidden,
|
||||
}) => {
|
||||
const ourRef = useRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const displayName = useObservableEagerState(vm.displayname$);
|
||||
const video = useObservableEagerState(vm.video$);
|
||||
const unencryptedWarning = useObservableEagerState(vm.unencryptedWarning$);
|
||||
const encryptionStatus = useObservableEagerState(vm.encryptionStatus$);
|
||||
|
||||
// Hook this item up to the intersection observer
|
||||
useEffect(() => {
|
||||
const element = ourRef.current!;
|
||||
let prevIo: IntersectionObserver | null = null;
|
||||
const subscription = intersectionObserver$.subscribe((io) => {
|
||||
prevIo?.unobserve(element);
|
||||
io.observe(element);
|
||||
prevIo = io;
|
||||
});
|
||||
return (): void => {
|
||||
subscription.unsubscribe();
|
||||
prevIo?.unobserve(element);
|
||||
};
|
||||
}, [intersectionObserver$]);
|
||||
|
||||
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
"data-id": vm.id,
|
||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
encryptionStatus,
|
||||
"aria-hidden": ariaHidden,
|
||||
localParticipant: vm.local,
|
||||
// Hook this item up to the intersection observer
|
||||
useEffect(() => {
|
||||
const element = ourRef.current!;
|
||||
let prevIo: IntersectionObserver | null = null;
|
||||
const subscription = intersectionObserver$.subscribe((io) => {
|
||||
prevIo?.unobserve(element);
|
||||
io.observe(element);
|
||||
prevIo = io;
|
||||
});
|
||||
return (): void => {
|
||||
subscription.unsubscribe();
|
||||
prevIo?.unobserve(element);
|
||||
};
|
||||
}, [intersectionObserver$]);
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
<MediaView
|
||||
videoEnabled
|
||||
videoFit="contain"
|
||||
mirror={false}
|
||||
{...baseProps}
|
||||
/>
|
||||
) : (
|
||||
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||
);
|
||||
},
|
||||
);
|
||||
const baseProps: SpotlightItemBaseProps & RefAttributes<HTMLDivElement> = {
|
||||
ref,
|
||||
"data-id": vm.id,
|
||||
className: classNames(styles.item, { [styles.snap]: snap }),
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
video,
|
||||
member: vm.member,
|
||||
unencryptedWarning,
|
||||
displayName,
|
||||
encryptionStatus,
|
||||
"aria-hidden": ariaHidden,
|
||||
localParticipant: vm.local,
|
||||
};
|
||||
|
||||
return vm instanceof ScreenShareViewModel ? (
|
||||
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
|
||||
) : (
|
||||
<SpotlightUserMediaItem vm={vm} {...baseProps} />
|
||||
);
|
||||
};
|
||||
|
||||
SpotlightItem.displayName = "SpotlightItem";
|
||||
|
||||
interface Props {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
vm: SpotlightTileViewModel;
|
||||
expanded: boolean;
|
||||
onToggleExpanded: (() => void) | null;
|
||||
@@ -192,156 +186,148 @@ interface Props {
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
}
|
||||
|
||||
export const SpotlightTile = forwardRef<HTMLDivElement, Props>(
|
||||
(
|
||||
{
|
||||
vm,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
showIndicators,
|
||||
className,
|
||||
style,
|
||||
},
|
||||
theirRef,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useObservableEagerState(vm.maximised$);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const [visibleId, setVisibleId] = useState<string | undefined>(
|
||||
media[0]?.id,
|
||||
);
|
||||
const latestMedia = useLatest(media);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
|
||||
const canGoBack = visibleIndex > 0;
|
||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
|
||||
export const SpotlightTile: FC<Props> = ({
|
||||
ref: theirRef,
|
||||
vm,
|
||||
expanded,
|
||||
onToggleExpanded,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
showIndicators,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useObservableEagerState(vm.maximised$);
|
||||
const media = useObservableEagerState(vm.media$);
|
||||
const [visibleId, setVisibleId] = useState<string | undefined>(media[0]?.id);
|
||||
const latestMedia = useLatest(media);
|
||||
const latestVisibleId = useLatest(visibleId);
|
||||
const visibleIndex = media.findIndex((vm) => vm.id === visibleId);
|
||||
const canGoBack = visibleIndex > 0;
|
||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
|
||||
|
||||
// To keep track of which item is visible, we need an intersection observer
|
||||
// hooked up to the root element and the items. Because the items will run
|
||||
// their effects before their parent does, we need to do this dance with an
|
||||
// Observable to actually give them the intersection observer.
|
||||
const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
|
||||
() =>
|
||||
root$.pipe(
|
||||
map(
|
||||
(r) =>
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.find((e) => e.isIntersecting);
|
||||
if (visible !== undefined)
|
||||
setVisibleId(visible.target.getAttribute("data-id")!);
|
||||
},
|
||||
{ root: r, threshold: 0.5 },
|
||||
),
|
||||
),
|
||||
// To keep track of which item is visible, we need an intersection observer
|
||||
// hooked up to the root element and the items. Because the items will run
|
||||
// their effects before their parent does, we need to do this dance with an
|
||||
// Observable to actually give them the intersection observer.
|
||||
const intersectionObserver$ = useInitial<Observable<IntersectionObserver>>(
|
||||
() =>
|
||||
root$.pipe(
|
||||
map(
|
||||
(r) =>
|
||||
new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries.find((e) => e.isIntersecting);
|
||||
if (visible !== undefined)
|
||||
setVisibleId(visible.target.getAttribute("data-id")!);
|
||||
},
|
||||
{ root: r, threshold: 0.5 },
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
|
||||
(prev) =>
|
||||
prev == null || prev === visibleId || media.every((vm) => vm.id !== prev)
|
||||
? null
|
||||
: prev,
|
||||
[visibleId],
|
||||
);
|
||||
|
||||
const onBackClick = useCallback(() => {
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex > 0) setScrollToId(media[visibleIndex - 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
|
||||
const [scrollToId, setScrollToId] = useReactiveState<string | null>(
|
||||
(prev) =>
|
||||
prev == null ||
|
||||
prev === visibleId ||
|
||||
media.every((vm) => vm.id !== prev)
|
||||
? null
|
||||
: prev,
|
||||
[visibleId],
|
||||
const onNextClick = useCallback(() => {
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex !== -1 && visibleIndex !== media.length - 1)
|
||||
setScrollToId(media[visibleIndex + 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
|
||||
const onBackClick = useCallback(() => {
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex > 0) setScrollToId(media[visibleIndex - 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
|
||||
|
||||
const onNextClick = useCallback(() => {
|
||||
const media = latestMedia.current;
|
||||
const visibleIndex = media.findIndex(
|
||||
(vm) => vm.id === latestVisibleId.current,
|
||||
);
|
||||
if (visibleIndex !== -1 && visibleIndex !== media.length - 1)
|
||||
setScrollToId(media[visibleIndex + 1].id);
|
||||
}, [latestVisibleId, latestMedia, setScrollToId]);
|
||||
|
||||
const ToggleExpandIcon = expanded ? CollapseIcon : ExpandIcon;
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={style}
|
||||
>
|
||||
{canGoBack && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.back)}
|
||||
aria-label={t("common.back")}
|
||||
onClick={onBackClick}
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.contents}>
|
||||
return (
|
||||
<animated.div
|
||||
ref={ref}
|
||||
className={classNames(className, styles.tile, {
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={style}
|
||||
>
|
||||
{canGoBack && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.back)}
|
||||
aria-label={t("common.back")}
|
||||
onClick={onBackClick}
|
||||
>
|
||||
<ChevronLeftIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.contents}>
|
||||
{media.map((vm) => (
|
||||
<SpotlightItem
|
||||
key={vm.id}
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
intersectionObserver$={intersectionObserver$}
|
||||
// This is how we get the container to scroll to the right media
|
||||
// when the previous/next buttons are clicked: we temporarily
|
||||
// remove all scroll snap points except for just the one media
|
||||
// that we want to bring into view
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{onToggleExpanded && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded ? t("video_tile.collapse") : t("video_tile.expand")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
{canGoToNext && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.next)}
|
||||
aria-label={t("common.next")}
|
||||
onClick={onNextClick}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
{!expanded && (
|
||||
<div
|
||||
className={classNames(styles.indicators, {
|
||||
[styles.show]: showIndicators && media.length > 1,
|
||||
})}
|
||||
>
|
||||
{media.map((vm) => (
|
||||
<SpotlightItem
|
||||
<div
|
||||
key={vm.id}
|
||||
vm={vm}
|
||||
targetWidth={targetWidth}
|
||||
targetHeight={targetHeight}
|
||||
intersectionObserver$={intersectionObserver$}
|
||||
// This is how we get the container to scroll to the right media
|
||||
// when the previous/next buttons are clicked: we temporarily
|
||||
// remove all scroll snap points except for just the one media
|
||||
// that we want to bring into view
|
||||
snap={scrollToId === null || scrollToId === vm.id}
|
||||
aria-hidden={(scrollToId ?? visibleId) !== vm.id}
|
||||
className={styles.item}
|
||||
data-visible={vm.id === visibleId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{onToggleExpanded && (
|
||||
<button
|
||||
className={classNames(styles.expand)}
|
||||
aria-label={
|
||||
expanded ? t("video_tile.collapse") : t("video_tile.expand")
|
||||
}
|
||||
onClick={onToggleExpanded}
|
||||
>
|
||||
<ToggleExpandIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
)}
|
||||
{canGoToNext && (
|
||||
<button
|
||||
className={classNames(styles.advance, styles.next)}
|
||||
aria-label={t("common.next")}
|
||||
onClick={onNextClick}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden width={24} height={24} />
|
||||
</button>
|
||||
)}
|
||||
{!expanded && (
|
||||
<div
|
||||
className={classNames(styles.indicators, {
|
||||
[styles.show]: showIndicators && media.length > 1,
|
||||
})}
|
||||
>
|
||||
{media.map((vm) => (
|
||||
<div
|
||||
key={vm.id}
|
||||
className={styles.item}
|
||||
data-visible={vm.id === visibleId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</animated.div>
|
||||
);
|
||||
},
|
||||
);
|
||||
)}
|
||||
</animated.div>
|
||||
);
|
||||
};
|
||||
|
||||
SpotlightTile.displayName = "SpotlightTile";
|
||||
|
||||
@@ -10,10 +10,12 @@ import { type FC } from "react";
|
||||
import { render } from "@testing-library/react";
|
||||
import userEvent, { type UserEvent } from "@testing-library/user-event";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext";
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import { useAudioContext } from "./useAudioContext";
|
||||
import { soundEffectVolume as soundEffectVolumeSetting } from "./settings/settings";
|
||||
import { mockMediaDevices } from "./utils/test";
|
||||
|
||||
const staticSounds = Promise.resolve({
|
||||
aSound: new ArrayBuffer(0),
|
||||
@@ -102,36 +104,38 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
test("can play a single sound", async () => {
|
||||
const { findByText } = render(<TestComponentWrapper />);
|
||||
const { findByText } = render(
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<TestComponentWrapper />
|
||||
</MediaDevicesContext>,
|
||||
);
|
||||
await user.click(await findByText("Valid sound"));
|
||||
expect(testAudioContext.createBufferSource).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("will ignore sounds that are not registered", async () => {
|
||||
const { findByText } = render(<TestComponentWrapper />);
|
||||
const { findByText } = render(
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<TestComponentWrapper />
|
||||
</MediaDevicesContext>,
|
||||
);
|
||||
await user.click(await findByText("Invalid sound"));
|
||||
expect(testAudioContext.createBufferSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("will use the correct device", () => {
|
||||
render(
|
||||
<MediaDevicesContext.Provider
|
||||
value={{
|
||||
audioInput: deviceStub,
|
||||
<MediaDevicesContext
|
||||
value={mockMediaDevices({
|
||||
audioOutput: {
|
||||
selectedId: "chosen-device",
|
||||
selectedGroupId: "",
|
||||
available: new Map(),
|
||||
available$: of(new Map<never, never>()),
|
||||
selected$: of({ id: "chosen-device", virtualEarpiece: false }),
|
||||
select: () => {},
|
||||
useAsEarpiece: false,
|
||||
},
|
||||
videoInput: deviceStub,
|
||||
startUsingDeviceNames: () => {},
|
||||
stopUsingDeviceNames: () => {},
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<TestComponentWrapper />
|
||||
</MediaDevicesContext.Provider>,
|
||||
</MediaDevicesContext>,
|
||||
);
|
||||
expect(testAudioContext.createBufferSource).not.toHaveBeenCalled();
|
||||
expect(testAudioContext.setSinkId).toHaveBeenCalledWith("chosen-device");
|
||||
@@ -139,7 +143,11 @@ test("will use the correct device", () => {
|
||||
|
||||
test("will use the correct volume level", async () => {
|
||||
soundEffectVolumeSetting.setValue(0.33);
|
||||
const { findByText } = render(<TestComponentWrapper />);
|
||||
const { findByText } = render(
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<TestComponentWrapper />
|
||||
</MediaDevicesContext>,
|
||||
);
|
||||
await user.click(await findByText("Valid sound"));
|
||||
expect(testAudioContext.gain.gain.setValueAtTime).toHaveBeenCalledWith(
|
||||
0.33,
|
||||
@@ -150,23 +158,17 @@ test("will use the correct volume level", async () => {
|
||||
|
||||
test("will use the pan if earpiece is selected", async () => {
|
||||
const { findByText } = render(
|
||||
<MediaDevicesContext.Provider
|
||||
value={{
|
||||
audioInput: deviceStub,
|
||||
<MediaDevicesContext
|
||||
value={mockMediaDevices({
|
||||
audioOutput: {
|
||||
selectedId: "chosen-device",
|
||||
selectedGroupId: "",
|
||||
available: new Map(),
|
||||
available$: of(new Map<never, never>()),
|
||||
selected$: of({ id: "chosen-device", virtualEarpiece: true }),
|
||||
select: () => {},
|
||||
useAsEarpiece: true,
|
||||
},
|
||||
videoInput: deviceStub,
|
||||
startUsingDeviceNames: () => {},
|
||||
stopUsingDeviceNames: () => {},
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<TestComponentWrapper />
|
||||
</MediaDevicesContext.Provider>,
|
||||
</MediaDevicesContext>,
|
||||
);
|
||||
await user.click(await findByText("Valid sound"));
|
||||
expect(testAudioContext.pan.pan.setValueAtTime).toHaveBeenCalledWith(1, 0);
|
||||
|
||||
@@ -7,17 +7,16 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
|
||||
import {
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
useSetting,
|
||||
} from "./settings/settings";
|
||||
import {
|
||||
useEarpieceAudioConfig,
|
||||
useMediaDevices,
|
||||
} from "./livekit/MediaDevicesContext";
|
||||
import { useEarpieceAudioConfig, useMediaDevices } from "./MediaDevicesContext";
|
||||
import { type PrefetchedSounds } from "./soundUtils";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import * as controls from "./controls";
|
||||
|
||||
/**
|
||||
* Play a sound though a given AudioContext. Will take
|
||||
@@ -42,6 +41,7 @@ async function playSound(
|
||||
src.buffer = buffer;
|
||||
src.connect(gain).connect(pan).connect(ctx.destination);
|
||||
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
|
||||
controls.setPlaybackStarted();
|
||||
src.start();
|
||||
return p;
|
||||
}
|
||||
@@ -71,8 +71,6 @@ export function useAudioContext<S extends string>(
|
||||
props: Props<S>,
|
||||
): UseAudioContext<S> | null {
|
||||
const [soundEffectVolume] = useSetting(soundEffectVolumeSetting);
|
||||
const { audioOutput } = useMediaDevices();
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
const [audioContext, setAudioContext] = useState<AudioContext>();
|
||||
const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>();
|
||||
|
||||
@@ -109,6 +107,11 @@ export function useAudioContext<S extends string>(
|
||||
};
|
||||
}, [props.sounds, props.latencyHint]);
|
||||
|
||||
const audioOutputId = useObservableEagerState(
|
||||
useMediaDevices().audioOutput.selected$,
|
||||
)?.id;
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
|
||||
// Update the sink ID whenever we change devices.
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -118,11 +121,11 @@ export function useAudioContext<S extends string>(
|
||||
) {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/setSinkId
|
||||
// @ts-expect-error - setSinkId doesn't exist yet in types, maybe because it's not supported everywhere.
|
||||
audioContext.setSinkId(audioOutput.selectedId).catch((ex) => {
|
||||
audioContext.setSinkId(audioOutputId).catch((ex) => {
|
||||
logger.warn("Unable to change sink for audio context", ex);
|
||||
});
|
||||
}
|
||||
}, [audioContext, audioOutput.selectedId, controlledAudioDevices]);
|
||||
}, [audioContext, audioOutputId, controlledAudioDevices]);
|
||||
const { pan: earpiecePan, volume: earpieceVolume } = useEarpieceAudioConfig();
|
||||
|
||||
// Don't return a function until we're ready.
|
||||
|
||||
88
src/useEvents.test.tsx
Normal file
88
src/useEvents.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk";
|
||||
|
||||
import { useTypedEventEmitterState } from "./useEvents";
|
||||
|
||||
class TestEmitter extends TypedEventEmitter<"change", { change: () => void }> {
|
||||
private state = 1;
|
||||
public readonly getState = (): number => this.state;
|
||||
public readonly getNegativeState = (): number => -this.state;
|
||||
public readonly setState = (value: number): void => {
|
||||
this.state = value;
|
||||
this.emit("change");
|
||||
};
|
||||
}
|
||||
|
||||
test("useTypedEventEmitterState reacts to events", async () => {
|
||||
const user = userEvent.setup();
|
||||
const emitter = new TestEmitter();
|
||||
|
||||
const Test: FC = () => {
|
||||
const value = useTypedEventEmitterState(
|
||||
emitter,
|
||||
"change",
|
||||
emitter.getState,
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => emitter.setState(2)}>Change value</button>
|
||||
<div>Value is {value}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Test />);
|
||||
screen.getByText("Value is 1");
|
||||
await user.click(screen.getByText("Change value"));
|
||||
screen.getByText("Value is 2");
|
||||
});
|
||||
|
||||
test("useTypedEventEmitterState reacts to changes made by an effect mounted on the same render", () => {
|
||||
const emitter = new TestEmitter();
|
||||
|
||||
const Test: FC = () => {
|
||||
useEffect(() => emitter.setState(2), []);
|
||||
const value = useTypedEventEmitterState(
|
||||
emitter,
|
||||
"change",
|
||||
emitter.getState,
|
||||
);
|
||||
return `Value is ${value}`;
|
||||
};
|
||||
|
||||
render(<Test />);
|
||||
screen.getByText("Value is 2");
|
||||
});
|
||||
|
||||
test("useTypedEventEmitterState reacts to changes in getState", async () => {
|
||||
const user = userEvent.setup();
|
||||
const emitter = new TestEmitter();
|
||||
|
||||
const Test: FC = () => {
|
||||
const [fn, setFn] = useState(() => emitter.getState);
|
||||
const value = useTypedEventEmitterState(emitter, "change", fn);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setFn(() => emitter.getNegativeState)}>
|
||||
Change getState
|
||||
</button>
|
||||
<div>Value is {value}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Test />);
|
||||
screen.getByText("Value is 1");
|
||||
await user.click(screen.getByText("Change getState"));
|
||||
screen.getByText("Value is -1");
|
||||
});
|
||||
@@ -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 { useEffect } from "react";
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
|
||||
import type {
|
||||
Listener,
|
||||
@@ -13,7 +13,9 @@ import type {
|
||||
TypedEventEmitter,
|
||||
} from "matrix-js-sdk/lib/models/typed-event-emitter";
|
||||
|
||||
// Shortcut for registering a listener on an EventTarget
|
||||
/**
|
||||
* Shortcut for registering a listener on an EventTarget.
|
||||
*/
|
||||
export function useEventTarget<T extends Event>(
|
||||
target: EventTarget | null | undefined,
|
||||
eventType: string,
|
||||
@@ -33,7 +35,9 @@ export function useEventTarget<T extends Event>(
|
||||
}, [target, eventType, listener, options]);
|
||||
}
|
||||
|
||||
// Shortcut for registering a listener on a TypedEventEmitter
|
||||
/**
|
||||
* Shortcut for registering a listener on a TypedEventEmitter.
|
||||
*/
|
||||
export function useTypedEventEmitter<
|
||||
Events extends string,
|
||||
Arguments extends ListenerMap<Events>,
|
||||
@@ -50,3 +54,33 @@ export function useTypedEventEmitter<
|
||||
};
|
||||
}, [emitter, eventType, listener]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactively tracks a value which is recalculated whenever the provided event
|
||||
* emitter emits an event. This is useful for bridging state from matrix-js-sdk
|
||||
* into React.
|
||||
*/
|
||||
export function useTypedEventEmitterState<
|
||||
Events extends string,
|
||||
Arguments extends ListenerMap<Events>,
|
||||
T extends Events,
|
||||
State,
|
||||
>(
|
||||
emitter: TypedEventEmitter<Events, Arguments>,
|
||||
eventType: T,
|
||||
getState: () => State,
|
||||
): State {
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
emitter.on(eventType, onChange as Listener<Events, Arguments, T>);
|
||||
return (): void => {
|
||||
emitter.off(eventType, onChange as Listener<Events, Arguments, T>);
|
||||
};
|
||||
},
|
||||
[emitter, eventType],
|
||||
);
|
||||
// See the React docs for useSyncExternalStore; given that we're trying to
|
||||
// bridge state from an external source into React, using this hook is exactly
|
||||
// what React recommends.
|
||||
return useSyncExternalStore(subscribe, getState);
|
||||
}
|
||||
|
||||
51
src/useLocalStorage.test.tsx
Normal file
51
src/useLocalStorage.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
setLocalStorageItemReactive,
|
||||
useLocalStorage,
|
||||
} from "./useLocalStorage";
|
||||
|
||||
test("useLocalStorage reacts to changes made by an effect mounted on the same render", () => {
|
||||
localStorage.clear();
|
||||
const Test: FC = () => {
|
||||
useEffect(() => setLocalStorageItemReactive("my-value", "Hello!"), []);
|
||||
const [myValue] = useLocalStorage("my-value");
|
||||
return myValue;
|
||||
};
|
||||
render(<Test />);
|
||||
screen.getByText("Hello!");
|
||||
});
|
||||
|
||||
test("useLocalStorage reacts to key changes", async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem("value-1", "1");
|
||||
localStorage.setItem("value-2", "2");
|
||||
|
||||
const Test: FC = () => {
|
||||
const [key, setKey] = useState("value-1");
|
||||
const [value] = useLocalStorage(key);
|
||||
if (key !== `value-${value}`) throw new Error("Value is out of sync");
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setKey("value-2")}>Switch keys</button>
|
||||
<div>Value is: {value}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const user = userEvent.setup();
|
||||
render(<Test />);
|
||||
|
||||
screen.getByText("Value is: 1");
|
||||
await user.click(screen.getByRole("button", { name: "Switch keys" }));
|
||||
screen.getByText("Value is: 2");
|
||||
});
|
||||
@@ -5,50 +5,44 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import EventEmitter from "events";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk";
|
||||
|
||||
import { useTypedEventEmitterState } from "./useEvents";
|
||||
|
||||
type LocalStorageItem = ReturnType<typeof localStorage.getItem>;
|
||||
|
||||
// Bus to notify other useLocalStorage consumers when an item is changed
|
||||
export const localStorageBus = new EventEmitter();
|
||||
export const localStorageBus = new TypedEventEmitter<
|
||||
string,
|
||||
{ [key: string]: () => void }
|
||||
>();
|
||||
|
||||
/**
|
||||
* Like useState, but reads from and persists the value to localStorage
|
||||
* This hook will not update when we write to localStorage.setItem(key, value) directly.
|
||||
* For the hook to react either use the returned setter or `setLocalStorageItemReactive`.
|
||||
*/
|
||||
export const useLocalStorage = (
|
||||
export function useLocalStorage(
|
||||
key: string,
|
||||
): [LocalStorageItem, (value: string) => void] => {
|
||||
const [value, setValue] = useState<LocalStorageItem>(() =>
|
||||
localStorage.getItem(key),
|
||||
): [LocalStorageItem, (value: string) => void] {
|
||||
const value = useTypedEventEmitterState(
|
||||
localStorageBus,
|
||||
key,
|
||||
useCallback(() => localStorage.getItem(key), [key]),
|
||||
);
|
||||
const setValue = useCallback(
|
||||
(newValue: string) => setLocalStorageItemReactive(key, newValue),
|
||||
[key],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageBus.on(key, setValue);
|
||||
return (): void => {
|
||||
localStorageBus.off(key, setValue);
|
||||
};
|
||||
}, [key, setValue]);
|
||||
|
||||
return [
|
||||
value,
|
||||
useCallback(
|
||||
(newValue: string) => {
|
||||
setValue(newValue);
|
||||
localStorage.setItem(key, newValue);
|
||||
localStorageBus.emit(key, newValue);
|
||||
},
|
||||
[key, setValue],
|
||||
),
|
||||
];
|
||||
};
|
||||
return [value, setValue];
|
||||
}
|
||||
|
||||
export const setLocalStorageItemReactive = (
|
||||
key: string,
|
||||
value: string,
|
||||
): void => {
|
||||
localStorage.setItem(key, value);
|
||||
localStorageBus.emit(key, value);
|
||||
localStorageBus.emit(key);
|
||||
};
|
||||
|
||||
@@ -10,33 +10,31 @@ import {
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useEffect, useState } from "react";
|
||||
import { TypedEventEmitter } from "matrix-js-sdk";
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { useTypedEventEmitterState } from "./useEvents";
|
||||
|
||||
const dummySession = new TypedEventEmitter();
|
||||
|
||||
export function useMatrixRTCSessionJoinState(
|
||||
rtcSession: MatrixRTCSession | undefined,
|
||||
): boolean {
|
||||
const [, setNumUpdates] = useState(0);
|
||||
// React doesn't allow you to run a hook conditionally, so we have to plug in
|
||||
// a dummy event emitter in case there is no rtcSession yet
|
||||
const isJoined = useTypedEventEmitterState(
|
||||
rtcSession ?? dummySession,
|
||||
MatrixRTCSessionEvent.JoinStateChanged,
|
||||
useCallback(() => rtcSession?.isJoined() ?? false, [rtcSession]),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (rtcSession !== undefined) {
|
||||
const onJoinStateChanged = (isJoined: boolean): void => {
|
||||
logger.info(
|
||||
`Session in room ${rtcSession.room.roomId} changed to ${
|
||||
isJoined ? "joined" : "left"
|
||||
}`,
|
||||
);
|
||||
setNumUpdates((n) => n + 1); // Force an update
|
||||
};
|
||||
rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, onJoinStateChanged);
|
||||
logger.info(
|
||||
`Session in room ${rtcSession?.room.roomId} changed to ${
|
||||
isJoined ? "joined" : "left"
|
||||
}`,
|
||||
);
|
||||
}, [rtcSession, isJoined]);
|
||||
|
||||
return (): void => {
|
||||
rtcSession.off(
|
||||
MatrixRTCSessionEvent.JoinStateChanged,
|
||||
onJoinStateChanged,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, [rtcSession]);
|
||||
|
||||
return rtcSession?.isJoined() ?? false;
|
||||
return isJoined;
|
||||
}
|
||||
|
||||
@@ -5,39 +5,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
type CallMembership,
|
||||
type MatrixRTCSession,
|
||||
MatrixRTCSessionEvent,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useTypedEventEmitterState } from "./useEvents";
|
||||
|
||||
export function useMatrixRTCSessionMemberships(
|
||||
rtcSession: MatrixRTCSession,
|
||||
): CallMembership[] {
|
||||
const [memberships, setMemberships] = useState(rtcSession.memberships);
|
||||
|
||||
const onMembershipsChanged = useCallback(() => {
|
||||
logger.info(
|
||||
`Memberships changed for call in room ${rtcSession.room.roomId} (${rtcSession.memberships.length} members)`,
|
||||
);
|
||||
setMemberships(rtcSession.memberships);
|
||||
}, [rtcSession]);
|
||||
|
||||
useEffect(() => {
|
||||
rtcSession.on(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
rtcSession.off(
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
onMembershipsChanged,
|
||||
);
|
||||
};
|
||||
}, [rtcSession, onMembershipsChanged]);
|
||||
|
||||
return memberships;
|
||||
return useTypedEventEmitterState(
|
||||
rtcSession,
|
||||
MatrixRTCSessionEvent.MembershipsChanged,
|
||||
useCallback(() => rtcSession.memberships, [rtcSession]),
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user