Compare commits

...

40 Commits

Author SHA1 Message Date
Timo
2281fd65e6 Update livekit-client to disable ScriptTransform for Chromium based browsers (#3543)
* Update livekit-client to disable ScriptTransform for Chromium based browsers

See: https://github.com/livekit/client-sdk-js/pull/1703
Signed-off-by: Timo K <toger5@hotmail.de>

* bump playwright

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-10-23 15:38:50 +02:00
Will Hunt
0f77b1c42d Add media hints for notification events. (#3493) (#3514)
* Add media hints for notification events.

* Prevent showing calling view when disconnected from Livekit. (#3491)

* Refactor disconnection handling

* Use "unknown"

* Update signature

* Add tests

* Expose livekitConnectionState directly

* fix whoopsie

* Update dependency livekit-client to v2.15.7 (#3496)



* Fix the interactivity of buttons while reconnecting or in earpiece mode (#3486)

* Fix the interactivity of buttons while reconnecting or in earpiece mode

When we're in one of these modes, we need to ensure that everything above the overlay (the header and footer buttons) is interactive, while everything obscured by the overlay (the media tiles) is non-interactive and removed from the accessibility tree. It's not a very easy task to trap focus *outside* an element, so the best solution I could come up with is to set tabindex="-1" manually on all interactive elements belonging to the media tiles.

* Write a Playwright test for reconnecting

* fix lints



* fix test



* enable http2 for matrx-rtc host to allow the jwt service to talk to the SFU

* remove rate limit for delayed events

* more time to connect to livekit SFU

* Due to a Firefox issue we set the start anchor for the tab test to the Mute microphone button

* adapt to most recent Element Web version

* Use the "End call" button as proofe for a started call

* Currrenty disabled due to recent Element Web
- not indicating the number of participants
- bypassing Lobby

* linting

* disable 'can only interact with header and footer while reconnecting' for firefox

---------






* Log when a track is unpublished or runs into an error (#3495)

* default mute states (unmuted!) in widget mode (embedded + intent) (#3494)

* default mute states (unmuted!) in widget mode (embedded + intent)



* review



* introduce a cache for the url params.



* Add an option to skip the cache.



---------



* Apply new hint code

* missed a bit

* fix intent

* Automatically update intent on mute change

* update packages

* lint

* Fix tests

* fix merge fails

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
Co-authored-by: Timo K <toger5@hotmail.de>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-09-25 18:44:45 +02:00
Timo
0a286b4276 Never skip lobby for huddles (#3508) (#3509)
* Never skip lobby for huddles



---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-22 12:00:45 +02:00
fkwp
cb7fc44fdf Backport: Misc/android allow rcs to be published as non snapshot versions (#3506)
* Android: allow RCs to be published as non-SNAPSHOT versions

* Fix broken string in build script

---------

Co-authored-by: Jorge Martín <jorgem@element.io>
2025-09-19 22:19:49 +02:00
Timo
93a1a6a3be Backport: Fix: never stop ring feedback on the sender side (#3503)
* make ring$ a behavior and add code comments to justify/explain the change.

Signed-off-by: Timo K <toger5@hotmail.de>

* Add test: reproduce "ring does not stop" race.

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-19 16:29:50 +02:00
Timo
d7824ce86e Backport: Add ring notification to UserIntent.StartNewCallDM (#3498)
* Add ring notification to UserIntent.StartNewCallDM

Signed-off-by: Timo K <toger5@hotmail.de>

* Add more tests (refactor to compute + get)

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-18 17:46:04 +02:00
Timo
db5c7cf9c7 default mute states (unmuted!) in widget mode (embedded + intent) (#3494)
* default mute states (unmuted!) in widget mode (embedded + intent)

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* introduce a cache for the url params.

Signed-off-by: Timo K <toger5@hotmail.de>

* Add an option to skip the cache.

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-18 13:21:12 +02:00
Will Hunt
63122c7f6b Log when a track is unpublished or runs into an error (#3495) 2025-09-18 12:09:54 +01:00
Robin
4be395500f Fix the interactivity of buttons while reconnecting or in earpiece mode (#3486)
* Fix the interactivity of buttons while reconnecting or in earpiece mode

When we're in one of these modes, we need to ensure that everything above the overlay (the header and footer buttons) is interactive, while everything obscured by the overlay (the media tiles) is non-interactive and removed from the accessibility tree. It's not a very easy task to trap focus *outside* an element, so the best solution I could come up with is to set tabindex="-1" manually on all interactive elements belonging to the media tiles.

* Write a Playwright test for reconnecting

* fix lints

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test

Signed-off-by: Timo K <toger5@hotmail.de>

* enable http2 for matrx-rtc host to allow the jwt service to talk to the SFU

* remove rate limit for delayed events

* more time to connect to livekit SFU

* Due to a Firefox issue we set the start anchor for the tab test to the Mute microphone button

* adapt to most recent Element Web version

* Use the "End call" button as proofe for a started call

* Currrenty disabled due to recent Element Web
- not indicating the number of participants
- bypassing Lobby

* linting

* disable 'can only interact with header and footer while reconnecting' for firefox

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
Co-authored-by: Timo K <toger5@hotmail.de>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-09-18 12:58:47 +02:00
renovate[bot]
df7bd8ff2b Update dependency livekit-client to v2.15.7 (#3496)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-18 11:46:55 +02:00
Will Hunt
2374a3fd33 Prevent showing calling view when disconnected from Livekit. (#3491)
* Refactor disconnection handling

* Use "unknown"

* Update signature

* Add tests

* Expose livekitConnectionState directly

* fix whoopsie
2025-09-16 13:16:11 +00:00
davidegirardi
5811794f31 Add drum/joke reaction (#3217)
* Add drum/joke reaction

* Add license information

* Update test snapshot

* update snapshot test

---------

Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Davide Girardi <davideg@element.io>
Co-authored-by: fkwp <github-fkwp@w4ve.de>
2025-09-16 11:46:25 +02:00
Will Hunt
e201258af3 Add sounds for ringing (#3490)
* add wait for pickup overlay

Signed-off-by: Timo K <toger5@hotmail.de>

* refactor and leave logic

Signed-off-by: Timo K <toger5@hotmail.de>

* recursive play sound logic

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* text color

Signed-off-by: Timo K <toger5@hotmail.de>

* overlay styling and interval fixes

Signed-off-by: Timo K <toger5@hotmail.de>

* fix permissions and styling

Signed-off-by: Timo K <toger5@hotmail.de>

* fix always getting pickup sound

Signed-off-by: Timo K <toger5@hotmail.de>

* Add sound effects for declined,timeout and ringtone

* better ringtone

* Integrate sounds

* Ensure leave sound does not play

* Remove unused blocked sound

* fix test

* Improve tests

* Loop ring sound inside Audio context for better perf.

* lint

* better ringtone

* Update to delay ringtone logic.

* lint + fix test

* Tidy up ring sync and add comments.

* lint

* Refactor onLeave to take a sound so we don't need to repeat the sound

* fix import

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo K <toger5@hotmail.de>
2025-09-15 15:41:15 +01:00
Timo
76465d0e63 Add "wait for pickup" overlay with sound. Leave on decline/timeout (#3489)
* add wait for pickup overlay

Signed-off-by: Timo K <toger5@hotmail.de>

* refactor and leave logic

Signed-off-by: Timo K <toger5@hotmail.de>

* recursive play sound logic

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* text color

Signed-off-by: Timo K <toger5@hotmail.de>

* overlay styling and interval fixes

Signed-off-by: Timo K <toger5@hotmail.de>

* fix permissions and styling

Signed-off-by: Timo K <toger5@hotmail.de>

* fix always getting pickup sound

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2025-09-15 09:58:16 +02:00
Robin
a2cf4c8139 Merge pull request #3479 from robintown/fix-reconnect
Fix the reconnect button
2025-09-12 15:05:47 +02:00
Will Hunt
65d358df58 Do not use preload mode by default in embedded mode (#3488)
* Set preload=false by default for inApp

* Pull through types for params so it's easy to document

* Cleanup boolean logic

* change test
2025-09-11 14:57:26 +01:00
Robin
32cb8541f4 Actually fix the test flake 2025-09-10 16:42:09 +02:00
Robin
62cfe09c7b Merge pull request #3482 from element-hq/renovate/npm-vite-vulnerability
Update dependency vite to v6.3.6 [SECURITY]
2025-09-10 12:37:16 +02:00
Robin
8e32deaba5 Upgrade to Vite v7
To remove all the duplicate Rollup dependencies.
2025-09-10 12:31:03 +02:00
renovate[bot]
8311b1619c Update dependency vite to v6.3.6 [SECURITY] 2025-09-10 02:28:00 +00:00
Timo
fc449bcfbf Merge pull request #3467 from element-hq/toger5/call-pickup-state-decline-event
View model for decline logic
2025-09-09 15:23:39 +02:00
Robin
34a8977dd1 Increase timeout to hopefully avoid test flakes 2025-09-08 15:24:46 +02:00
Robin
db04cbfbfc Fix type error 2025-09-08 14:58:46 +02:00
Robin
c8d3d58940 Merge pull request #3400 from element-hq/robin/reactions-small
Prefer showing the reaction button at small screen widths
2025-09-08 14:54:51 +02:00
Robin
d85cf5f929 Fix the reconnect button
After a membership manager error, clicking the 'reconnect' button did nothing. This is because we were forgetting to clear the external error state, causing it to transition directly back to the same error state.
2025-09-08 14:21:38 +02:00
Robin
1e32b355ce Ignore decline events from the local user 2025-09-05 21:22:32 +02:00
Robin
1193a22658 Fix tests 2025-09-05 14:49:37 +02:00
Robin
2541f810fa Ensure that non-ringing notifications lead to a null pickup state 2025-09-05 14:36:27 +02:00
Robin
7961bb35be Merge pull request #3466 from element-hq/toger5/waitForNotificationAnswer
Add dialing/ringing state to CallViewModel (`callPickupState$`)
2025-09-05 14:11:37 +02:00
Andrew Ferrazzutti
075a4cbe0d Merge pull request #3475 from element-hq/af/docker-compose-pull-policy-always
docker compose: always pull latest image
2025-09-04 07:47:45 -04:00
Mohammed Tayeh
cd845b69d2 Add Caddy configuration example and fix ConfigOptions.ts link (#3476)
Signed-off-by: Mohammed Tayeh <m.tayeh94@gmail.com>
2025-09-04 10:19:37 +02:00
Andrew Ferrazzutti
779f792cbf docker compose: always pull latest image
Otherwise, there is a risk of using outdated images for some services.
2025-09-03 15:39:27 -04:00
Robin
e9c43856d0 Merge branch 'toger5/waitForNotificationAnswer' into toger5/call-pickup-state-decline-event 2025-09-03 17:59:16 +02:00
Timo K
7724cbf9be fix tests
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 19:21:27 +02:00
Timo K
fe65c1f4da fix decline event type
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 19:07:52 +02:00
Timo K
a91c71a8e4 types
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:36:23 +02:00
Timo K
e30142a43b Add decline logic and tests
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-26 18:36:23 +02:00
Robin
30024877fc Test that the reaction button is visible at small screen widths 2025-08-14 17:00:56 +02:00
Robin
aa0f3a1126 Merge branch 'robin/switch-camera-tile' into robin/reactions-small 2025-08-14 16:39:51 +02:00
Robin
735d63d393 Prefer showing the reaction button at small screen widths
Rather than the 'share screen' button. Small screens are most likely to be mobile devices which wouldn't have the ability to share their screen, anyways.
2025-07-10 16:07:14 -04:00
59 changed files with 1544 additions and 511 deletions

View File

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

View File

@@ -59,6 +59,8 @@ server {
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
http2 on;
location ^~ /livekit/jwt/ {

View File

@@ -44,6 +44,10 @@ rc_message:
per_second: 10000
burst_count: 10000
rc_delayed_event_mgmt:
per_second: 10000
burst_count: 10000
rc_login:
address:
per_second: 10000

View File

@@ -26,6 +26,7 @@ services:
livekit:
image: livekit/livekit-server:latest
pull_policy: always
hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml
restart: unless-stopped
@@ -45,6 +46,7 @@ services:
redis:
image: redis:6-alpine
pull_policy: always
command: redis-server /etc/redis.conf
ports:
# HOST_PORT:CONTAINER_PORT
@@ -57,6 +59,7 @@ services:
synapse:
hostname: homeserver
image: docker.io/matrixdotorg/synapse:latest
pull_policy: always
environment:
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
# Needed for rootless podman-compose such that the uid/gid mapping does
@@ -87,6 +90,7 @@ services:
# see backend/dev_tls_setup for how to generate the tls certs
hostname: synapse.m.localhost
image: nginx:latest
pull_policy: always
volumes:
- ./backend/dev_nginx.conf:/etc/nginx/conf.d/default.conf:Z
- ./backend/dev_tls_m.localhost.key:/root/ssl/key.pem:Z

View File

@@ -126,6 +126,32 @@ server {
}
```
Or Using Caddy, you can achieve this by:
```caddy configuration file
# Route for lk-jwt-service with livekit/jwt prefix
@jwt_service path /livekit/jwt/sfu/get /livekit/jwt/healthz
handle @jwt_service {
uri strip_prefix /livekit/jwt
reverse_proxy http://[::1]:8080 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
# Default route for livekit
handle {
reverse_proxy http://localhost:7880 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
```
#### MatrixRTC backend announcement
> [!IMPORTANT]
@@ -214,7 +240,7 @@ server {
There are currently two different config files. `.env` holds variables that are
used at build time, while `public/config.json` holds variables that are used at
runtime. Documentation and default values for `public/config.json` can be found
in [ConfigOptions.ts](src/config/ConfigOptions.ts).
in [ConfigOptions.ts](../src/config/ConfigOptions.ts).
> [!CAUTION]
> Please note configuring MatrixRTC backend via `config.json` of

View File

@@ -44,7 +44,7 @@
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.12.0",
"@livekit/components-react": "^2.0.0",
"@livekit/protocol": "^1.38.0",
"@livekit/protocol": "^1.42.2",
"@livekit/track-processors": "^0.5.5",
"@mediapipe/tasks-vision": "^0.10.18",
"@opentelemetry/api": "^1.4.0",
@@ -54,7 +54,7 @@
"@opentelemetry/sdk-trace-base": "^2.0.0",
"@opentelemetry/sdk-trace-web": "^2.0.0",
"@opentelemetry/semantic-conventions": "^1.25.1",
"@playwright/test": "^1.52.0",
"@playwright/test": "^1.56.1",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
@@ -105,7 +105,7 @@
"i18next-parser": "^9.1.0",
"jsdom": "^26.0.0",
"knip": "^5.27.2",
"livekit-client": "^2.13.0",
"livekit-client": "^2.15.13",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",
@@ -129,7 +129,7 @@
"typescript-eslint-language-service": "^5.0.5",
"unique-names-generator": "^4.6.0",
"vaul": "^1.0.0",
"vite": "^6.0.0",
"vite": "^7.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-svgr": "^4.0.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,11 +46,11 @@ interface Props {
*/
Icon?: ComponentType<SVGAttributes<SVGElement>>;
/**
* Whether the toast should be portaled into the root of the document (rather
* than rendered in-place within the component tree).
* Whether the toast should be modal, making it fill the screen (by portalling
* it into the root of the document) and trap focus until dismissed.
* @default true
*/
portal?: boolean;
modal?: boolean;
}
/**
@@ -62,7 +62,7 @@ export const Toast: FC<Props> = ({
autoDismiss,
children,
Icon,
portal = true,
modal = true,
}) => {
const onOpenChange = useCallback(
(open: boolean) => {
@@ -103,8 +103,8 @@ export const Toast: FC<Props> = ({
);
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
{portal ? <DialogPortal>{content}</DialogPortal> : content}
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
{modal ? <DialogPortal>{content}</DialogPortal> : content}
</DialogRoot>
);
};

View File

@@ -5,12 +5,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import * as PlatformMod from "../src/Platform";
import {
getRoomIdentifierFromUrl,
getUrlParams,
computeUrlParams,
HeaderStyle,
getUrlParams,
} from "../src/UrlParams";
const ROOM_NAME = "roomNameHere";
@@ -103,16 +106,16 @@ describe("UrlParams", () => {
describe("preload", () => {
it("defaults to false", () => {
expect(getUrlParams().preload).toBe(false);
expect(computeUrlParams().preload).toBe(false);
});
it("ignored in SPA mode", () => {
expect(getUrlParams("?preload=true").preload).toBe(false);
expect(computeUrlParams("?preload=true").preload).toBe(false);
});
it("respected in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?preload=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).preload,
).toBe(true);
@@ -121,19 +124,20 @@ describe("UrlParams", () => {
describe("returnToLobby", () => {
it("is false in SPA mode", () => {
expect(getUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
expect(computeUrlParams("?returnToLobby=true").returnToLobby).toBe(false);
});
it("defaults to false in widget mode", () => {
expect(
getUrlParams("?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo")
.returnToLobby,
computeUrlParams(
"?widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby,
).toBe(false);
});
it("respected in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?returnToLobby=true&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).returnToLobby,
).toBe(true);
@@ -142,12 +146,12 @@ describe("UrlParams", () => {
describe("userId", () => {
it("is ignored in SPA mode", () => {
expect(getUrlParams("?userId=asd").userId).toBe(null);
expect(computeUrlParams("?userId=asd").userId).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?userId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).userId,
).toBe("asd");
@@ -156,12 +160,12 @@ describe("UrlParams", () => {
describe("deviceId", () => {
it("is ignored in SPA mode", () => {
expect(getUrlParams("?deviceId=asd").deviceId).toBe(null);
expect(computeUrlParams("?deviceId=asd").deviceId).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?deviceId=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).deviceId,
).toBe("asd");
@@ -170,12 +174,12 @@ describe("UrlParams", () => {
describe("baseUrl", () => {
it("is ignored in SPA mode", () => {
expect(getUrlParams("?baseUrl=asd").baseUrl).toBe(null);
expect(computeUrlParams("?baseUrl=asd").baseUrl).toBe(null);
});
it("is parsed in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?baseUrl=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).baseUrl,
).toBe("asd");
@@ -185,28 +189,28 @@ describe("UrlParams", () => {
describe("viaServers", () => {
it("is ignored in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?viaServers=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).viaServers,
).toBe(null);
});
it("is parsed in SPA mode", () => {
expect(getUrlParams("?viaServers=asd").viaServers).toBe("asd");
expect(computeUrlParams("?viaServers=asd").viaServers).toBe("asd");
});
});
describe("homeserver", () => {
it("is ignored in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?homeserver=asd&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).homeserver,
).toBe(null);
});
it("is parsed in SPA mode", () => {
expect(getUrlParams("?homeserver=asd").homeserver).toBe("asd");
expect(computeUrlParams("?homeserver=asd").homeserver).toBe("asd");
});
});
@@ -228,7 +232,7 @@ describe("UrlParams", () => {
const startNewCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
@@ -237,12 +241,12 @@ describe("UrlParams", () => {
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
sendNotificationType: platform === "desktop" ? "notification" : "ring",
});
const joinExistingCallDefaults = (platform: string): object => ({
confineToRoom: true,
appPrompt: false,
preload: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
@@ -252,24 +256,55 @@ describe("UrlParams", () => {
skipLobby: false,
returnToLobby: false,
sendNotificationType: "notification",
defaultAudioEnabled: true,
defaultVideoEnabled: true,
});
it("use no-intent-defaults with unknown intent", () => {
expect(getUrlParams()).toMatchObject(noIntentDefaults);
expect(computeUrlParams()).toMatchObject(noIntentDefaults);
});
it("ignores intent if it is not a valid value", () => {
expect(getUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
expect(computeUrlParams("?intent=foo")).toMatchObject(noIntentDefaults);
});
it("accepts start_call", () => {
expect(
getUrlParams("?intent=start_call&widgetId=1234&parentUrl=parent.org"),
).toMatchObject(startNewCallDefaults("desktop"));
computeUrlParams(
"?intent=start_call&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false });
});
it("accepts start_call_dm mobile", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(startNewCallDefaults("android"));
});
it("accepts start_call_dm mobile and prioritizes overwritten params", () => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("android");
onTestFinished(() => {
vi.spyOn(PlatformMod, "platform", "get").mockReturnValue("desktop");
});
expect(
computeUrlParams(
"?intent=start_call_dm&widgetId=1234&parentUrl=parent.org&sendNotificationType=notification",
),
).toMatchObject({
...startNewCallDefaults("android"),
sendNotificationType: "notification",
});
});
it("accepts join_existing", () => {
expect(
getUrlParams(
computeUrlParams(
"?intent=join_existing&widgetId=1234&parentUrl=parent.org",
),
).toMatchObject(joinExistingCallDefaults("desktop"));
@@ -278,31 +313,55 @@ describe("UrlParams", () => {
describe("skipLobby", () => {
it("defaults to false", () => {
expect(getUrlParams().skipLobby).toBe(false);
expect(computeUrlParams().skipLobby).toBe(false);
});
it("defaults to false if intent is start_call in SPA mode", () => {
expect(getUrlParams("?intent=start_call").skipLobby).toBe(false);
expect(computeUrlParams("?intent=start_call").skipLobby).toBe(false);
});
it("defaults to true if intent is start_call in widget mode", () => {
it("defaults to false if intent is start_call in widget mode", () => {
expect(
getUrlParams(
computeUrlParams(
"?intent=start_call&widgetId=12345&parentUrl=https%3A%2F%2Flocalhost%2Ffoo",
).skipLobby,
).toBe(true);
).toBe(false);
});
it("default to false if intent is join_existing", () => {
expect(getUrlParams("?intent=join_existing").skipLobby).toBe(false);
expect(computeUrlParams("?intent=join_existing").skipLobby).toBe(false);
});
});
describe("header", () => {
it("uses header if provided", () => {
expect(getUrlParams("?header=app_bar&hideHeader=true").header).toBe(
expect(computeUrlParams("?header=app_bar&hideHeader=true").header).toBe(
"app_bar",
);
expect(getUrlParams("?header=none&hideHeader=false").header).toBe("none");
expect(computeUrlParams("?header=none&hideHeader=false").header).toBe(
"none",
);
});
});
describe("getUrlParams", () => {
it("uses cached values", () => {
const spy = vi.spyOn(logger, "info");
// call get once
const params = getUrlParams("?header=app_bar&hideHeader=true", "");
// call get twice
expect(getUrlParams("?header=app_bar&hideHeader=true", "")).toBe(params);
// expect compute to only be called once
// it will only log when it is computing the values
expect(spy).toHaveBeenCalledExactlyOnceWith(
"UrlParams: final set of url params\n",
"intent:",
"unknown",
"\nproperties:",
expect.any(Object),
"configuration:",
expect.any(Object),
"intentAndPlatformDerivedConfiguration:",
{},
);
});
});
});

View File

@@ -8,7 +8,10 @@ Please see LICENSE in the repository root for full details.
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { pickBy } from "lodash-es";
import { Config } from "./config/Config";
@@ -26,7 +29,9 @@ export enum UserIntent {
StartNewCall = "start_call",
JoinExistingCall = "join_existing",
StartNewCallDM = "start_call_dm",
StartNewCallDMVoice = "start_call_dm_voice",
JoinExistingCallDM = "join_existing_dm",
JoinExistingCallDMVoice = "join_existing_dm_voice",
Unknown = "unknown",
}
@@ -227,6 +232,16 @@ export interface UrlConfiguration {
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
waitForCallPickup: boolean;
callIntent?: RTCCallIntent;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
interface IntentAndPlatformDerivedConfiguration {
defaultAudioEnabled?: boolean;
defaultVideoEnabled?: boolean;
}
// If you need to add a new flag to this interface, prefer a name that describes
@@ -234,7 +249,10 @@ export interface UrlConfiguration {
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams extends UrlProperties, UrlConfiguration {}
export interface UrlParams
extends UrlProperties,
UrlConfiguration,
IntentAndPlatformDerivedConfiguration {}
// This is here as a stopgap, but what would be far nicer is a function that
// takes a UrlParams and returns a query string. That would enable us to
@@ -310,8 +328,14 @@ class ParamParser {
}
}
let urlParamCache: {
search?: string;
hash?: string;
params?: UrlParams;
} = {};
/**
* Gets the app parameters for the current URL.
* Gets the url params and loads them from a cache if already computed.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
@@ -320,6 +344,26 @@ export const getUrlParams = (
search = window.location.search,
hash = window.location.hash,
): UrlParams => {
if (
urlParamCache.search === search &&
urlParamCache.hash === hash &&
urlParamCache.params
) {
return urlParamCache.params;
}
const params = computeUrlParams(search, hash);
urlParamCache = { search, hash, params };
return params;
};
/**
* Gets the app parameters for the current URL.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
*/
export const computeUrlParams = (search = "", hash = ""): UrlParams => {
const parser = new ParamParser(search, hash);
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
@@ -343,11 +387,10 @@ export const getUrlParams = (
? UserIntent.Unknown
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
// Here we only use constants and `platform` to determine the intent preset.
let intentPreset: UrlConfiguration;
const inAppDefault = {
let intentPreset: UrlConfiguration = {
confineToRoom: true,
appPrompt: false,
preload: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
@@ -356,37 +399,38 @@ export const getUrlParams = (
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification" as RTCNotificationType,
sendNotificationType: "notification",
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
switch (intent) {
case UserIntent.StartNewCall:
intentPreset = {
...inAppDefault,
skipLobby: true,
};
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.JoinExistingCall:
intentPreset = {
...inAppDefault,
skipLobby: false,
};
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
break;
case UserIntent.StartNewCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
intentPreset = {
...inAppDefault,
skipLobby: true,
autoLeaveWhenOthersLeft: true,
waitForCallPickup: true,
};
intentPreset.skipLobby = true;
intentPreset.sendNotificationType = "ring";
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.waitForCallPickup = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
break;
case UserIntent.JoinExistingCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
intentPreset = {
...inAppDefault,
skipLobby: true,
autoLeaveWhenOthersLeft: true,
};
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = true;
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
break;
// Non widget usecase defaults
default:
@@ -408,6 +452,29 @@ export const getUrlParams = (
};
}
const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
{};
// Desktop also includes web. Its anything that is not mobile.
const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
switch (desktopMobile) {
case "desktop":
case "mobile":
switch (intent) {
case UserIntent.StartNewCall:
case UserIntent.JoinExistingCall:
case UserIntent.StartNewCallDM:
case UserIntent.JoinExistingCallDM:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
break;
case UserIntent.StartNewCallDMVoice:
case UserIntent.JoinExistingCallDMVoice:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = false;
break;
}
}
const properties: UrlProperties = {
widgetId,
parentUrl,
@@ -460,10 +527,25 @@ export const getUrlParams = (
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
};
// Log the final configuration for debugging purposes.
// This will only log when the cache is not yet set.
logger.info(
"UrlParams: final set of url params\n",
"intent:",
intent,
"\nproperties:",
properties,
"configuration:",
configuration,
"intentAndPlatformDerivedConfiguration:",
intentAndPlatformDerivedConfiguration,
);
return {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
...intentAndPlatformDerivedConfiguration,
};
};

View File

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

View File

@@ -9,6 +9,7 @@ import {
ConnectionState,
type E2EEManagerOptions,
ExternalE2EEKeyProvider,
type LocalTrackPublication,
LocalVideoTrack,
Room,
type RoomOptions,
@@ -181,6 +182,33 @@ export function useLivekit(
sfuConfig,
);
// Log errors when local participant has issues publishing a track.
useEffect(() => {
const localTrackUnpublishedFn = (
publication: LocalTrackPublication,
): void => {
logger.info(
"Local track unpublished",
publication.trackName,
publication.trackInfo,
);
};
const mediaDevicesErrorFn = (error: Error): void => {
logger.warn("Media devices error when publishing a track", error);
};
room.localParticipant.on("localTrackUnpublished", localTrackUnpublishedFn);
room.localParticipant.on("mediaDevicesError", mediaDevicesErrorFn);
return (): void => {
room.localParticipant.off(
"localTrackUnpublished",
localTrackUnpublishedFn,
);
room.localParticipant.off("mediaDevicesError", mediaDevicesErrorFn);
};
}, [room.localParticipant]);
useEffect(() => {
// Sync the requested mute states with LiveKit's mute states. We do it this
// way around rather than using LiveKit as the source of truth, so that the

View File

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

View File

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

View File

@@ -54,6 +54,8 @@ beforeEach(() => {
playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
});
});
@@ -105,6 +107,20 @@ test("plays a sound when a user leaves", () => {
expect(playSound).toBeCalledWith("left");
});
test("does not play a sound before the call is successful", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
[local, alice],
[localRtcMember],
{ waitForCallPickup: true },
);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
rtcMemberships$.next([localRtcMember]);
});
expect(playSound).not.toBeCalledWith("left");
});
test("plays no sound when the participant list is more than the maximum size", () => {
const mockRtcMemberships: CallMembership[] = [localRtcMember];
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {

View File

@@ -16,6 +16,10 @@ import handSoundOgg from "../sound/raise_hand.ogg";
import handSoundMp3 from "../sound/raise_hand.mp3";
import screenShareStartedOgg from "../sound/screen_share_started.ogg";
import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
import declineMp3 from "../sound/call_declined.mp3?url";
import declineOgg from "../sound/call_declined.ogg?url";
import timeoutMp3 from "../sound/call_timeout.mp3?url";
import timeoutOgg from "../sound/call_timeout.ogg?url";
import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest";
@@ -37,8 +41,18 @@ export const callEventAudioSounds = prefetchSounds({
mp3: screenShareStartedMp3,
ogg: screenShareStartedOgg,
},
decline: {
mp3: declineMp3,
ogg: declineOgg,
},
timeout: {
mp3: timeoutMp3,
ogg: timeoutOgg,
},
});
export type CallEventSounds = keyof Awaited<typeof callEventAudioSounds>;
export function CallEventAudioRenderer({
vm,
muted,

View File

@@ -34,7 +34,6 @@
.overlay[data-show="false"] {
animation: fade-out 130ms forwards;
content-visibility: hidden;
pointer-events: none;
}

View File

@@ -20,7 +20,7 @@ interface Props {
export const EarpieceOverlay: FC<Props> = ({ show, onBackToVideoPressed }) => {
const { t } = useTranslation();
return (
<div className={styles.overlay} data-show={show}>
<div className={styles.overlay} data-show={show} aria-hidden={!show}>
<BigIcon className={styles.icon}>
<VoiceCallIcon aria-hidden />
</BigIcon>

View File

@@ -132,9 +132,10 @@ test("ConnectionLostError: Action handling should reset error state", async () =
const WrapComponent = (): ReactNode => {
const [failState, setFailState] = useState(true);
const reconnectCallback = useCallback(
(action: CallErrorRecoveryAction) => {
async (action: CallErrorRecoveryAction) => {
reconnectCallbackSpy(action);
setFailState(false);
return Promise.resolve();
},
[setFailState],
);

View File

@@ -36,7 +36,9 @@ import { type WidgetHelpers } from "../widget.ts";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
export type RecoveryActionHandler = (action: CallErrorRecoveryAction) => void;
export type RecoveryActionHandler = (
action: CallErrorRecoveryAction,
) => Promise<void>;
interface ErrorPageProps {
error: ElementCallError;
@@ -71,7 +73,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
if (error instanceof ConnectionLostError) {
actions.push({
label: t("call_ended_view.reconnect_button"),
onClick: () => recoveryActionHandler("reconnect"),
onClick: () => void recoveryActionHandler("reconnect"),
});
}
@@ -131,9 +133,9 @@ export const GroupCallErrorBoundary = ({
widget={widget ?? null}
error={callError}
resetError={resetError}
recoveryActionHandler={(action: CallErrorRecoveryAction) => {
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
await recoveryActionHandler(action);
resetError();
recoveryActionHandler(action);
}}
/>
);

View File

@@ -12,10 +12,14 @@ import {
onTestFinished,
test,
vi,
vitest,
} from "vitest";
import { render, waitFor, screen } from "@testing-library/react";
import { render, waitFor, screen, act } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
@@ -94,13 +98,15 @@ beforeEach(() => {
playSound = vi.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vi.fn(),
soundDuration: {},
});
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
({ onLeave }) => {
return (
<div>
<button onClick={() => onLeave()}>Leave</button>
<button onClick={() => onLeave("user")}>Leave</button>
</div>
);
},
@@ -206,6 +212,8 @@ test("GroupCallView plays a leave sound synchronously in widget mode", async ()
);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
});
const { getByText, rtcSession } = createGroupCallView(
@@ -258,3 +266,19 @@ test("GroupCallView shows errors that occur during joining", async () => {
await user.click(screen.getByRole("button", { name: "Join call" }));
screen.getByText("Call is not supported");
});
test("user can reconnect after a membership manager error", async () => {
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null, true);
await act(() =>
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
);
// XXX: Wrapping the following click in act() shouldn't be necessary (the
// async state update should be processed automatically by the waitFor call),
// and yet here we are.
await act(async () =>
user.click(screen.getByRole("button", { name: "Reconnect" })),
);
// In-call controls should be visible again
await waitFor(() => screen.getByRole("button", { name: "Leave" }));
});

View File

@@ -50,10 +50,13 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
import {
callEventAudioSounds,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle";
import {
@@ -83,8 +86,8 @@ interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
confineToRoom: boolean;
preload: boolean;
skipLobby: boolean;
preload: UrlParams["preload"];
skipLobby: UrlParams["skipLobby"];
header: HeaderStyle;
rtcSession: MatrixRTCSession;
isJoined: boolean;
@@ -125,6 +128,16 @@ export const GroupCallView: FC<Props> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update our member event when our mute state changes.
useEffect(() => {
if (!isJoined) {
return;
}
void rtcSession.updateCallIntent(
muteStates.video.enabled ? "video" : "audio",
);
}, [rtcSession, isJoined, muteStates.video.enabled]);
useEffect(() => {
logger.info("[Lifecycle] GroupCallView Component mounted");
return (): void => {
@@ -276,34 +289,28 @@ export const GroupCallView: FC<Props> = ({
};
if (skipLobby) {
if (widget) {
if (preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
(async (): Promise<void> => {
await defaultDeviceSetup(
ev.detail.data as unknown as JoinCallData,
);
await enterRTCSessionOrError(rtcSession);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else {
// No lobby and no preload: we enter the rtc session right away
if (widget && preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
(async (): Promise<void> => {
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
await enterRTCSessionOrError(rtcSession);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
logger.error("Error joining RTC session on preload", e);
});
}
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else {
void enterRTCSessionOrError(rtcSession);
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await enterRTCSessionOrError(rtcSession);
})().catch((e) => {
logger.error("Error joining RTC session immediately", e);
});
}
}
}, [
@@ -323,8 +330,11 @@ export const GroupCallView: FC<Props> = ({
const navigate = useNavigate();
const onLeave = useCallback(
(cause: "user" | "error" = "user"): void => {
const audioPromise = leaveSoundContext.current?.playSound("left");
(
cause: "user" | "error" = "user",
playSound: CallEventSounds = "left",
): void => {
const audioPromise = leaveSoundContext.current?.playSound(playSound);
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
// therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget;
@@ -494,6 +504,7 @@ export const GroupCallView: FC<Props> = ({
// Left in widget mode:
body = returnToLobby ? lobbyView : null;
} else if (preload || skipLobby) {
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
body = null;
} else {
body = lobbyView;
@@ -502,10 +513,11 @@ export const GroupCallView: FC<Props> = ({
return (
<GroupCallErrorBoundary
widget={widget}
recoveryActionHandler={(action) => {
recoveryActionHandler={async (action) => {
setExternalError(null);
if (action == "reconnect") {
setLeft(false);
enterRTCSessionOrError(rtcSession).catch((e) => {
await enterRTCSessionOrError(rtcSession).catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}

View File

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

View File

@@ -17,7 +17,7 @@ import { act, render, type RenderResult } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { ConnectionState, type LocalParticipant } from "livekit-client";
import { type LocalParticipant } from "livekit-client";
import { of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -180,7 +180,6 @@ function createInCallView(): RenderResult & {
onLeave={function (): void {
throw new Error("Function not implemented.");
}}
connState={ConnectionState.Connected}
onShareClick={null}
/>
</RoomContext>

View File

@@ -25,7 +25,11 @@ import useMeasure from "react-use-measure";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import classNames from "classnames";
import { BehaviorSubject, map } from "rxjs";
import { useObservable, useSubscription } from "observable-hooks";
import {
useObservable,
useObservableEagerState,
useSubscription,
} from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import {
@@ -62,7 +66,6 @@ import { type MuteStates } from "./MuteStates";
import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton";
import { LayoutToggle } from "./LayoutToggle";
import { type ECConnectionState } from "../livekit/useECConnectionState";
import { useOpenIDSFU } from "../livekit/openIDSFU";
import {
CallViewModel,
@@ -94,7 +97,10 @@ import {
} from "../reactions/useReactionsSender";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
CallEventAudioRenderer,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import {
debugTileLayout as debugTileLayoutSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
@@ -112,6 +118,13 @@ import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
import { Avatar, Size as AvatarSize } from "../Avatar";
import waitingStyles from "./WaitingForJoin.module.css";
import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext";
import ringtoneMp3 from "../sound/ringtone.mp3?url";
import ringtoneOgg from "../sound/ringtone.ogg?url";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -158,7 +171,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
}, [livekitRoom]);
const { autoLeaveWhenOthersLeft } = useUrlParams();
const { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup } =
useUrlParams();
useEffect(() => {
if (livekitRoom !== undefined) {
@@ -171,6 +185,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
waitForCallPickup:
waitForCallPickup && sendNotificationType === "ring",
},
connStateObservable$,
reactionsReader.raisedHands$,
@@ -190,6 +206,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.e2eeSystem,
connStateObservable$,
autoLeaveWhenOthersLeft,
sendNotificationType,
waitForCallPickup,
]);
if (livekitRoom === undefined || vm === null) return null;
@@ -197,12 +215,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
return (
<RoomContext value={livekitRoom}>
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView
{...props}
vm={vm}
livekitRoom={livekitRoom}
connState={connState}
/>
<InCallView {...props} vm={vm} livekitRoom={livekitRoom} />
</ReactionsSenderProvider>
</RoomContext>
);
@@ -217,10 +230,9 @@ export interface InCallViewProps {
livekitRoom: LivekitRoom;
muteStates: MuteStates;
/** Function to call when the user explicitly ends the call */
onLeave: () => void;
onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
header: HeaderStyle;
otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState;
onShareClick: (() => void) | null;
}
@@ -234,7 +246,6 @@ export const InCallView: FC<InCallViewProps> = ({
muteStates,
onLeave,
header: headerStyle,
connState,
onShareClick,
}) => {
const { t } = useTranslation();
@@ -242,10 +253,11 @@ export const InCallView: FC<InCallViewProps> = ({
useReactionsSender();
useWakeLock();
const connectionState = useObservableEagerState(vm.livekitConnectionState$);
// annoyingly we don't get the disconnection reason this way,
// only by listening for the emitted event
if (connState === ConnectionState.Disconnected)
if (connectionState === ConnectionState.Disconnected)
throw new ConnectionLostError();
const containerRef1 = useRef<HTMLDivElement | null>(null);
@@ -260,6 +272,21 @@ export const InCallView: FC<InCallViewProps> = ({
});
const muteAllAudio = useBehavior(muteAllAudio$);
// Call pickup state and display names are needed for waiting overlay/sounds
const callPickupState = useBehavior(vm.callPickupState$);
// Preload a waiting and decline sounds
const pickupPhaseSoundCache = useInitial(async () => {
return prefetchSounds({
waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
});
});
const pickupPhaseAudio = useAudioContext({
sounds: pickupPhaseSoundCache,
latencyHint: "interactive",
muted: muteAllAudio,
});
// This seems like it might be enough logic to use move it into the call view model?
const [didFallbackToRoomKey, setDidFallbackToRoomKey] = useState(false);
@@ -321,7 +348,90 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
useSubscription(vm.autoLeave$, onLeave);
useSubscription(vm.autoLeave$, () => onLeave("user"));
// We need to set the proper timings on the animation based upon the sound length.
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
useEffect((): (() => void) => {
// The CSS animation includes the delay, so we must double the length of the sound.
window.document.body.style.setProperty(
"--call-ring-duration-s",
`${ringDuration * 2}s`,
);
window.document.body.style.setProperty(
"--call-ring-delay-s",
`${ringDuration}s`,
);
// Remove properties when we unload.
return () => {
window.document.body.style.removeProperty("--call-ring-duration-s");
window.document.body.style.removeProperty("--call-ring-delay-s");
};
}, [pickupPhaseAudio?.soundDuration, ringDuration]);
// When we enter timeout or decline we will leave the call.
useEffect((): void | (() => void) => {
if (callPickupState === "timeout") {
onLeave("user", "timeout");
}
if (callPickupState === "decline") {
onLeave("user", "decline");
}
}, [callPickupState, onLeave, pickupPhaseAudio]);
// When waiting for pickup, loop a waiting sound
useEffect((): void | (() => void) => {
if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
const endSound = pickupPhaseAudio.playSoundLooping("waiting", ringDuration);
return () => {
void endSound().catch((e) => {
logger.error("Failed to stop ringing sound", e);
});
};
}, [callPickupState, pickupPhaseAudio, ringDuration]);
// Waiting UI overlay
const waitingOverlay: JSX.Element | null = useMemo(() => {
// No overlay if not in ringing state
if (callPickupState !== "ringing") return null;
// Use room state for other participants data (the one that we likely want to reach)
const roomOthers = [
...matrixRoom.getMembersWithMembership("join"),
...matrixRoom.getMembersWithMembership("invite"),
].filter((m) => m.userId !== client.getUserId());
// Yield if there are not other members in the room.
if (roomOthers.length === 0) return null;
const otherMember = roomOthers.length > 0 ? roomOthers[0] : undefined;
const isOneOnOne = roomOthers.length === 1 && otherMember;
const text = isOneOnOne
? `Waiting for ${otherMember.name ?? otherMember.userId} to join…`
: "Waiting for other participants…";
const avatarMxc = isOneOnOne
? (otherMember.getMxcAvatarUrl?.() ?? undefined)
: (matrixRoom.getMxcAvatarUrl() ?? undefined);
return (
<div className={classNames(overlayStyles.bg, waitingStyles.overlay)}>
<div
className={classNames(overlayStyles.content, waitingStyles.content)}
>
<div className={waitingStyles.pulse}>
<Avatar
id={isOneOnOne ? otherMember.userId : matrixRoom.roomId}
name={isOneOnOne ? otherMember.name : matrixRoom.name}
src={avatarMxc}
size={AvatarSize.XL}
/>
</div>
<Text size="md" className={waitingStyles.text}>
{text}
</Text>
</div>
</div>
);
}, [callPickupState, client, matrixRoom]);
// Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported
@@ -531,6 +641,38 @@ export const InCallView: FC<InCallViewProps> = ({
}
}
// The reconnecting toast cannot be dismissed
const onDismissReconnectingToast = useCallback(() => {}, []);
// We need to use a non-modal toast to avoid trapping focus within the toast.
// However, a non-modal toast will not render any background overlay on its
// own, so we must render one manually.
const reconnectingToast = (
<>
<div
className={classNames(overlayStyles.bg, overlayStyles.animate)}
data-state={reconnecting ? "open" : "closed"}
/>
<Toast
onDismiss={onDismissReconnectingToast}
open={reconnecting}
modal={false}
>
{t("common.reconnecting")}
</Toast>
</>
);
const earpieceOverlay = (
<EarpieceOverlay
show={earpieceMode && !reconnecting}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
);
// If the reconnecting toast or earpiece overlay obscures the media tiles, we
// need to remove them from the accessibility tree and block focus.
const contentObscured = reconnecting || earpieceMode;
const Tile = useMemo(
() =>
function Tile({
@@ -560,6 +702,7 @@ export const InCallView: FC<InCallViewProps> = ({
className={classNames(className, styles.tile)}
style={style}
showSpeakingIndicators={showSpeakingIndicatorsValue}
focusable={!contentObscured}
/>
) : (
<SpotlightTile
@@ -570,12 +713,13 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={targetWidth}
targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
style={style}
/>
);
},
[vm, openProfile],
[vm, openProfile, contentObscured],
);
const layouts = useMemo(() => {
@@ -604,6 +748,8 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={gridBounds.height}
targetHeight={gridBounds.width}
showIndicators={false}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
);
}
@@ -621,6 +767,7 @@ export const InCallView: FC<InCallViewProps> = ({
model={layout}
Layout={layers.fixed}
Tile={Tile}
aria-hidden={contentObscured}
/>
);
const scrollingGrid = (
@@ -630,6 +777,7 @@ export const InCallView: FC<InCallViewProps> = ({
model={layout}
Layout={layers.scrolling}
Tile={Tile}
aria-hidden={contentObscured}
/>
);
// The grid tiles go *under* the spotlight in the portrait layout, but
@@ -718,7 +866,7 @@ export const InCallView: FC<InCallViewProps> = ({
<EndCallButton
key="end_call"
onClick={function (): void {
onLeave();
onLeave("user");
}}
onTouchEnd={onControlsTouchEnd}
data-testid="incall_leave"
@@ -759,9 +907,6 @@ export const InCallView: FC<InCallViewProps> = ({
</div>
);
// The reconnecting toast cannot be dismissed
const onDismissReconnectingToast = useCallback(() => {}, []);
return (
<div
className={styles.inRoom}
@@ -789,18 +934,10 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<Toast
onDismiss={onDismissReconnectingToast}
open={reconnecting}
portal={false}
>
{t("common.reconnecting")}
</Toast>
<EarpieceOverlay
show={earpieceMode && !reconnecting}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
{reconnectingToast}
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{waitingOverlay}
{footer}
{layout.type !== "pip" && (
<>

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
@@ -26,7 +27,6 @@ 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 {
@@ -110,9 +110,10 @@ function mockMediaDevices(
return new MediaDevices(scope);
}
describe("useMuteStates", () => {
describe("useMuteStates VITE_PACKAGE='full' (SPA) mode", () => {
afterEach(() => {
vi.clearAllMocks();
vi.stubEnv("VITE_PACKAGE", "full");
});
afterAll(() => {
@@ -256,3 +257,67 @@ describe("useMuteStates", () => {
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
});
describe("useMuteStates in VITE_PACKAGE='embedded' (widget) mode", () => {
beforeEach(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
it("uses defaults from config", () => {
mockConfig({
media_devices: {
enable_audio: false,
enable_video: false,
},
});
render(
<MemoryRouter>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
expect(screen.getByTestId("video-enabled").textContent).toBe("false");
});
it("skipLobby does not mute inputs", () => {
mockConfig();
render(
<MemoryRouter
initialEntries={[
"/room/?skipLobby=true&widgetId=1234&parentUrl=www.parent.org",
]}
>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
it("url params win over config", () => {
// The config sets audio and video to disabled
mockConfig({ media_devices: { enable_audio: false, enable_video: false } });
render(
<MemoryRouter
initialEntries={[
// The Intent sets both audio and video enabled to true via the url param configuration
"/room/?intent=start_call_dm&widgetId=1234&parentUrl=www.parent.org",
]}
>
<MediaDevicesContext value={mockMediaDevices()}>
<TestComponent />
</MediaDevicesContext>
</MemoryRouter>,
);
// At the end we expect the url param to take precedence, resulting in true
expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
});

View File

@@ -81,11 +81,15 @@ function useMuteState(
export function useMuteStates(isJoined: boolean): MuteStates {
const devices = useMediaDevices();
const { skipLobby } = useUrlParams();
const { skipLobby, defaultAudioEnabled, defaultVideoEnabled } =
useUrlParams();
const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
});
const audio = useMuteState(
devices.audioInput,
() =>
(defaultAudioEnabled ?? Config.get().media_devices.enable_audio) &&
allowJoinUnmuted(skipLobby, isJoined),
);
useEffect(() => {
// If audio is enabled, we need to request the device names again,
// because iOS will not be able to switch to the correct device after un-muting.
@@ -97,7 +101,9 @@ export function useMuteStates(isJoined: boolean): MuteStates {
const isEarpiece = useIsEarpiece();
const video = useMuteState(
devices.videoInput,
() => Config.get().media_devices.enable_video && !skipLobby && !isJoined,
() =>
(defaultVideoEnabled ?? Config.get().media_devices.enable_video) &&
allowJoinUnmuted(skipLobby, isJoined),
isEarpiece, // Force video to be unavailable if using earpiece
);
@@ -164,3 +170,9 @@ export function useMuteStates(isJoined: boolean): MuteStates {
return useMemo(() => ({ audio, video }), [audio, video]);
}
function allowJoinUnmuted(skipLobby: boolean, isJoined: boolean): boolean {
return (
(!skipLobby && !isJoined) || import.meta.env.VITE_PACKAGE === "embedded"
);
}

View File

@@ -69,6 +69,8 @@ beforeEach(() => {
playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
});
});

View File

@@ -0,0 +1,61 @@
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.pulse {
position: relative;
height: 90px;
}
.pulse::before {
content: "";
position: absolute;
inset: -12px;
border-radius: 9999px;
border: 12px solid rgba(255, 255, 255, 0.6);
animation: pulse var(--call-ring-duration-s) ease-out infinite;
animation-delay: 1s;
opacity: 0;
}
.text {
color: var(--cpd-color-text-on-solid-primary);
}
@keyframes pulse {
0% {
transform: scale(0.95);
opacity: 0.7;
transform: scale(0);
opacity: 1;
}
35% {
transform: scale(1.15);
opacity: 0.15;
}
50% {
transform: scale(1.2);
opacity: 0;
}
50.01% {
transform: scale(0);
}
85% {
transform: scale(0);
}
100% {
transform: scale(0);
}
}

View File

@@ -111,6 +111,11 @@ exports[`InCallView > rendering > renders 1`] = `
<div />
</div>
<div
class="bg animate"
data-state="closed"
/>
<div
aria-hidden="true"
class="overlay"
data-show="false"
>

View File

@@ -120,11 +120,13 @@ export async function enterRTCSession(
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const useDeviceSessionMemberEvents =
features?.feature_use_device_session_member_events;
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
rtcSession.joinRoomSession(
await makePreferredLivekitFoci(rtcSession, livekitAlias),
makeActiveFocus(),
{
notificationType: getUrlParams().sendNotificationType,
notificationType,
callIntent,
useNewMembershipManager,
manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && {

View File

@@ -23,3 +23,4 @@ The following sound effects have been originally created by Element.
- `end_call`
- `reactions/rock`
- `reactions/wave`
- `reactions/baduntss`

Binary file not shown.

Binary file not shown.

BIN
src/sound/call_declined.mp3 Normal file

Binary file not shown.

BIN
src/sound/call_declined.ogg Normal file

Binary file not shown.

BIN
src/sound/call_timeout.mp3 Normal file

Binary file not shown.

BIN
src/sound/call_timeout.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/sound/ringtone.mp3 Normal file

Binary file not shown.

BIN
src/sound/ringtone.ogg Normal file

Binary file not shown.

View File

@@ -18,7 +18,16 @@ import {
of,
switchMap,
} from "rxjs";
import { ClientEvent, SyncState, type MatrixClient } from "matrix-js-sdk";
import {
ClientEvent,
SyncState,
type MatrixClient,
RoomEvent as MatrixRoomEvent,
MatrixEvent,
type IRoomTimelineData,
EventType,
type IEvent,
} from "matrix-js-sdk";
import {
ConnectionState,
type LocalParticipant,
@@ -237,10 +246,27 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
}
function mockRingEvent(
eventId: string,
lifetimeMs: number | undefined,
sender = local.userId,
): { event_id: string } & IRTCNotificationContent {
return {
event_id: eventId,
...(lifetimeMs === undefined ? {} : { lifetime: lifetimeMs }),
notification_type: "ring",
sender,
} as unknown as { event_id: string } & IRTCNotificationContent;
}
// The app doesn't really care about the content of these legacy events, we just
// need a value to fill in for them when emitting notifications
const mockLegacyRingEvent = {} as { event_id: string } & ICallNotifyContent;
interface CallViewModelInputs {
remoteParticipants$: Behavior<RemoteParticipant[]>;
rtcMembers$: Behavior<Partial<CallMembership>[]>;
connectionState$: Observable<ECConnectionState>;
livekitConnectionState$: Observable<ECConnectionState>;
speaking: Map<Participant, Observable<boolean>>;
mediaDevices: MediaDevices;
initialSyncState: SyncState;
@@ -250,7 +276,7 @@ function withCallViewModel(
{
remoteParticipants$ = constant([]),
rtcMembers$ = constant([localRtcMember]),
connectionState$ = of(ConnectionState.Connected),
livekitConnectionState$: connectionState$ = of(ConnectionState.Connected),
speaking = new Map(),
mediaDevices = mockMediaDevices({}),
initialSyncState = SyncState.Syncing,
@@ -358,7 +384,7 @@ test("participants are retained during a focus switch", () => {
b: [],
}),
rtcMembers$: constant([localRtcMember, aliceRtcMember, bobRtcMember]),
connectionState$: behavior(connectionInputMarbles, {
livekitConnectionState$: behavior(connectionInputMarbles, {
c: ConnectionState.Connected,
s: ECAddonConnectionState.ECSwitchingFocus,
}),
@@ -1205,10 +1231,43 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 30 } as unknown as {
event_id: string;
} & IRTCNotificationContent,
{} as unknown as { event_id: string } & ICallNotifyContent,
mockRingEvent("$notif1", 30),
mockLegacyRingEvent,
);
},
});
expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms c", {
a: "unknown",
b: "ringing",
c: "timeout",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("regression test: does stop ringing in case livekitConnectionState$ emits after didSendCallNotification$ has already emitted", () => {
withTestScheduler(({ schedule, expectObservable, behavior }) => {
withCallViewModel(
{
livekitConnectionState$: behavior("d 9ms c", {
d: ConnectionState.Disconnected,
c: ConnectionState.Connected,
}),
},
(vm, rtcSession) => {
// Fire a call notification IMMEDIATELY (its important for this test, that this happens before the livekitConnectionState$ emits)
schedule("n", {
n: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$notif1", 30),
mockLegacyRingEvent,
);
},
});
@@ -1247,12 +1306,8 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 100 } as unknown as {
event_id: string;
} & IRTCNotificationContent,
{} as unknown as {
event_id: string;
} & ICallNotifyContent,
mockRingEvent("$notif2", 100),
mockLegacyRingEvent,
);
},
});
@@ -1271,6 +1326,51 @@ describe("waitForCallPickup$", () => {
});
});
test("ringing -> unknown if we get disconnected", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
const connectionState$ = new BehaviorSubject(ConnectionState.Connected);
// Someone joins at 20ms (both LiveKit participant and MatrixRTC member)
withCallViewModel(
{
remoteParticipants$: behavior("a 19ms b", {
a: [],
b: [aliceParticipant],
}),
rtcMembers$: behavior("a 19ms b", {
a: [localRtcMember],
b: [localRtcMember, aliceRtcMember],
}),
livekitConnectionState$: connectionState$,
},
(vm, rtcSession) => {
// Notify at 5ms so we enter ringing, then get disconnected 5ms later
schedule(" 5ms r 5ms d", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$notif2", 100),
mockLegacyRingEvent,
);
},
d: () => {
connectionState$.next(ConnectionState.Disconnected);
},
});
expectObservable(vm.callPickupState$).toBe("a 4ms b 5ms c", {
a: "unknown",
b: "ringing",
c: "unknown",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("success when someone joins before we notify", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Join at 10ms, notify later at 20ms (state should stay success)
@@ -1290,12 +1390,8 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 50 } as unknown as {
event_id: string;
} & IRTCNotificationContent,
{} as unknown as {
event_id: string;
} & ICallNotifyContent,
mockRingEvent("$notif3", 50),
mockLegacyRingEvent,
);
},
});
@@ -1321,12 +1417,8 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{} as unknown as {
event_id: string;
} & IRTCNotificationContent, // no lifetime
{} as unknown as {
event_id: string;
} & ICallNotifyContent,
mockRingEvent("$notif4", undefined),
mockLegacyRingEvent,
);
},
});
@@ -1361,12 +1453,8 @@ describe("waitForCallPickup$", () => {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
{ lifetime: 30 } as unknown as {
event_id: string;
} & IRTCNotificationContent,
{} as unknown as {
event_id: string;
} & ICallNotifyContent,
mockRingEvent("$notif5", 30),
mockLegacyRingEvent,
);
},
});
@@ -1381,6 +1469,149 @@ describe("waitForCallPickup$", () => {
);
});
});
test("decline before timeout window ends -> decline", () => {
withTestScheduler(({ schedule, expectObservable }) => {
withCallViewModel(
{},
(vm, rtcSession) => {
// Notify at 10ms with 50ms lifetime, decline at 40ms with matching id
schedule(" 10ms r 29ms d", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$decl1", 50),
mockLegacyRingEvent,
);
},
d: () => {
// Emit decline timeline event with id matching the notification
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({
type: EventType.RTCDecline,
content: {
"m.relates_to": {
rel_type: "m.reference",
event_id: "$decl1",
},
},
}),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
});
expectObservable(vm.callPickupState$).toBe("a 9ms b 29ms e", {
a: "unknown",
b: "ringing",
e: "decline",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
test("decline after timeout window ends -> stays timeout", () => {
withTestScheduler(({ schedule, expectObservable }) => {
withCallViewModel(
{},
(vm, rtcSession) => {
// Notify at 10ms with 20ms lifetime (timeout at 30ms), decline at 40ms
schedule(" 10ms r 20ms t 10ms d", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$decl2", 20),
mockLegacyRingEvent,
);
},
t: () => {},
d: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent({ event_id: "$decl2", type: "m.rtc.decline" }),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
});
expectObservable(vm.callPickupState$).toBe("a 9ms b 19ms c", {
a: "unknown",
b: "ringing",
c: "timeout",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
});
function testStaysRinging(declineEvent: Partial<IEvent>): void {
withTestScheduler(({ schedule, expectObservable }) => {
withCallViewModel(
{},
(vm, rtcSession) => {
// Notify at 10ms with id A, decline arrives at 20ms with id B
schedule(" 10ms r 10ms d", {
r: () => {
rtcSession.emit(
MatrixRTCSessionEvent.DidSendCallNotification,
mockRingEvent("$right", 50),
mockLegacyRingEvent,
);
},
d: () => {
rtcSession.room.emit(
MatrixRoomEvent.Timeline,
new MatrixEvent(declineEvent),
rtcSession.room,
undefined,
false,
{} as IRoomTimelineData,
);
},
});
// We assert up to 21ms to see the ringing at 10ms and no change at 20ms
expectObservable(vm.callPickupState$, "21ms !").toBe("a 9ms b", {
a: "unknown",
b: "ringing",
});
},
{
waitForCallPickup: true,
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
},
);
});
}
test("decline with wrong id is ignored (stays ringing)", () => {
testStaysRinging({
event_id: "$wrong",
type: "m.rtc.decline",
sender: local.userId,
});
});
test("decline with sender being the local user is ignored (stays ringing)", () => {
testStaysRinging({
event_id: "$right",
type: "m.rtc.decline",
sender: alice.userId,
});
});
});
test("audio output changes when toggling earpiece mode", () => {

View File

@@ -19,6 +19,9 @@ import {
} from "livekit-client";
import {
ClientEvent,
type EventTimelineSetHandlerMap,
EventType,
RoomEvent,
RoomStateEvent,
SyncState,
type Room as MatrixRoom,
@@ -33,6 +36,7 @@ import {
combineLatest,
concat,
distinctUntilChanged,
endWith,
filter,
forkJoin,
fromEvent,
@@ -45,6 +49,7 @@ import {
race,
scan,
skip,
skipWhile,
startWith,
switchAll,
switchMap,
@@ -58,9 +63,9 @@ import {
import { logger } from "matrix-js-sdk/lib/logger";
import {
type CallMembership,
type IRTCNotificationContent,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type MatrixRTCSessionEventHandlerMap,
MembershipManagerEvent,
Status,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -849,17 +854,6 @@ export class CallViewModel extends ViewModel {
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
public readonly leaveSoundEffect$ = this.userMedia$.pipe(
pairwise(),
filter(
([prev, current]) =>
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current.length < prev.length,
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
/**
* The number of participants currently in the call.
*
@@ -886,18 +880,69 @@ export class CallViewModel extends ViewModel {
? this.allOthersLeft$
: NEVER;
private readonly didSendCallNotification$ = fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.DidSendCallNotification,
) as Observable<
Parameters<
MatrixRTCSessionEventHandlerMap[MatrixRTCSessionEvent.DidSendCallNotification]
>
>;
/**
* Emits whenever the RTC session tells us that it intends to ring for a given
* duration.
* Whenever the RTC session tells us that it intends to ring the remote
* participant's devices, this emits an Observable tracking the current state of
* that ringing process.
*/
private readonly beginRingingForMs$ = (
fromEvent(
this.matrixRTCSession,
MatrixRTCSessionEvent.DidSendCallNotification,
) as Observable<[IRTCNotificationContent]>
)
// event.lifetime is expected to be in ms
.pipe(map(([notificationEvent]) => notificationEvent?.lifetime ?? 0));
// This is a behavior since we need to store the latest state for when we subscribe to this after `didSendCallNotification$`
// has already emitted but we still need the latest observable with a timeout timer that only gets created on after receiving `notificationEvent`.
// A behavior will emit the latest observable with the running timer to new subscribers.
// see also: callPickupState$ and in particular the line: `return this.ring$.pipe(mergeAll());` here we otherwise might get an EMPTY observable if
// `ring$` would not be a behavior.
private readonly ring$: Behavior<
Observable<"ringing" | "timeout" | "decline"> | Observable<never>
> = this.scope.behavior(
this.didSendCallNotification$.pipe(
filter(
([notificationEvent]) => notificationEvent.notification_type === "ring",
),
map(([notificationEvent]) => {
const lifetimeMs = notificationEvent?.lifetime ?? 0;
return concat(
lifetimeMs === 0
? // If no lifetime, skip the ring state
EMPTY
: // Ring until lifetime ms have passed
timer(lifetimeMs).pipe(
ignoreElements(),
startWith("ringing" as const),
),
// The notification lifetime has timed out, meaning ringing has likely
// stopped on all receiving clients.
of("timeout" as const),
NEVER,
).pipe(
takeUntil(
(
fromEvent(this.matrixRoom, RoomEvent.Timeline) as Observable<
Parameters<EventTimelineSetHandlerMap[RoomEvent.Timeline]>
>
).pipe(
filter(
([event]) =>
event.getType() === EventType.RTCDecline &&
event.getRelation()?.rel_type === "m.reference" &&
event.getRelation()?.event_id ===
notificationEvent.event_id &&
event.getSender() !== this.userId,
),
),
),
endWith("decline" as const),
);
}),
),
EMPTY,
);
/**
* Whether some Matrix user other than ourself is joined to the call.
@@ -910,46 +955,62 @@ export class CallViewModel extends ViewModel {
* The current call pickup state of the call.
* - "unknown": The client has not yet sent the notification event. We don't know if it will because it first needs to send its own membership.
* Then we can conclude if we were the first one to join or not.
* This may also be set if we are disconnected.
* - "ringing": The call is ringing on other devices in this room (This client should give audiovisual feedback that this is happening).
* - "timeout": No-one picked up in the defined time this call should be ringing on others devices.
* The call failed. If desired this can be used as a trigger to exit the call.
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
* - null: EC is configured to never show any waiting for answer state.
*/
public readonly callPickupState$ = this.options.waitForCallPickup
? this.scope.behavior<"unknown" | "ringing" | "timeout" | "success">(
concat(
concat(
// We don't know if the RTC session decides to send a notify event
// yet. It will only be known once we sent our own membership and
// know we were the first one to join.
of("unknown" as const),
// Once we get the signal to begin ringing:
this.beginRingingForMs$.pipe(
take(1),
switchMap((lifetime) =>
lifetime === 0
? // If no lifetime, skip the ring state
EMPTY
: // Ring until lifetime ms have passed
timer(lifetime).pipe(
ignoreElements(),
startWith("ringing" as const),
),
),
),
// The notification lifetime has timed out, meaning ringing has
// likely stopped on all receiving clients.
of("timeout" as const),
NEVER,
).pipe(
takeUntil(this.someoneElseJoined$.pipe(filter((joined) => joined))),
),
of("success" as const),
public readonly callPickupState$: Behavior<
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
> = this.options.waitForCallPickup
? this.scope.behavior<
"unknown" | "ringing" | "timeout" | "decline" | "success"
>(
combineLatest([
this.livekitConnectionState$,
this.someoneElseJoined$,
]).pipe(
switchMap(([livekitConnectionState, someoneElseJoined]) => {
if (livekitConnectionState === ConnectionState.Disconnected) {
// Do not ring until we're connected.
return of("unknown" as const);
} else if (someoneElseJoined) {
return of("success" as const);
}
// Show the ringing state of the most recent ringing attempt.
// ring$ is a behavior so it will emit the latest observable which very well might already have a running timer.
// this is important in case livekitConnectionState$ after didSendCallNotification$ has already emitted.
return this.ring$.pipe(switchAll());
}),
// The state starts as 'unknown' because we don't know if the RTC
// session will actually send a notify event yet. It will only be
// known once we send our own membership and see that we were the
// first one to join.
startWith("unknown" as const),
),
)
: constant(null);
public readonly leaveSoundEffect$ = combineLatest([
this.callPickupState$,
this.userMedia$,
]).pipe(
// Until the call is successful, do not play a leave sound.
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
skipWhile(([c]) => c !== null && c !== "success"),
map(([, userMedia]) => userMedia),
pairwise(),
filter(
([prev, current]) =>
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current.length < prev.length,
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
/**
* List of MediaItems that we want to display, that are of type ScreenShare
*/
@@ -1639,7 +1700,7 @@ export class CallViewModel extends ViewModel {
private readonly livekitRoom: LivekitRoom,
private readonly mediaDevices: MediaDevices,
private readonly options: CallViewModelOptions,
private readonly livekitConnectionState$: Observable<ECConnectionState>,
public readonly livekitConnectionState$: Observable<ECConnectionState>,
private readonly handsRaisedSubject$: Observable<
Record<string, RaisedHandInfo>
>,

View File

@@ -64,6 +64,7 @@ test("GridTile is accessible", async () => {
targetWidth={300}
targetHeight={200}
showSpeakingIndicators
focusable={true}
/>
</ReactionsSenderProvider>,
);

View File

@@ -60,6 +60,7 @@ interface TileProps {
targetHeight: number;
displayName: string;
showSpeakingIndicators: boolean;
focusable: boolean;
}
interface UserMediaTileProps extends TileProps {
@@ -81,6 +82,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
menuEnd,
className,
displayName,
focusable,
...props
}) => {
const { toggleRaisedHand } = useReactionsSender();
@@ -162,6 +164,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
/>
}
displayName={displayName}
focusable={focusable}
primaryButton={
primaryButton ?? (
<Menu
@@ -169,7 +172,10 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<button
aria-label={t("common.options")}
tabIndex={focusable ? undefined : -1}
>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
@@ -208,6 +214,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
ref,
vm,
onOpenProfile,
focusable,
...props
}) => {
const { t } = useTranslation();
@@ -236,6 +243,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
className={styles.switchCamera}
aria-label={t("switch_camera")}
onClick={switchCamera}
tabIndex={focusable ? undefined : -1}
>
<SwitchCameraSolidIcon aria-hidden width={20} height={20} />
</button>
@@ -258,6 +266,7 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
/>
)
}
focusable={focusable}
{...props}
/>
);
@@ -337,6 +346,7 @@ interface GridTileProps {
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean;
focusable: boolean;
}
export const GridTile: FC<GridTileProps> = ({

View File

@@ -47,6 +47,7 @@ describe("MediaView", () => {
video: trackReference,
member: undefined,
localParticipant: false,
focusable: true,
};
test("is accessible", async () => {

View File

@@ -38,6 +38,7 @@ interface Props extends ComponentProps<typeof animated.div> {
encryptionStatus: EncryptionStatus;
nameTagLeadingIcon?: ReactNode;
displayName: string;
focusable: boolean;
primaryButton?: ReactNode;
raisedHandTime?: Date;
currentReaction?: ReactionOption;
@@ -61,6 +62,7 @@ export const MediaView: FC<Props> = ({
unencryptedWarning,
nameTagLeadingIcon,
displayName,
focusable,
primaryButton,
encryptionStatus,
raisedHandTime,
@@ -114,6 +116,7 @@ export const MediaView: FC<Props> = ({
miniature={avatarSize < 96}
showTimer={handRaiseTimerVisible}
onClick={raisedHandOnClick}
tabIndex={focusable ? undefined : -1}
/>
{currentReaction && (
<ReactionIndicator
@@ -164,6 +167,7 @@ export const MediaView: FC<Props> = ({
label={t("common.unencrypted")}
placement="bottom"
isTriggerInteractive={false}
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
>
<ErrorSolidIcon
width={20}

View File

@@ -59,6 +59,7 @@ test("SpotlightTile is accessible", async () => {
expanded={false}
onToggleExpanded={toggleExpanded}
showIndicators
focusable={true}
/>,
);

View File

@@ -59,6 +59,7 @@ interface SpotlightItemBaseProps {
unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus;
displayName: string;
focusable: boolean;
"aria-hidden"?: boolean;
localParticipant: boolean;
}
@@ -112,6 +113,7 @@ interface SpotlightItemProps {
vm: MediaViewModel;
targetWidth: number;
targetHeight: number;
focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>;
/**
* Whether this item should act as a scroll snapping point.
@@ -125,6 +127,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
vm,
targetWidth,
targetHeight,
focusable,
intersectionObserver$,
snap,
"aria-hidden": ariaHidden,
@@ -163,6 +166,7 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
member: vm.member,
unencryptedWarning,
displayName,
focusable,
encryptionStatus,
"aria-hidden": ariaHidden,
localParticipant: vm.local,
@@ -185,6 +189,7 @@ interface Props {
targetWidth: number;
targetHeight: number;
showIndicators: boolean;
focusable: boolean;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
}
@@ -197,6 +202,7 @@ export const SpotlightTile: FC<Props> = ({
targetWidth,
targetHeight,
showIndicators,
focusable = true,
className,
style,
}) => {
@@ -293,6 +299,7 @@ export const SpotlightTile: FC<Props> = ({
className={classNames(styles.advance, styles.back)}
aria-label={t("common.back")}
onClick={onBackClick}
tabIndex={focusable ? undefined : -1}
>
<ChevronLeftIcon aria-hidden width={24} height={24} />
</button>
@@ -304,6 +311,7 @@ export const SpotlightTile: FC<Props> = ({
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
focusable={focusable}
intersectionObserver$={intersectionObserver$}
// This is how we get the container to scroll to the right media
// when the previous/next buttons are clicked: we temporarily
@@ -319,6 +327,7 @@ export const SpotlightTile: FC<Props> = ({
className={classNames(styles.expand)}
aria-label={"maximise"}
onClick={onToggleFullscreen}
tabIndex={focusable ? undefined : -1}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
@@ -330,6 +339,7 @@ export const SpotlightTile: FC<Props> = ({
expanded ? t("video_tile.collapse") : t("video_tile.expand")
}
onClick={onToggleExpanded}
tabIndex={focusable ? undefined : -1}
>
<ToggleExpandIcon aria-hidden width={20} height={20} />
</button>
@@ -341,6 +351,7 @@ export const SpotlightTile: FC<Props> = ({
className={classNames(styles.advance, styles.next)}
aria-label={t("common.next")}
onClick={onNextClick}
tabIndex={focusable ? undefined : -1}
>
<ChevronRightIcon aria-hidden width={24} height={24} />
</button>

View File

@@ -32,6 +32,8 @@ async function playSound(
buffer: AudioBuffer,
volume: number,
stereoPan: number,
delayS = 0,
abort?: AbortController,
): Promise<void> {
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, 0);
@@ -39,13 +41,62 @@ async function playSound(
pan.pan.setValueAtTime(stereoPan, 0);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(gain).connect(pan).connect(ctx.destination);
abort?.signal.addEventListener("abort", () => {
src.disconnect();
});
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
src.connect(gain).connect(pan).connect(ctx.destination);
controls.setPlaybackStarted();
src.start();
src.start(ctx.currentTime + delayS);
return p;
}
/**
* Play a sound though a given AudioContext, looping until stopped. Will take
* care of connecting the correct buffer and gating
* through gain.
* @param volume The volume to play at.
* @param ctx The context to play through.
* @param buffer The buffer to play.
* @returns A function used to end the sound. This function will return a promise when the sound has stopped.
*/
function playSoundLooping(
ctx: AudioContext,
buffer: AudioBuffer,
volume: number,
stereoPan: number,
delayS?: number,
): () => Promise<void> {
if (delayS === 0) {
throw Error("Looping sounds must have a delay");
}
// Our audio loop
let lastSoundPromise: Promise<void>;
let nextSoundPromise: Promise<void>;
let ac: AbortController | undefined;
void (async (): Promise<void> => {
ac = new AbortController();
// Play a sound immediately
lastSoundPromise = Promise.resolve();
do {
// Queue up the next sound.
nextSoundPromise = playSound(ctx, buffer, volume, stereoPan, delayS, ac);
// Await the previous sound.
await lastSoundPromise;
// Swap the promises over, and loop round to play the next sound.
lastSoundPromise = nextSoundPromise;
} while (!ac.signal.aborted);
})();
return async () => {
ac?.abort();
// Wait for sounds to finish.
await lastSoundPromise;
await nextSoundPromise;
};
}
interface Props<S extends string> {
/**
* The sounds to play. If no sounds should be played then
@@ -57,8 +108,13 @@ interface Props<S extends string> {
muted?: boolean;
}
interface UseAudioContext<S> {
interface UseAudioContext<S extends string> {
playSound(soundName: S): Promise<void>;
playSoundLooping(soundName: S, delayS?: number): () => Promise<void>;
/**
* Map of sound name to duration in seconds.
*/
soundDuration: Record<string, number>;
}
/**
@@ -146,5 +202,23 @@ export function useAudioContext<S extends string>(
earpiecePan,
);
},
playSoundLooping: (name, delayS: number): (() => Promise<void>) => {
if (!audioBuffers[name]) {
throw Error(`Tried to play a sound that wasn't buffered (${name})`);
}
return playSoundLooping(
audioContext,
audioBuffers[name],
soundEffectVolume * earpieceVolume,
earpiecePan,
delayS,
);
},
soundDuration: Object.fromEntries(
Object.entries(audioBuffers).map(([k, v]) => [
k,
(v as AudioBuffer).duration,
]),
),
};
}

View File

@@ -22,7 +22,10 @@ import {
} from "matrix-js-sdk";
import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel";
import {
CallViewModel,
type CallViewModelOptions,
} from "../state/CallViewModel";
import {
mockLivekitRoom,
mockMatrixRoom,
@@ -122,6 +125,7 @@ export function getBasicRTCSession(
export function getBasicCallViewModelEnvironment(
members: RoomMember[],
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
callViewModelOptions: Partial<CallViewModelOptions> = {},
): {
vm: CallViewModel;
rtcMemberships$: BehaviorSubject<CallMembership[]>;
@@ -148,6 +152,7 @@ export function getBasicCallViewModelEnvironment(
mockMediaDevices({}),
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
...callViewModelOptions,
},
of(ConnectionState.Connected),
handRaisedSubject$,

View File

@@ -38,6 +38,7 @@ import {
type RoomAndToDeviceEventsHandlerMap,
} from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import { type TrackReference } from "@livekit/components-core";
import EventEmitter from "events";
import {
LocalUserMediaViewModel,
@@ -144,26 +145,25 @@ export function withTestScheduler(
}
interface EmitterMock<T> {
on: () => T;
off: () => T;
addListener: () => T;
removeListener: () => T;
on: (...args: unknown[]) => T;
off: (...args: unknown[]) => T;
addListener: (...args: unknown[]) => T;
removeListener: (...args: unknown[]) => T;
emit: (event: string | symbol, ...args: unknown[]) => boolean;
}
export function mockEmitter<T>(): EmitterMock<T> {
const ee = new EventEmitter();
return {
on(): T {
return this as T;
},
off(): T {
return this as T;
},
addListener(): T {
return this as T;
},
removeListener(): T {
return this as T;
},
on: ee.on.bind(ee) as unknown as (...args: unknown[]) => T,
off: ee.off.bind(ee) as unknown as (...args: unknown[]) => T,
addListener: ee.addListener.bind(ee) as unknown as (
...args: unknown[]
) => T,
removeListener: ee.removeListener.bind(ee) as unknown as (
...args: unknown[]
) => T,
emit: ee.emit.bind(ee),
};
}
@@ -360,6 +360,8 @@ export class MockRTCSession extends TypedEventEmitter<
return this;
}
public updateCallIntent = vitest.fn();
private _membershipStatus = Status.Connected;
public get membershipStatus(): Status {
return this._membershipStatus;

View File

@@ -116,6 +116,7 @@ export const widget = ((): WidgetHelpers | null => {
EventType.Reaction,
EventType.RoomRedaction,
ElementCallReactionEventType,
EventType.RTCDecline,
];
const sendState = [

424
yarn.lock
View File

@@ -2772,12 +2772,12 @@ __metadata:
languageName: node
linkType: hard
"@livekit/protocol@npm:1.39.3, @livekit/protocol@npm:^1.38.0":
version: 1.39.3
resolution: "@livekit/protocol@npm:1.39.3"
"@livekit/protocol@npm:1.42.2, @livekit/protocol@npm:^1.42.2":
version: 1.42.2
resolution: "@livekit/protocol@npm:1.42.2"
dependencies:
"@bufbuild/protobuf": "npm:^1.10.0"
checksum: 10c0/192ce7283db4505232aa487e71d08670783a1045a8935926f95ff4b9d3d310db706c2ad4af2ebdb0bd735c2e9042c1ee3b1f62e44a9d01bd41fd58cf8dc272b9
checksum: 10c0/73e77e0975d4d8ca0baa23ad03cb9ac3a844e8d89e9cefbd17343131a774c7aeb4d190fd0397152fcf5a396d921af3eb83e83e660a4aa12215d9ac4e52bf037a
languageName: node
linkType: hard
@@ -2793,10 +2793,10 @@ __metadata:
languageName: node
linkType: hard
"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.1.0":
version: 15.1.0
resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.1.0"
checksum: 10c0/19edc6d0045ff49fad8d77b6e561cee994f7513f8c18a7176ae2d3f0116c1a91980e02d10300b09c2b72dea4da4a8c3392f2bf1752057f2d6b53030a056d76d8
"@matrix-org/matrix-sdk-crypto-wasm@npm:^15.3.0":
version: 15.3.0
resolution: "@matrix-org/matrix-sdk-crypto-wasm@npm:15.3.0"
checksum: 10c0/45628f36b7b0e54a8777ae67a7233dbdf3e3cf14e0d95d21f62f89a7ea7e3f907232f1eb7b1262193b1e227759fad47af829dcccc103ded89011f13c66f01d76
languageName: node
linkType: hard
@@ -3371,14 +3371,14 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:^1.52.0":
version: 1.54.1
resolution: "@playwright/test@npm:1.54.1"
"@playwright/test@npm:^1.56.1":
version: 1.56.1
resolution: "@playwright/test@npm:1.56.1"
dependencies:
playwright: "npm:1.54.1"
playwright: "npm:1.56.1"
bin:
playwright: cli.js
checksum: 10c0/1b414356bc1049927d7b9efc14d5b3bf000ef6483313926bb795b4f27fe3707e8e0acf0db59063a452bb4f7e34559758d17640401b6f3e2f5290f299a8d8d02f
checksum: 10c0/2b5b0e1f2e6a18f6e5ce6897c7440ca78f64e0b004834e9808e93ad2b78b96366b562ae4366602669cf8ad793a43d85481b58541e74be71e905e732d833dd691
languageName: node
linkType: hard
@@ -4503,142 +4503,149 @@ __metadata:
languageName: node
linkType: hard
"@rollup/rollup-android-arm-eabi@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-android-arm-eabi@npm:4.41.1"
"@rollup/rollup-android-arm-eabi@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.1"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@rollup/rollup-android-arm64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-android-arm64@npm:4.41.1"
"@rollup/rollup-android-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-android-arm64@npm:4.50.1"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-arm64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-darwin-arm64@npm:4.41.1"
"@rollup/rollup-darwin-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-darwin-arm64@npm:4.50.1"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-x64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-darwin-x64@npm:4.41.1"
"@rollup/rollup-darwin-x64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-darwin-x64@npm:4.50.1"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rollup/rollup-freebsd-arm64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-freebsd-arm64@npm:4.41.1"
"@rollup/rollup-freebsd-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-freebsd-arm64@npm:4.50.1"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-freebsd-x64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-freebsd-x64@npm:4.41.1"
"@rollup/rollup-freebsd-x64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-freebsd-x64@npm:4.50.1"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1"
"@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1"
conditions: os=linux & cpu=arm & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-arm-musleabihf@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.41.1"
"@rollup/rollup-linux-arm-musleabihf@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.50.1"
conditions: os=linux & cpu=arm & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.41.1"
"@rollup/rollup-linux-arm64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.50.1"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-musl@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.41.1"
"@rollup/rollup-linux-arm64-musl@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.50.1"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1"
"@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1"
conditions: os=linux & cpu=loong64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1"
"@rollup/rollup-linux-ppc64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.50.1"
conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.41.1"
"@rollup/rollup-linux-riscv64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.50.1"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-musl@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.41.1"
"@rollup/rollup-linux-riscv64-musl@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.50.1"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-s390x-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.41.1"
"@rollup/rollup-linux-s390x-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.50.1"
conditions: os=linux & cpu=s390x & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.41.1"
"@rollup/rollup-linux-x64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.50.1"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-musl@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-x64-musl@npm:4.41.1"
"@rollup/rollup-linux-x64-musl@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-x64-musl@npm:4.50.1"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-win32-arm64-msvc@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.41.1"
"@rollup/rollup-openharmony-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-openharmony-arm64@npm:4.50.1"
conditions: os=openharmony & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-win32-arm64-msvc@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.50.1"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-win32-ia32-msvc@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.41.1"
"@rollup/rollup-win32-ia32-msvc@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.50.1"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rollup/rollup-win32-x64-msvc@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.41.1"
"@rollup/rollup-win32-x64-msvc@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.50.1"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -5148,10 +5155,10 @@ __metadata:
languageName: node
linkType: hard
"@types/estree@npm:1.0.7":
version: 1.0.7
resolution: "@types/estree@npm:1.0.7"
checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c
"@types/estree@npm:1.0.8":
version: 1.0.8
resolution: "@types/estree@npm:1.0.8"
checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5
languageName: node
linkType: hard
@@ -5283,13 +5290,6 @@ __metadata:
languageName: node
linkType: hard
"@types/retry@npm:0.12.0":
version: 0.12.0
resolution: "@types/retry@npm:0.12.0"
checksum: 10c0/7c5c9086369826f569b83a4683661557cab1361bac0897a1cefa1a915ff739acd10ca0d62b01071046fe3f5a3f7f2aec80785fe283b75602dc6726781ea3e328
languageName: node
linkType: hard
"@types/sdp-transform@npm:^2.4.5":
version: 2.4.10
resolution: "@types/sdp-transform@npm:2.4.10"
@@ -7473,7 +7473,7 @@ __metadata:
"@formatjs/intl-segmenter": "npm:^11.7.3"
"@livekit/components-core": "npm:^0.12.0"
"@livekit/components-react": "npm:^2.0.0"
"@livekit/protocol": "npm:^1.38.0"
"@livekit/protocol": "npm:^1.42.2"
"@livekit/track-processors": "npm:^0.5.5"
"@mediapipe/tasks-vision": "npm:^0.10.18"
"@opentelemetry/api": "npm:^1.4.0"
@@ -7483,7 +7483,7 @@ __metadata:
"@opentelemetry/sdk-trace-base": "npm:^2.0.0"
"@opentelemetry/sdk-trace-web": "npm:^2.0.0"
"@opentelemetry/semantic-conventions": "npm:^1.25.1"
"@playwright/test": "npm:^1.52.0"
"@playwright/test": "npm:^1.56.1"
"@radix-ui/react-dialog": "npm:^1.0.4"
"@radix-ui/react-slider": "npm:^1.1.2"
"@radix-ui/react-visually-hidden": "npm:^1.0.3"
@@ -7534,7 +7534,7 @@ __metadata:
i18next-parser: "npm:^9.1.0"
jsdom: "npm:^26.0.0"
knip: "npm:^5.27.2"
livekit-client: "npm:^2.13.0"
livekit-client: "npm:^2.15.13"
lodash-es: "npm:^4.17.21"
loglevel: "npm:^1.9.1"
matrix-js-sdk: "github:matrix-org/matrix-js-sdk#head=develop"
@@ -7558,7 +7558,7 @@ __metadata:
typescript-eslint-language-service: "npm:^5.0.5"
unique-names-generator: "npm:^4.6.0"
vaul: "npm:^1.0.0"
vite: "npm:^6.0.0"
vite: "npm:^7.0.0"
vite-plugin-generate-file: "npm:^0.3.0"
vite-plugin-html: "npm:^3.2.2"
vite-plugin-svgr: "npm:^4.0.0"
@@ -8483,6 +8483,18 @@ __metadata:
languageName: node
linkType: hard
"fdir@npm:^6.5.0":
version: 6.5.0
resolution: "fdir@npm:6.5.0"
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f
languageName: node
linkType: hard
"fflate@npm:^0.4.8":
version: 0.4.8
resolution: "fflate@npm:0.4.8"
@@ -9509,6 +9521,13 @@ __metadata:
languageName: node
linkType: hard
"is-network-error@npm:^1.1.0":
version: 1.3.0
resolution: "is-network-error@npm:1.3.0"
checksum: 10c0/3e85a69e957988db66d5af5412efdd531a5a63e150d1bdd5647cfd4dc54fd89b1dbdd472621f8915233c3176ba1e6922afa8a51a9e363ba4693edf96a294f898
languageName: node
linkType: hard
"is-number-object@npm:^1.1.1":
version: 1.1.1
resolution: "is-number-object@npm:1.1.1"
@@ -9771,6 +9790,13 @@ __metadata:
languageName: node
linkType: hard
"jose@npm:^6.1.0":
version: 6.1.0
resolution: "jose@npm:6.1.0"
checksum: 10c0/f4518579e907317e144facd15c7627acd06097bbea17735097437217498aa419564c039dd4020f6af5f2d024a7cee6b7be4648ccbbdc238aedb80a47c061217d
languageName: node
linkType: hard
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -10045,13 +10071,14 @@ __metadata:
languageName: node
linkType: hard
"livekit-client@npm:^2.13.0":
version: 2.15.6
resolution: "livekit-client@npm:2.15.6"
"livekit-client@npm:^2.15.13":
version: 2.15.13
resolution: "livekit-client@npm:2.15.13"
dependencies:
"@livekit/mutex": "npm:1.1.1"
"@livekit/protocol": "npm:1.39.3"
"@livekit/protocol": "npm:1.42.2"
events: "npm:^3.3.0"
jose: "npm:^6.1.0"
loglevel: "npm:^1.9.2"
sdp-transform: "npm:^2.15.0"
ts-debounce: "npm:^4.0.0"
@@ -10060,7 +10087,7 @@ __metadata:
webrtc-adapter: "npm:^9.0.1"
peerDependencies:
"@types/dom-mediacapture-record": ^1
checksum: 10c0/f1ab6cdf2b85647036e9de906734c1394dac497da0bd879a29d0c587c437ada262021478fcef24df99b3489a39d97fe67ab33de0785ed0a63335da2fef577192
checksum: 10c0/5a061df9000461a6d40ef8aa1e72e8aedc640181cc57fe6f2c48c5c7f90ce96a735b125aede377fc43f4692a685e098f17eeae0f42c5b2fed473305867bf2789
languageName: node
linkType: hard
@@ -10279,11 +10306,11 @@ __metadata:
linkType: hard
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
version: 37.13.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4c7f945141e142e6f846b243c33c4af97a9a44b"
version: 38.3.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=41d70d0b5d3f0eba92686f8089cb329d875b26b5"
dependencies:
"@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.3.0"
another-json: "npm:^0.2.0"
bs58: "npm:^6.0.0"
content-type: "npm:^1.0.4"
@@ -10292,11 +10319,11 @@ __metadata:
matrix-events-sdk: "npm:0.0.1"
matrix-widget-api: "npm:^1.10.0"
oidc-client-ts: "npm:^3.0.1"
p-retry: "npm:4"
p-retry: "npm:7"
sdp-transform: "npm:^2.14.1"
unhomoglyph: "npm:^1.0.6"
uuid: "npm:11"
checksum: 10c0/caa4b8a6d924ac36a21773dc2c8be6cb6b658a9feaabccdb24426719c563ac2cfe4778abb86f0889854ae36fc7ba02a6ed39acdbc0b73fdc31ce9a9789e7f36a
uuid: "npm:13"
checksum: 10c0/b48528fec573f3e14d1297f360a56d52d7f313da0d4cf82ab51e4c29798b86995b8a6bd72409779746e7bcf02949bc2788bffa9aba276bfb1a76dbcbe89900a0
languageName: node
linkType: hard
@@ -10903,13 +10930,12 @@ __metadata:
languageName: node
linkType: hard
"p-retry@npm:4":
version: 4.6.2
resolution: "p-retry@npm:4.6.2"
"p-retry@npm:7":
version: 7.0.0
resolution: "p-retry@npm:7.0.0"
dependencies:
"@types/retry": "npm:0.12.0"
retry: "npm:^0.13.1"
checksum: 10c0/d58512f120f1590cfedb4c2e0c42cb3fa66f3cea8a4646632fcb834c56055bb7a6f138aa57b20cc236fb207c9d694e362e0b5c2b14d9b062f67e8925580c73b0
is-network-error: "npm:^1.1.0"
checksum: 10c0/3c090ac72bbe00fd2f062ee6178c44f7302f298936ab2290a458575e73650e7834b556beb2b09fa9fbebedab2ec3358cb474c09710cf828972b670c3c0cb89e4
languageName: node
linkType: hard
@@ -11097,27 +11123,34 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.54.1":
version: 1.54.1
resolution: "playwright-core@npm:1.54.1"
bin:
playwright-core: cli.js
checksum: 10c0/b821262b024d7753b1bfa71eb2bc99f2dda12a869d175b2e1bc6ac2764bd661baf36d9d42f45caf622854ad7e4a6077b9b57014c74bb5a78fe339c9edf1c9019
"picomatch@npm:^4.0.3":
version: 4.0.3
resolution: "picomatch@npm:4.0.3"
checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2
languageName: node
linkType: hard
"playwright@npm:1.54.1":
version: 1.54.1
resolution: "playwright@npm:1.54.1"
"playwright-core@npm:1.56.1":
version: 1.56.1
resolution: "playwright-core@npm:1.56.1"
bin:
playwright-core: cli.js
checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7
languageName: node
linkType: hard
"playwright@npm:1.56.1":
version: 1.56.1
resolution: "playwright@npm:1.56.1"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.54.1"
playwright-core: "npm:1.56.1"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/c5fedae31a03a1f4c4846569aef3ffb98da23000a4d255abfc8c2ede15b43cc7cd87b80f6fa078666c030373de8103787cf77ef7653ae9458aabbbd4320c2599
checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4
languageName: node
linkType: hard
@@ -11523,7 +11556,7 @@ __metadata:
languageName: node
linkType: hard
"postcss@npm:^8.4.41, postcss@npm:^8.5.3":
"postcss@npm:^8.4.41, postcss@npm:^8.5.6":
version: 8.5.6
resolution: "postcss@npm:8.5.6"
dependencies:
@@ -12204,13 +12237,6 @@ __metadata:
languageName: node
linkType: hard
"retry@npm:^0.13.1":
version: 0.13.1
resolution: "retry@npm:0.13.1"
checksum: 10c0/9ae822ee19db2163497e074ea919780b1efa00431d197c7afdb950e42bf109196774b92a49fc9821f0b8b328a98eea6017410bfc5e8a0fc19c85c6d11adb3772
languageName: node
linkType: hard
"reusify@npm:^1.0.4":
version: 1.1.0
resolution: "reusify@npm:1.1.0"
@@ -12251,31 +12277,32 @@ __metadata:
languageName: node
linkType: hard
"rollup@npm:^4.34.9, rollup@npm:^4.40.0":
version: 4.41.1
resolution: "rollup@npm:4.41.1"
"rollup@npm:^4.43.0":
version: 4.50.1
resolution: "rollup@npm:4.50.1"
dependencies:
"@rollup/rollup-android-arm-eabi": "npm:4.41.1"
"@rollup/rollup-android-arm64": "npm:4.41.1"
"@rollup/rollup-darwin-arm64": "npm:4.41.1"
"@rollup/rollup-darwin-x64": "npm:4.41.1"
"@rollup/rollup-freebsd-arm64": "npm:4.41.1"
"@rollup/rollup-freebsd-x64": "npm:4.41.1"
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.41.1"
"@rollup/rollup-linux-arm-musleabihf": "npm:4.41.1"
"@rollup/rollup-linux-arm64-gnu": "npm:4.41.1"
"@rollup/rollup-linux-arm64-musl": "npm:4.41.1"
"@rollup/rollup-linux-loongarch64-gnu": "npm:4.41.1"
"@rollup/rollup-linux-powerpc64le-gnu": "npm:4.41.1"
"@rollup/rollup-linux-riscv64-gnu": "npm:4.41.1"
"@rollup/rollup-linux-riscv64-musl": "npm:4.41.1"
"@rollup/rollup-linux-s390x-gnu": "npm:4.41.1"
"@rollup/rollup-linux-x64-gnu": "npm:4.41.1"
"@rollup/rollup-linux-x64-musl": "npm:4.41.1"
"@rollup/rollup-win32-arm64-msvc": "npm:4.41.1"
"@rollup/rollup-win32-ia32-msvc": "npm:4.41.1"
"@rollup/rollup-win32-x64-msvc": "npm:4.41.1"
"@types/estree": "npm:1.0.7"
"@rollup/rollup-android-arm-eabi": "npm:4.50.1"
"@rollup/rollup-android-arm64": "npm:4.50.1"
"@rollup/rollup-darwin-arm64": "npm:4.50.1"
"@rollup/rollup-darwin-x64": "npm:4.50.1"
"@rollup/rollup-freebsd-arm64": "npm:4.50.1"
"@rollup/rollup-freebsd-x64": "npm:4.50.1"
"@rollup/rollup-linux-arm-gnueabihf": "npm:4.50.1"
"@rollup/rollup-linux-arm-musleabihf": "npm:4.50.1"
"@rollup/rollup-linux-arm64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-arm64-musl": "npm:4.50.1"
"@rollup/rollup-linux-loongarch64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-ppc64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-riscv64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-riscv64-musl": "npm:4.50.1"
"@rollup/rollup-linux-s390x-gnu": "npm:4.50.1"
"@rollup/rollup-linux-x64-gnu": "npm:4.50.1"
"@rollup/rollup-linux-x64-musl": "npm:4.50.1"
"@rollup/rollup-openharmony-arm64": "npm:4.50.1"
"@rollup/rollup-win32-arm64-msvc": "npm:4.50.1"
"@rollup/rollup-win32-ia32-msvc": "npm:4.50.1"
"@rollup/rollup-win32-x64-msvc": "npm:4.50.1"
"@types/estree": "npm:1.0.8"
fsevents: "npm:~2.3.2"
dependenciesMeta:
"@rollup/rollup-android-arm-eabi":
@@ -12300,7 +12327,7 @@ __metadata:
optional: true
"@rollup/rollup-linux-loongarch64-gnu":
optional: true
"@rollup/rollup-linux-powerpc64le-gnu":
"@rollup/rollup-linux-ppc64-gnu":
optional: true
"@rollup/rollup-linux-riscv64-gnu":
optional: true
@@ -12312,6 +12339,8 @@ __metadata:
optional: true
"@rollup/rollup-linux-x64-musl":
optional: true
"@rollup/rollup-openharmony-arm64":
optional: true
"@rollup/rollup-win32-arm64-msvc":
optional: true
"@rollup/rollup-win32-ia32-msvc":
@@ -12322,7 +12351,7 @@ __metadata:
optional: true
bin:
rollup: dist/bin/rollup
checksum: 10c0/c4d5f2257320b50dc0e035e31d8d2f78d36b7015aef2f87cc984c0a1c97ffebf14337dddeb488b4b11ae798fea6486189b77e7cf677617dcf611d97db41ebfda
checksum: 10c0/2029282826d5fb4e308be261b2c28329a4d2bd34304cc3960da69fd21d5acccd0267d6770b1656ffc8f166203ef7e865b4583d5f842a519c8ef059ac71854205
languageName: node
linkType: hard
@@ -13158,7 +13187,7 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.14":
"tinyglobby@npm:^0.2.14":
version: 0.2.14
resolution: "tinyglobby@npm:0.2.14"
dependencies:
@@ -13168,6 +13197,16 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.15":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
dependencies:
fdir: "npm:^6.5.0"
picomatch: "npm:^4.0.3"
checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844
languageName: node
linkType: hard
"tinypool@npm:^1.1.1":
version: 1.1.1
resolution: "tinypool@npm:1.1.1"
@@ -13727,12 +13766,12 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:11":
version: 11.0.5
resolution: "uuid@npm:11.0.5"
"uuid@npm:13":
version: 13.0.0
resolution: "uuid@npm:13.0.0"
bin:
uuid: dist/esm/bin/uuid
checksum: 10c0/6f59f0c605e02c14515401084ca124b9cb462b4dcac866916a49862bcf831874508a308588c23a7718269226ad11a92da29b39d761ad2b86e736623e3a33b6e7
uuid: dist-node/bin/uuid
checksum: 10c0/950e4c18d57fef6c69675344f5700a08af21e26b9eff2bf2180427564297368c538ea11ac9fb2e6528b17fc3966a9fd2c5049361b0b63c7d654f3c550c9b3d67
languageName: node
linkType: hard
@@ -13886,17 +13925,17 @@ __metadata:
languageName: node
linkType: hard
"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0":
version: 7.0.0-beta.0
resolution: "vite@npm:7.0.0-beta.0"
"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0, vite@npm:^7.0.0":
version: 7.1.5
resolution: "vite@npm:7.1.5"
dependencies:
esbuild: "npm:^0.25.0"
fdir: "npm:^6.4.4"
fdir: "npm:^6.5.0"
fsevents: "npm:~2.3.3"
picomatch: "npm:^4.0.2"
postcss: "npm:^8.5.3"
rollup: "npm:^4.40.0"
tinyglobby: "npm:^0.2.14"
picomatch: "npm:^4.0.3"
postcss: "npm:^8.5.6"
rollup: "npm:^4.43.0"
tinyglobby: "npm:^0.2.15"
peerDependencies:
"@types/node": ^20.19.0 || >=22.12.0
jiti: ">=1.21.0"
@@ -13937,62 +13976,7 @@ __metadata:
optional: true
bin:
vite: bin/vite.js
checksum: 10c0/d13907d67b4991a2862dafe6a31d10ffe28f26ba04e511049e9d86d06293b3a8d6733c896c8fb38e3f2d5805d240e3cad27700f3c42536602035e4c324b48d58
languageName: node
linkType: hard
"vite@npm:^6.0.0":
version: 6.3.5
resolution: "vite@npm:6.3.5"
dependencies:
esbuild: "npm:^0.25.0"
fdir: "npm:^6.4.4"
fsevents: "npm:~2.3.3"
picomatch: "npm:^4.0.2"
postcss: "npm:^8.5.3"
rollup: "npm:^4.34.9"
tinyglobby: "npm:^0.2.13"
peerDependencies:
"@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0
jiti: ">=1.21.0"
less: "*"
lightningcss: ^1.21.0
sass: "*"
sass-embedded: "*"
stylus: "*"
sugarss: "*"
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
dependenciesMeta:
fsevents:
optional: true
peerDependenciesMeta:
"@types/node":
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
bin:
vite: bin/vite.js
checksum: 10c0/df70201659085133abffc6b88dcdb8a57ef35f742a01311fc56a4cfcda6a404202860729cc65a2c401a724f6e25f9ab40ce4339ed4946f550541531ced6fe41c
checksum: 10c0/782d2f20c25541b26d1fb39bef5f194149caff39dc25b7836e25f049ca919f2e2ce186bddb21f3f20f6195354b3579ec637a8ca08d65b117f8b6f81e3e730a9c
languageName: node
linkType: hard