Compare commits

..

2 Commits

Author SHA1 Message Date
Timo K
708581ea38 use body instead of root node
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-08 15:37:55 +02:00
Timo K
eaa7020911 Add a fullscreen button that uses the element request Fullscreen browser api
Signed-off-by: Timo K <toger5@hotmail.de>
2025-08-08 14:47:39 +02:00
93 changed files with 1028 additions and 3112 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 941 KiB

After

Width:  |  Height:  |  Size: 929 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 KiB

After

Width:  |  Height:  |  Size: 886 KiB

View File

@@ -64,7 +64,7 @@ rc_delayed_event_mgmt:
```
As a prerequisite for the
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
[Matrix LiveKit JWT auth service](https://github.com/element-hq/lk-jwt-service)
make sure that your Synapse server has either a `federation` or `openid`
[listener configured](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#listeners).
@@ -77,7 +77,7 @@ required for each site deployment.
As depicted above in the `example.com` site deployment, Element Call requires a
[Livekit SFU](https://github.com/livekit/livekit) alongside a
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
[Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service)
to implement
[MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md).
@@ -89,7 +89,7 @@ the example above, this results in:
| Service | Endpoint | Example |
| -------- | ------- | ------- |
| [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` |
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
| [Matrix Livekit JWT auth service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
Using Nginx, you can achieve this by:
@@ -102,7 +102,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# MatrixRTC Authorization Service running at port 8080
# JWT Service running at port 8080
proxy_pass http://localhost:8080/;
}
@@ -126,32 +126,6 @@ server {
}
```
Or Using Caddy, you can achieve this by:
```caddy configuration file
# Route for lk-jwt-service with livekit/jwt prefix
@jwt_service path /livekit/jwt/sfu/get /livekit/jwt/healthz
handle @jwt_service {
uri strip_prefix /livekit/jwt
reverse_proxy http://[::1]:8080 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
# Default route for livekit
handle {
reverse_proxy http://localhost:7880 {
header_up Host {host}
header_up X-Forwarded-Server {host}
header_up X-Real-IP {remote_addr}
header_up X-Forwarded-For {remote_addr}
}
}
```
#### MatrixRTC backend announcement
> [!IMPORTANT]
@@ -240,7 +214,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

@@ -70,8 +70,6 @@ These parameters are relevant to both [widget](./embedded-standalone.md) and [st
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the users default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
### Widget-only parameters

View File

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

11
knip.ts
View File

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

View File

@@ -82,7 +82,7 @@
"error": {
"call_is_not_supported": "Volání není podporováno",
"call_not_found": "Volání nebylo nalezeno",
"call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu hovoru. Zkontrolujte, zda máte správný odkaz, nebo<2> vytvořte nový</2>.</0>",
"call_not_found_description": "<0>Zdá se, že tento odkaz nepatří k žádnému existujícímu volání. Zkontrolujte, zda máte správný odkaz, nebo <1>vytvořte nový</1>.</0>",
"connection_lost": "Spojení ztraceno",
"connection_lost_description": "Hovor byl přerušen.",
"e2ee_unsupported": "Nekompatibilní prohlížeč",
@@ -105,11 +105,6 @@
"knock_reject_heading": "Přístup odepřen",
"reason": "Důvod"
},
"handset": {
"overlay_back_button": "Zpět do režimu reproduktoru",
"overlay_description": "Funguje pouze při používání aplikace",
"overlay_title": "Režim sluchátka"
},
"hangup_button_label": "Ukončit hovor",
"header_label": "Domov Element Call",
"header_participants_label": "Účastníci",
@@ -182,8 +177,6 @@
"change_device_button": "Změnit zvukové zařízení",
"default": "Výchozí",
"default_named": "Výchozí <2> ({{name}}) </2>",
"handset": "Sluchátko",
"loudspeaker": "Reproduktor",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Reproduktor",

View File

@@ -94,8 +94,6 @@
"matrix_rtc_focus_missing": "Serveren er ikke konfigureret til at arbejde med {{brand}}{{domain}}. Kontakt venligst din serveradministrator (domæne:{{domain}}, fejlkode: {{ errorCode }}).",
"open_elsewhere": "Åbnet i en anden fane",
"open_elsewhere_description": "{{brand}} er blevet åbnet i en anden fane. Hvis det ikke lyder rigtigt, kan du prøve at genindlæse siden.",
"room_creation_restricted": "Kunne ikke oprette opkald",
"room_creation_restricted_description": "Oprettelse af opkald er muligvis begrænset til autoriserede brugere. Prøv igen senere, eller kontakt din serveradministrator, hvis problemet fortsætter.",
"unexpected_ec_error": "Der opstod en uventet fejl (<0>Fejlkode:</0> <1> {{ errorCode }}</1>). Kontakt venligst din serveradministrator."
},
"group_call_loader": {
@@ -107,11 +105,6 @@
"knock_reject_heading": "Adgang nægtet",
"reason": "Årsag: {{reason}}"
},
"handset": {
"overlay_back_button": "Tilbage til højttalertilstand",
"overlay_description": "Virker kun, når du bruger appen",
"overlay_title": "Telefon-højtaler"
},
"hangup_button_label": "Afslut opkald",
"header_label": "Element Ring hjem",
"header_participants_label": "Deltagere",
@@ -183,7 +176,6 @@
"change_device_button": "Skift lydenhed",
"default": "Standard",
"default_named": "Standard <2>({{name}})</2>",
"handset": "Telefon",
"loudspeaker": "Højttaler",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",

View File

@@ -55,7 +55,6 @@
"profile": "Profil",
"reaction": "Reaktion",
"reactions": "Reaktionen",
"reconnecting": "Verbindung wird wiederhergestellt...",
"settings": "Einstellungen",
"unencrypted": "Nicht verschlüsselt",
"username": "Benutzername",

View File

@@ -55,7 +55,6 @@
"profile": "Profile",
"reaction": "Reaction",
"reactions": "Reactions",
"reconnecting": "Reconnecting…",
"settings": "Settings",
"unencrypted": "Not encrypted",
"username": "Username",

View File

@@ -82,7 +82,7 @@
"error": {
"call_is_not_supported": "Call stöds inte",
"call_not_found": "Samtal hittades inte",
"call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <2>skapa en ny</2>.</0>",
"call_not_found_description": "<0>Den länken verkar inte tillhöra något befintligt samtal. Kontrollera att du har rätt länk, eller <1>skapa en ny</1>.</0>",
"connection_lost": "Anslutning förlorad",
"connection_lost_description": "Du kopplades bort från samtalet.",
"e2ee_unsupported": "Inkompatibel webbläsare",
@@ -94,8 +94,6 @@
"matrix_rtc_focus_missing": "Servern är inte konfigurerad för att fungera med {{brand}}. Vänligen kontakta serveradministratören (Domän: {{domain}}, Felkod: {{ errorCode }}).",
"open_elsewhere": "Öppnades i en annan flik",
"open_elsewhere_description": "{{brand}} har öppnats i en annan flik. Om det inte låter rätt, pröva att ladda om sidan.",
"room_creation_restricted": "Misslyckades att skapa samtal",
"room_creation_restricted_description": "Samtalsskapande kan vara begränsat till endast behöriga användare. Försök igen senare eller kontakta serveradministratören om problemet kvarstår.",
"unexpected_ec_error": "Ett oväntat fel inträffade (<0>Felkod:</0> <1>{{ errorCode }}</1>). Kontakta din serveradministratör."
},
"group_call_loader": {
@@ -107,11 +105,6 @@
"knock_reject_heading": "Inte tillåten att gå med",
"reason": "Anledning"
},
"handset": {
"overlay_back_button": "Tillbaka till högtalarläge",
"overlay_description": "Fungerar bara när appen används",
"overlay_title": "Telefonläge"
},
"hangup_button_label": "Avsluta samtal",
"header_label": "Element Call Hem",
"header_participants_label": "Deltagare",
@@ -183,8 +176,6 @@
"change_device_button": "Byt ljudenhet",
"default": "Förval",
"default_named": "Förval <2>({{name}})</2>",
"handset": "Telefonlur",
"loudspeaker": "Högtalare",
"microphone": "Mikrofon",
"microphone_numbered": "Mikrofon {{n}}",
"speaker": "Högtalare",

View File

@@ -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.42.2",
"@livekit/protocol": "^1.38.0",
"@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.56.1",
"@playwright/test": "^1.52.0",
"@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.15.13",
"livekit-client": "^2.13.0",
"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": "^7.0.0",
"vite": "^6.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-svgr": "^4.0.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,11 +61,7 @@ export const AppBar: FC<Props> = ({ children }) => {
style={{ display: hidden ? "none" : "block" }}
className={styles.bar}
>
<Header
// App bar is mainly seen in the call view, which has its own
// 'reconnecting' toast
disconnectedBanner={false}
>
<Header>
<LeftNav>
<Tooltip label={t("common.back")}>
<IconButton onClick={onBackClick}>

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,10 +8,7 @@ Please see LICENSE in the repository root for full details.
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { type RTCNotificationType } from "matrix-js-sdk/lib/matrixrtc";
import { pickBy } from "lodash-es";
import { Config } from "./config/Config";
@@ -29,9 +26,7 @@ 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",
}
@@ -215,33 +210,6 @@ export interface UrlConfiguration {
* Whether and what type of notification EC should send, when the user joins the call.
*/
sendNotificationType?: RTCNotificationType;
/**
* Whether the app should automatically leave the call when there
* is no one left in the call.
* This is one part to make the call matrixRTC session behave like a telephone call.
*/
autoLeaveWhenOthersLeft: boolean;
/**
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
* This is a no-op if not combined with sendNotificationType.
*
* This entails:
* - show ui that it is awaiting an answer
* - play a sound that indicates that it is awaiting an answer
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
waitForCallPickup: boolean;
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
@@ -249,10 +217,7 @@ interface IntentAndPlatformDerivedConfiguration {
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams
extends UrlProperties,
UrlConfiguration,
IntentAndPlatformDerivedConfiguration {}
export interface UrlParams extends UrlProperties, UrlConfiguration {}
// This is here as a stopgap, but what would be far nicer is a function that
// takes a UrlParams and returns a query string. That would enable us to
@@ -312,30 +277,18 @@ class ParamParser {
];
}
/**
* Returns true if the flag exists and is not "false".
*/
public getFlagParam(name: string, defaultValue = false): boolean {
const param = this.getParam(name);
return param === null ? defaultValue : param !== "false";
}
/**
* Returns the value of the flag if it exists, or undefined if it does not.
*/
public getFlag(name: string): boolean | undefined {
const param = this.getParam(name);
return param !== null ? param !== "false" : undefined;
}
}
let urlParamCache: {
search?: string;
hash?: string;
params?: UrlParams;
} = {};
/**
* Gets the url params and loads them from a cache if already computed.
* Gets the app parameters for the current URL.
* @param search The URL search string
* @param hash The URL hash
* @returns The app parameters encoded in the URL
@@ -344,26 +297,6 @@ 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") ?? "");
@@ -387,10 +320,11 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
? UserIntent.Unknown
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
// Here we only use constants and `platform` to determine the intent preset.
let intentPreset: UrlConfiguration = {
let intentPreset: UrlConfiguration;
const inAppDefault = {
confineToRoom: true,
appPrompt: false,
preload: false,
preload: true,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
@@ -399,38 +333,34 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
sendNotificationType: "notification" as RTCNotificationType,
};
switch (intent) {
case UserIntent.StartNewCall:
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
intentPreset = {
...inAppDefault,
skipLobby: true,
};
break;
case UserIntent.JoinExistingCall:
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = false;
intentPreset.callIntent = "video";
intentPreset = {
...inAppDefault,
skipLobby: false,
};
break;
case UserIntent.StartNewCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
intentPreset.skipLobby = true;
intentPreset.sendNotificationType = "ring";
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.waitForCallPickup = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
intentPreset = {
...inAppDefault,
skipLobby: true,
// autoLeaveWhenOthersLeft: true, // TODO: add this once available
};
break;
case UserIntent.JoinExistingCallDMVoice:
intentPreset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
// On desktop this will be overridden based on which button was used to join the call
intentPreset.skipLobby = true;
intentPreset.autoLeaveWhenOthersLeft = true;
intentPreset.callIntent = intentPreset.callIntent ?? "video";
intentPreset = {
...inAppDefault,
skipLobby: true,
// autoLeaveWhenOthersLeft: true, // TODO: add this once available
};
break;
// Non widget usecase defaults
default:
@@ -447,34 +377,9 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
}
const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
{};
// Desktop also includes web. Its anything that is not mobile.
const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
switch (desktopMobile) {
case "desktop":
case "mobile":
switch (intent) {
case UserIntent.StartNewCall:
case UserIntent.JoinExistingCall:
case UserIntent.StartNewCallDM:
case UserIntent.JoinExistingCallDM:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
break;
case UserIntent.StartNewCallDMVoice:
case UserIntent.JoinExistingCallDMVoice:
intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
intentAndPlatformDerivedConfiguration.defaultVideoEnabled = false;
break;
}
}
const properties: UrlProperties = {
widgetId,
parentUrl,
@@ -523,29 +428,12 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
"ring",
"notification",
]),
waitForCallPickup: parser.getFlag("waitForCallPickup"),
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
};
// Log the final configuration for debugging purposes.
// This will only log when the cache is not yet set.
logger.info(
"UrlParams: final set of url params\n",
"intent:",
intent,
"\nproperties:",
properties,
"configuration:",
configuration,
"intentAndPlatformDerivedConfiguration:",
intentAndPlatformDerivedConfiguration,
);
return {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
...intentAndPlatformDerivedConfiguration,
...pickBy(configuration, (v) => v !== undefined),
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useEffect } from "react";
import { filter, interval, throttle } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel";
import joinCallSoundMp3 from "../sound/join_call.mp3";
@@ -16,14 +17,15 @@ import handSoundOgg from "../sound/raise_hand.ogg";
import handSoundMp3 from "../sound/raise_hand.mp3";
import screenShareStartedOgg from "../sound/screen_share_started.ogg";
import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
import declineMp3 from "../sound/call_declined.mp3?url";
import declineOgg from "../sound/call_declined.ogg?url";
import timeoutMp3 from "../sound/call_timeout.mp3?url";
import timeoutOgg from "../sound/call_timeout.ogg?url";
import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest";
// Do not play any sounds if the participant count has exceeded this
// number.
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500;
export const callEventAudioSounds = prefetchSounds({
join: {
mp3: joinCallSoundMp3,
@@ -41,18 +43,8 @@ export const callEventAudioSounds = prefetchSounds({
mp3: screenShareStartedMp3,
ogg: screenShareStartedOgg,
},
decline: {
mp3: declineMp3,
ogg: declineOgg,
},
timeout: {
mp3: timeoutMp3,
ogg: timeoutOgg,
},
});
export type CallEventSounds = keyof Awaited<typeof callEventAudioSounds>;
export function CallEventAudioRenderer({
vm,
muted,
@@ -68,18 +60,37 @@ export function CallEventAudioRenderer({
const audioEngineRef = useLatest(audioEngineCtx);
useEffect(() => {
const joinSub = vm.joinSoundEffect$.subscribe(
() => void audioEngineRef.current?.playSound("join"),
);
const leftSub = vm.leaveSoundEffect$.subscribe(
() => void audioEngineRef.current?.playSound("left"),
);
const handRaisedSub = vm.newHandRaised$.subscribe(
() => void audioEngineRef.current?.playSound("raiseHand"),
);
const screenshareSub = vm.newScreenShare$.subscribe(
() => void audioEngineRef.current?.playSound("screenshareStarted"),
);
const joinSub = vm.memberChanges$
.pipe(
filter(
({ joined, ids }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && joined.length > 0,
),
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
void audioEngineRef.current?.playSound("join");
});
const leftSub = vm.memberChanges$
.pipe(
filter(
({ ids, left }) =>
ids.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND && left.length > 0,
),
throttle(() => interval(THROTTLE_SOUND_EFFECT_MS)),
)
.subscribe(() => {
void audioEngineRef.current?.playSound("left");
});
const handRaisedSub = vm.newHandRaised$.subscribe(() => {
void audioEngineRef.current?.playSound("raiseHand");
});
const screenshareSub = vm.newScreenShare$.subscribe(() => {
void audioEngineRef.current?.playSound("screenshareStarted");
});
return (): void => {
joinSub.unsubscribe();

View File

@@ -34,6 +34,7 @@
.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} aria-hidden={!show}>
<div className={styles.overlay} data-show={show}>
<BigIcon className={styles.icon}>
<VoiceCallIcon aria-hidden />
</BigIcon>

View File

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

View File

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

View File

@@ -12,14 +12,10 @@ import {
onTestFinished,
test,
vi,
vitest,
} from "vitest";
import { render, waitFor, screen, act } from "@testing-library/react";
import { render, waitFor, screen } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
import {
MatrixRTCSessionEvent,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import { 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";
@@ -98,15 +94,13 @@ 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("user")}>Leave</button>
<button onClick={() => onLeave()}>Leave</button>
</div>
);
},
@@ -143,9 +137,11 @@ function createGroupCallView(
getJoinRule: () => JoinRule.Invite,
} as Partial<RoomState> as RoomState,
});
const rtcSession = new MockRTCSession(room, []).withMemberships(
constant([localRtcMember]),
);
const rtcSession = new MockRTCSession(
room,
localRtcMember,
[],
).withMemberships(constant([]));
rtcSession.joined = joined;
const muteState = {
audio: { enabled: false },
@@ -212,8 +208,6 @@ 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(
@@ -266,19 +260,3 @@ 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,13 +50,10 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext";
import {
callEventAudioSounds,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import { callEventAudioSounds } from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle";
import {
@@ -86,8 +83,8 @@ interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
confineToRoom: boolean;
preload: UrlParams["preload"];
skipLobby: UrlParams["skipLobby"];
preload: boolean;
skipLobby: boolean;
header: HeaderStyle;
rtcSession: MatrixRTCSession;
isJoined: boolean;
@@ -128,16 +125,6 @@ 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 => {
@@ -179,11 +166,7 @@ export const GroupCallView: FC<Props> = ({
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
} = useUrlParams();
const { perParticipantE2EE, returnToLobby } = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const [useNewMembershipManager] = useSetting(useNewMembershipManagerSetting);
const [useExperimentalToDeviceTransport] = useSetting(
@@ -191,6 +174,7 @@ export const GroupCallView: FC<Props> = ({
);
// Save the password once we start the groupCallView
const { password: passwordFromUrl } = useUrlParams();
useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]);
@@ -289,28 +273,34 @@ export const GroupCallView: FC<Props> = ({
};
if (skipLobby) {
if (widget && preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
if (widget) {
if (preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
(async (): Promise<void> => {
await defaultDeviceSetup(
ev.detail.data as unknown as JoinCallData,
);
await enterRTCSessionOrError(rtcSession);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session", e);
});
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
await enterRTCSessionOrError(rtcSession);
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session on preload", e);
logger.error("Error joining RTC session", e);
});
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
}
} else {
// No lobby and no preload: we enter the rtc session right away
(async (): Promise<void> => {
await enterRTCSessionOrError(rtcSession);
})().catch((e) => {
logger.error("Error joining RTC session immediately", e);
});
void enterRTCSessionOrError(rtcSession);
}
}
}, [
@@ -330,11 +320,8 @@ export const GroupCallView: FC<Props> = ({
const navigate = useNavigate();
const onLeave = useCallback(
(
cause: "user" | "error" = "user",
playSound: CallEventSounds = "left",
): void => {
const audioPromise = leaveSoundContext.current?.playSound(playSound);
(cause: "user" | "error" = "user"): void => {
const audioPromise = leaveSoundContext.current?.playSound("left");
// 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;
@@ -462,7 +449,7 @@ export const GroupCallView: FC<Props> = ({
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room}
participantCount={participantCount}
onLeave={onLeave}
header={header}
muteStates={muteStates}
@@ -504,7 +491,6 @@ export const GroupCallView: FC<Props> = ({
// Left in widget mode:
body = returnToLobby ? lobbyView : null;
} else if (preload || skipLobby) {
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
body = null;
} else {
body = lobbyView;
@@ -513,11 +499,10 @@ export const GroupCallView: FC<Props> = ({
return (
<GroupCallErrorBoundary
widget={widget}
recoveryActionHandler={async (action) => {
setExternalError(null);
recoveryActionHandler={(action) => {
if (action == "reconnect") {
setLeft(false);
await enterRTCSessionOrError(rtcSession).catch((e) => {
enterRTCSessionOrError(rtcSession).catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}

View File

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

View File

@@ -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 { type LocalParticipant } from "livekit-client";
import { ConnectionState, type LocalParticipant } from "livekit-client";
import { of } from "rxjs";
import { BrowserRouter } from "react-router-dom";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -175,11 +175,12 @@ function createInCallView(): RenderResult & {
kind: E2eeType.NONE,
},
}}
matrixRoom={room}
livekitRoom={livekitRoom}
participantCount={0}
onLeave={function (): void {
throw new Error("Function not implemented.");
}}
connState={ConnectionState.Connected}
onShareClick={null}
/>
</RoomContext>

View File

@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
import { ConnectionState, type Room as LivekitRoom } from "livekit-client";
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
import { ConnectionState, type Room } from "livekit-client";
import { type MatrixClient } from "matrix-js-sdk";
import {
type FC,
type PointerEvent,
@@ -25,11 +25,7 @@ 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,
useObservableEagerState,
useSubscription,
} from "observable-hooks";
import { useObservable } from "observable-hooks";
import { logger } from "matrix-js-sdk/lib/logger";
import { RoomAndToDeviceEvents } from "matrix-js-sdk/lib/matrixrtc/RoomAndToDeviceKeyTransport";
import {
@@ -48,6 +44,7 @@ import {
ShareScreenButton,
SettingsButton,
ReactionToggleButton,
SwitchCameraButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { type HeaderStyle, useUrlParams } from "../UrlParams";
@@ -66,6 +63,7 @@ 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,
@@ -96,11 +94,9 @@ import {
useReactionsSender,
} from "../reactions/useReactionsSender";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { useSwitchCamera } from "./useSwitchCamera";
import { ReactionsOverlay } from "./ReactionsOverlay";
import {
CallEventAudioRenderer,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer";
import {
debugTileLayout as debugTileLayoutSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
@@ -117,14 +113,6 @@ import { useMediaDevices } from "../MediaDevicesContext.ts";
import { EarpieceOverlay } from "./EarpieceOverlay.tsx";
import { useAppBarHidden, useAppBarSecondaryButton } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { Toast } from "../Toast.tsx";
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 ?? {});
@@ -152,11 +140,11 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useEffect(() => {
logger.info(
`[Lifecycle] InCallView Component mounted, livekit room state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component mounted, livekitroom state ${livekitRoom?.state}`,
);
return (): void => {
logger.info(
`[Lifecycle] InCallView Component unmounted, livekit room state ${livekitRoom?.state}`,
`[Lifecycle] InCallView Component unmounted, livekitroom state ${livekitRoom?.state}`,
);
livekitRoom
?.disconnect()
@@ -171,23 +159,14 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
}, [livekitRoom]);
const { autoLeaveWhenOthersLeft, sendNotificationType, waitForCallPickup } =
useUrlParams();
useEffect(() => {
if (livekitRoom !== undefined) {
const reactionsReader = new ReactionsReader(props.rtcSession);
const vm = new CallViewModel(
props.rtcSession,
props.matrixRoom,
livekitRoom,
mediaDevices,
{
encryptionSystem: props.e2eeSystem,
autoLeaveWhenOthersLeft,
waitForCallPickup:
waitForCallPickup && sendNotificationType === "ring",
},
props.e2eeSystem,
connStateObservable$,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
@@ -200,14 +179,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
}
}, [
props.rtcSession,
props.matrixRoom,
livekitRoom,
mediaDevices,
props.e2eeSystem,
connStateObservable$,
autoLeaveWhenOthersLeft,
sendNotificationType,
waitForCallPickup,
]);
if (livekitRoom === undefined || vm === null) return null;
@@ -215,7 +190,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
return (
<RoomContext value={livekitRoom}>
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView {...props} vm={vm} livekitRoom={livekitRoom} />
<InCallView
{...props}
vm={vm}
livekitRoom={livekitRoom}
connState={connState}
/>
</ReactionsSenderProvider>
</RoomContext>
);
@@ -226,13 +206,14 @@ export interface InCallViewProps {
vm: CallViewModel;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
livekitRoom: LivekitRoom;
livekitRoom: Room;
muteStates: MuteStates;
participantCount: number;
/** Function to call when the user explicitly ends the call */
onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
onLeave: () => void;
header: HeaderStyle;
otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState;
onShareClick: (() => void) | null;
}
@@ -241,11 +222,12 @@ export const InCallView: FC<InCallViewProps> = ({
vm,
matrixInfo,
rtcSession,
matrixRoom,
livekitRoom,
muteStates,
participantCount,
onLeave,
header: headerStyle,
connState,
onShareClick,
}) => {
const { t } = useTranslation();
@@ -253,11 +235,10 @@ 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 (connectionState === ConnectionState.Disconnected)
if (connState === ConnectionState.Disconnected)
throw new ConnectionLostError();
const containerRef1 = useRef<HTMLDivElement | null>(null);
@@ -272,21 +253,6 @@ 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);
@@ -300,7 +266,7 @@ export const InCallView: FC<InCallViewProps> = ({
const [useExperimentalToDeviceTransport] = useSetting(
useExperimentalToDeviceTransportSetting,
);
const encryptionSystem = useRoomEncryptionSystem(matrixRoom.roomId);
const encryptionSystem = useRoomEncryptionSystem(rtcSession.room.roomId);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const showToDeviceEncryption = useMemo(
@@ -337,8 +303,6 @@ export const InCallView: FC<InCallViewProps> = ({
() => void toggleRaisedHand(),
);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
const windowMode = useBehavior(vm.windowMode$);
const layout = useBehavior(vm.layout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
@@ -348,90 +312,7 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
useSubscription(vm.autoLeave$, () => onLeave("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]);
const switchCamera = useSwitchCamera(vm.localVideo$);
// Ideally we could detect taps by listening for click events and checking
// that the pointerType of the event is "touch", but this isn't yet supported
@@ -614,11 +495,7 @@ export const InCallView: FC<InCallViewProps> = ({
break;
case "standard":
header = (
<Header
className={styles.header}
ref={headerRef}
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
>
<Header className={styles.header} ref={headerRef}>
<LeftNav>
<RoomHeaderInfo
id={matrixInfo.roomId}
@@ -641,38 +518,6 @@ 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({
@@ -702,7 +547,6 @@ export const InCallView: FC<InCallViewProps> = ({
className={classNames(className, styles.tile)}
style={style}
showSpeakingIndicators={showSpeakingIndicatorsValue}
focusable={!contentObscured}
/>
) : (
<SpotlightTile
@@ -713,13 +557,12 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={targetWidth}
targetHeight={targetHeight}
showIndicators={showSpotlightIndicatorsValue}
focusable={!contentObscured}
className={classNames(className, styles.tile)}
style={style}
/>
);
},
[vm, openProfile, contentObscured],
[vm, openProfile],
);
const layouts = useMemo(() => {
@@ -748,8 +591,6 @@ export const InCallView: FC<InCallViewProps> = ({
targetWidth={gridBounds.height}
targetHeight={gridBounds.width}
showIndicators={false}
focusable={!contentObscured}
aria-hidden={contentObscured}
/>
);
}
@@ -767,7 +608,6 @@ export const InCallView: FC<InCallViewProps> = ({
model={layout}
Layout={layers.fixed}
Tile={Tile}
aria-hidden={contentObscured}
/>
);
const scrollingGrid = (
@@ -777,7 +617,6 @@ 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
@@ -796,7 +635,7 @@ export const InCallView: FC<InCallViewProps> = ({
};
const rageshakeRequestModalProps = useRageshakeRequestModal(
matrixRoom.roomId,
rtcSession.room.roomId,
);
const toggleScreensharing = useCallback(() => {
@@ -830,6 +669,15 @@ export const InCallView: FC<InCallViewProps> = ({
data-testid="incall_videomute"
/>,
);
if (switchCamera !== null)
buttons.push(
<SwitchCameraButton
key="switch_camera"
className={styles.switchCamera}
onClick={switchCamera}
onTouchEnd={onControlsTouchEnd}
/>,
);
if (canScreenshare && !hideScreensharing) {
buttons.push(
<ShareScreenButton
@@ -866,7 +714,7 @@ export const InCallView: FC<InCallViewProps> = ({
<EndCallButton
key="end_call"
onClick={function (): void {
onLeave("user");
onLeave();
}}
onTouchEnd={onControlsTouchEnd}
data-testid="incall_leave"
@@ -934,17 +782,18 @@ export const InCallView: FC<InCallViewProps> = ({
{renderContent()}
<CallEventAudioRenderer vm={vm} muted={muteAllAudio} />
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
{reconnectingToast}
{earpieceOverlay}
<EarpieceOverlay
show={earpieceMode}
onBackToVideoPressed={audioOutputSwitcher?.switch}
/>
<ReactionsOverlay vm={vm} />
{waitingOverlay}
{footer}
{layout.type !== "pip" && (
<>
<RageshakeRequestModal {...rageshakeRequestModalProps} />
<SettingsModal
client={client}
roomId={matrixRoom.roomId}
roomId={rtcSession.room.roomId}
open={settingsModalOpen}
onDismiss={closeSettings}
tab={settingsTab}

View File

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

View File

@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
it,
@@ -27,6 +26,7 @@ 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,10 +110,9 @@ function mockMediaDevices(
return new MediaDevices(scope);
}
describe("useMuteStates VITE_PACKAGE='full' (SPA) mode", () => {
describe("useMuteStates", () => {
afterEach(() => {
vi.clearAllMocks();
vi.stubEnv("VITE_PACKAGE", "full");
});
afterAll(() => {
@@ -257,67 +256,3 @@ describe("useMuteStates VITE_PACKAGE='full' (SPA) mode", () => {
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,15 +81,11 @@ function useMuteState(
export function useMuteStates(isJoined: boolean): MuteStates {
const devices = useMediaDevices();
const { skipLobby, defaultAudioEnabled, defaultVideoEnabled } =
useUrlParams();
const { skipLobby } = useUrlParams();
const audio = useMuteState(
devices.audioInput,
() =>
(defaultAudioEnabled ?? Config.get().media_devices.enable_audio) &&
allowJoinUnmuted(skipLobby, isJoined),
);
const audio = useMuteState(devices.audioInput, () => {
return Config.get().media_devices.enable_audio && !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.
@@ -101,9 +97,7 @@ export function useMuteStates(isJoined: boolean): MuteStates {
const isEarpiece = useIsEarpiece();
const video = useMuteState(
devices.videoInput,
() =>
(defaultVideoEnabled ?? Config.get().media_devices.enable_video) &&
allowJoinUnmuted(skipLobby, isJoined),
() => Config.get().media_devices.enable_video && !skipLobby && !isJoined,
isEarpiece, // Force video to be unavailable if using earpiece
);
@@ -170,9 +164,3 @@ 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,8 +69,6 @@ beforeEach(() => {
playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
});
});

View File

@@ -1,61 +0,0 @@
.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

@@ -49,34 +49,6 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</span>
</div>
<div
class="participantsLine"
>
<svg
aria-label="Participants"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.175 13.825Q10.35 15 12 15t2.825-1.175T16 11t-1.175-2.825T12 7 9.175 8.175 8 11t1.175 2.825m4.237-1.412A1.93 1.93 0 0 1 12 13q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 11q0-.825.588-1.412A1.93 1.93 0 0 1 12 9q.825 0 1.412.588Q14 10.175 14 11t-.588 1.412"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0"
/>
<path
d="M16.23 18.792a13 13 0 0 0-1.455-.455 11.6 11.6 0 0 0-5.55 0q-.73.18-1.455.455a8 8 0 0 1-1.729-1.454q1.336-.618 2.709-.95A13.8 13.8 0 0 1 12 16q1.65 0 3.25.387 1.373.333 2.709.95a8 8 0 0 1-1.73 1.455"
/>
</svg>
<span
class="_typography_6v6n8_153 _font-body-sm-medium_6v6n8_41"
data-testid="roomHeader_participants_count"
>
2
</span>
</div>
</div>
</div>
<div
@@ -111,11 +83,6 @@ exports[`InCallView > rendering > renders 1`] = `
<div />
</div>
<div
class="bg animate"
data-state="closed"
/>
<div
aria-hidden="true"
class="overlay"
data-show="false"
>
@@ -289,7 +256,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
aria-disabled="false"
aria-labelledby="«r8»"
aria-labelledby="«r5»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -312,7 +279,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
aria-disabled="false"
aria-labelledby="«rd»"
aria-labelledby="«ra»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="primary"
data-size="lg"
@@ -334,7 +301,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="«ri»"
aria-labelledby="«rf»"
class="_button_vczzf_8 _has-icon_vczzf_57 _icon-only_vczzf_50"
data-kind="secondary"
data-size="lg"
@@ -355,7 +322,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="«rn»"
aria-labelledby="«rk»"
class="_button_vczzf_8 endCall _has-icon_vczzf_57 _icon-only_vczzf_50 _destructive_vczzf_107"
data-kind="primary"
data-size="lg"
@@ -381,7 +348,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="toggle layout"
>
<input
aria-labelledby="«rs»"
aria-labelledby="«rp»"
name="layout"
type="radio"
value="spotlight"
@@ -399,7 +366,7 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby="«r11»"
aria-labelledby="«ru»"
checked=""
name="layout"
type="radio"

View File

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

View File

@@ -120,13 +120,11 @@ 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,
callIntent,
notificationType: getUrlParams().sendNotificationType,
useNewMembershipManager,
manageMediaKeys: encryptMedia,
...(useDeviceSessionMemberEvents !== undefined && {

View File

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

BIN
src/sound/blocked.mp3 Normal file

Binary file not shown.

BIN
src/sound/blocked.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,6 @@ import {
UserProfileIcon,
ExpandIcon,
VolumeOffSolidIcon,
SwitchCameraSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ContextMenu,
@@ -60,14 +59,12 @@ interface TileProps {
targetHeight: number;
displayName: string;
showSpeakingIndicators: boolean;
focusable: boolean;
}
interface UserMediaTileProps extends TileProps {
vm: UserMediaViewModel;
mirror: boolean;
locallyMuted: boolean;
primaryButton?: ReactNode;
menuStart?: ReactNode;
menuEnd?: ReactNode;
}
@@ -77,12 +74,10 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
vm,
showSpeakingIndicators,
locallyMuted,
primaryButton,
menuStart,
menuEnd,
className,
displayName,
focusable,
...props
}) => {
const { toggleRaisedHand } = useReactionsSender();
@@ -164,27 +159,21 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
/>
}
displayName={displayName}
focusable={focusable}
primaryButton={
primaryButton ?? (
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button
aria-label={t("common.options")}
tabIndex={focusable ? undefined : -1}
>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
)
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
raisedHandTime={handRaised ?? undefined}
currentReaction={reaction ?? undefined}
@@ -214,14 +203,11 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
ref,
vm,
onOpenProfile,
focusable,
...props
}) => {
const { t } = useTranslation();
const mirror = useBehavior(vm.mirror$);
const alwaysShow = useBehavior(vm.alwaysShow$);
const switchCamera = useBehavior(vm.switchCamera$);
const latestAlwaysShow = useLatest(alwaysShow);
const onSelectAlwaysShow = useCallback(
(e: Event) => {
@@ -237,18 +223,6 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
vm={vm}
locallyMuted={false}
mirror={mirror}
primaryButton={
switchCamera === null ? undefined : (
<button
className={styles.switchCamera}
aria-label={t("switch_camera")}
onClick={switchCamera}
tabIndex={focusable ? undefined : -1}
>
<SwitchCameraSolidIcon aria-hidden width={20} height={20} />
</button>
)
}
menuStart={
<ToggleMenuItem
Icon={VisibilityOnIcon}
@@ -266,7 +240,6 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
/>
)
}
focusable={focusable}
{...props}
/>
);
@@ -346,7 +319,6 @@ interface GridTileProps {
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicators: boolean;
focusable: boolean;
}
export const GridTile: FC<GridTileProps> = ({

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,17 +54,16 @@ interface SpotlightItemBaseProps {
targetWidth: number;
targetHeight: number;
video: TrackReferenceOrPlaceholder | undefined;
videoEnabled: boolean;
member: RoomMember | undefined;
unencryptedWarning: boolean;
encryptionStatus: EncryptionStatus;
displayName: string;
focusable: boolean;
"aria-hidden"?: boolean;
localParticipant: boolean;
}
interface SpotlightUserMediaItemBaseProps extends SpotlightItemBaseProps {
videoEnabled: boolean;
videoFit: "contain" | "cover";
}
@@ -91,10 +90,12 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
vm,
...props
}) => {
const videoEnabled = useBehavior(vm.videoEnabled$);
const cropVideo = useBehavior(vm.cropVideo$);
const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = {
videoEnabled,
videoFit: cropVideo ? "cover" : "contain",
...props,
};
@@ -113,7 +114,6 @@ interface SpotlightItemProps {
vm: MediaViewModel;
targetWidth: number;
targetHeight: number;
focusable: boolean;
intersectionObserver$: Observable<IntersectionObserver>;
/**
* Whether this item should act as a scroll snapping point.
@@ -127,7 +127,6 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
vm,
targetWidth,
targetHeight,
focusable,
intersectionObserver$,
snap,
"aria-hidden": ariaHidden,
@@ -136,7 +135,6 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
const ref = useMergedRefs(ourRef, theirRef);
const displayName = useBehavior(vm.displayName$);
const video = useBehavior(vm.video$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const unencryptedWarning = useBehavior(vm.unencryptedWarning$);
const encryptionStatus = useBehavior(vm.encryptionStatus$);
@@ -162,18 +160,16 @@ const SpotlightItem: FC<SpotlightItemProps> = ({
targetWidth,
targetHeight,
video,
videoEnabled,
member: vm.member,
unencryptedWarning,
displayName,
focusable,
encryptionStatus,
"aria-hidden": ariaHidden,
localParticipant: vm.local,
};
return vm instanceof ScreenShareViewModel ? (
<MediaView videoFit="contain" mirror={false} {...baseProps} />
<MediaView videoEnabled videoFit="contain" mirror={false} {...baseProps} />
) : (
<SpotlightUserMediaItem vm={vm} {...baseProps} />
);
@@ -189,7 +185,6 @@ interface Props {
targetWidth: number;
targetHeight: number;
showIndicators: boolean;
focusable: boolean;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
}
@@ -202,7 +197,6 @@ export const SpotlightTile: FC<Props> = ({
targetWidth,
targetHeight,
showIndicators,
focusable = true,
className,
style,
}) => {
@@ -299,7 +293,6 @@ 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>
@@ -311,7 +304,6 @@ 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
@@ -327,7 +319,6 @@ 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>
@@ -339,7 +330,6 @@ 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>
@@ -351,7 +341,6 @@ 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,8 +32,6 @@ async function playSound(
buffer: AudioBuffer,
volume: number,
stereoPan: number,
delayS = 0,
abort?: AbortController,
): Promise<void> {
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, 0);
@@ -41,62 +39,13 @@ async function playSound(
pan.pan.setValueAtTime(stereoPan, 0);
const src = ctx.createBufferSource();
src.buffer = buffer;
abort?.signal.addEventListener("abort", () => {
src.disconnect();
});
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
src.connect(gain).connect(pan).connect(ctx.destination);
const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
controls.setPlaybackStarted();
src.start(ctx.currentTime + delayS);
src.start();
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
@@ -108,13 +57,8 @@ interface Props<S extends string> {
muted?: boolean;
}
interface UseAudioContext<S extends string> {
interface UseAudioContext<S> {
playSound(soundName: S): Promise<void>;
playSoundLooping(soundName: S, delayS?: number): () => Promise<void>;
/**
* Map of sound name to duration in seconds.
*/
soundDuration: Record<string, number>;
}
/**
@@ -202,23 +146,5 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -116,13 +116,12 @@ export const widget = ((): WidgetHelpers | null => {
EventType.Reaction,
EventType.RoomRedaction,
ElementCallReactionEventType,
EventType.RTCDecline,
];
const sendState = [
userId, // Legacy call membership events
`_${userId}_${deviceId}_m.call`, // Session membership events
`${userId}_${deviceId}_m.call`, // The above with no leading underscore, for room versions whose auth rules allow it
`_${userId}_${deviceId}`, // Session membership events
`${userId}_${deviceId}`, // The above with no leading underscore, for room versions whose auth rules allow it
].map((stateKey) => ({
eventType: EventType.GroupCallMemberPrefix,
stateKey,

View File

@@ -1,14 +1,6 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { defineConfig, mergeConfig } from "vite";
import generateFile from "vite-plugin-generate-file";
import fullConfig from "./vite.config";
import generateFile from "vite-plugin-generate-file";
const base = "./";

View File

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

View File

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

420
yarn.lock
View File

@@ -2772,12 +2772,12 @@ __metadata:
languageName: node
linkType: hard
"@livekit/protocol@npm:1.42.2, @livekit/protocol@npm:^1.42.2":
version: 1.42.2
resolution: "@livekit/protocol@npm:1.42.2"
"@livekit/protocol@npm:1.39.3, @livekit/protocol@npm:^1.38.0":
version: 1.39.3
resolution: "@livekit/protocol@npm:1.39.3"
dependencies:
"@bufbuild/protobuf": "npm:^1.10.0"
checksum: 10c0/73e77e0975d4d8ca0baa23ad03cb9ac3a844e8d89e9cefbd17343131a774c7aeb4d190fd0397152fcf5a396d921af3eb83e83e660a4aa12215d9ac4e52bf037a
checksum: 10c0/192ce7283db4505232aa487e71d08670783a1045a8935926f95ff4b9d3d310db706c2ad4af2ebdb0bd735c2e9042c1ee3b1f62e44a9d01bd41fd58cf8dc272b9
languageName: node
linkType: hard
@@ -2793,10 +2793,10 @@ __metadata:
languageName: node
linkType: hard
"@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
"@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
languageName: node
linkType: hard
@@ -3371,14 +3371,14 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:^1.56.1":
version: 1.56.1
resolution: "@playwright/test@npm:1.56.1"
"@playwright/test@npm:^1.52.0":
version: 1.54.1
resolution: "@playwright/test@npm:1.54.1"
dependencies:
playwright: "npm:1.56.1"
playwright: "npm:1.54.1"
bin:
playwright: cli.js
checksum: 10c0/2b5b0e1f2e6a18f6e5ce6897c7440ca78f64e0b004834e9808e93ad2b78b96366b562ae4366602669cf8ad793a43d85481b58541e74be71e905e732d833dd691
checksum: 10c0/1b414356bc1049927d7b9efc14d5b3bf000ef6483313926bb795b4f27fe3707e8e0acf0db59063a452bb4f7e34559758d17640401b6f3e2f5290f299a8d8d02f
languageName: node
linkType: hard
@@ -4503,149 +4503,142 @@ __metadata:
languageName: node
linkType: hard
"@rollup/rollup-android-arm-eabi@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-android-arm-eabi@npm:4.50.1"
"@rollup/rollup-android-arm-eabi@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-android-arm-eabi@npm:4.41.1"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@rollup/rollup-android-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-android-arm64@npm:4.50.1"
"@rollup/rollup-android-arm64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-android-arm64@npm:4.41.1"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-darwin-arm64@npm:4.50.1"
"@rollup/rollup-darwin-arm64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-darwin-arm64@npm:4.41.1"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-darwin-x64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-darwin-x64@npm:4.50.1"
"@rollup/rollup-darwin-x64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-darwin-x64@npm:4.41.1"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@rollup/rollup-freebsd-arm64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-freebsd-arm64@npm:4.50.1"
"@rollup/rollup-freebsd-arm64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-freebsd-arm64@npm:4.41.1"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-freebsd-x64@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-freebsd-x64@npm:4.50.1"
"@rollup/rollup-freebsd-x64@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-freebsd-x64@npm:4.41.1"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.50.1"
"@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.41.1"
conditions: os=linux & cpu=arm & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-arm-musleabihf@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.50.1"
"@rollup/rollup-linux-arm-musleabihf@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.41.1"
conditions: os=linux & cpu=arm & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.50.1"
"@rollup/rollup-linux-arm64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.41.1"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-arm64-musl@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.50.1"
"@rollup/rollup-linux-arm64-musl@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-arm64-musl@npm:4.41.1"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.50.1"
"@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.41.1"
conditions: os=linux & cpu=loong64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-ppc64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.50.1"
"@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.41.1"
conditions: os=linux & cpu=ppc64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.50.1"
"@rollup/rollup-linux-riscv64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.41.1"
conditions: os=linux & cpu=riscv64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-riscv64-musl@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.50.1"
"@rollup/rollup-linux-riscv64-musl@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.41.1"
conditions: os=linux & cpu=riscv64 & libc=musl
languageName: node
linkType: hard
"@rollup/rollup-linux-s390x-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.50.1"
"@rollup/rollup-linux-s390x-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.41.1"
conditions: os=linux & cpu=s390x & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-gnu@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.50.1"
"@rollup/rollup-linux-x64-gnu@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-x64-gnu@npm:4.41.1"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
"@rollup/rollup-linux-x64-musl@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-linux-x64-musl@npm:4.50.1"
"@rollup/rollup-linux-x64-musl@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-linux-x64-musl@npm:4.41.1"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
"@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"
"@rollup/rollup-win32-arm64-msvc@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.41.1"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@rollup/rollup-win32-ia32-msvc@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.50.1"
"@rollup/rollup-win32-ia32-msvc@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.41.1"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@rollup/rollup-win32-x64-msvc@npm:4.50.1":
version: 4.50.1
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.50.1"
"@rollup/rollup-win32-x64-msvc@npm:4.41.1":
version: 4.41.1
resolution: "@rollup/rollup-win32-x64-msvc@npm:4.41.1"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -5155,10 +5148,10 @@ __metadata:
languageName: node
linkType: hard
"@types/estree@npm:1.0.8":
version: 1.0.8
resolution: "@types/estree@npm:1.0.8"
checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5
"@types/estree@npm:1.0.7":
version: 1.0.7
resolution: "@types/estree@npm:1.0.7"
checksum: 10c0/be815254316882f7c40847336cd484c3bc1c3e34f710d197160d455dc9d6d050ffbf4c3bc76585dba86f737f020ab20bdb137ebe0e9116b0c86c7c0342221b8c
languageName: node
linkType: hard
@@ -5290,6 +5283,13 @@ __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.42.2"
"@livekit/protocol": "npm:^1.38.0"
"@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.56.1"
"@playwright/test": "npm:^1.52.0"
"@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.15.13"
livekit-client: "npm:^2.13.0"
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:^7.0.0"
vite: "npm:^6.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,18 +8483,6 @@ __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"
@@ -9521,13 +9509,6 @@ __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"
@@ -9790,13 +9771,6 @@ __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"
@@ -10071,14 +10045,13 @@ __metadata:
languageName: node
linkType: hard
"livekit-client@npm:^2.15.13":
version: 2.15.13
resolution: "livekit-client@npm:2.15.13"
"livekit-client@npm:^2.13.0":
version: 2.15.4
resolution: "livekit-client@npm:2.15.4"
dependencies:
"@livekit/mutex": "npm:1.1.1"
"@livekit/protocol": "npm:1.42.2"
"@livekit/protocol": "npm:1.39.3"
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"
@@ -10087,7 +10060,7 @@ __metadata:
webrtc-adapter: "npm:^9.0.1"
peerDependencies:
"@types/dom-mediacapture-record": ^1
checksum: 10c0/5a061df9000461a6d40ef8aa1e72e8aedc640181cc57fe6f2c48c5c7f90ce96a735b125aede377fc43f4692a685e098f17eeae0f42c5b2fed473305867bf2789
checksum: 10c0/f12a3b604aed8e075791c60a75c9eea5467e5a0bab48c2eb23216ec357e89a50199274121e0330b61f946d0be9556058824ce1b832003e722786e54ba33017a6
languageName: node
linkType: hard
@@ -10306,11 +10279,11 @@ __metadata:
linkType: hard
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#head=develop":
version: 38.3.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=41d70d0b5d3f0eba92686f8089cb329d875b26b5"
version: 37.12.0
resolution: "matrix-js-sdk@https://github.com/matrix-org/matrix-js-sdk.git#commit=c4e1e0723e3674725f8ed9cda5be7a7efe304e29"
dependencies:
"@babel/runtime": "npm:^7.12.5"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.3.0"
"@matrix-org/matrix-sdk-crypto-wasm": "npm:^15.1.0"
another-json: "npm:^0.2.0"
bs58: "npm:^6.0.0"
content-type: "npm:^1.0.4"
@@ -10319,11 +10292,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:7"
p-retry: "npm:4"
sdp-transform: "npm:^2.14.1"
unhomoglyph: "npm:^1.0.6"
uuid: "npm:13"
checksum: 10c0/b48528fec573f3e14d1297f360a56d52d7f313da0d4cf82ab51e4c29798b86995b8a6bd72409779746e7bcf02949bc2788bffa9aba276bfb1a76dbcbe89900a0
uuid: "npm:11"
checksum: 10c0/e8f97b90ded9e46bdd803dde9861e178c76741999c224cbc869ad38695f300702405e5c365e270663b90e2a317b646bbf2b9e9bd7d51ffce36e6d2ca3ab29ed9
languageName: node
linkType: hard
@@ -10930,12 +10903,13 @@ __metadata:
languageName: node
linkType: hard
"p-retry@npm:7":
version: 7.0.0
resolution: "p-retry@npm:7.0.0"
"p-retry@npm:4":
version: 4.6.2
resolution: "p-retry@npm:4.6.2"
dependencies:
is-network-error: "npm:^1.1.0"
checksum: 10c0/3c090ac72bbe00fd2f062ee6178c44f7302f298936ab2290a458575e73650e7834b556beb2b09fa9fbebedab2ec3358cb474c09710cf828972b670c3c0cb89e4
"@types/retry": "npm:0.12.0"
retry: "npm:^0.13.1"
checksum: 10c0/d58512f120f1590cfedb4c2e0c42cb3fa66f3cea8a4646632fcb834c56055bb7a6f138aa57b20cc236fb207c9d694e362e0b5c2b14d9b062f67e8925580c73b0
languageName: node
linkType: hard
@@ -11123,34 +11097,27 @@ __metadata:
languageName: node
linkType: hard
"picomatch@npm:^4.0.3":
version: 4.0.3
resolution: "picomatch@npm:4.0.3"
checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2
languageName: node
linkType: hard
"playwright-core@npm:1.56.1":
version: 1.56.1
resolution: "playwright-core@npm:1.56.1"
"playwright-core@npm:1.54.1":
version: 1.54.1
resolution: "playwright-core@npm:1.54.1"
bin:
playwright-core: cli.js
checksum: 10c0/ffd40142b99c68678b387445d5b42f1fee4ab0b65d983058c37f342e5629f9cdbdac0506ea80a0dfd41a8f9f13345bad54e9a8c35826ef66dc765f4eb3db8da7
checksum: 10c0/b821262b024d7753b1bfa71eb2bc99f2dda12a869d175b2e1bc6ac2764bd661baf36d9d42f45caf622854ad7e4a6077b9b57014c74bb5a78fe339c9edf1c9019
languageName: node
linkType: hard
"playwright@npm:1.56.1":
version: 1.56.1
resolution: "playwright@npm:1.56.1"
"playwright@npm:1.54.1":
version: 1.54.1
resolution: "playwright@npm:1.54.1"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.56.1"
playwright-core: "npm:1.54.1"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10c0/8e9965aede86df0f4722063385748498977b219630a40a10d1b82b8bd8d4d4e9b6b65ecbfa024331a30800163161aca292fb6dd7446c531a1ad25f4155625ab4
checksum: 10c0/c5fedae31a03a1f4c4846569aef3ffb98da23000a4d255abfc8c2ede15b43cc7cd87b80f6fa078666c030373de8103787cf77ef7653ae9458aabbbd4320c2599
languageName: node
linkType: hard
@@ -11556,7 +11523,7 @@ __metadata:
languageName: node
linkType: hard
"postcss@npm:^8.4.41, postcss@npm:^8.5.6":
"postcss@npm:^8.4.41, postcss@npm:^8.5.3":
version: 8.5.6
resolution: "postcss@npm:8.5.6"
dependencies:
@@ -12237,6 +12204,13 @@ __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"
@@ -12277,32 +12251,31 @@ __metadata:
languageName: node
linkType: hard
"rollup@npm:^4.43.0":
version: 4.50.1
resolution: "rollup@npm:4.50.1"
"rollup@npm:^4.34.9, rollup@npm:^4.40.0":
version: 4.41.1
resolution: "rollup@npm:4.41.1"
dependencies:
"@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"
"@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"
fsevents: "npm:~2.3.2"
dependenciesMeta:
"@rollup/rollup-android-arm-eabi":
@@ -12327,7 +12300,7 @@ __metadata:
optional: true
"@rollup/rollup-linux-loongarch64-gnu":
optional: true
"@rollup/rollup-linux-ppc64-gnu":
"@rollup/rollup-linux-powerpc64le-gnu":
optional: true
"@rollup/rollup-linux-riscv64-gnu":
optional: true
@@ -12339,8 +12312,6 @@ __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":
@@ -12351,7 +12322,7 @@ __metadata:
optional: true
bin:
rollup: dist/bin/rollup
checksum: 10c0/2029282826d5fb4e308be261b2c28329a4d2bd34304cc3960da69fd21d5acccd0267d6770b1656ffc8f166203ef7e865b4583d5f842a519c8ef059ac71854205
checksum: 10c0/c4d5f2257320b50dc0e035e31d8d2f78d36b7015aef2f87cc984c0a1c97ffebf14337dddeb488b4b11ae798fea6486189b77e7cf677617dcf611d97db41ebfda
languageName: node
linkType: hard
@@ -13187,7 +13158,7 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.14":
"tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.14":
version: 0.2.14
resolution: "tinyglobby@npm:0.2.14"
dependencies:
@@ -13197,16 +13168,6 @@ __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"
@@ -13766,12 +13727,12 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:13":
version: 13.0.0
resolution: "uuid@npm:13.0.0"
"uuid@npm:11":
version: 11.0.5
resolution: "uuid@npm:11.0.5"
bin:
uuid: dist-node/bin/uuid
checksum: 10c0/950e4c18d57fef6c69675344f5700a08af21e26b9eff2bf2180427564297368c538ea11ac9fb2e6528b17fc3966a9fd2c5049361b0b63c7d654f3c550c9b3d67
uuid: dist/esm/bin/uuid
checksum: 10c0/6f59f0c605e02c14515401084ca124b9cb462b4dcac866916a49862bcf831874508a308588c23a7718269226ad11a92da29b39d761ad2b86e736623e3a33b6e7
languageName: node
linkType: hard
@@ -13925,17 +13886,17 @@ __metadata:
languageName: node
linkType: hard
"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"
"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"
dependencies:
esbuild: "npm:^0.25.0"
fdir: "npm:^6.5.0"
fdir: "npm:^6.4.4"
fsevents: "npm:~2.3.3"
picomatch: "npm:^4.0.3"
postcss: "npm:^8.5.6"
rollup: "npm:^4.43.0"
tinyglobby: "npm:^0.2.15"
picomatch: "npm:^4.0.2"
postcss: "npm:^8.5.3"
rollup: "npm:^4.40.0"
tinyglobby: "npm:^0.2.14"
peerDependencies:
"@types/node": ^20.19.0 || >=22.12.0
jiti: ">=1.21.0"
@@ -13976,7 +13937,62 @@ __metadata:
optional: true
bin:
vite: bin/vite.js
checksum: 10c0/782d2f20c25541b26d1fb39bef5f194149caff39dc25b7836e25f049ca919f2e2ce186bddb21f3f20f6195354b3579ec637a8ca08d65b117f8b6f81e3e730a9c
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
languageName: node
linkType: hard