mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-19 23:52:26 +00:00
Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb7fe07b7 | ||
|
|
873dfdbfbd | ||
|
|
e68d1d1b5f | ||
|
|
b7efa02adc | ||
|
|
c807b8ef7d | ||
|
|
6b5f396e1e | ||
|
|
182be8676d | ||
|
|
f6f47ede62 | ||
|
|
eb117249d1 | ||
|
|
f28d9e9b06 | ||
|
|
bc6c232ef0 | ||
|
|
8e78ac76bd | ||
|
|
d8d70812b8 | ||
|
|
6e44332fd1 | ||
|
|
bc6015eff2 | ||
|
|
0dca5835f6 | ||
|
|
0a8c24bca8 | ||
|
|
7485b3d71f | ||
|
|
efdee7dcdb | ||
|
|
d7d3dcde58 | ||
|
|
9330b907fc | ||
|
|
256ff7a0b6 | ||
|
|
6ddcc15206 | ||
|
|
a56e934841 | ||
|
|
efde51b8c1 | ||
|
|
2a7c6c3c06 | ||
|
|
5bab17b668 | ||
|
|
d7d28a1b55 | ||
|
|
790bd1e2bf | ||
|
|
17231e545c | ||
|
|
17d9d19c91 | ||
|
|
3d37abf490 | ||
|
|
92f504d886 | ||
|
|
b0d098e065 | ||
|
|
a526a8ae19 | ||
|
|
1146d3820a | ||
|
|
a4dda428f5 | ||
|
|
5982586f24 | ||
|
|
e6b4aae714 | ||
|
|
ede29bdf1b | ||
|
|
b5b2bd2193 | ||
|
|
21ca382306 | ||
|
|
25e379546e | ||
|
|
0443ef55d1 | ||
|
|
465c413691 | ||
|
|
06b9105cee | ||
|
|
2b5979162a | ||
|
|
5399f14012 | ||
|
|
f2ffb06f6c | ||
|
|
6cf0e1df51 | ||
|
|
bfa20ef747 | ||
|
|
5955d22c1b | ||
|
|
ce4426858b | ||
|
|
4ec75ecda4 | ||
|
|
b51a33c4e1 | ||
|
|
c3837464a3 | ||
|
|
e7372d4c03 | ||
|
|
69eddd7846 | ||
|
|
de3e4c6a78 | ||
|
|
3abc5a0435 | ||
|
|
8e20d48b1d | ||
|
|
ff57c217aa | ||
|
|
b60e7452b7 | ||
|
|
1c03ad335c | ||
|
|
87873b726f | ||
|
|
3448a3a2ef | ||
|
|
c625fb8845 | ||
|
|
cab9698fc7 | ||
|
|
7410bd8a82 | ||
|
|
f17670b731 | ||
|
|
33a0653ea5 | ||
|
|
b1c29e9a41 | ||
|
|
ad92892063 | ||
|
|
1a827e0930 | ||
|
|
a1b37bb3c9 | ||
|
|
061d25a54c | ||
|
|
9999a755dd | ||
|
|
9857ed53e3 | ||
|
|
0aa9d94826 | ||
|
|
f2622f0032 |
@@ -9,7 +9,7 @@ on:
|
||||
- opened
|
||||
- labeled
|
||||
push:
|
||||
branches: [livekit]
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
versioning:
|
||||
|
||||
@@ -4,12 +4,12 @@ on:
|
||||
|
||||
push:
|
||||
branches:
|
||||
- livekit
|
||||
- main
|
||||
paths:
|
||||
- .github/labels.yml
|
||||
- .github/workflows/sync-labels.yml
|
||||
|
||||
permissions: {} # We use ELEMENT_BOT_TOKEN instead
|
||||
permissions: {} # We use LABEL_SYNC_GITHUB_TOKEN instead
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
DELETE: true
|
||||
WET: true
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.LABEL_SYNC_GITHUB_TOKEN }}
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Test
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [livekit]
|
||||
branches: [main]
|
||||
jobs:
|
||||
vitest:
|
||||
name: Run unit tests
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Upload translation files to Localazy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- livekit
|
||||
- main
|
||||
paths-ignore:
|
||||
- ".github/**"
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: GitHub Actions Security Analysis with zizmor 🌈
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["livekit", "full-mesh"]
|
||||
branches: ["main", "full-mesh"]
|
||||
pull_request: {}
|
||||
|
||||
permissions: {}
|
||||
|
||||
@@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import i18n from "i18next";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import EN from "../locales/en/app.json";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
import "../src/index.css";
|
||||
|
||||
// Bare-minimum i18n config
|
||||
// Bare-minimum i18n config.
|
||||
// Unlike the app, stories register the instance as react-i18next's default
|
||||
// rather than wrapping every story in an <I18nextProvider>.
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
"server_name": "synapse.m.localhost"
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
|
||||
@@ -8,9 +8,6 @@
|
||||
"livekit": {
|
||||
"livekit_service_url": "https://livekit-jwt.mydomain.com"
|
||||
},
|
||||
"features": {
|
||||
"feature_use_device_session_member_events": true
|
||||
},
|
||||
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
"matrix_rtc_mode": "compatibility",
|
||||
"matrix_rtc_session": {
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
element-web:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:af44e1e18bdd363a3332a577c3af66a9dde4ad352508a42ee5012c4a9528b30d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
|
||||
element-web-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:af44e1e18bdd363a3332a577c3af66a9dde4ad352508a42ee5012c4a9528b30d
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
|
||||
|
||||
@@ -5,5 +5,6 @@ This folder contains documentation for setup, usage, and development of Element
|
||||
- [Embedded vs standalone mode](./embedded_standalone.md)
|
||||
- [Url format and parameters](./url_params.md)
|
||||
- [Global JS controls](./controls.md)
|
||||
- [MatrixRTC modes](./matrix_rtc_modes.md)
|
||||
- [Self-Hosting](./self_hosting.md)
|
||||
- [Developing with linked packages](./linking.md)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# MatrixRTC modes
|
||||
|
||||
Element Call is in the middle of a transition of how a call session is
|
||||
represented: from room _state_ events (`org.matrix.msc3401.call.member`) to
|
||||
_sticky_ events
|
||||
([MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354)),
|
||||
which are a much better fit for the short lived, per-device nature of call
|
||||
memberships.
|
||||
|
||||
Not every homeserver supports sticky events yet. The two MatrixRTC modes
|
||||
controls whether Element Call uses them.
|
||||
|
||||
## The modes
|
||||
|
||||
| Mode | Membership events | SFU selection | JWT endpoint |
|
||||
| --------------- | ----------------- | ------------- | ---------------------------- |
|
||||
| `compatibility` | state events | multi SFU | legacy |
|
||||
| `matrix_2_0` | sticky events | multi SFU | Matrix 2.0 (hashed identity) |
|
||||
|
||||
**`compatibility`** — multi SFU, but still state events. Use it when the
|
||||
homeserver does not support sticky events. This is the default.
|
||||
|
||||
**`matrix_2_0`** — the target state. Requires a homeserver that advertises
|
||||
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
|
||||
token from the Matrix 2.0 JWT endpoint of the
|
||||
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
|
||||
and identifies the room by a hashed identity instead of a `livekit_alias`.
|
||||
(Remote memberships always try the new endpoint first and fall back to the
|
||||
legacy one, so remote participants can be on either.)
|
||||
|
||||
## Selecting a mode
|
||||
|
||||
Users can choose a mode under **Settings → Developer → MatrixRTC mode**. The
|
||||
Matrix 2.0 option is disabled if the homeserver does not support sticky events.
|
||||
|
||||
A deployment can pin the mode for all its clients in `config.json`, which
|
||||
disables the Developer Settings choice:
|
||||
|
||||
```json
|
||||
{
|
||||
"matrix_rtc_mode": "compatibility"
|
||||
}
|
||||
```
|
||||
|
||||
Valid values are `compatibility` and `matrix_2_0`; an invalid value is
|
||||
ignored (with a warning) and the user's choice applies. Pinning `matrix_2_0` on a
|
||||
homeserver without sticky event support makes joining fail with a "sticky events
|
||||
required" error.
|
||||
+19
-20
@@ -16,7 +16,7 @@
|
||||
The following [MSCs](https://github.com/matrix-org/matrix-spec-proposals) are
|
||||
required for Element Call to work properly:
|
||||
|
||||
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md):
|
||||
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md)
|
||||
Room Summary API**: In Standalone mode Element Call is able to join rooms
|
||||
over federation using knocking. In this context MSC3266 is required as it
|
||||
allows to request a room summary of rooms you are not joined. The summary
|
||||
@@ -29,19 +29,26 @@ required for Element Call to work properly:
|
||||
signalling. If disabled it is very likely that you end up with stuck calls in
|
||||
Matrix rooms.
|
||||
|
||||
- **[MSC4519](https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/voip-transports-registry/proposals/4519-rtc-transports-registry.md)
|
||||
MatrixRTC Transports Registry**: Defines an endpoint that clients can use to
|
||||
query the available MatrixRTC transports (i.e. find your LiveKit SFU).
|
||||
|
||||
- **[MSC4222](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sync_v2_state_after/proposals/4222-sync-v2-state-after.md)
|
||||
Adding `state_after` to sync v2**: Allow clients to opt-in to a change of the
|
||||
sync v2 API that allows them to correctly track the state of the room. This is
|
||||
required by Element Call to track room state reliably.
|
||||
|
||||
If you're using [Synapse](https://github.com/element-hq/synapse/) as your
|
||||
homeserver, you'll need to additionally add the following config items to
|
||||
`homeserver.yaml` to comply with Element Call:
|
||||
homeserver, you can configure these features by adding the following entries to
|
||||
`homeserver.yaml`:
|
||||
|
||||
```yaml
|
||||
experimental_features:
|
||||
# MSC3266: Room summary API. Used for knocking over federation
|
||||
msc3266_enabled: true
|
||||
# MSC4143: MatrixRTC. For historical reasons this flag enables the transports
|
||||
# endpoint defined in MSC4519.
|
||||
msc4143_enabled: true
|
||||
# MSC4222 needed for syncv2 state_after. This allow clients to
|
||||
# correctly track the state of the room.
|
||||
msc4222_enabled: true
|
||||
@@ -61,6 +68,15 @@ rc_delayed_event_mgmt:
|
||||
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2Hz
|
||||
per_second: 1
|
||||
burst_count: 20
|
||||
|
||||
matrix_rtc:
|
||||
transports:
|
||||
# The transport you specify will be made available to clients over the
|
||||
# /_matrix/client/unstable/org.matrix.msc4143/rtc/transports endpoint as
|
||||
# defined in MSC4519.
|
||||
- type: livekit
|
||||
# Replace this with the actual URL of your MatrixRTC Authorization Service
|
||||
livekit_service_url: https://matrix-rtc.example.com/livekit/jwt
|
||||
```
|
||||
|
||||
As a prerequisite for the
|
||||
@@ -187,23 +203,6 @@ backend mxrtc_auth_backend
|
||||
|
||||
```
|
||||
|
||||
#### MatrixRTC transport announcement
|
||||
|
||||
Enable the unstable feature flag `msc4143_enabled`, and update the
|
||||
[`matrix_rtc` section](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#matrix_rtc)
|
||||
of your Synapse config file:
|
||||
|
||||
```yaml
|
||||
matrix_rtc:
|
||||
transports:
|
||||
- type: livekit
|
||||
livekit_service_url: https://matrix-rtc.example.com/livekit/jwt
|
||||
```
|
||||
|
||||
The transport you specify will be made available to clients over the
|
||||
`/_matrix/client/unstable/org.matrix.msc4143/rtc/transports` endpoint as defined
|
||||
in [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143).
|
||||
|
||||
## Building Element Call
|
||||
|
||||
> [!NOTE]
|
||||
|
||||
@@ -80,10 +80,6 @@
|
||||
"description": "Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)",
|
||||
"label": "Compatibility: state events & multi SFU"
|
||||
},
|
||||
"Legacy": {
|
||||
"description": "Compatible with old versions of EC that do not support multi SFU",
|
||||
"label": "Legacy: state events & oldest membership SFU"
|
||||
},
|
||||
"Matrix_2_0": {
|
||||
"description": "Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later",
|
||||
"label": "Matrix 2.0: sticky events & multi SFU"
|
||||
|
||||
@@ -101,9 +101,7 @@ async function setRtcModeFromSettings(
|
||||
|
||||
// Move to Developer tab now
|
||||
await page.getByRole("tab", { name: "Developer" }).click();
|
||||
if (mode == "legacy") {
|
||||
await page.getByText("Legacy: state events").click();
|
||||
} else if (mode == "2_0") {
|
||||
if (mode == "2_0") {
|
||||
await page.getByText("Matrix 2.0").click();
|
||||
} else {
|
||||
// compat
|
||||
|
||||
@@ -12,9 +12,7 @@ import { HOST1, HOST2, type RtcMode, TestHelpers } from "./test-helpers";
|
||||
|
||||
const modePairs: [RtcMode, RtcMode][] = [
|
||||
["compat", "compat"],
|
||||
["legacy", "legacy"],
|
||||
["legacy", "compat"],
|
||||
["compat", "legacy"],
|
||||
// TODO: Compatibility + Matrix 2.0?
|
||||
];
|
||||
|
||||
modePairs.forEach(([rtcMode1, rtcMode2]) => {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user";
|
||||
import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
|
||||
widgetTest(
|
||||
"Bug new joiner was not publishing on correct SFU",
|
||||
async ({ addUser, browserName }) => {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"This is a bug in the old widget, not a browser problem.",
|
||||
);
|
||||
|
||||
test.slow();
|
||||
|
||||
// 2 users in federation
|
||||
const florian = await addUser("floriant", HOST1);
|
||||
const timo = await addUser("timo", HOST2);
|
||||
|
||||
// Florian creates a room and invites Timo to it
|
||||
const roomName = "Call Room";
|
||||
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
|
||||
|
||||
// Timo joins the room
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
// Ensure we are in legacy mode (should be the default)
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
"legacy",
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
timo.page,
|
||||
"legacy",
|
||||
);
|
||||
|
||||
// Let timo create a call
|
||||
await TestHelpers.startCallInCurrentRoom(timo.page, false);
|
||||
await TestHelpers.joinCallFromLobby(timo.page);
|
||||
|
||||
// We want to simulate that the oldest membership authentication is way slower than
|
||||
// the preffered auth.
|
||||
// In this setup, timo advertised$ transport will be it's own, and the active will be the one from florian
|
||||
await florian.page.route(
|
||||
"**/matrix-rtc.othersite.m.localhost/livekit/jwt/**",
|
||||
async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000)); // 5 second delay
|
||||
await route.continue();
|
||||
},
|
||||
);
|
||||
|
||||
// Florian joins the call
|
||||
await expect(florian.page.getByTestId("join-call-button")).toBeVisible();
|
||||
await florian.page.getByTestId("join-call-button").click();
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
|
||||
await florian.page.waitForTimeout(3000);
|
||||
await timo.page.waitForTimeout(3000);
|
||||
|
||||
// We should see 2 video tiles everywhere now
|
||||
for (const user of [timo, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// No one should be waiting for media
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
await expect(frame.locator("video")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,91 +0,0 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { widgetTest } from "../fixtures/widget-user";
|
||||
import { HOST1, HOST2, TestHelpers } from "./test-helpers";
|
||||
|
||||
// ## Issue
|
||||
// This test reproduces an issue with the publisher.
|
||||
// When switching local focus, we need to recreate the publisher.
|
||||
// This failed because of a dead lock in the old publishers destruction.
|
||||
//
|
||||
// There are numerus ways to enforece this situation:
|
||||
// - oldest member swap (manually set the oldest member focus and leave with the prev oldest member)
|
||||
// This almost never happens in the real worls since clients will set their preferredFoci list to what the oldest member is.
|
||||
// - switch from oldest member to multi sfu as the NOT the first joiner + the first joiner is on a different sfu than your preferred sfu.
|
||||
//
|
||||
// This test uses the "switch from oldest member to multi sfu" approach.
|
||||
//
|
||||
// It is a copy of federated-call.test.ts in the `["legacy", "legacy"]` setup,
|
||||
// which once connected will make the second user switch to multi sfu.
|
||||
widgetTest(
|
||||
`Test swapping publisher from ${HOST1} to ${HOST2}`,
|
||||
async ({ addUser, browserName }) => {
|
||||
test.slow();
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
|
||||
);
|
||||
|
||||
const florian = await addUser("floriant", HOST1);
|
||||
const timo = await addUser("timo", HOST2);
|
||||
|
||||
const roomName = "Call Room";
|
||||
|
||||
await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]);
|
||||
|
||||
await TestHelpers.acceptRoomInvite(roomName, timo.page);
|
||||
|
||||
await florian.page.pause();
|
||||
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
florian.page,
|
||||
"legacy",
|
||||
);
|
||||
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
|
||||
timo.page,
|
||||
"legacy",
|
||||
);
|
||||
|
||||
await TestHelpers.startCallInCurrentRoom(florian.page, false);
|
||||
await TestHelpers.joinCallFromLobby(florian.page);
|
||||
|
||||
// timo joins
|
||||
await TestHelpers.joinCallInCurrentRoom(timo.page);
|
||||
|
||||
// We should see 2 video tiles everywhere now
|
||||
for (const user of [timo, florian]) {
|
||||
const frame = user.page
|
||||
.locator('iframe[title="Element Call"]')
|
||||
.contentFrame();
|
||||
await expect(frame.getByTestId("videoTile")).toHaveCount(2);
|
||||
|
||||
// Wait for "Waiting for media..." to disappear (with timeout)
|
||||
await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
|
||||
timeout: 10000, // Maximum time to wait
|
||||
});
|
||||
|
||||
// There should be 2 video elements, visible and autoplaying
|
||||
await expect(frame.locator("video")).toHaveCount(2, {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(frame, 2);
|
||||
}
|
||||
|
||||
// now we switch the mode for timo (second joiner on multi-sfu HOST2 but currently HOST1)
|
||||
await TestHelpers.setEmbeddedElementCallRtcMode(timo.page, "compat");
|
||||
await timo.page.waitForTimeout(3000);
|
||||
|
||||
await TestHelpers.expectVisibleVideoCount(
|
||||
timo.page.locator('iframe[title="Element Call"]').contentFrame(),
|
||||
2,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -21,7 +21,7 @@ const PASSWORD = "foobarbaz1!";
|
||||
export const HOST1 = "https://app.m.localhost/#/welcome";
|
||||
export const HOST2 = "https://app.othersite.m.localhost/#/welcome";
|
||||
|
||||
export type RtcMode = "legacy" | "compat" | "2_0";
|
||||
export type RtcMode = "compat" | "2_0";
|
||||
|
||||
export class TestHelpers {
|
||||
public static async startCallInCurrentRoom(
|
||||
@@ -309,9 +309,7 @@ export class TestHelpers {
|
||||
|
||||
// Move to Developer tab now
|
||||
await iframe.getByRole("tab", { name: "Developer" }).click();
|
||||
if (mode == "legacy") {
|
||||
await iframe.getByText("Legacy: state events").click();
|
||||
} else if (mode == "2_0") {
|
||||
if (mode == "2_0") {
|
||||
await iframe.getByText("Matrix 2.0").click();
|
||||
} else {
|
||||
// compat
|
||||
|
||||
Generated
+56
-50
@@ -39,13 +39,13 @@ importers:
|
||||
version: 11.7.12
|
||||
'@livekit/components-core':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.15(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
version: 0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-react':
|
||||
specifier: ^2.0.0
|
||||
version: 2.9.24(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
version: 2.9.24(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
'@livekit/track-processors':
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))
|
||||
'@mediapipe/tasks-vision':
|
||||
specifier: ^0.10.18
|
||||
version: 0.10.35
|
||||
@@ -66,7 +66,7 @@ importers:
|
||||
version: 10.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@rolldown/plugin-babel':
|
||||
specifier: ^0.2.3
|
||||
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))
|
||||
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))
|
||||
'@sentry/react':
|
||||
specifier: ^8.0.0
|
||||
version: 8.55.2(react@19.2.8)
|
||||
@@ -132,13 +132,13 @@ importers:
|
||||
version: 10.3.1(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens':
|
||||
specifier: ^10.0.0
|
||||
version: 10.2.3(@types/react@19.2.17)(react@19.2.8)
|
||||
version: 10.2.4(@types/react@19.2.17)(react@19.2.8)
|
||||
'@vector-im/compound-web':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.3(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
version: 10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))
|
||||
version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))
|
||||
'@vitest/browser-playwright':
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10)
|
||||
@@ -183,7 +183,7 @@ importers:
|
||||
version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3)
|
||||
livekit-client:
|
||||
specifier: ^2.18.1
|
||||
version: 2.21.0(@types/dom-mediacapture-record@1.0.22)
|
||||
version: 2.22.0(@types/dom-mediacapture-record@1.0.22)
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.18.1
|
||||
@@ -192,10 +192,10 @@ importers:
|
||||
version: 1.9.2
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#develop
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
|
||||
matrix-widget-api:
|
||||
specifier: ^1.18.0
|
||||
version: 1.18.0
|
||||
version: 1.19.0
|
||||
node-stdlib-browser:
|
||||
specifier: ^1.3.1
|
||||
version: 1.3.1
|
||||
@@ -1191,8 +1191,8 @@ packages:
|
||||
'@types/dom-mediacapture-transform': ^0.1.9
|
||||
livekit-client: ^1.12.0 || ^2.1.0
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.4.0':
|
||||
resolution: {integrity: sha512-osxkU1DQ+05+anGHapjWyvZqdHUb94Id37gy54mCKn1Cq/D7iGT5oEUEhjp4oTnCLo4TOtI6ULJ/LHsapaIptQ==}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0':
|
||||
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
@@ -3240,14 +3240,20 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>= 16.8.0'
|
||||
|
||||
'@vector-im/compound-design-tokens@10.2.3':
|
||||
resolution: {integrity: sha512-K2xNkjOzYn2Ya0kUqCy7JIYyx9jbkNIWpex2t9m3FUG4wzkNscvaiRJ/a9Wse1aJmwDDARcHU6mUjtHf5os5MA==}
|
||||
'@vector-im/compound-design-tokens@10.2.4':
|
||||
resolution: {integrity: sha512-2aCjhSvJxktPWnvh5s9Xdc0Rd76AfHqbDbN4QjOxo2VIac6zYdLyQbxMUQ5ZeoR9G3vAxILd0H1UvApRfMilpQ==}
|
||||
peerDependencies:
|
||||
'@adobe/leonardo-contrast-colors': ^1.0.0
|
||||
'@types/react': '*'
|
||||
chroma-js: ^3.0.0
|
||||
react: ^17 || ^18 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@adobe/leonardo-contrast-colors':
|
||||
optional: true
|
||||
'@types/react':
|
||||
optional: true
|
||||
chroma-js:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
@@ -3664,9 +3670,9 @@ packages:
|
||||
constants-browserify@1.0.0:
|
||||
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
|
||||
|
||||
content-type@2.0.0:
|
||||
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
|
||||
engines: {node: '>=18'}
|
||||
content-type@3.0.0:
|
||||
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
|
||||
engines: {node: '>=22'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
@@ -4459,8 +4465,8 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
jose@6.2.4:
|
||||
resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==}
|
||||
jose@6.2.10:
|
||||
resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==}
|
||||
|
||||
jose@6.2.9:
|
||||
resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==}
|
||||
@@ -4608,8 +4614,8 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
livekit-client@2.21.0:
|
||||
resolution: {integrity: sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==}
|
||||
livekit-client@2.22.0:
|
||||
resolution: {integrity: sha512-GLtYQfRh/RsvXaOX1x609bFZ17yyKmKWDqu9JmkMKn9vIFLi2GsapRv9gT8OJO/1R2dsitrkbGmloyUfxWQsaA==}
|
||||
peerDependencies:
|
||||
'@types/dom-mediacapture-record': ^1
|
||||
|
||||
@@ -4679,13 +4685,13 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0:
|
||||
resolution: {gitHosted: true, integrity: sha512-UmlkoXs9VMZvNaDeQqBb2k+gLifnIqEZc5cxrLeEwdYEDakQLbRqrFtmvIp4ipUCjdIPkCgtzb7QLlpPBLAwqA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0}
|
||||
version: 42.1.0
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
|
||||
resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a}
|
||||
version: 42.2.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-widget-api@1.18.0:
|
||||
resolution: {integrity: sha512-4T2f2koWmx05p1BLcT/9YGGGPSXpPT+PA4Oap/5fjhXsWPxMGJiGL97YpANMYLKsnE1sScYFeuyxIgdc1Qo+Ew==}
|
||||
matrix-widget-api@1.19.0:
|
||||
resolution: {integrity: sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==}
|
||||
|
||||
md5.js@1.3.5:
|
||||
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
|
||||
@@ -7064,21 +7070,21 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22)
|
||||
loglevel: 1.9.1
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
clsx: 2.1.1
|
||||
events: 3.3.0
|
||||
jose: 6.2.9
|
||||
livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
@@ -7090,13 +7096,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 1.10.1
|
||||
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))':
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))':
|
||||
dependencies:
|
||||
'@mediapipe/tasks-vision': 0.10.35
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.4.0': {}
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8)':
|
||||
dependencies:
|
||||
@@ -8047,13 +8053,13 @@ snapshots:
|
||||
'@rolldown/binding-win32-x64-msvc@1.1.5':
|
||||
optional: true
|
||||
|
||||
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))':
|
||||
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7(supports-color@7.2.0)
|
||||
picomatch: 4.0.4
|
||||
rolldown: 1.1.5
|
||||
optionalDependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@babel/runtime': 8.0.0
|
||||
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
@@ -8711,12 +8717,12 @@ snapshots:
|
||||
'@use-gesture/core': 10.3.1
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-design-tokens@10.2.3(@types/react@19.2.17)(react@19.2.8)':
|
||||
'@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8)':
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
react: 19.2.8
|
||||
|
||||
'@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.3(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
'@vector-im/compound-web@10.0.1(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(@vector-im/compound-design-tokens@10.2.4(@types/react@19.2.17)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -8728,7 +8734,7 @@ snapshots:
|
||||
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 10.2.3(@types/react@19.2.17)(react@19.2.8)
|
||||
'@vector-im/compound-design-tokens': 10.2.4(@types/react@19.2.17)(react@19.2.8)
|
||||
classnames: 2.5.1
|
||||
react: 19.2.8
|
||||
vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
|
||||
@@ -8738,12 +8744,12 @@ snapshots:
|
||||
- '@types/react-dom'
|
||||
- react-dom
|
||||
|
||||
'@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))':
|
||||
'@vitejs/plugin-react@6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))':
|
||||
dependencies:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0)
|
||||
optionalDependencies:
|
||||
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))
|
||||
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))
|
||||
babel-plugin-react-compiler: 1.0.0
|
||||
|
||||
'@vitest/browser-playwright@4.1.10(playwright@1.62.0)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.102.0)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.10)':
|
||||
@@ -9189,7 +9195,7 @@ snapshots:
|
||||
|
||||
constants-browserify@1.0.0: {}
|
||||
|
||||
content-type@2.0.0: {}
|
||||
content-type@3.0.0: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
@@ -10053,7 +10059,7 @@ snapshots:
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
jose@6.2.4: {}
|
||||
jose@6.2.10: {}
|
||||
|
||||
jose@6.2.9: {}
|
||||
|
||||
@@ -10197,13 +10203,13 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22):
|
||||
livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22):
|
||||
dependencies:
|
||||
'@livekit/mutex': 1.1.1
|
||||
'@livekit/protocol': 1.50.4
|
||||
'@types/dom-mediacapture-record': 1.0.22
|
||||
events: 3.3.0
|
||||
jose: 6.2.4
|
||||
jose: 6.2.10
|
||||
loglevel: 1.9.2
|
||||
sdp-transform: 2.15.0
|
||||
tslib: 2.8.1
|
||||
@@ -10269,21 +10275,21 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
|
||||
dependencies:
|
||||
'@babel/runtime': 8.0.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.4.0
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.5.0
|
||||
another-json: 0.2.0
|
||||
bs58: 6.0.0
|
||||
content-type: 2.0.0
|
||||
content-type: 3.0.0
|
||||
loglevel: 1.9.2
|
||||
matrix-events-sdk: 0.0.1
|
||||
matrix-widget-api: 1.18.0
|
||||
matrix-widget-api: 1.19.0
|
||||
p-retry: 8.0.0
|
||||
sdp-transform: 3.0.0
|
||||
unhomoglyph: 1.0.6
|
||||
|
||||
matrix-widget-api@1.18.0:
|
||||
matrix-widget-api@1.19.0:
|
||||
dependencies:
|
||||
'@types/events': 3.0.3
|
||||
events: 3.3.0
|
||||
|
||||
+32
-23
@@ -46,7 +46,10 @@ import {
|
||||
// Can this be done in the tsconfig.json
|
||||
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
|
||||
import { type Behavior, constant } from "../src/state/Behavior";
|
||||
import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
createCallViewModel$,
|
||||
} from "../src/state/CallViewModel/CallViewModel";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
@@ -54,12 +57,9 @@ import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
widget as _widget,
|
||||
initializeWidget,
|
||||
} from "../src/widget";
|
||||
import { initializeWidget } from "../src/widget";
|
||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||
|
||||
interface MatrixRTCSdk {
|
||||
/**
|
||||
@@ -109,14 +109,15 @@ export async function createMatrixRTCSdk(
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// widget client
|
||||
initializeWidget(application, true);
|
||||
const widget = _widget;
|
||||
const widget = initializeWidget(application, true);
|
||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||
const client = await widget.client;
|
||||
const hostBridge = createWidgetHostBridge(widget);
|
||||
logger.info("client created");
|
||||
|
||||
// url params
|
||||
const { roomId } = getUrlParams();
|
||||
const urlParams = getUrlParams();
|
||||
const { roomId, controlledAudioDevices, callIntent } = urlParams;
|
||||
if (roomId === null) throw Error("could not get roomId from url params");
|
||||
const room = client.getRoom(roomId);
|
||||
if (room === null) throw Error("could not get room from client");
|
||||
@@ -128,11 +129,16 @@ export async function createMatrixRTCSdk(
|
||||
const rtcSession = rtcSessionManager.getRoomSession(room);
|
||||
|
||||
// media devices
|
||||
const mediaDevices = new MediaDevices(scope);
|
||||
const muteStates = new MuteStates(scope, mediaDevices, {
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
const mediaDevices = new MediaDevices(scope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
});
|
||||
const muteStates = new MuteStates(
|
||||
scope,
|
||||
mediaDevices,
|
||||
{ audioEnabled: false, videoEnabled: false },
|
||||
hostBridge,
|
||||
);
|
||||
|
||||
// call view model
|
||||
const callViewModel = createCallViewModel$(
|
||||
@@ -141,7 +147,11 @@ export async function createMatrixRTCSdk(
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
hostBridge,
|
||||
},
|
||||
of({}),
|
||||
of({}),
|
||||
constant({ supported: false, processor: undefined }),
|
||||
@@ -282,18 +292,17 @@ export async function createMatrixRTCSdk(
|
||||
});
|
||||
await leaveResolver.promise;
|
||||
logger.info("send Unstick");
|
||||
await widget.api
|
||||
await hostBridge
|
||||
.setAlwaysOnScreen(false)
|
||||
.catch((e) =>
|
||||
logger.error(
|
||||
"Failed to set call widget `alwaysOnScreen` to false",
|
||||
e,
|
||||
),
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to set `alwaysOnScreen` to false", e),
|
||||
);
|
||||
logger.info("send Close");
|
||||
await widget.api.transport
|
||||
.send(ElementWidgetActions.Close, {})
|
||||
.catch((e) => logger.error("Failed to send close action", e));
|
||||
await hostBridge
|
||||
.close?.()
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to ask the host to close", e),
|
||||
);
|
||||
};
|
||||
|
||||
// schedule close first and then leave (scope.end)
|
||||
|
||||
+96
-53
@@ -5,18 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
type FC,
|
||||
type JSX,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
|
||||
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
@@ -25,13 +20,26 @@ import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { widget } from "./widget";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
|
||||
import { type AppViewModel } from "./state/AppViewModel";
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
|
||||
import {
|
||||
HeaderStyle,
|
||||
UrlParamsProvider,
|
||||
useUrlParams,
|
||||
useUrlParamsFromLocation,
|
||||
} from "./UrlParams";
|
||||
import { AppBar } from "./AppBar";
|
||||
import { i18n } from "./utils/i18n";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
import {
|
||||
createWidgetHostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
import { useInitial } from "./useInitial";
|
||||
|
||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||
|
||||
@@ -39,15 +47,24 @@ interface SimpleProviderProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the URL-derived params to the rest of the app. Only the standalone
|
||||
* and widget builds own the URL, so this lives here in the app shell rather
|
||||
* than alongside the context itself.
|
||||
*/
|
||||
const LocationUrlParamsProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const urlParams = useUrlParamsFromLocation();
|
||||
return <UrlParamsProvider value={urlParams}>{children}</UrlParamsProvider>;
|
||||
};
|
||||
|
||||
const BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const { pathname } = useLocation();
|
||||
const { background } = useUrlParams();
|
||||
const rootElement = useRootElement();
|
||||
|
||||
useEffect(() => {
|
||||
document
|
||||
.getElementsByTagName("body")[0]
|
||||
.setAttribute("data-background", background);
|
||||
}, [pathname, background]);
|
||||
rootElement.setAttribute("data-background", background);
|
||||
}, [pathname, background, rootElement]);
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -57,61 +74,87 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
return children;
|
||||
};
|
||||
|
||||
/** Wraps the app in an {@link AppBar}, if the params ask for one. */
|
||||
const MaybeAppBar: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const { header } = useUrlParams();
|
||||
return header === HeaderStyle.AppBar ? <AppBar>{children}</AppBar> : children;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
vm: AppViewModel;
|
||||
/** A point of access to the widget API, if running as a widget. */
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const App: FC<Props> = ({ vm }) => {
|
||||
export const App: FC<Props> = ({ vm, widget }) => {
|
||||
// The standalone build has no host; the widget build's host is the client it
|
||||
// is a widget of.
|
||||
const hostBridge = useInitial(() =>
|
||||
widget === null ? nullHostBridge : createWidgetHostBridge(widget),
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
Initializer.init()
|
||||
?.then(async () => {
|
||||
if (loaded) return;
|
||||
setLoaded(true);
|
||||
await widget?.api.sendContentLoaded();
|
||||
await hostBridge.contentLoaded();
|
||||
})
|
||||
.catch(logger.error);
|
||||
});
|
||||
|
||||
// Since we are outside the router component, we cannot use useUrlParams here
|
||||
const { header } = useMemo(getUrlParams, []);
|
||||
|
||||
const content = loaded ? (
|
||||
<ClientProvider>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
||||
>
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
<SentryRoute path="/register" element={<RegisterPage />} />
|
||||
<SentryRoute path="*" element={<RoomPage />} />
|
||||
</Routes>
|
||||
</Sentry.ErrorBoundary>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
// As a widget, the client comes from the host over the widget API. Standalone,
|
||||
// Element Call finds one itself, so there is nothing to wait for here.
|
||||
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (widget === null) return;
|
||||
widget.client
|
||||
.then(setWidgetClient)
|
||||
.catch((e) => logger.error("Failed to obtain the host's client", e));
|
||||
}, [widget]);
|
||||
const clientReady = widget === null || widgetClient !== undefined;
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
>
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
<SentryRoute path="/register" element={<RegisterPage />} />
|
||||
<SentryRoute path="*" element={<RoomPage />} />
|
||||
</Routes>
|
||||
</Sentry.ErrorBoundary>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<BackgroundProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<Suspense fallback={null}>
|
||||
{header === HeaderStyle.AppBar ? (
|
||||
<AppBar>{content}</AppBar>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BackgroundProvider>
|
||||
</BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<BrowserRouter>
|
||||
<LocationUrlParamsProvider>
|
||||
<BackgroundProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<Suspense fallback={null}>
|
||||
<MaybeAppBar>{content}</MaybeAppBar>
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BackgroundProvider>
|
||||
</LocationUrlParamsProvider>
|
||||
</BrowserRouter>
|
||||
</HostBridgeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
+14
-49
@@ -9,18 +9,22 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { ClientContextProvider } from "./ClientContext";
|
||||
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||
import { widget } from "./widget";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
|
||||
const TestComponent: FC<
|
||||
PropsWithChildren<{
|
||||
client: MatrixClient;
|
||||
hostBridge?: HostBridge;
|
||||
}>
|
||||
> = ({ client, children }) => {
|
||||
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||
return (
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
@@ -38,17 +42,11 @@ const TestComponent: FC<
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||
</ClientContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock("./widget", () => ({
|
||||
widget: {
|
||||
api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -135,7 +133,7 @@ test("should attempt to fetch authenticated media from the server", async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test("should attempt to use widget API if running as a widget", async () => {
|
||||
test("should download media through the host when it offers to", async () => {
|
||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||
const expectedObjectURL = "my-object-url";
|
||||
const theBlob = new Blob([]);
|
||||
@@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
getAccessToken: () => undefined,
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
|
||||
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
|
||||
const downloadMedia = vi.fn().mockResolvedValue(theBlob);
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia };
|
||||
const member = mockMatrixRoomMember(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
@@ -161,7 +159,7 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
);
|
||||
const displayName = "Alice";
|
||||
render(
|
||||
<TestComponent client={client}>
|
||||
<TestComponent client={client} hostBridge={hostBridge}>
|
||||
<Avatar
|
||||
id={member.userId}
|
||||
name={displayName}
|
||||
@@ -176,38 +174,5 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
document.querySelector(`img[src='${expectedObjectURL}']`),
|
||||
);
|
||||
|
||||
expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl);
|
||||
});
|
||||
|
||||
test("Supports download files as base64", async () => {
|
||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||
const expectedBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" +
|
||||
"+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" +
|
||||
"ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" +
|
||||
"UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" +
|
||||
"Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" +
|
||||
"oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" +
|
||||
"A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" +
|
||||
"nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" +
|
||||
"ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" +
|
||||
"Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" +
|
||||
"2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" +
|
||||
"M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" +
|
||||
"kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" +
|
||||
"uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" +
|
||||
"XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" +
|
||||
"YII=";
|
||||
const mockWidgetAPI = {
|
||||
downloadFile: vi.fn().mockImplementation(async (contentUri) => {
|
||||
if (contentUri !== expectedMXCUrl) {
|
||||
return Promise.reject(new Error("Unexpected content URI"));
|
||||
}
|
||||
return { file: expectedBase64 };
|
||||
}),
|
||||
} as unknown as WidgetApi;
|
||||
|
||||
const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(downloadMedia).toBeCalledWith(expectedMXCUrl);
|
||||
});
|
||||
|
||||
+7
-27
@@ -14,10 +14,9 @@ import {
|
||||
} from "react";
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { useClientState } from "./ClientContext";
|
||||
import { widget } from "./widget";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export enum Size {
|
||||
XS = "xs",
|
||||
@@ -76,6 +75,7 @@ export const Avatar: FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const clientState = useClientState();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const sizePx = useMemo(
|
||||
() =>
|
||||
@@ -87,7 +87,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs.
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra media
|
||||
// downloads, but in practice they should be stable long before this code runs.
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setAvatarUrl(undefined);
|
||||
@@ -96,8 +97,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
let blob: Promise<Blob>;
|
||||
|
||||
if (widget?.api) {
|
||||
blob = getAvatarFromWidgetAPI(widget.api, src);
|
||||
if (hostBridge.downloadMedia) {
|
||||
blob = hostBridge.downloadMedia(src);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
@@ -132,7 +133,7 @@ export const Avatar: FC<Props> = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [clientState, src, sizePx]);
|
||||
}, [clientState, hostBridge, src, sizePx]);
|
||||
|
||||
return (
|
||||
<CompoundAvatar
|
||||
@@ -172,24 +173,3 @@ async function getAvatarFromServer(
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
// export for testing
|
||||
export async function getAvatarFromWidgetAPI(
|
||||
api: WidgetApi,
|
||||
src: string,
|
||||
): Promise<Blob> {
|
||||
const response = await api.downloadFile(src);
|
||||
const file = response.file;
|
||||
|
||||
// element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that
|
||||
if (file instanceof Blob) {
|
||||
return file;
|
||||
} else if (typeof file === "string") {
|
||||
// it is a base64 string
|
||||
const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0));
|
||||
return new Blob([bytes]);
|
||||
}
|
||||
throw new Error(
|
||||
"Downloaded file format is not supported: " + typeof file + "",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations 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, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { ClientProvider, useClientState } from "./ClientContext";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => userId,
|
||||
getDeviceId: () => "AAAA",
|
||||
stopClient: vi.fn(),
|
||||
}) as Partial<MatrixClient> as MatrixClient;
|
||||
|
||||
/** Reports what the context says, so a test can assert on it. */
|
||||
const ShowClientState: FC = () => {
|
||||
const state = useClientState();
|
||||
if (state === undefined) return <span>loading</span>;
|
||||
if (state.state === "error") return <span>error</span>;
|
||||
return (
|
||||
<span>{state.authenticated?.client.getUserId() ?? "unauthenticated"}</span>
|
||||
);
|
||||
};
|
||||
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const client = mockClient();
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={client}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Available on the very first render: a supplied client needs no session
|
||||
// restoring, so there is no loading state to pass through.
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
test("does not claim exclusive use of storage when given a client", () => {
|
||||
// The channel is created when the module loads, so spy on the prototype
|
||||
// rather than trying to replace the global.
|
||||
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={mockClient()}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// The broadcast shuts down other instances to protect Element Call's own
|
||||
// stores. A host's client brings its own, so there is nothing to protect.
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
|
||||
postMessage.mockRestore();
|
||||
});
|
||||
|
||||
test("follows the client when the host swaps it", () => {
|
||||
const first = mockClient();
|
||||
const second = mockClient("@bob:example.org");
|
||||
|
||||
const { container, rerender } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={first}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
|
||||
// A host that re-authenticates hands us a new client on a mounted component
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={second}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe("@bob:example.org");
|
||||
});
|
||||
|
||||
test("finds a client of its own when the host supplies none", async () => {
|
||||
const client = mockClient();
|
||||
vi.doMock("./utils/spa", () => ({
|
||||
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Nothing to show until a session has been restored or created
|
||||
expect(container.textContent).toBe("loading");
|
||||
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
|
||||
|
||||
vi.doUnmock("./utils/spa");
|
||||
});
|
||||
+51
-49
@@ -21,9 +21,8 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import type { WidgetApi } from "matrix-widget-api";
|
||||
import { ErrorPage } from "./FullScreenView";
|
||||
import { widget } from "./widget";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
import {
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
@@ -134,18 +133,48 @@ const loadChannel =
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
/**
|
||||
* The client Element Call should use.
|
||||
*
|
||||
* When a host embeds Element Call it already has a client, and owns the
|
||||
* user's session; supplying it here means Element Call neither authenticates
|
||||
* anyone nor manages their session. Left out, Element Call finds a client
|
||||
* itself — from the widget API, or by restoring or creating a session of its
|
||||
* own.
|
||||
*/
|
||||
client?: MatrixClient;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
const navigate = useNavigate();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
// null = signed out, undefined = loading
|
||||
const [initClientState, setInitClientState] = useState<
|
||||
InitResult | null | undefined
|
||||
>(undefined);
|
||||
>(
|
||||
client === undefined
|
||||
? undefined
|
||||
: // A supplied client belongs to the host, so there is no session of ours
|
||||
// to restore and nothing to wait for.
|
||||
{ client, passwordlessUser: false },
|
||||
);
|
||||
|
||||
const initializing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (client !== undefined) {
|
||||
// Nothing to load, but a host may hand us a different client later — on
|
||||
// re-authenticating, say — so follow whichever one it has given us.
|
||||
setInitClientState((current) =>
|
||||
current?.client === client
|
||||
? current
|
||||
: { client, passwordlessUser: false },
|
||||
);
|
||||
// Analytics still need to follow the user's choices.
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
return;
|
||||
}
|
||||
// In case the component is mounted, unmounted, and remounted quickly (as
|
||||
// React does in strict mode), we need to make sure not to doubly initialize
|
||||
// the client.
|
||||
@@ -160,7 +189,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, []);
|
||||
}, [client]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
@@ -201,7 +230,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
saveSession(session);
|
||||
setInitClientState({
|
||||
widgetApi: null,
|
||||
client,
|
||||
passwordlessUser: session.passwordlessUser,
|
||||
});
|
||||
@@ -228,11 +256,13 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
// To protect against multiple sessions writing to the same storage
|
||||
// simultaneously, we send a broadcast message that shuts down all other
|
||||
// running instances of the app. This isn't necessary if the app is running in
|
||||
// a widget though, since then it'll be mostly stateless.
|
||||
// running instances of the app. Element Call only has storage of its own to
|
||||
// protect when it created the session itself; given a client — by a host, or
|
||||
// over the widget API — it is mostly stateless.
|
||||
const ownsSession = client === undefined;
|
||||
useEffect(() => {
|
||||
if (!widget) loadChannel?.postMessage({});
|
||||
}, []);
|
||||
if (ownsSession) loadChannel?.postMessage({});
|
||||
}, [ownsSession]);
|
||||
|
||||
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
|
||||
undefined,
|
||||
@@ -307,64 +337,36 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||
}
|
||||
|
||||
if (initClientState.widgetApi) {
|
||||
const reactSend = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.reaction",
|
||||
);
|
||||
const redactSend = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||
);
|
||||
const reactRcv = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.reaction",
|
||||
);
|
||||
const redactRcv = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||
);
|
||||
|
||||
if (!reactSend || !reactRcv || !redactSend || !redactRcv) {
|
||||
logger.warn("Widget does not support reactions");
|
||||
setSupportsReactions(false);
|
||||
} else {
|
||||
setSupportsReactions(true);
|
||||
}
|
||||
} else {
|
||||
setSupportsReactions(true);
|
||||
}
|
||||
if (!hostBridge.supportsReactions)
|
||||
logger.warn("The host does not permit reactions");
|
||||
setSupportsReactions(hostBridge.supportsReactions);
|
||||
|
||||
return (): void => {
|
||||
if (initClientState.client) {
|
||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
};
|
||||
}, [initClientState, onSync]);
|
||||
}, [initClientState, onSync, hostBridge]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return <ClientContext value={state}>{children}</ClientContext>;
|
||||
};
|
||||
|
||||
export type InitResult = {
|
||||
widgetApi: WidgetApi | null;
|
||||
client: MatrixClient;
|
||||
passwordlessUser: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own. Only reached when no
|
||||
* client was supplied for it to use.
|
||||
*/
|
||||
async function loadClient(): Promise<InitResult | null> {
|
||||
if (widget) {
|
||||
// We're inside a widget, so let's engage *matryoshka mode*
|
||||
logger.log("Using a matryoshka client");
|
||||
const client = await widget.client;
|
||||
return {
|
||||
widgetApi: widget.api,
|
||||
client,
|
||||
passwordlessUser: false,
|
||||
};
|
||||
} else {
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
|
||||
+13
-19
@@ -21,7 +21,7 @@ import { RageshakeButton } from "./settings/RageshakeButton";
|
||||
import styles from "./ErrorView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { LinkButton } from "./button";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
|
||||
import { useHostBridge } from "./HostBridge.ts";
|
||||
|
||||
interface Props {
|
||||
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
@@ -38,7 +38,6 @@ interface Props {
|
||||
*/
|
||||
fatal?: boolean;
|
||||
children: ReactNode;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const ErrorView: FC<Props> = ({
|
||||
@@ -47,32 +46,27 @@ export const ErrorView: FC<Props> = ({
|
||||
rageshake,
|
||||
fatal,
|
||||
children,
|
||||
widget,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { confineToRoom } = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const onReload = useCallback(() => {
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
|
||||
widget,
|
||||
const CloseButton: FC<{ close: () => Promise<void> }> = ({
|
||||
close,
|
||||
}): ReactElement => {
|
||||
// in widget mode we don't want to show the return home button but a close button
|
||||
const closeWidget = (): void => {
|
||||
widget.api.transport
|
||||
.send(ElementWidgetActions.Close, {})
|
||||
.catch((e) => {
|
||||
// What to do here?
|
||||
logger.error("Failed to send close action", e);
|
||||
})
|
||||
.finally(() => {
|
||||
widget.api.transport.stop();
|
||||
});
|
||||
// When the host can dismiss us, offer that instead of a link home
|
||||
const onClose = (): void => {
|
||||
close().catch((e) => {
|
||||
// What to do here?
|
||||
logger.error("Failed to ask the host to close Element Call", e);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button kind="primary" onClick={closeWidget}>
|
||||
<Button kind="primary" onClick={onClose}>
|
||||
{t("action.close")}
|
||||
</Button>
|
||||
);
|
||||
@@ -108,8 +102,8 @@ export const ErrorView: FC<Props> = ({
|
||||
{rageshake && (
|
||||
<RageshakeButton description={`***Error View***: ${title}`} />
|
||||
)}
|
||||
{widget ? (
|
||||
<CloseWidgetButton widget={widget} />
|
||||
{hostBridge.close ? (
|
||||
<CloseButton close={hostBridge.close} />
|
||||
) : (
|
||||
!confineToRoom && <ReturnToHomeButton />
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { RichError } from "./RichError";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { type WidgetHelpers } from "./widget.ts";
|
||||
|
||||
interface FullScreenViewProps {
|
||||
className?: string;
|
||||
@@ -48,12 +47,11 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: unknown;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
// Due to this component being used as the crash fallback for Sentry, which has
|
||||
// weird type requirements, we can't just give this a type of FC<ErrorPageProps>
|
||||
export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
logger.error(error);
|
||||
@@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
error.richMessage
|
||||
) : (
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={ErrorSolidIcon}
|
||||
title={t("error.generic")}
|
||||
rageshake
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api";
|
||||
import EventEmitter from "events";
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
createWidgetHostBridge,
|
||||
type HostBridge,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget";
|
||||
|
||||
function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
|
||||
return {
|
||||
api: api as WidgetApi,
|
||||
lazyActions: new EventEmitter(),
|
||||
client: Promise.resolve(),
|
||||
} as unknown as WidgetHelpers;
|
||||
}
|
||||
|
||||
/** A widget whose transport records what Element Call sends it. */
|
||||
function mockTransport(): {
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
reply: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
reply: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createWidgetHostBridge", () => {
|
||||
describe("telling the host what Element Call is doing", () => {
|
||||
test("asks to be kept on screen, and to stop being", async () => {
|
||||
const setAlwaysOnScreen = vi.fn().mockResolvedValue(true);
|
||||
const bridge = createWidgetHostBridge(mockWidget({ setAlwaysOnScreen }));
|
||||
|
||||
await bridge.setAlwaysOnScreen(true);
|
||||
await bridge.setAlwaysOnScreen(false);
|
||||
|
||||
expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(1, true);
|
||||
expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
test("reports that it has loaded", async () => {
|
||||
const sendContentLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
const bridge = createWidgetHostBridge(mockWidget({ sendContentLoaded }));
|
||||
|
||||
await bridge.contentLoaded();
|
||||
|
||||
expect(sendContentLoaded).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["notifyJoined", ElementWidgetActions.JoinCall, {}],
|
||||
["notifyHungUp", ElementWidgetActions.HangupCall, {}],
|
||||
] as const)("sends %s as %s", async (method, action, payload) => {
|
||||
const transport = mockTransport();
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
await bridge[method]();
|
||||
|
||||
expect(transport.send).toHaveBeenCalledWith(action, payload);
|
||||
});
|
||||
|
||||
test("sends the mute state the host needs to mirror", async () => {
|
||||
const transport = mockTransport();
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
await bridge.notifyDeviceMute({
|
||||
audio_enabled: true,
|
||||
video_enabled: false,
|
||||
});
|
||||
|
||||
expect(transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.DeviceMute,
|
||||
{ audio_enabled: true, video_enabled: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relaying what the host asks for", () => {
|
||||
/** Emits a widget action the way widget.ts does, and returns the event. */
|
||||
function askHost(
|
||||
widget: WidgetHelpers,
|
||||
action: string,
|
||||
data: unknown,
|
||||
): CustomEvent {
|
||||
const ev = new CustomEvent(action, { detail: { action, data } });
|
||||
widget.lazyActions.emit(action, ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
// Selectors rather than keys, so each stream keeps its own request type
|
||||
const inboundStreams: [
|
||||
name: string,
|
||||
select: (bridge: HostBridge) => Observable<{ data: unknown }>,
|
||||
action: string,
|
||||
][] = [
|
||||
[
|
||||
"themeChange$",
|
||||
(bridge) => bridge.themeChange$,
|
||||
WidgetApiToWidgetAction.ThemeChange,
|
||||
],
|
||||
["join$", (bridge) => bridge.join$, ElementWidgetActions.JoinCall],
|
||||
["hangUp$", (bridge) => bridge.hangUp$, ElementWidgetActions.HangupCall],
|
||||
[
|
||||
"deviceMute$",
|
||||
(bridge) => bridge.deviceMute$,
|
||||
ElementWidgetActions.DeviceMute,
|
||||
],
|
||||
];
|
||||
|
||||
test.each(inboundStreams)(
|
||||
"surfaces %s with the host's data",
|
||||
(_name, select, action) => {
|
||||
const widget = mockWidget({ transport: mockTransport() } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
const seen: unknown[] = [];
|
||||
select(bridge).subscribe((request) => seen.push(request.data));
|
||||
|
||||
askHost(widget, action, { some: "payload" });
|
||||
|
||||
expect(seen).toEqual([{ some: "payload" }]);
|
||||
},
|
||||
);
|
||||
|
||||
test("replies to the host against the request it made", () => {
|
||||
const transport = mockTransport();
|
||||
const widget = mockWidget({ transport } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
bridge.deviceMute$.subscribe((request) =>
|
||||
request.reply({ audio_enabled: false, video_enabled: true }),
|
||||
);
|
||||
|
||||
const ev = askHost(widget, ElementWidgetActions.DeviceMute, {
|
||||
audio_enabled: false,
|
||||
});
|
||||
|
||||
expect(transport.reply).toHaveBeenCalledWith(ev.detail, {
|
||||
audio_enabled: false,
|
||||
video_enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("still replies when there is nothing to say", () => {
|
||||
const transport = mockTransport();
|
||||
const widget = mockWidget({ transport } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
bridge.hangUp$.subscribe((request) => request.reply());
|
||||
|
||||
const ev = askHost(widget, ElementWidgetActions.HangupCall, {});
|
||||
|
||||
// The widget API requires an answer, so an empty reply becomes {}
|
||||
expect(transport.reply).toHaveBeenCalledWith(ev.detail, {});
|
||||
});
|
||||
|
||||
test("stops listening once unsubscribed", () => {
|
||||
const widget = mockWidget({ transport: mockTransport() } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
const seen: unknown[] = [];
|
||||
const subscription = bridge.hangUp$.subscribe((r) => seen.push(r.data));
|
||||
|
||||
subscription.unsubscribe();
|
||||
askHost(widget, ElementWidgetActions.HangupCall, {});
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMedia", () => {
|
||||
const mxcUri = "mxc://example.org/alice-avatar";
|
||||
|
||||
test("passes a Blob through unchanged", async () => {
|
||||
const file = new Blob([]);
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file }) }),
|
||||
);
|
||||
|
||||
await expect(bridge.downloadMedia!(mxcUri)).resolves.toBe(file);
|
||||
});
|
||||
|
||||
test("decodes a base64 string into a Blob", async () => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({
|
||||
// "hello" in base64
|
||||
downloadFile: vi.fn().mockResolvedValue({ file: "aGVsbG8=" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const blob = await bridge.downloadMedia!(mxcUri);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
// The five decoded bytes, rather than the eight characters of base64 —
|
||||
// which is what we'd get if the string were stored verbatim.
|
||||
expect(blob.size).toBe(5);
|
||||
});
|
||||
|
||||
test("rejects a file format it does not understand", async () => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file: 42 }) }),
|
||||
);
|
||||
|
||||
await expect(bridge.downloadMedia!(mxcUri)).rejects.toThrow(
|
||||
"Downloaded file format is not supported",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close", () => {
|
||||
test("asks the host to close, then stops the transport", async () => {
|
||||
const transport = {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
await bridge.close!();
|
||||
|
||||
expect(transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
{},
|
||||
);
|
||||
expect(transport.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("stops the transport even when the host refuses to close", async () => {
|
||||
const transport = {
|
||||
send: vi.fn().mockRejectedValue(new Error("no")),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
// Leaving the messaging live would leave the close affordance dead
|
||||
await expect(bridge.close!()).rejects.toThrow("no");
|
||||
expect(transport.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsReactions", () => {
|
||||
const capabilities = [
|
||||
"org.matrix.msc2762.send.event:m.reaction",
|
||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||
"org.matrix.msc2762.receive.event:m.reaction",
|
||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||
];
|
||||
|
||||
test("is true when the host grants every reaction capability", () => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ hasCapability: () => true }),
|
||||
);
|
||||
|
||||
expect(bridge.supportsReactions).toBe(true);
|
||||
});
|
||||
|
||||
test.each(capabilities)("is false without %s", (missing) => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ hasCapability: (c) => c !== missing }),
|
||||
);
|
||||
|
||||
expect(bridge.supportsReactions).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("nullHostBridge", () => {
|
||||
test("offers no way to close, so the interface falls back to navigation", () => {
|
||||
expect(nullHostBridge.close).toBeUndefined();
|
||||
});
|
||||
|
||||
test("offers no media download, so Element Call uses its own client", () => {
|
||||
expect(nullHostBridge.downloadMedia).toBeUndefined();
|
||||
});
|
||||
|
||||
test("supports reactions, since nothing is mediating its homeserver access", () => {
|
||||
expect(nullHostBridge.supportsReactions).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createContext, use } from "react";
|
||||
import { fromEvent, map, NEVER, type Observable } from "rxjs";
|
||||
import {
|
||||
type IWidgetApiRequest,
|
||||
type IWidgetApiRequestData,
|
||||
WidgetApiToWidgetAction,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
type JoinCallData,
|
||||
type WidgetHelpers,
|
||||
} from "./widget";
|
||||
|
||||
// Note: these are type aliases rather than interfaces so that they satisfy the
|
||||
// widget API's index-signature payload types.
|
||||
|
||||
/** The mute state Element Call and its host exchange. */
|
||||
export type DeviceMuteState = {
|
||||
audio_enabled: boolean;
|
||||
video_enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A mute state change requested by the host. An absent field means "leave this
|
||||
* one as it is".
|
||||
*/
|
||||
export type DeviceMuteRequest = {
|
||||
audio_enabled?: boolean;
|
||||
video_enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Something the host has asked of Element Call, which it is expected to
|
||||
* acknowledge.
|
||||
*/
|
||||
export interface HostRequest<Data, Reply = void> {
|
||||
data: Data;
|
||||
/** Acknowledges the request. Should be called exactly once. */
|
||||
reply(reply: Reply): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element Call's view of the application hosting it.
|
||||
*
|
||||
* Element Call can run as its own page, as a widget inside a Matrix client, or
|
||||
* embedded directly into one. Only the last two give it a host, and each of
|
||||
* them reaches it by a different route — so everything Element Call needs from
|
||||
* whatever is hosting it goes through this interface, rather than being
|
||||
* expressed in terms of the widget API.
|
||||
*
|
||||
* This covers the interactions Element Call has with its host while running.
|
||||
* The Matrix client it talks to is supplied separately, at startup.
|
||||
*/
|
||||
export interface HostBridge {
|
||||
// What Element Call tells the host.
|
||||
|
||||
/**
|
||||
* Asks the host to keep Element Call on screen (or stop doing so), so that a
|
||||
* call in progress is not torn down when the user navigates elsewhere.
|
||||
*/
|
||||
setAlwaysOnScreen(alwaysOnScreen: boolean): Promise<void>;
|
||||
/** Tells the host that Element Call has finished loading. */
|
||||
contentLoaded(): Promise<void>;
|
||||
/** Tells the host that the user has joined the call. */
|
||||
notifyJoined(): Promise<void>;
|
||||
/** Tells the host that the user has hung up. */
|
||||
notifyHungUp(): Promise<void>;
|
||||
/** Tells the host the user's current audio and video mute state. */
|
||||
notifyDeviceMute(state: DeviceMuteState): Promise<void>;
|
||||
/**
|
||||
* Asks the host to close Element Call, and stops communicating with it. No
|
||||
* further calls should be made on this bridge afterwards.
|
||||
*
|
||||
* Absent when the host has no way to dismiss Element Call — standalone, the
|
||||
* user navigates away instead — so its presence is what tells the interface
|
||||
* whether to offer a close affordance.
|
||||
*/
|
||||
close?(): Promise<void>;
|
||||
|
||||
// What the host asks of Element Call.
|
||||
|
||||
/** The host has changed the theme Element Call should use. */
|
||||
themeChange$: Observable<HostRequest<{ name?: string }>>;
|
||||
/** The host wants a preloaded Element Call to join the call now. */
|
||||
join$: Observable<HostRequest<JoinCallData>>;
|
||||
/** The host wants Element Call to leave the call. */
|
||||
hangUp$: Observable<HostRequest<Record<string, never>>>;
|
||||
/** The host wants to change, or read back, the device mute state. */
|
||||
deviceMute$: Observable<HostRequest<DeviceMuteRequest, DeviceMuteState>>;
|
||||
|
||||
// What the host is capable of.
|
||||
|
||||
/** Whether the host permits Element Call to send and receive reactions. */
|
||||
readonly supportsReactions: boolean;
|
||||
/**
|
||||
* Fetches media on Element Call's behalf, for hosts that do not give it
|
||||
* direct access to the homeserver. Absent when Element Call should fetch
|
||||
* media itself using its own client.
|
||||
*/
|
||||
downloadMedia?(mxcUri: string): Promise<Blob>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bridge to nowhere, for when Element Call has no host — that is, when it is
|
||||
* running as its own page and talks to the homeserver directly.
|
||||
*/
|
||||
export const nullHostBridge: HostBridge = {
|
||||
setAlwaysOnScreen: async () => {},
|
||||
contentLoaded: async () => {},
|
||||
notifyJoined: async () => {},
|
||||
notifyHungUp: async () => {},
|
||||
notifyDeviceMute: async () => {},
|
||||
themeChange$: NEVER,
|
||||
join$: NEVER,
|
||||
hangUp$: NEVER,
|
||||
deviceMute$: NEVER,
|
||||
// Standalone Element Call reaches the homeserver itself, so nothing is
|
||||
// withholding these from it.
|
||||
supportsReactions: true,
|
||||
};
|
||||
|
||||
/** Bridges to a host that Element Call is a widget of. */
|
||||
export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
|
||||
const requests = <Data, Reply>(
|
||||
action: string,
|
||||
): Observable<HostRequest<Data, Reply>> =>
|
||||
(
|
||||
fromEvent(widget.lazyActions, action) as Observable<
|
||||
CustomEvent<IWidgetApiRequest>
|
||||
>
|
||||
).pipe(
|
||||
map((ev) => ({
|
||||
data: ev.detail.data as Data,
|
||||
// The widget API requires a reply for every request, and carries the
|
||||
// payload as a plain object, so an empty reply becomes {}.
|
||||
reply: (reply: Reply): void =>
|
||||
widget.api.transport.reply(ev.detail, reply ?? {}),
|
||||
})),
|
||||
);
|
||||
|
||||
const send = async (
|
||||
action: ElementWidgetActions,
|
||||
data: IWidgetApiRequestData = {},
|
||||
): Promise<void> => {
|
||||
await widget.api.transport.send(action, data);
|
||||
};
|
||||
|
||||
return {
|
||||
setAlwaysOnScreen: async (alwaysOnScreen) => {
|
||||
await widget.api.setAlwaysOnScreen(alwaysOnScreen);
|
||||
},
|
||||
contentLoaded: async () => widget.api.sendContentLoaded(),
|
||||
notifyJoined: async () => send(ElementWidgetActions.JoinCall),
|
||||
notifyHungUp: async () => send(ElementWidgetActions.HangupCall),
|
||||
notifyDeviceMute: async (state) =>
|
||||
send(ElementWidgetActions.DeviceMute, state),
|
||||
close: async () => {
|
||||
try {
|
||||
await send(ElementWidgetActions.Close);
|
||||
} finally {
|
||||
// Stop regardless of whether the host acknowledged the request. A host
|
||||
// that rejects or never answers would otherwise leave the messaging
|
||||
// live, and the close affordance doing nothing at all.
|
||||
widget.api.transport.stop();
|
||||
}
|
||||
},
|
||||
themeChange$: requests(WidgetApiToWidgetAction.ThemeChange),
|
||||
join$: requests(ElementWidgetActions.JoinCall),
|
||||
hangUp$: requests(ElementWidgetActions.HangupCall),
|
||||
deviceMute$: requests(ElementWidgetActions.DeviceMute),
|
||||
// Element Call needs the host's permission to send reactions on its behalf.
|
||||
// Read on access rather than up front: the widget API negotiates its
|
||||
// capabilities asynchronously, and the bridge is built before that settles.
|
||||
get supportsReactions(): boolean {
|
||||
return (
|
||||
widget.api.hasCapability("org.matrix.msc2762.send.event:m.reaction") &&
|
||||
widget.api.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||
) &&
|
||||
widget.api.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.reaction",
|
||||
) &&
|
||||
widget.api.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||
)
|
||||
);
|
||||
},
|
||||
downloadMedia: async (mxcUri) => {
|
||||
const { file } = await widget.api.downloadFile(mxcUri);
|
||||
if (file instanceof Blob) return file;
|
||||
if (typeof file === "string")
|
||||
// it is a base64 string
|
||||
return new Blob([Uint8Array.from(atob(file), (c) => c.charCodeAt(0))]);
|
||||
throw new Error(
|
||||
`Downloaded file format is not supported: ${typeof file}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const HostBridgeContext = createContext<HostBridge | null>(null);
|
||||
|
||||
export const HostBridgeProvider = HostBridgeContext.Provider;
|
||||
|
||||
/**
|
||||
* The application hosting Element Call.
|
||||
*
|
||||
* Defaults to {@link nullHostBridge}, so that tests and stories, which have no
|
||||
* host, need no provider.
|
||||
*/
|
||||
export const useHostBridge = (): HostBridge =>
|
||||
use(HostBridgeContext) ?? nullHostBridge;
|
||||
+4
-2
@@ -24,6 +24,7 @@ import { Heading, Glass } from "@vector-im/compound-web";
|
||||
import styles from "./Modal.module.css";
|
||||
import overlayStyles from "./Overlay.module.css";
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -78,6 +79,7 @@ export const Modal: FC<Props> = ({
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const rootElement = useRootElement();
|
||||
// Empirically, Chrome on Android can end up not matching (hover: none), but
|
||||
// still matching (pointer: coarse) :/
|
||||
const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)");
|
||||
@@ -100,7 +102,7 @@ export const Modal: FC<Props> = ({
|
||||
onOpenChange={onOpenChange}
|
||||
dismissible={onDismiss !== undefined}
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Portal container={rootElement}>
|
||||
<Drawer.Overlay className={classNames(overlayStyles.bg)} />
|
||||
<Drawer.Content
|
||||
className={classNames(
|
||||
@@ -155,7 +157,7 @@ export const Modal: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogPortal container={rootElement}>
|
||||
<DialogOverlay
|
||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||
/>
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { toDataURL } from "qrcode";
|
||||
import classNames from "classnames";
|
||||
import { t } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./QrCode.module.css";
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const QrCode: FC<Props> = ({ data, className }) => {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+1
-6
@@ -10,7 +10,6 @@ import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { widget } from "./widget.ts";
|
||||
|
||||
/**
|
||||
* An error consisting of a terse message to be logged to the console and a
|
||||
@@ -32,11 +31,7 @@ const OpenElsewhere: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={PopOutIcon}
|
||||
title={t("error.open_elsewhere")}
|
||||
>
|
||||
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
|
||||
<p>
|
||||
{t("error.open_elsewhere_description", {
|
||||
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createContext, use } from "react";
|
||||
|
||||
/**
|
||||
* The element that Element Call treats as the root of its own interface.
|
||||
*
|
||||
* Element Call decorates this element with the theme, layout and background
|
||||
* attributes its stylesheets key off, and portals its modals into it. When
|
||||
* Element Call owns the page this is simply the document body; the intent is
|
||||
* that when embedded in a host application it becomes the container the host
|
||||
* mounted it into, so that Element Call does not reach outside its own subtree.
|
||||
*
|
||||
* That intent is not yet achievable: several selectors still name `body`
|
||||
* directly — `body[data-background="gradient"]` and `body[data-platform=…]` in
|
||||
* `index.css`, and `body[data-platform="ios"]` in `AppBar.module.css` and
|
||||
* `Modal.module.css` — and `Initializer.initBeforeReact` writes
|
||||
* `data-platform` straight onto the body. So anything other than the body will
|
||||
* be decorated correctly and styled incorrectly, silently. Until those are
|
||||
* scoped, treat this as preparation rather than a working seam.
|
||||
*/
|
||||
// No provider is exported yet: nothing supplies a root element, so every
|
||||
// consumer falls back to the document body. M1 adds one along with the
|
||||
// component that mounts Element Call into a container.
|
||||
const RootElementContext = createContext<HTMLElement | null>(null);
|
||||
|
||||
/**
|
||||
* The element Element Call should decorate and portal into.
|
||||
*
|
||||
* Defaults to the document body, so that the standalone and widget builds work
|
||||
* without a provider.
|
||||
*/
|
||||
export const useRootElement = (): HTMLElement =>
|
||||
use(RootElementContext) ?? document.body;
|
||||
+7
-1
@@ -25,6 +25,7 @@ import { Text } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./Toast.module.css";
|
||||
import overlayStyles from "./Overlay.module.css";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -64,6 +65,7 @@ export const Toast: FC<Props> = ({
|
||||
Icon,
|
||||
modal = true,
|
||||
}) => {
|
||||
const rootElement = useRootElement();
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) onDismiss();
|
||||
@@ -104,7 +106,11 @@ export const Toast: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
|
||||
{modal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||
{modal ? (
|
||||
<DialogPortal container={rootElement}>{content}</DialogPortal>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</DialogRoot>
|
||||
);
|
||||
};
|
||||
|
||||
+41
-4
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
@@ -59,6 +59,17 @@ export interface UrlProperties {
|
||||
// Widget api related params
|
||||
widgetId: string | null;
|
||||
parentUrl: string | null;
|
||||
/**
|
||||
* Whether Element Call was started as a widget of a Matrix client, which is
|
||||
* to say whether it was given a widget ID and a parent to talk to.
|
||||
*
|
||||
* Only meaningful to the standalone and widget builds, which own the URL —
|
||||
* so use it for decisions that belong to the app shell, such as whether
|
||||
* Element Call is responsible for authenticating the user. Anything the call
|
||||
* interface itself needs to know about its host should come from the host
|
||||
* bridge instead.
|
||||
*/
|
||||
isWidget: boolean;
|
||||
/**
|
||||
* Anything about what room we're pointed to should be from useRoomIdentifier which
|
||||
* parses the path and resolves alias with respect to the default server name, however
|
||||
@@ -448,6 +459,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
const properties: UrlProperties = {
|
||||
widgetId,
|
||||
parentUrl,
|
||||
isWidget,
|
||||
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
|
||||
// what would we do if it were invalid? If the widget API says that's what
|
||||
// the room ID is, then that's what it is.
|
||||
@@ -519,11 +531,36 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
};
|
||||
};
|
||||
|
||||
const UrlParamsContext = createContext<UrlParams | null>(null);
|
||||
|
||||
/**
|
||||
* Hook to simplify use of getUrlParams.
|
||||
* @returns The app parameters for the current URL
|
||||
* Supplies the parameters Element Call should run with.
|
||||
*
|
||||
* The standalone and widget builds derive these from the URL, but an embedder
|
||||
* has no URL of its own to put them in, so it provides them directly instead.
|
||||
*
|
||||
* TODO: `UrlParams` is no longer an accurate name now that these need not come
|
||||
* from a URL. Renaming it touches every consumer, so it is left until the rest
|
||||
* of the de-globalisation work has settled.
|
||||
*/
|
||||
export const useUrlParams = (): UrlParams => {
|
||||
export const UrlParamsProvider = UrlParamsContext.Provider;
|
||||
|
||||
/**
|
||||
* The parameters Element Call is running with.
|
||||
*
|
||||
* Falls back to parsing `window.location` when no provider is present, so that
|
||||
* tests and stories keep working without one.
|
||||
*/
|
||||
export const useUrlParams = (): UrlParams =>
|
||||
use(UrlParamsContext) ?? getUrlParams();
|
||||
|
||||
/**
|
||||
* Derives {@link UrlParams} from the current router location.
|
||||
*
|
||||
* Only meaningful when Element Call owns the URL; embedders provide the params
|
||||
* directly through {@link UrlParamsProvider}.
|
||||
*/
|
||||
export const useUrlParamsFromLocation = (): UrlParams => {
|
||||
const { search, hash } = useLocation();
|
||||
return useMemo(() => getUrlParams(search, hash), [search, hash]);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
import posthog, { type CaptureResult } from "posthog-js";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
Anonymity,
|
||||
@@ -22,75 +23,122 @@ import {
|
||||
PosthogAnalytics,
|
||||
} from "./PosthogAnalytics";
|
||||
import { mockConfig } from "../utils/test";
|
||||
import { analyticsConfigFromEnvironment } from "../initializer";
|
||||
import { optInAnalytics } from "../settings/settings";
|
||||
|
||||
describe("PosthogAnalytics", () => {
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
describe("enablement", () => {
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value or URL params", () => {
|
||||
it("stays off until it is configured", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores config value and does not create instance", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
it("stays off when configured without credentials", () => {
|
||||
PosthogAnalytics.configure({ matrixBackend: "jssdk" });
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays off when given only a key", () => {
|
||||
PosthogAnalytics.configure({
|
||||
matrixBackend: "jssdk",
|
||||
apiKey: "api_key",
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses URL params if both set", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
it("turns on when given both a key and a host", () => {
|
||||
PosthogAnalytics.configure({
|
||||
matrixBackend: "jssdk",
|
||||
apiKey: "api_key",
|
||||
apiHost: "https://api.example.com.localhost",
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
// Which of the URL and config.json the credentials come from is a deliberate
|
||||
// policy: an embedder is responsible for its own users' telemetry, so it must
|
||||
// not pick up the deployment's, and vice versa.
|
||||
describe("analyticsConfigFromEnvironment", () => {
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`;
|
||||
const configCredentials = {
|
||||
posthog: {
|
||||
api_host: "https://config.example.com.localhost",
|
||||
api_key: "config_key",
|
||||
},
|
||||
};
|
||||
|
||||
it("ignores URL params and does not create instance", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("creates instance with config value", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
|
||||
it("has no credentials without URL params", () => {
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes the credentials from the URL", () => {
|
||||
window.location.hash = `#?${urlCredentials}`;
|
||||
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||
apiKey: "url_key",
|
||||
apiHost: "https://url.example.com.localhost",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the deployment's config", () => {
|
||||
mockConfig(configCredentials);
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
it("has no credentials without config", () => {
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes the credentials from the config", () => {
|
||||
mockConfig(configCredentials);
|
||||
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||
apiKey: "config_key",
|
||||
apiHost: "https://config.example.com.localhost",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the URL params", () => {
|
||||
window.location.hash = `#?${urlCredentials}`;
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Who owns the user's analytics identity depends on how Element Call is
|
||||
// running, not on which package it was built as.
|
||||
it("reports the embedded backend when running as a widget", () => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
window.location.hash = `#?widgetId=id&parentUrl=${encodeURIComponent("https://host.example.com.localhost")}&posthogUserId=given_id`;
|
||||
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||
matrixBackend: "embedded",
|
||||
hostAnalyticsId: "given_id",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the jssdk backend when running standalone", () => {
|
||||
expect(analyticsConfigFromEnvironment().matrixBackend).toBe("jssdk");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,22 +252,13 @@ describe("PosthogAnalytics", () => {
|
||||
// posthog-js bumps renaming/removing the hook. The filter logic itself is
|
||||
// covered by the applyPrivacyFilters block above.
|
||||
describe("posthog.init wiring", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
});
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
PosthogAnalytics.configure({
|
||||
matrixBackend: "jssdk",
|
||||
apiKey: "api_key",
|
||||
apiHost: "https://api.example.com.localhost",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes events through the privacy filter via before_send", () => {
|
||||
@@ -244,3 +283,86 @@ describe("PosthogAnalytics", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("identifying the user", () => {
|
||||
const credentials = {
|
||||
apiKey: "api_key",
|
||||
apiHost: "https://api.example.com.localhost",
|
||||
};
|
||||
|
||||
function mockClient(accountDataId: string | null): MatrixClient {
|
||||
return {
|
||||
isGuest: () => false,
|
||||
getCrypto: () => undefined,
|
||||
getAccountDataFromServer: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
accountDataId === null ? null : { id: accountDataId },
|
||||
),
|
||||
setAccountData: vi.fn().mockResolvedValue({}),
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
PosthogAnalytics.resetInstance();
|
||||
optInAnalytics.setValue(true);
|
||||
});
|
||||
|
||||
it("reports under the ID its host assigned, and stores nothing", async () => {
|
||||
const client = mockClient(null);
|
||||
window.matrixclient = client;
|
||||
PosthogAnalytics.configure({
|
||||
...credentials,
|
||||
matrixBackend: "embedded",
|
||||
hostAnalyticsId: "assigned-by-host",
|
||||
});
|
||||
const identify = vi.spyOn(posthog, "identify");
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
await vi.waitFor(() =>
|
||||
expect(identify).toHaveBeenCalledWith("assigned-by-host"),
|
||||
);
|
||||
|
||||
// The host owns the user's account, so Element Call must not write to it
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps its own ID in account data when it owns the session", async () => {
|
||||
const client = mockClient(null);
|
||||
window.matrixclient = client;
|
||||
PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" });
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
|
||||
// No ID on the server yet, so one is minted and stored for other devices
|
||||
await vi.waitFor(() => expect(client.setAccountData).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("reuses the ID already in account data", async () => {
|
||||
const client = mockClient("stored-earlier");
|
||||
window.matrixclient = client;
|
||||
PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" });
|
||||
const identify = vi.spyOn(posthog, "identify");
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
await vi.waitFor(() =>
|
||||
expect(identify).toHaveBeenCalledWith("stored-earlier"),
|
||||
);
|
||||
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records how it reaches Matrix as a super property", async () => {
|
||||
window.matrixclient = mockClient("stored-earlier");
|
||||
PosthogAnalytics.configure({ ...credentials, matrixBackend: "embedded" });
|
||||
const register = vi.spyOn(posthog, "register");
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ matrixBackend: "embedded" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Subscription } from "rxjs";
|
||||
|
||||
import { widget } from "../widget";
|
||||
import {
|
||||
CallEndedTracker,
|
||||
CallStartedTracker,
|
||||
@@ -29,8 +28,6 @@ import {
|
||||
CallConnectDurationTracker,
|
||||
CallReconnectingTracker,
|
||||
} from "./PosthogEvents";
|
||||
import { Config } from "../config/Config";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { optInAnalytics } from "../settings/settings";
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
@@ -140,6 +137,27 @@ interface PlatformProperties {
|
||||
cryptoVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How analytics reporting should be set up, supplied by whoever is starting
|
||||
* Element Call rather than discovered from the page it happens to be on.
|
||||
*/
|
||||
export interface AnalyticsConfig {
|
||||
/** The PostHog project key. Without one, analytics stay switched off. */
|
||||
apiKey?: string;
|
||||
apiHost?: string;
|
||||
/**
|
||||
* How Element Call reaches Matrix. When `embedded`, the host owns the user's
|
||||
* identity: it supplies the analytics ID, and Element Call must not store one
|
||||
* in the user's account data.
|
||||
*/
|
||||
matrixBackend: "embedded" | "jssdk";
|
||||
/** The analytics ID the host has assigned to this user, when embedded. */
|
||||
hostAnalyticsId?: string | null;
|
||||
}
|
||||
|
||||
/** Analytics are off until someone asks for them. */
|
||||
const analyticsDisabled: AnalyticsConfig = { matrixBackend: "jssdk" };
|
||||
|
||||
export class PosthogAnalytics {
|
||||
/* Wrapper for Posthog analytics.
|
||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||
@@ -167,13 +185,32 @@ export class PosthogAnalytics {
|
||||
private registrationType: RegistrationType = RegistrationType.Guest;
|
||||
private optInListener: Subscription | null = null;
|
||||
|
||||
private static analyticsConfig: AnalyticsConfig = analyticsDisabled;
|
||||
|
||||
/**
|
||||
* Sets up analytics reporting. Must be called before the instance is first
|
||||
* used; without it, analytics stay switched off.
|
||||
*/
|
||||
public static configure(config: AnalyticsConfig): void {
|
||||
if (this.internalInstance)
|
||||
// Configuration is read once, when the instance is built, so arriving
|
||||
// late means analytics are already running unconfigured.
|
||||
logger.warn(
|
||||
"Analytics were configured after they had already been started; the new configuration will not take effect",
|
||||
);
|
||||
this.analyticsConfig = config;
|
||||
}
|
||||
|
||||
public static hasInstance(): boolean {
|
||||
return Boolean(this.internalInstance);
|
||||
}
|
||||
|
||||
public static get instance(): PosthogAnalytics {
|
||||
if (!this.internalInstance) {
|
||||
this.internalInstance = new PosthogAnalytics(posthog);
|
||||
this.internalInstance = new PosthogAnalytics(
|
||||
posthog,
|
||||
PosthogAnalytics.analyticsConfig,
|
||||
);
|
||||
}
|
||||
return this.internalInstance;
|
||||
}
|
||||
@@ -181,20 +218,14 @@ export class PosthogAnalytics {
|
||||
public static resetInstance(): void {
|
||||
// Reset the singleton instance
|
||||
this.internalInstance = null;
|
||||
this.analyticsConfig = analyticsDisabled;
|
||||
}
|
||||
|
||||
private constructor(private readonly posthog: PostHog) {
|
||||
let apiKey: string | undefined;
|
||||
let apiHost: string | undefined;
|
||||
if (import.meta.env.VITE_PACKAGE === "embedded") {
|
||||
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
|
||||
apiKey = getUrlParams().posthogApiKey ?? undefined;
|
||||
apiHost = getUrlParams().posthogApiHost ?? undefined;
|
||||
} else if (import.meta.env.VITE_PACKAGE === "full") {
|
||||
// in full package it is the server responsible for the analytics
|
||||
apiKey = Config.get().posthog?.api_key;
|
||||
apiHost = Config.get().posthog?.api_host;
|
||||
}
|
||||
private constructor(
|
||||
private readonly posthog: PostHog,
|
||||
private readonly config: AnalyticsConfig,
|
||||
) {
|
||||
const { apiKey, apiHost } = config;
|
||||
|
||||
if (apiKey && apiHost) {
|
||||
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
|
||||
@@ -225,15 +256,15 @@ export class PosthogAnalytics {
|
||||
}
|
||||
}
|
||||
|
||||
private static getPlatformProperties(): PlatformProperties {
|
||||
private getPlatformProperties(): PlatformProperties {
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
return {
|
||||
appVersion,
|
||||
matrixBackend: widget ? "embedded" : "jssdk",
|
||||
matrixBackend: this.config.matrixBackend,
|
||||
callBackend: "livekit",
|
||||
cryptoVersion: widget
|
||||
? undefined
|
||||
: window.matrixclient?.getCrypto()?.getVersion(),
|
||||
// Undefined when Element Call has no crypto of its own, which is the case
|
||||
// whenever a host is doing the encrypting for it.
|
||||
cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,8 +314,8 @@ export class PosthogAnalytics {
|
||||
// different devices to send the same ID.
|
||||
let analyticsID = await this.getAnalyticsId();
|
||||
try {
|
||||
if (!analyticsID && !widget) {
|
||||
// only try setting up a new analytics ID in the standalone app.
|
||||
if (!analyticsID && this.config.matrixBackend !== "embedded") {
|
||||
// only mint an analytics ID when we are the ones storing it.
|
||||
|
||||
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
|
||||
// Note there's a race condition here - if two devices do these steps at the same time, last write
|
||||
@@ -313,8 +344,8 @@ export class PosthogAnalytics {
|
||||
|
||||
private async getAnalyticsId(): Promise<string | null> {
|
||||
const client: MatrixClient = window.matrixclient;
|
||||
if (widget) {
|
||||
return getUrlParams().posthogUserId;
|
||||
if (this.config.matrixBackend === "embedded") {
|
||||
return this.config.hostAnalyticsId ?? null;
|
||||
} else {
|
||||
const accountData = await client.getAccountDataFromServer(
|
||||
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
||||
@@ -324,7 +355,7 @@ export class PosthogAnalytics {
|
||||
}
|
||||
|
||||
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
||||
if (!widget) {
|
||||
if (this.config.matrixBackend !== "embedded") {
|
||||
const client = window.matrixclient;
|
||||
|
||||
// the analytics ID only needs to be set in the standalone version.
|
||||
@@ -362,7 +393,7 @@ export class PosthogAnalytics {
|
||||
// These properties will be subsequently passed in every event.
|
||||
//
|
||||
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
||||
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
|
||||
this.platformSuperProperties = this.getPlatformProperties();
|
||||
this.registerSuperProperties({
|
||||
...this.platformSuperProperties,
|
||||
registrationType:
|
||||
|
||||
@@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { widget } from "../widget";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const useInteractiveRegistration = (
|
||||
oldClient?: MatrixClient,
|
||||
@@ -32,6 +32,7 @@ export const useInteractiveRegistration = (
|
||||
passwordlessUser: boolean,
|
||||
) => Promise<[MatrixClient, Session]>;
|
||||
} => {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -47,7 +48,7 @@ export const useInteractiveRegistration = (
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) return;
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
authClient.current!.registerRequest({}).catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
@@ -55,7 +56,7 @@ export const useInteractiveRegistration = (
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, []);
|
||||
}, [isWidget]);
|
||||
|
||||
const register = useCallback(
|
||||
async (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useClient } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { widget } from "../widget";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
interface UseRegisterPasswordlessUserType {
|
||||
privacyPolicyUrl?: string;
|
||||
@@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType {
|
||||
|
||||
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
const { setClient } = useClient();
|
||||
const { isWidget } = useUrlParams();
|
||||
const { privacyPolicyUrl, recaptchaKey, register } =
|
||||
useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
@@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
if (!setClient) {
|
||||
throw new Error("No client context");
|
||||
}
|
||||
if (widget) {
|
||||
if (isWidget) {
|
||||
throw new Error(
|
||||
"Registration was skipped: We should never try to register password-less user in embedded mode.",
|
||||
);
|
||||
@@ -53,7 +54,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[execute, reset, register, setClient],
|
||||
[execute, reset, register, setClient, isWidget],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||
|
||||
@@ -29,7 +29,9 @@ const reactionData = {
|
||||
reactions$: new BehaviorSubject({}),
|
||||
};
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope);
|
||||
const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* A wrapper component that is used for:
|
||||
@@ -127,6 +129,7 @@ export const Default: Story = {
|
||||
showFooter: true,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface FooterState {
|
||||
/** The footer should be used as an overlay.
|
||||
* (Over the Call Grid) This saves spaces on small screens. */
|
||||
asOverlay: boolean;
|
||||
showModals: boolean;
|
||||
|
||||
buttonSize: "md" | "lg";
|
||||
showLogo: boolean;
|
||||
@@ -121,6 +122,7 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
const asOverlay = useBehavior(vm.asOverlay$);
|
||||
const showFooter = useBehavior(vm.showFooter$);
|
||||
const hideControls = useBehavior(vm.hideControls$);
|
||||
const showModals = useBehavior(vm.showModals$);
|
||||
const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
|
||||
const openSettings = useBehavior(vm.openSettings$);
|
||||
const audioEnabled = useBehavior(vm.audioEnabled$);
|
||||
@@ -235,7 +237,8 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (reactionIdentifier && reactionData) {
|
||||
// Reaction button contains a pretty large menu, so treat it like a modal
|
||||
if (reactionIdentifier && reactionData && showModals) {
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
size={buttonSize}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Alignment, Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
@@ -49,6 +50,7 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
|
||||
reactions$: constant({}),
|
||||
tileStoreGeneration$: constant(0),
|
||||
showFooter$: constant(true),
|
||||
showModals$: constant(true),
|
||||
settingsOpen$: constant(false),
|
||||
setSettingsOpen$: constant(() => {}),
|
||||
} as unknown as CallViewModel;
|
||||
@@ -104,6 +106,7 @@ describe("createCallFooterViewModel", () => {
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$.value).toEqual([]);
|
||||
@@ -125,6 +128,7 @@ describe("createCallFooterViewModel", () => {
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$?.value).toEqual([
|
||||
|
||||
@@ -19,7 +19,7 @@ import { type Behavior, constant } from "../state/Behavior";
|
||||
import type { ObservableScope } from "../state/ObservableScope";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||
import { getUrlParams, HeaderStyle } from "../UrlParams";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
|
||||
@@ -138,6 +138,8 @@ function buildDeviceBehaviors(
|
||||
* @param mediaDevices - Available and selected input devices.
|
||||
* @param reactionIdentifier - The local user's reaction identifier string, or
|
||||
* undefined when reactions are not supported (hides the reaction button).
|
||||
* @param options - `showControls`: whether the call controls should be shown.
|
||||
* `header`: the style of header, which decides whether to show the logo.
|
||||
*/
|
||||
export function createCallFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
@@ -145,8 +147,9 @@ export function createCallFooterViewModel(
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
reactionIdentifier: string | undefined,
|
||||
options: { showControls: boolean; header: HeaderStyle },
|
||||
): ViewModel<FooterSnapshot> {
|
||||
const { showControls, header: headerStyle } = getUrlParams();
|
||||
const { showControls, header: headerStyle } = options;
|
||||
const showLogo = headerStyle === HeaderStyle.Standard;
|
||||
|
||||
const isPip$ = scope.behavior(
|
||||
@@ -161,6 +164,7 @@ export function createCallFooterViewModel(
|
||||
// candidat to move into the FooterViewModel
|
||||
showFooter$: callModel.showFooter$,
|
||||
hideControls$: constant(!showControls),
|
||||
showModals$: callModel.showModals$,
|
||||
asOverlay$: callModel.edgeToEdge$,
|
||||
buttonSize$: scope.behavior(
|
||||
isPip$.pipe(map<boolean, "md" | "lg">((pip) => (pip ? "md" : "lg"))),
|
||||
@@ -168,12 +172,12 @@ export function createCallFooterViewModel(
|
||||
|
||||
openSettings$: scope.behavior(
|
||||
combineLatest([
|
||||
isPip$,
|
||||
callModel.showModals$,
|
||||
callModel.showHeader$,
|
||||
callModel.setSettingsOpen$,
|
||||
]).pipe(
|
||||
map(([isPip, showHeader, setSettingsOpen]) =>
|
||||
!isPip && headerStyle !== HeaderStyle.AppBar && showControls
|
||||
map(([showModals, showHeader, setSettingsOpen]) =>
|
||||
showModals && headerStyle !== HeaderStyle.AppBar && showControls
|
||||
? (): void => setSettingsOpen(true)
|
||||
: undefined,
|
||||
),
|
||||
@@ -239,6 +243,7 @@ export function createLobbyFooterViewModel(
|
||||
showLogo,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
buttonSize: "lg",
|
||||
openSettings,
|
||||
hangup,
|
||||
|
||||
@@ -14,7 +14,9 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope);
|
||||
const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
component: MediaMuteAndSwitchButton,
|
||||
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details.
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { validateConfig } from "./Config";
|
||||
import { MatrixRTCMode } from "./ConfigOptions";
|
||||
import { Config, validateConfig } from "./Config";
|
||||
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
|
||||
|
||||
describe("validateConfig", () => {
|
||||
afterEach(() => {
|
||||
@@ -52,3 +52,52 @@ describe("validateConfig", () => {
|
||||
expect(result.ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config.initWith", () => {
|
||||
// vitest.setup.ts has already called initDefault(), so every test here is
|
||||
// free to re-initialize; the last call wins.
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Config.initDefault();
|
||||
});
|
||||
|
||||
it("makes the supplied config readable", () => {
|
||||
Config.initWith({ ssla: "https://example.invalid/ssla" });
|
||||
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
|
||||
it("fills in defaults for keys the embedder did not supply", () => {
|
||||
Config.initWith({ ssla: "https://example.invalid/ssla" });
|
||||
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
|
||||
});
|
||||
|
||||
it("validates the supplied config just as a fetched one would be", () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
Config.initWith({
|
||||
matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode,
|
||||
});
|
||||
expect(Config.get().matrix_rtc_mode).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share nested state with DEFAULT_CONFIG", () => {
|
||||
Config.initWith({});
|
||||
expect(Config.get().media_quality).not.toBe(DEFAULT_CONFIG.media_quality);
|
||||
});
|
||||
|
||||
it("stops a later init() from fetching over the top of it", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
Config.initWith({ ssla: "https://example.invalid/ssla" });
|
||||
|
||||
await Config.init();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
|
||||
it("replaces a config initialized earlier", () => {
|
||||
Config.initWith({ ssla: "https://first.invalid/ssla" });
|
||||
Config.initWith({ ssla: "https://second.invalid/ssla" });
|
||||
expect(Config.get().ssla).toBe("https://second.invalid/ssla");
|
||||
});
|
||||
});
|
||||
|
||||
+42
-7
@@ -30,6 +30,14 @@ export class Config {
|
||||
return this.internalInstance.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the config by fetching `config.json`, locating it relative to
|
||||
* the current page.
|
||||
*
|
||||
* Does nothing if the config has already been initialized, including by
|
||||
* {@link Config.initWith}, so that the regular startup path can run unchanged
|
||||
* when an embedder has already supplied the config.
|
||||
*/
|
||||
public static async init(): Promise<void> {
|
||||
if (!Config.internalInstance?.initPromise) {
|
||||
const internalInstance = new Config();
|
||||
@@ -50,17 +58,36 @@ export class Config {
|
||||
|
||||
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
|
||||
(config) => {
|
||||
internalInstance.config = merge(
|
||||
{},
|
||||
DEFAULT_CONFIG,
|
||||
validateConfig(config),
|
||||
);
|
||||
internalInstance.config = resolveConfig(config);
|
||||
},
|
||||
);
|
||||
}
|
||||
return Config.internalInstance.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the config from an object supplied by the embedder, instead of
|
||||
* fetching `config.json`.
|
||||
*
|
||||
* {@link Config.init} derives the location of `config.json` from
|
||||
* `window.location`, which only makes sense while Element Call owns the page.
|
||||
* When it is embedded in a host application the host owns the configuration
|
||||
* and passes it in here.
|
||||
*
|
||||
* The config goes through the same validation and defaulting as a fetched
|
||||
* one, so that an injected config behaves identically to a hosted one.
|
||||
*
|
||||
* Replaces any config initialized earlier.
|
||||
*/
|
||||
public static initWith(config: ConfigOptions): void {
|
||||
const internalInstance = new Config();
|
||||
internalInstance.config = resolveConfig(config);
|
||||
// Mark initialization as already done, so that a later init() resolves
|
||||
// immediately rather than fetching config.json over the top of this.
|
||||
internalInstance.initPromise = Promise.resolve();
|
||||
Config.internalInstance = internalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a alternative initializer that does not load anything
|
||||
* from a hosted config file but instead just initializes the config using the
|
||||
@@ -69,8 +96,7 @@ export class Config {
|
||||
* It is supposed to only be used in tests. (It is executed in `vite.setup.js`)
|
||||
*/
|
||||
public static initDefault(): void {
|
||||
Config.internalInstance = new Config();
|
||||
Config.internalInstance.config = { ...DEFAULT_CONFIG };
|
||||
Config.initWith({});
|
||||
}
|
||||
|
||||
// Convenience accessors
|
||||
@@ -94,6 +120,15 @@ export class Config {
|
||||
private initPromise?: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies validation and the built-in defaults to a config, however it was
|
||||
* obtained. Deep-merges onto a fresh object so that the result never shares
|
||||
* nested state with {@link DEFAULT_CONFIG}.
|
||||
*/
|
||||
function resolveConfig(config: ConfigOptions): ResolvedConfigOptions {
|
||||
return merge({}, DEFAULT_CONFIG, validateConfig(config));
|
||||
}
|
||||
|
||||
export function validateConfig(config: ConfigOptions): ConfigOptions {
|
||||
const mode = config.matrix_rtc_mode;
|
||||
if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {
|
||||
|
||||
+16
-16
@@ -12,9 +12,7 @@ Please see LICENSE in the repository root for full details.
|
||||
* Settings, or pinned for a deployment via `matrix_rtc_mode` in config.json.
|
||||
*/
|
||||
export enum MatrixRTCMode {
|
||||
/** Legacy single-SFU + user-keyed memberships + legacy JWT endpoint. */
|
||||
Legacy = "legacy",
|
||||
/** Multi-SFU transport, legacy JWT endpoint, no sticky events. */
|
||||
/** Multi-SFU transport, legacy JWT endpoint, state events. */
|
||||
Compatibility = "compatibility",
|
||||
/**
|
||||
* Multi-SFU transport with:
|
||||
@@ -86,15 +84,6 @@ export interface ConfigOptions {
|
||||
* Allow to join group calls without audio and video.
|
||||
*/
|
||||
feature_group_calls_without_video_and_audio?: boolean;
|
||||
|
||||
/**
|
||||
* Send device-specific call session membership state events instead of
|
||||
* legacy user-specific call membership state events.
|
||||
* This setting has no effect when the user joins an active call with
|
||||
* legacy state events. For compatibility, Element Call will always join
|
||||
* active legacy calls with legacy state events.
|
||||
*/
|
||||
feature_use_device_session_member_events?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -179,7 +168,8 @@ export interface ConfigOptions {
|
||||
/**
|
||||
* Pins the {@link MatrixRTCMode} for all clients on this deployment,
|
||||
* overriding any per-user choice from the Developer Settings. If unset,
|
||||
* the user's Developer Settings choice (or its default of `Legacy`) wins.
|
||||
* the user's Developer Settings choice (or its default of `Compatibility`)
|
||||
* wins.
|
||||
*/
|
||||
matrix_rtc_mode?: MatrixRTCMode;
|
||||
|
||||
@@ -229,6 +219,18 @@ export interface ConfigOptions {
|
||||
* This is what goes into the m.rtc.member event expiry field and is typically set to a number of hours.
|
||||
*/
|
||||
membership_event_expiry_ms?: number;
|
||||
|
||||
/**
|
||||
* The number of participants in the session at which the media encryption key will no longer
|
||||
* be rotated.
|
||||
*
|
||||
* Rotating a key requires sending it to every participant device, so in large sessions the
|
||||
* cost of rotating on every join/leave becomes prohibitive. At this limit the current key is
|
||||
* kept and distributed to new joiners; no new keys are generated for joiners/leavers.
|
||||
*
|
||||
* Defaults to the js-sdk default (undefined). Which means that rotation will always happen.
|
||||
*/
|
||||
key_rotation_participant_limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,13 +264,11 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
delayed_leave_event_restart_ms?: number;
|
||||
network_error_retry_ms: number;
|
||||
membership_event_expiry_ms?: number;
|
||||
key_rotation_participant_limit?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
features: {
|
||||
feature_use_device_session_member_events: true,
|
||||
},
|
||||
sync_disconnect_grace_period_ms: 10000,
|
||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
media_quality: {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
|
||||
describe("getKeyForRoom", () => {
|
||||
afterEach(() => {
|
||||
window.location.hash = "#";
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test("prefers a key given in the parameters over the stored one", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`;
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBe("from-the-link");
|
||||
});
|
||||
|
||||
test("falls back to the stored key", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBe("stored");
|
||||
});
|
||||
|
||||
test("has no key to offer for a room it has never seen", () => {
|
||||
expect(getKeyForRoom(roomId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
setLocalStorageItemReactive,
|
||||
useLocalStorage,
|
||||
} from "../useLocalStorage";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { getUrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "./e2eeType";
|
||||
import { useClient } from "../ClientContext";
|
||||
|
||||
@@ -57,19 +57,31 @@ const useRoomSharedKey = (
|
||||
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
|
||||
};
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
const { roomId: urlRoomId, password } = getUrlParams();
|
||||
if (roomId !== urlRoomId)
|
||||
/**
|
||||
* The shared key for a room, preferring one supplied in the parameters Element
|
||||
* Call was started with over whatever is in local storage.
|
||||
*/
|
||||
function keyForRoom(
|
||||
roomId: string,
|
||||
paramsRoomId: string | null,
|
||||
password: string | null,
|
||||
): string | null {
|
||||
if (roomId !== paramsRoomId)
|
||||
logger.warn(
|
||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
||||
roomId,
|
||||
urlRoomId,
|
||||
paramsRoomId,
|
||||
);
|
||||
return (
|
||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
||||
);
|
||||
}
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
const { roomId: paramsRoomId, password } = getUrlParams();
|
||||
return keyForRoom(roomId, paramsRoomId, password);
|
||||
}
|
||||
|
||||
export type Unencrypted = { kind: E2eeType.NONE };
|
||||
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||
@@ -77,10 +89,15 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
|
||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { client } = useClient();
|
||||
const { roomId: paramsRoomId, password } = useUrlParams();
|
||||
|
||||
const [storedPassword] = useRoomSharedKey(
|
||||
// TODO: this passes an already-prefixed key where a room ID is expected, so
|
||||
// the local storage key ends up prefixed twice and never matches what
|
||||
// saveKeyForRoom writes. Preserved as-is here to keep this commit a pure
|
||||
// refactor; the reactive read is effectively dead until it is fixed.
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
getKeyForRoom(roomId) ?? undefined,
|
||||
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
|
||||
);
|
||||
|
||||
const room = client?.getRoom(roomId);
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,16 +0,0 @@
|
||||
<svg width="1440" height="800" viewBox="0 0 1440 800" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_f_4162_80259)">
|
||||
<path d="M-37.0486 666.028C267.76 138.867 944.304 -46.1945 1477.05 260.929" stroke="url(#paint0_linear_4162_80259)" stroke-width="192" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_4162_80259" x="-333.118" y="-183.694" width="2106.24" height="1145.68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_4162_80259"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_4162_80259" x1="1255.31" y1="320.254" x2="184.899" y2="607.497" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00C59E"/>
|
||||
<stop offset="0.75" stop-color="#0044A5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 938 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,86 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="1200"
|
||||
height="285"
|
||||
viewBox="0 0 1200 285"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<g
|
||||
filter="url(#filter0_f_3970_9366)"
|
||||
id="g2"
|
||||
transform="translate(422.00201)">
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint0_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path1"
|
||||
style="stroke:url(#paint0_linear_3970_9366)" />
|
||||
<path
|
||||
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
|
||||
stroke="url(#paint1_linear_3970_9366)"
|
||||
style="mix-blend-mode:overlay;stroke:url(#paint1_linear_3970_9366)"
|
||||
stroke-width="235.517"
|
||||
stroke-linecap="round"
|
||||
id="path2" />
|
||||
</g>
|
||||
<defs
|
||||
id="defs5">
|
||||
<filter
|
||||
id="filter0_f_3970_9366"
|
||||
x="-517.617"
|
||||
y="-0.00012207"
|
||||
width="1391.23"
|
||||
height="848.409"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB">
|
||||
<feFlood
|
||||
flood-opacity="0"
|
||||
result="BackgroundImageFix"
|
||||
id="feFlood2" />
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="BackgroundImageFix"
|
||||
result="shape"
|
||||
id="feBlend2" />
|
||||
<feGaussianBlur
|
||||
stdDeviation="117.758"
|
||||
result="effect1_foregroundBlur_3970_9366"
|
||||
id="feGaussianBlur2" />
|
||||
</filter>
|
||||
<linearGradient
|
||||
id="paint0_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_3970_9366"
|
||||
x1="349.17099"
|
||||
y1="323.96301"
|
||||
x2="6.82898"
|
||||
y2="666.30499"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#0D5CBD"
|
||||
id="stop4" />
|
||||
<stop
|
||||
offset="0.730863"
|
||||
stop-color="#0DBDA8"
|
||||
id="stop5" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView";
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { RegisteredView } from "./RegisteredView";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { widget } from "../widget.ts";
|
||||
|
||||
export const HomePage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,7 +23,7 @@ export const HomePage: FC = () => {
|
||||
if (!clientState) {
|
||||
return <LoadingPage />;
|
||||
} else if (clientState.state === "error") {
|
||||
return <ErrorPage widget={widget} error={clientState.error} />;
|
||||
return <ErrorPage error={clientState.error} />;
|
||||
} else {
|
||||
return clientState.authenticated ? (
|
||||
<RegisteredView client={clientState.authenticated.client} />
|
||||
|
||||
+8
-5
@@ -78,16 +78,19 @@ body {
|
||||
body[data-background="gradient"]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("graphics/mobile-gradient.svg");
|
||||
background-size: auto;
|
||||
/* Chromium abruptly fades our images to fully transparent at the edge of
|
||||
the element. If we just make the element a little bigger than the viewport,
|
||||
this is no longer visible. */
|
||||
inset: -20px;
|
||||
background-image: url("graphics/mobile-gradient.png");
|
||||
background-size: 1400px 305px;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
body[data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.svg");
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
background-image: url("graphics/desktop-gradient.png");
|
||||
background-size: max(1440px, 100vw) max(1440px, 100vh);
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
+45
-6
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import i18n, {
|
||||
import {
|
||||
type BackendModule,
|
||||
type ReadCallback,
|
||||
type ResourceKey,
|
||||
} from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
@@ -33,8 +32,13 @@ import { Config } from "./config/Config";
|
||||
import { seedSettingsFromConfig } from "./settings/settings";
|
||||
import { platform } from "./Platform";
|
||||
import { isFailure } from "./utils/fetch";
|
||||
import { initializeWidget } from "./widget";
|
||||
import { initializeWidget, type WidgetHelpers } from "./widget";
|
||||
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
||||
import {
|
||||
type AnalyticsConfig,
|
||||
PosthogAnalytics,
|
||||
} from "./analytics/PosthogAnalytics.ts";
|
||||
import { i18n } from "./utils/i18n.ts";
|
||||
|
||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||
// {
|
||||
@@ -98,6 +102,36 @@ const Backend = {
|
||||
},
|
||||
} satisfies BackendModule;
|
||||
|
||||
/**
|
||||
* Where analytics reporting is configured from.
|
||||
*
|
||||
* Note the two halves are decided differently, and deliberately so. *Where the
|
||||
* PostHog credentials come from* depends on the package: an embedder passes
|
||||
* them in through the URL because it is responsible for its own users'
|
||||
* telemetry, whereas a standalone deployment is configured by whoever operates
|
||||
* it. *Who owns the user's analytics identity*, on the other hand, depends on
|
||||
* how Element Call is actually running right now — the full package can be used
|
||||
* as a widget too.
|
||||
*/
|
||||
// Exported for testing
|
||||
export function analyticsConfigFromEnvironment(): AnalyticsConfig {
|
||||
const { posthogApiKey, posthogApiHost, posthogUserId, isWidget } =
|
||||
getUrlParams();
|
||||
return {
|
||||
matrixBackend: isWidget ? "embedded" : "jssdk",
|
||||
hostAnalyticsId: posthogUserId,
|
||||
...(import.meta.env.VITE_PACKAGE === "embedded"
|
||||
? {
|
||||
apiKey: posthogApiKey ?? undefined,
|
||||
apiHost: posthogApiHost ?? undefined,
|
||||
}
|
||||
: {
|
||||
apiKey: Config.get().posthog?.api_key,
|
||||
apiHost: Config.get().posthog?.api_host,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
enum LoadState {
|
||||
None,
|
||||
Loading,
|
||||
@@ -121,8 +155,8 @@ export class Initializer {
|
||||
return !!Initializer.internalInstance?.isInitialized;
|
||||
}
|
||||
|
||||
public static async initBeforeReact(): Promise<void> {
|
||||
initializeWidget();
|
||||
public static async initBeforeReact(): Promise<WidgetHelpers | null> {
|
||||
const widget = initializeWidget();
|
||||
|
||||
const polyfills: Promise<unknown>[] = [];
|
||||
if (shouldPolyfillSegmenter()) {
|
||||
@@ -148,10 +182,12 @@ export class Initializer {
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
// Note: deliberately no `.use(initReactI18next)` — that would register this
|
||||
// instance as react-i18next's global default, which is the very global we
|
||||
// are avoiding. Components receive it through `<I18nextProvider>` instead.
|
||||
await i18n
|
||||
.use(Backend)
|
||||
.use(languageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
defaultNS: "app",
|
||||
@@ -207,6 +243,8 @@ export class Initializer {
|
||||
});
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
return widget;
|
||||
}
|
||||
|
||||
public static init(): Promise<void> | null {
|
||||
@@ -239,6 +277,7 @@ export class Initializer {
|
||||
Config.init().then(
|
||||
() => {
|
||||
seedSettingsFromConfig(Config.get().media_quality);
|
||||
PosthogAnalytics.configure(analyticsConfigFromEnvironment());
|
||||
this.loadStates.config = LoadState.Loaded;
|
||||
this.initStep(resolve);
|
||||
},
|
||||
|
||||
+12
-2
@@ -21,6 +21,7 @@ import { init as initRageshake } from "./settings/rageshake";
|
||||
import { Initializer } from "./initializer";
|
||||
import { AppViewModel } from "./state/AppViewModel";
|
||||
import { globalScope } from "./state/ObservableScope";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
|
||||
initRageshake().catch((e) => {
|
||||
logger.error("Failed to initialize rageshake", e);
|
||||
@@ -48,10 +49,19 @@ if (fatalError !== null) {
|
||||
}
|
||||
|
||||
Initializer.initBeforeReact()
|
||||
.then(() => {
|
||||
.then((widget) => {
|
||||
const { controlledAudioDevices, callIntent } = getUrlParams();
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App vm={new AppViewModel(globalScope)} />
|
||||
<App
|
||||
vm={
|
||||
new AppViewModel(globalScope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
})
|
||||
}
|
||||
widget={widget}
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
})
|
||||
|
||||
@@ -36,7 +36,11 @@ import {
|
||||
UnknownCallError,
|
||||
} from "../utils/errors.ts";
|
||||
import { mockConfig } from "../utils/test.ts";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "../HostBridge.ts";
|
||||
|
||||
test.each([
|
||||
{
|
||||
@@ -79,7 +83,6 @@ test.each([
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -108,7 +111,6 @@ test("should render the error page with link back to home", async () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -154,10 +156,7 @@ test("ConnectionLostError: Action handling should reset error state", async () =
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={reconnectCallback}
|
||||
widget={null}
|
||||
>
|
||||
<GroupCallErrorBoundary recoveryActionHandler={reconnectCallback}>
|
||||
<TestComponent fail={failState} />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>
|
||||
@@ -199,7 +198,6 @@ describe("Rageshake button", () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -224,29 +222,27 @@ describe("Rageshake button", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should have a close button in widget mode", async () => {
|
||||
test("should have a close button when the host can dismiss us", async () => {
|
||||
const error = new MatrixRTCTransportMissingError("example.com");
|
||||
const TestComponent = (): ReactNode => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const mockWidget = {
|
||||
api: {
|
||||
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
|
||||
},
|
||||
} as unknown as WidgetHelpers;
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, close };
|
||||
|
||||
const user = userEvent.setup();
|
||||
const onErrorMock = vi.fn();
|
||||
const { asFragment } = render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
widget={mockWidget}
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<GroupCallErrorBoundary
|
||||
onError={onErrorMock}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</HostBridgeProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@@ -258,11 +254,7 @@ test("should have a close button in widget mode", async () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
|
||||
expect(close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should show technical details when error has a matrixError cause", async () => {
|
||||
@@ -282,11 +274,7 @@ test("should show technical details when error has a matrixError cause", async (
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
@@ -315,11 +303,7 @@ test("should not show technical details when error has no matrix error cause", a
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
</BrowserRouter>,
|
||||
@@ -376,7 +360,6 @@ describe("LiveKit ConnectionError variants", () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
@@ -406,7 +389,6 @@ describe("LiveKit ConnectionError variants", () => {
|
||||
<GroupCallErrorBoundary
|
||||
onError={vi.fn()}
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
<TestComponent />
|
||||
</GroupCallErrorBoundary>
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
} from "../utils/errors.ts";
|
||||
import { FullScreenView } from "../FullScreenView.tsx";
|
||||
import { ErrorView } from "../ErrorView.tsx";
|
||||
import { type WidgetHelpers } from "../widget.ts";
|
||||
import styles from "../ErrorView.module.css";
|
||||
|
||||
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||
@@ -47,13 +46,11 @@ interface ErrorPageProps {
|
||||
error: ElementCallError;
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
resetError: () => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
const ErrorPage: FC<ErrorPageProps> = ({
|
||||
error,
|
||||
recoveryActionHandler,
|
||||
widget,
|
||||
}: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
logger.error("Error boundary caught:", error);
|
||||
@@ -89,7 +86,6 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
||||
Icon={icon}
|
||||
title={error.localisedTitle}
|
||||
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
|
||||
widget={widget}
|
||||
>
|
||||
<p>
|
||||
{error.localisedMessageKey ? (
|
||||
@@ -148,14 +144,12 @@ interface BoundaryProps {
|
||||
children: ReactNode | (() => ReactNode);
|
||||
recoveryActionHandler: RecoveryActionHandler;
|
||||
onError?: (error: unknown) => void;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const GroupCallErrorBoundary = ({
|
||||
recoveryActionHandler,
|
||||
onError,
|
||||
children,
|
||||
widget,
|
||||
}: BoundaryProps): ReactElement => {
|
||||
const fallbackRenderer: FallbackRender = useCallback(
|
||||
({ error, resetError }): ReactElement => {
|
||||
@@ -165,7 +159,6 @@ export const GroupCallErrorBoundary = ({
|
||||
: new UnknownCallError(error instanceof Error ? error : new Error());
|
||||
return (
|
||||
<ErrorPage
|
||||
widget={widget ?? null}
|
||||
error={callError}
|
||||
resetError={resetError}
|
||||
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
|
||||
@@ -175,7 +168,7 @@ export const GroupCallErrorBoundary = ({
|
||||
/>
|
||||
);
|
||||
},
|
||||
[recoveryActionHandler, widget],
|
||||
[recoveryActionHandler],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -36,7 +36,6 @@ import userEvent, {
|
||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||
import { useState } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { type ITransport } from "matrix-widget-api";
|
||||
|
||||
import { prefetchSounds } from "../soundUtils";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
@@ -52,8 +51,11 @@ import {
|
||||
} from "../utils/test";
|
||||
import { GroupCallView } from "./GroupCallView";
|
||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
|
||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "../HostBridge";
|
||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
@@ -134,7 +136,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
function createGroupCallView(
|
||||
widget: WidgetHelpers | null,
|
||||
hostBridge: HostBridge,
|
||||
joined = true,
|
||||
options: {
|
||||
withErrorBoundary?: boolean;
|
||||
@@ -184,7 +186,6 @@ function createGroupCallView(
|
||||
skipLobby={false}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
muteStates={muteState}
|
||||
widget={widget}
|
||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
||||
joined={true}
|
||||
setJoined={function (value: boolean): void {}}
|
||||
@@ -192,22 +193,21 @@ function createGroupCallView(
|
||||
);
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary
|
||||
recoveryActionHandler={vi.fn()}
|
||||
widget={null}
|
||||
>
|
||||
{groupCallView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
groupCallView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<TooltipProvider>
|
||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||
<ProcessorProvider>
|
||||
{options.withErrorBoundary ? (
|
||||
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||
{groupCallView}
|
||||
</GroupCallErrorBoundary>
|
||||
) : (
|
||||
groupCallView
|
||||
)}
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</TooltipProvider>
|
||||
</HostBridgeProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
return {
|
||||
@@ -218,7 +218,7 @@ function createGroupCallView(
|
||||
|
||||
test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByText, rtcSession } = createGroupCallView(null);
|
||||
const { getByText, rtcSession } = createGroupCallView(nullHostBridge);
|
||||
const leaveButton = getByText("Leave");
|
||||
await user.click(leaveButton);
|
||||
expect(playSound).toHaveBeenCalledWith("left");
|
||||
@@ -235,12 +235,7 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async
|
||||
|
||||
test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const widget = {
|
||||
api: {
|
||||
setAlwaysOnScreen: async () => Promise.resolve(true),
|
||||
} as Partial<WidgetHelpers["api"]>,
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
};
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() };
|
||||
let resolvePlaySound: () => void;
|
||||
playSound = vi
|
||||
.fn()
|
||||
@@ -253,9 +248,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
soundDuration: {},
|
||||
});
|
||||
|
||||
const { getByText, rtcSession } = createGroupCallView(
|
||||
widget as WidgetHelpers,
|
||||
);
|
||||
const { getByText, rtcSession } = createGroupCallView(hostBridge);
|
||||
const leaveButton = getByText("Leave");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
@@ -272,28 +265,13 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
||||
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("Should close widget when all other left and play a sound", async () => {
|
||||
test("Should ask the host to close when all other left and play a sound", async () => {
|
||||
const user = userEvent.setup();
|
||||
let widgetClosedCalled = false;
|
||||
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
|
||||
Promise.withResolvers<void>();
|
||||
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
|
||||
if (action === ElementWidgetActions.Close) {
|
||||
widgetClosedCalled = true;
|
||||
widgetClosedResolver();
|
||||
}
|
||||
});
|
||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
||||
const widget = {
|
||||
api: {
|
||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
|
||||
transport: {
|
||||
send: widgetSendMock,
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
stop: widgetStopMock,
|
||||
} as unknown as ITransport,
|
||||
} as Partial<WidgetHelpers["api"]>,
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const hostBridge: HostBridge = {
|
||||
...nullHostBridge,
|
||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined),
|
||||
close,
|
||||
};
|
||||
const resolvePlaySound = Promise.withResolvers<void>();
|
||||
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
|
||||
@@ -303,50 +281,38 @@ test("Should close widget when all other left and play a sound", async () => {
|
||||
soundDuration: {},
|
||||
});
|
||||
|
||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
||||
const { getByText } = createGroupCallView(hostBridge);
|
||||
const leaveButton = getByText("SimulateOtherLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
expect(widgetClosedCalled).toBeFalsy();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
resolvePlaySound.resolve();
|
||||
|
||||
expect(playSound).toHaveBeenCalledWith("left", 0);
|
||||
await widgetClosedPromise;
|
||||
await flushPromises();
|
||||
expect(widgetClosedCalled).toBeTruthy();
|
||||
expect(widgetStopMock).toHaveBeenCalledOnce();
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
}, 80000);
|
||||
|
||||
test("Should not close widget when auto leave due to error", async () => {
|
||||
test("Should not ask the host to close when auto leave due to error", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
||||
const widgetSendMock = vi.fn().mockResolvedValue(undefined);
|
||||
const widget = {
|
||||
api: {
|
||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
|
||||
transport: {
|
||||
send: widgetSendMock,
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
stop: widgetStopMock,
|
||||
} as unknown as ITransport,
|
||||
} as Partial<WidgetHelpers["api"]>,
|
||||
lazyActions: new LazyEventEmitter(),
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined);
|
||||
const hostBridge: HostBridge = {
|
||||
...nullHostBridge,
|
||||
setAlwaysOnScreen,
|
||||
close,
|
||||
};
|
||||
|
||||
const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen");
|
||||
|
||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
||||
const { getByText } = createGroupCallView(hostBridge);
|
||||
const leaveButton = getByText("SimulateErrorLeft");
|
||||
await user.click(leaveButton);
|
||||
await flushPromises();
|
||||
|
||||
// When onLeft is called, we first set always on screen to false
|
||||
await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false));
|
||||
await waitFor(() => expect(setAlwaysOnScreen).toHaveBeenCalledWith(false));
|
||||
await flushPromises();
|
||||
// But then we do not close the widget automatically
|
||||
expect(widgetStopMock).not.toHaveBeenCalledOnce();
|
||||
expect(widgetSendMock).not.toHaveBeenCalledOnce();
|
||||
// But then we do not ask to be closed automatically
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||
@@ -360,7 +326,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
||||
);
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(null);
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge);
|
||||
await user.click(screen.getByRole("button", { name: "Panic!" }));
|
||||
screen.getByText("Something went wrong");
|
||||
expect(leaveRTCSession).toHaveBeenCalledWith(
|
||||
@@ -377,7 +343,7 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
|
||||
onTestFinished(() => {
|
||||
enterRTCSession.mockReset();
|
||||
});
|
||||
createGroupCallView(null, false);
|
||||
createGroupCallView(nullHostBridge, false);
|
||||
await user.click(screen.getByRole("button", { name: "Join call" }));
|
||||
screen.getByText("Call is not supported");
|
||||
});
|
||||
@@ -396,7 +362,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
||||
{ cause: stickyError },
|
||||
);
|
||||
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
@@ -408,7 +374,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
||||
});
|
||||
|
||||
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
||||
const { rtcSession } = createGroupCallView(null, true, {
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
|
||||
withErrorBoundary: true,
|
||||
});
|
||||
|
||||
@@ -424,7 +390,7 @@ test("falls back to ConnectionLostError for unrecognised membership manager erro
|
||||
|
||||
test("user can reconnect after a membership manager error", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rtcSession } = createGroupCallView(null, true);
|
||||
const { rtcSession } = createGroupCallView(nullHostBridge, true);
|
||||
await act(() =>
|
||||
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
||||
);
|
||||
|
||||
+63
-73
@@ -30,12 +30,7 @@ import {
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
type JoinCallData,
|
||||
type WidgetHelpers,
|
||||
} from "../widget";
|
||||
import { type JoinCallData } from "../widget";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { CallEndedView } from "./CallEndedView";
|
||||
@@ -54,12 +49,7 @@ import { useRoomAvatar } from "./useRoomAvatar";
|
||||
import { useRoomName } from "./useRoomName";
|
||||
import { useJoinRule } from "./useJoinRule";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import {
|
||||
getUrlParams,
|
||||
HeaderStyle,
|
||||
type UrlParams,
|
||||
useUrlParams,
|
||||
} from "../UrlParams";
|
||||
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { useAudioContext } from "../useAudioContext";
|
||||
import {
|
||||
@@ -80,6 +70,8 @@ import { useTypedEventEmitter } from "../useEvents";
|
||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||
import { useAppBarTitle } from "../AppBar.tsx";
|
||||
import { useBehavior } from "../useBehavior.ts";
|
||||
import { useRootElement } from "../RootElementContext.ts";
|
||||
import { useHostBridge } from "../HostBridge.ts";
|
||||
|
||||
/**
|
||||
* If there already are this many participants in the call, we automatically mute
|
||||
@@ -103,7 +95,6 @@ interface Props {
|
||||
joined: boolean;
|
||||
setJoined: (value: boolean) => void;
|
||||
muteStates: MuteStates;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const GroupCallView: FC<Props> = ({
|
||||
@@ -116,13 +107,21 @@ export const GroupCallView: FC<Props> = ({
|
||||
joined,
|
||||
setJoined,
|
||||
muteStates,
|
||||
widget,
|
||||
}) => {
|
||||
// Used to thread through any errors that occur outside the error boundary
|
||||
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
||||
null,
|
||||
);
|
||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||
const rootElement = useRootElement();
|
||||
const hostBridge = useHostBridge();
|
||||
// A host that can close us is a host that decides when we stop existing, so
|
||||
// we neither show our own post-call screens nor assume we have time to
|
||||
// finish what we are doing.
|
||||
// TODO: this reads a capability as a proxy for who owns our lifetime. Worth
|
||||
// finding a more direct way to express it — see the guidance in UrlParams.ts
|
||||
// on naming behaviours rather than situations.
|
||||
const hostControlsLifetime = hostBridge.close !== undefined;
|
||||
|
||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||
const leaveSoundContext = useLatest(
|
||||
@@ -150,11 +149,11 @@ export const GroupCallView: FC<Props> = ({
|
||||
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
|
||||
// tested on different devices...)
|
||||
useEffect(() => {
|
||||
document.body.classList.add("no-scroll-body");
|
||||
rootElement.classList.add("no-scroll-body");
|
||||
return (): void => {
|
||||
document.body.classList.remove("no-scroll-body");
|
||||
rootElement.classList.remove("no-scroll-body");
|
||||
};
|
||||
}, []);
|
||||
}, [rootElement]);
|
||||
|
||||
useEffect(() => {
|
||||
window.rtcSession = rtcSession;
|
||||
@@ -297,28 +296,26 @@ export const GroupCallView: FC<Props> = ({
|
||||
};
|
||||
|
||||
if (skipLobby) {
|
||||
if (widget && preload) {
|
||||
// `preload` is only ever set when we have a host to be preloaded by.
|
||||
if (preload) {
|
||||
// In preload mode without lobby we wait for a join action before entering
|
||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||
const subscription = hostBridge.join$.subscribe(({ data, reply }) => {
|
||||
(async (): Promise<void> => {
|
||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
||||
await defaultDeviceSetup(data);
|
||||
setJoined(true);
|
||||
widget.api.transport.reply(ev.detail, {});
|
||||
reply();
|
||||
})().catch((e) => {
|
||||
logger.error("Error joining RTC session on preload", e);
|
||||
});
|
||||
};
|
||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
||||
return (): void => {
|
||||
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
||||
};
|
||||
});
|
||||
return (): void => subscription.unsubscribe();
|
||||
} else {
|
||||
// No lobby and no preload: we enter the rtc session right away
|
||||
setJoined(true);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
widget,
|
||||
hostBridge,
|
||||
rtcSession,
|
||||
preload,
|
||||
skipLobby,
|
||||
@@ -344,7 +341,7 @@ export const GroupCallView: FC<Props> = ({
|
||||
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
|
||||
// already plays the "left" sound when the remote participant's media
|
||||
// disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise.
|
||||
// (used to destory the widget)
|
||||
// (which is what delays asking the host to close us)
|
||||
audioPromise = leaveSoundContext.current?.playSound("left", 0);
|
||||
break;
|
||||
case "timeout":
|
||||
@@ -359,12 +356,12 @@ export const GroupCallView: FC<Props> = ({
|
||||
setLeft(true);
|
||||
|
||||
// We need to wait until the callEnded event is tracked on PostHog,
|
||||
// otherwise the iframe may get killed first.
|
||||
// otherwise we may be torn down first.
|
||||
const posthogRequest = new Promise((resolve) => {
|
||||
// To increase the likelihood of the PostHog event being sent out in
|
||||
// widget mode before the iframe is killed, we ask it to skip the
|
||||
// usual queuing/batching of requests.
|
||||
const sendInstantly = widget !== null;
|
||||
// To increase the likelihood of the PostHog event being sent out
|
||||
// before the host disposes of us, we ask it to skip the usual
|
||||
// queuing/batching of requests.
|
||||
const sendInstantly = hostControlsLifetime;
|
||||
PosthogAnalytics.instance.eventCallEnded.track(
|
||||
room.roomId,
|
||||
rtcSession.memberships.length,
|
||||
@@ -372,8 +369,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
rtcSession,
|
||||
);
|
||||
// Unfortunately the PostHog library provides no way to await the
|
||||
// tracking of an event, but we don't really want it to hold up the
|
||||
// closing of the widget that long anyway, so giving it 10 ms will do.
|
||||
// tracking of an event, but we don't really want it to hold up our
|
||||
// disposal that long anyway, so giving it 10 ms will do.
|
||||
window.setTimeout(resolve, 10);
|
||||
});
|
||||
|
||||
@@ -392,25 +389,19 @@ export const GroupCallView: FC<Props> = ({
|
||||
)
|
||||
void navigate("/");
|
||||
|
||||
if (widget) {
|
||||
// After this point the iframe could die at any moment!
|
||||
// After this point the host could dispose of us at any moment!
|
||||
try {
|
||||
await hostBridge.setAlwaysOnScreen(false);
|
||||
} catch (e) {
|
||||
logger.error("Failed to set `alwaysOnScreen` to false", e);
|
||||
}
|
||||
// On a normal user hangup we can shut down and ask to be closed. But
|
||||
// if an error occurs we should stay open until the user reads it.
|
||||
if (reason != "error" && !returnToLobby) {
|
||||
try {
|
||||
await widget.api.setAlwaysOnScreen(false);
|
||||
await hostBridge.close?.();
|
||||
} catch (e) {
|
||||
logger.error(
|
||||
"Failed to set call widget `alwaysOnScreen` to false",
|
||||
e,
|
||||
);
|
||||
}
|
||||
// On a normal user hangup we can shut down and close the widget. But if an
|
||||
// error occurs we should keep the widget open until the user reads it.
|
||||
if (reason != "error" && !getUrlParams().returnToLobby) {
|
||||
try {
|
||||
await widget.api.transport.send(ElementWidgetActions.Close, {});
|
||||
} catch (e) {
|
||||
logger.error("Failed to send close action", e);
|
||||
}
|
||||
widget.api.transport.stop();
|
||||
logger.error("Failed to ask the host to close Element Call", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -418,22 +409,24 @@ export const GroupCallView: FC<Props> = ({
|
||||
[
|
||||
setJoined,
|
||||
leaveSoundContext,
|
||||
widget,
|
||||
hostBridge,
|
||||
hostControlsLifetime,
|
||||
room.roomId,
|
||||
rtcSession,
|
||||
isPasswordlessUser,
|
||||
confineToRoom,
|
||||
returnToLobby,
|
||||
navigate,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (widget && joined)
|
||||
// set widget to sticky once joined.
|
||||
widget.api.setAlwaysOnScreen(true).catch((e) => {
|
||||
if (joined)
|
||||
// ask to be kept on screen once joined.
|
||||
hostBridge.setAlwaysOnScreen(true).catch((e) => {
|
||||
logger.error("Error calling setAlwaysOnScreen(true)", e);
|
||||
});
|
||||
}, [widget, joined, rtcSession]);
|
||||
}, [hostBridge, joined, rtcSession]);
|
||||
|
||||
const joinRule = useJoinRule(room);
|
||||
|
||||
@@ -503,19 +496,16 @@ export const GroupCallView: FC<Props> = ({
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} else if (left && widget === null) {
|
||||
// Left in SPA mode:
|
||||
} else if (left && !hostControlsLifetime) {
|
||||
// Left, and it is up to us what to show next:
|
||||
|
||||
// The call ended view is shown for two reasons: prompting guests to create
|
||||
// an account, and prompting users that have opted into analytics to provide
|
||||
// feedback. We don't show a feedback prompt to widget users however (at
|
||||
// least for now), because we don't yet have designs that would allow widget
|
||||
// users to dismiss the feedback prompt and close the call window without
|
||||
// submitting anything.
|
||||
if (
|
||||
isPasswordlessUser ||
|
||||
(PosthogAnalytics.instance.isEnabled() && widget === null)
|
||||
) {
|
||||
// feedback. We don't show a feedback prompt when a host owns our lifetime
|
||||
// however (at least for now), because we don't yet have designs that would
|
||||
// allow those users to dismiss the feedback prompt and close the call
|
||||
// window without submitting anything.
|
||||
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
|
||||
body = (
|
||||
<CallEndedView
|
||||
endedCallId={rtcSession.room.roomId}
|
||||
@@ -531,8 +521,8 @@ export const GroupCallView: FC<Props> = ({
|
||||
// LobbyView again which would open capture devices again.
|
||||
body = null;
|
||||
}
|
||||
} else if (left && widget !== null) {
|
||||
// Left in widget mode:
|
||||
} else if (left && hostControlsLifetime) {
|
||||
// Left, and the host decides what happens next:
|
||||
body = returnToLobby ? lobbyView : null;
|
||||
} else if (preload || skipLobby) {
|
||||
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
|
||||
@@ -543,7 +533,6 @@ export const GroupCallView: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<GroupCallErrorBoundary
|
||||
widget={widget}
|
||||
recoveryActionHandler={async (action) => {
|
||||
setExternalError(null);
|
||||
if (action == "reconnect") {
|
||||
@@ -555,9 +544,10 @@ export const GroupCallView: FC<Props> = ({
|
||||
}}
|
||||
onError={(_error) => {
|
||||
if (rtcSession.isJoined()) onLeft("error");
|
||||
// If there is an error we need to be able to close the widget. This is done in `onLeft` as well
|
||||
// We need it here explicitly in case rtcSession.isJoined is false.
|
||||
void widget?.api.setAlwaysOnScreen(false);
|
||||
// If there is an error we need to be dismissible again. This is done in
|
||||
// `onLeft` as well; we need it here explicitly in case
|
||||
// rtcSession.isJoined is false.
|
||||
void hostBridge.setAlwaysOnScreen(false);
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
|
||||
@@ -22,7 +22,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { RoomContext, useLocalParticipant } from "@livekit/components-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { InCallView } from "./InCallView";
|
||||
import { ActiveCall, InCallView } from "./InCallView";
|
||||
import {
|
||||
mockLivekitRoom,
|
||||
mockLocalParticipant,
|
||||
@@ -33,7 +33,10 @@ import {
|
||||
type MockRTCSession,
|
||||
} from "../utils/test";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
|
||||
import {
|
||||
getBasicCallViewModelEnvironment,
|
||||
getBasicRTCSession,
|
||||
} from "../utils/test-viewmodel";
|
||||
import {
|
||||
type CallViewModel,
|
||||
type CallViewModelOptions,
|
||||
@@ -45,6 +48,8 @@ import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
|
||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
|
||||
import { AppBar } from "../AppBar";
|
||||
import { type MatrixInfo } from "./VideoPreview";
|
||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||
import { initializeWidget } from "../widget";
|
||||
|
||||
initializeWidget();
|
||||
@@ -78,6 +83,17 @@ const remoteParticipant = mockRemoteParticipant({
|
||||
identity: "@alice:example.org:AAAAAA",
|
||||
});
|
||||
|
||||
const matrixInfo = {
|
||||
userId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: { kind: E2eeType.NONE },
|
||||
} satisfies MatrixInfo;
|
||||
|
||||
let useRoomEncryptionSystemMock: MockedFunction<typeof useRoomEncryptionSystem>;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -122,12 +138,13 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
remoteParticipants$: of([remoteParticipant]),
|
||||
},
|
||||
);
|
||||
const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
undefined,
|
||||
mediaDevices,
|
||||
args.callViewModelOptions,
|
||||
);
|
||||
const { vm, footerVm, developerSettingsVm, rtcSession } =
|
||||
getBasicCallViewModelEnvironment(
|
||||
[local, alice],
|
||||
undefined,
|
||||
mediaDevices,
|
||||
args.callViewModelOptions,
|
||||
);
|
||||
|
||||
rtcSession.joined = true;
|
||||
const room = rtcSession.room;
|
||||
@@ -140,18 +157,8 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
|
||||
muteStates={muteState}
|
||||
vm={vm}
|
||||
footerVm={footerVm}
|
||||
matrixInfo={{
|
||||
userId: "",
|
||||
displayName: "",
|
||||
avatarUrl: "",
|
||||
roomId: "",
|
||||
roomName: "",
|
||||
roomAlias: null,
|
||||
roomAvatar: null,
|
||||
e2eeSystem: {
|
||||
kind: E2eeType.NONE,
|
||||
},
|
||||
}}
|
||||
developerSettingsVm={developerSettingsVm}
|
||||
matrixInfo={matrixInfo}
|
||||
matrixRoom={room}
|
||||
onShareClick={null}
|
||||
/>
|
||||
@@ -226,3 +233,34 @@ describe("InCallView", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActiveCall", () => {
|
||||
it("creates the view models and renders the call", async () => {
|
||||
const mediaDevices = mockMediaDevices({});
|
||||
const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]);
|
||||
const { findByTestId } = render(
|
||||
<BrowserRouter>
|
||||
<MediaDevicesContext value={mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<TooltipProvider>
|
||||
<RoomContext value={mockLivekitRoom({ localParticipant })}>
|
||||
<ActiveCall
|
||||
client={matrixRoom.client}
|
||||
rtcSession={rtcSession.asMockedSession()}
|
||||
matrixRoom={matrixRoom}
|
||||
muteStates={mockMuteStates()}
|
||||
matrixInfo={matrixInfo}
|
||||
onShareClick={null}
|
||||
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||
onLeft={(): void => {}}
|
||||
/>
|
||||
</RoomContext>
|
||||
</TooltipProvider>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
// Rendering at all proves ActiveCall created all of its view models
|
||||
expect(await findByTestId("incall_leave")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
+33
-7
@@ -28,7 +28,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||
import { widget } from "../widget";
|
||||
import { useHostBridge } from "../HostBridge.ts";
|
||||
import styles from "./InCallView.module.css";
|
||||
import { GridTile } from "../tile/GridTile";
|
||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||
@@ -41,6 +41,7 @@ import { type MatrixInfo } from "./VideoPreview";
|
||||
import { InviteButton } from "../button/InviteButton";
|
||||
import {
|
||||
type CallViewModel,
|
||||
callViewModelOptionsFromParams,
|
||||
createCallViewModel$,
|
||||
} from "../state/CallViewModel/CallViewModel.ts";
|
||||
import { Grid, type TileProps } from "../grid/Grid";
|
||||
@@ -83,6 +84,8 @@ import { ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
|
||||
import { SettingsIconButton } from "../button/Button.tsx";
|
||||
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
|
||||
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel.ts";
|
||||
import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab.tsx";
|
||||
import { type ViewModel } from "../state/ViewModel.ts";
|
||||
import { RingingStatus } from "../tile/RingingStatus.tsx";
|
||||
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
|
||||
@@ -96,7 +99,7 @@ declare module "react" {
|
||||
|
||||
export interface ActiveCallProps extends Omit<
|
||||
InCallViewProps,
|
||||
"vm" | "livekitRoom" | "connState" | "footerVm"
|
||||
"vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
|
||||
> {
|
||||
e2eeSystem: EncryptionSystem;
|
||||
// TODO refactor those reasons into an enum
|
||||
@@ -110,7 +113,11 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
|
||||
null,
|
||||
);
|
||||
const [developerSettingsVm, setDeveloperSettingsVm] =
|
||||
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
|
||||
|
||||
const urlParams = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
const mediaDevices = useMediaDevices();
|
||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||
useEffect(() => {
|
||||
@@ -127,7 +134,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
mediaDevices,
|
||||
props.muteStates,
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: props.e2eeSystem,
|
||||
hostBridge,
|
||||
autoLeaveWhenOthersLeft,
|
||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||
matrixRTCMode$: matrixRTCModeSetting.value$,
|
||||
@@ -152,6 +161,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.e2eeSystem,
|
||||
props.onLeft,
|
||||
urlParams,
|
||||
hostBridge,
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
props.client,
|
||||
@@ -167,8 +177,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
props.muteStates,
|
||||
mediaDevices,
|
||||
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
||||
{ showControls: urlParams.showControls, header: urlParams.header },
|
||||
);
|
||||
setFooterVm(footerVm);
|
||||
setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm));
|
||||
|
||||
return (): void => {
|
||||
scope.end();
|
||||
@@ -188,10 +200,16 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
||||
|
||||
if (vm === null) return null;
|
||||
if (footerVm === null) return null;
|
||||
if (developerSettingsVm === null) return null;
|
||||
|
||||
return (
|
||||
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
|
||||
<InCallView {...props} vm={vm} footerVm={footerVm} />
|
||||
<InCallView
|
||||
{...props}
|
||||
vm={vm}
|
||||
footerVm={footerVm}
|
||||
developerSettingsVm={developerSettingsVm}
|
||||
/>
|
||||
</ReactionsSenderProvider>
|
||||
);
|
||||
};
|
||||
@@ -200,6 +218,7 @@ export interface InCallViewProps {
|
||||
client: MatrixClient;
|
||||
vm: CallViewModel;
|
||||
footerVm: ViewModel<FooterSnapshot>;
|
||||
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
|
||||
matrixInfo: MatrixInfo;
|
||||
rtcSession: MatrixRTCSession;
|
||||
matrixRoom: MatrixRoom;
|
||||
@@ -211,6 +230,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
client,
|
||||
vm,
|
||||
footerVm,
|
||||
developerSettingsVm,
|
||||
matrixInfo,
|
||||
matrixRoom,
|
||||
muteStates,
|
||||
@@ -218,6 +238,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
}) => {
|
||||
const logger = rootLogger.getChild("[InCallView]");
|
||||
const { t } = useTranslation();
|
||||
const hostBridge = useHostBridge();
|
||||
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
||||
|
||||
useWakeLock();
|
||||
@@ -261,6 +282,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
const overflowing = useBehavior(vm.overflowing$);
|
||||
const showNameTags = useBehavior(vm.showNameTags$);
|
||||
const showHeader = useBehavior(vm.showHeader$);
|
||||
const showModals = useBehavior(vm.showModals$);
|
||||
const settingsOpen = useBehavior(vm.settingsOpen$);
|
||||
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
|
||||
const earpieceMode = useBehavior(vm.earpieceMode$);
|
||||
@@ -300,14 +322,17 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
|
||||
const openProfile = useMemo(
|
||||
() =>
|
||||
// Profile settings are unavailable in widget mode
|
||||
widget === null
|
||||
// A host that can dismiss us is a host that owns the user's account, so
|
||||
// their profile is not ours to edit.
|
||||
// TODO: another use of the close capability as a proxy — see the note in
|
||||
// GroupCallView.
|
||||
hostBridge.close === undefined
|
||||
? (): void => {
|
||||
setSettingsTab("profile");
|
||||
setSettingsOpen(true);
|
||||
}
|
||||
: null,
|
||||
[setSettingsTab, setSettingsOpen],
|
||||
[setSettingsTab, setSettingsOpen, hostBridge],
|
||||
);
|
||||
|
||||
const [headerRef, headerBounds] = useMeasure();
|
||||
@@ -622,7 +647,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{earpieceOverlay}
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
{showModals && (
|
||||
<>
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
<SettingsModal
|
||||
@@ -632,6 +657,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
onDismiss={(): void => setSettingsOpen(false)}
|
||||
tab={settingsTab}
|
||||
onTabChange={setSettingsTab}
|
||||
developerSettingsVm={developerSettingsVm}
|
||||
livekitRooms={allConnections
|
||||
.getConnections()
|
||||
.map((connectionItem) => ({
|
||||
|
||||
@@ -29,7 +29,7 @@ import { GroupCallView } from "./GroupCallView";
|
||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||
import { HomePage } from "../home/HomePage";
|
||||
import { widget } from "../widget";
|
||||
import { useHostBridge } from "../HostBridge.ts";
|
||||
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
import { E2eeType } from "../e2ee/e2eeType";
|
||||
@@ -44,6 +44,7 @@ import { calculateInitialMuteState } from "../state/initialMuteState.ts";
|
||||
|
||||
export const RoomPage: FC = (): ReactNode => {
|
||||
const urlParams = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
const { confineToRoom, preload, header, displayName, skipLobby } = urlParams;
|
||||
const { t } = useTranslation();
|
||||
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
|
||||
@@ -75,17 +76,18 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
calculateInitialMuteState(
|
||||
urlParams.skipLobby,
|
||||
urlParams.callIntent,
|
||||
widget !== null,
|
||||
urlParams.isWidget,
|
||||
),
|
||||
hostBridge,
|
||||
),
|
||||
);
|
||||
return (): void => scope.end();
|
||||
}, [devices, urlParams]);
|
||||
}, [devices, urlParams, hostBridge]);
|
||||
|
||||
useEffect(() => {
|
||||
// If we've finished loading, are not already authed and we've been given a display name as
|
||||
// a URL param, automatically register a passwordless user
|
||||
if (!loading && !authenticated && displayName && !widget) {
|
||||
if (!loading && !authenticated && displayName && !urlParams.isWidget) {
|
||||
setIsRegistering(true);
|
||||
registerPasswordlessUser(displayName)
|
||||
.catch((e) => {
|
||||
@@ -99,6 +101,7 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
loading,
|
||||
authenticated,
|
||||
displayName,
|
||||
urlParams.isWidget,
|
||||
setIsRegistering,
|
||||
registerPasswordlessUser,
|
||||
]);
|
||||
@@ -123,7 +126,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
return (
|
||||
muteStates && (
|
||||
<GroupCallView
|
||||
widget={widget}
|
||||
client={client!}
|
||||
rtcSession={groupCallState.rtcSession}
|
||||
joined={joined}
|
||||
@@ -198,7 +200,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
<ErrorView
|
||||
Icon={UnknownSolidIcon}
|
||||
title={t("error.call_not_found")}
|
||||
widget={widget}
|
||||
>
|
||||
<Trans i18nKey="error.call_not_found_description">
|
||||
<p>
|
||||
@@ -216,7 +217,6 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
<ErrorView
|
||||
Icon={groupCallState.error.icon}
|
||||
title={groupCallState.error.message}
|
||||
widget={widget}
|
||||
>
|
||||
<p>{groupCallState.error.messageBody}</p>
|
||||
{groupCallState.error.reason && (
|
||||
@@ -230,7 +230,7 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
return <ErrorPage widget={widget} error={groupCallState.error} />;
|
||||
return <ErrorPage error={groupCallState.error} />;
|
||||
}
|
||||
default:
|
||||
return <> </>;
|
||||
@@ -238,7 +238,7 @@ export const RoomPage: FC = (): ReactNode => {
|
||||
};
|
||||
|
||||
if (loading || isRegistering) return <LoadingPage />;
|
||||
if (error) return <ErrorPage widget={widget} error={error} />;
|
||||
if (error) return <ErrorPage error={error} />;
|
||||
if (!client) return <RoomAuthView />;
|
||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||
if (!roomIdOrAlias) return <HomePage />;
|
||||
|
||||
@@ -22,6 +22,9 @@ Please see LICENSE in the repository root for full details.
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
background-color: var(--video-tile-background);
|
||||
/* In FF if you add a transform: scale/translate/matrix filter on an element,
|
||||
it'll ignore the parents' border-radius, so force back the radius to avoid UI glitch*/
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
video.mirror {
|
||||
|
||||
@@ -1100,7 +1100,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`should have a close button in widget mode 1`] = `
|
||||
exports[`should have a close button when the host can dismiss us 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="_page_4be5c0"
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
EndCallIcon,
|
||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import { widget } from "../widget";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export type GroupCallLoaded = {
|
||||
kind: "loaded";
|
||||
@@ -132,6 +132,7 @@ export const useLoadGroupCall = (
|
||||
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
||||
const activeRoom = useRef<Room | undefined>(undefined);
|
||||
const { t } = useTranslation();
|
||||
const { isWidget } = useUrlParams();
|
||||
|
||||
const bannedError = useCallback(
|
||||
(): CallTerminatedMessage =>
|
||||
@@ -249,7 +250,7 @@ export const useLoadGroupCall = (
|
||||
// room already joined so we are done here already.
|
||||
return room!;
|
||||
}
|
||||
if (widget)
|
||||
if (isWidget)
|
||||
// in widget mode we never should reach this point. (getRoom should return the room.)
|
||||
throw new Error(
|
||||
"Room not found. The widget-api did not pass over the relevant room events/information.",
|
||||
@@ -373,6 +374,7 @@ export const useLoadGroupCall = (
|
||||
}, [
|
||||
bannedError,
|
||||
client,
|
||||
isWidget,
|
||||
knockRejectError,
|
||||
removeNoticeError,
|
||||
roomIdOrAlias,
|
||||
|
||||
@@ -12,7 +12,12 @@ import { TooltipProvider } from "@vector-im/compound-web";
|
||||
|
||||
import type { MatrixClient } from "matrix-js-sdk";
|
||||
import type { Room as LivekitRoom } from "livekit-client";
|
||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
import {
|
||||
DeveloperSettingsTab,
|
||||
type DeveloperSettingsSnapshot,
|
||||
} from "./DeveloperSettingsTab";
|
||||
import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel";
|
||||
import { createStaticViewModel } from "../state/ViewModel";
|
||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||
import {
|
||||
customLivekitUrl as customLivekitUrlSetting,
|
||||
@@ -107,6 +112,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
roomId={"#room:example.org"}
|
||||
livekitRooms={livekitRooms}
|
||||
env={{ MY_MOCK_ENV: 10, ENV: "test" } as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -136,6 +142,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -159,6 +166,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -181,6 +189,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -206,6 +215,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -236,6 +246,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
client={client}
|
||||
roomId="#testRoom"
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -273,6 +284,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -305,6 +317,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -317,19 +330,15 @@ describe("DeveloperSettingsTab", () => {
|
||||
|
||||
describe("matrix rtc mode", () => {
|
||||
afterEach(() => {
|
||||
matrixRTCModeSetting.setValue(MatrixRTCMode.Legacy);
|
||||
matrixRTCModeSetting.setValue(MatrixRTCMode.Compatibility);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function getModeRadios(): {
|
||||
legacy: HTMLInputElement;
|
||||
compatibility: HTMLInputElement;
|
||||
matrix20: HTMLInputElement;
|
||||
} {
|
||||
return {
|
||||
legacy: screen.getByDisplayValue(
|
||||
MatrixRTCMode.Legacy,
|
||||
) as HTMLInputElement,
|
||||
compatibility: screen.getByDisplayValue(
|
||||
MatrixRTCMode.Compatibility,
|
||||
) as HTMLInputElement,
|
||||
@@ -349,6 +358,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -359,27 +369,21 @@ describe("DeveloperSettingsTab", () => {
|
||||
|
||||
const radios = getModeRadios();
|
||||
expect(radios.compatibility).toBeChecked();
|
||||
expect(radios.legacy).not.toBeChecked();
|
||||
expect(radios.matrix20).not.toBeChecked();
|
||||
// None are disabled by config; only Matrix_2_0 may be disabled by sticky-events support.
|
||||
expect(radios.legacy).not.toBeDisabled();
|
||||
expect(radios.compatibility).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
MatrixRTCMode.Legacy,
|
||||
MatrixRTCMode.Compatibility,
|
||||
MatrixRTCMode.Matrix_2_0,
|
||||
])(
|
||||
it.each([MatrixRTCMode.Compatibility, MatrixRTCMode.Matrix_2_0])(
|
||||
"disables all radios and shows the config value (%s) as checked when matrix_rtc_mode is set",
|
||||
async (configMode) => {
|
||||
mockConfig({ matrix_rtc_mode: configMode });
|
||||
// Local setting is intentionally different from the config value to
|
||||
// prove config wins.
|
||||
matrixRTCModeSetting.setValue(
|
||||
configMode === MatrixRTCMode.Legacy
|
||||
? MatrixRTCMode.Compatibility
|
||||
: MatrixRTCMode.Legacy,
|
||||
configMode === MatrixRTCMode.Compatibility
|
||||
? MatrixRTCMode.Matrix_2_0
|
||||
: MatrixRTCMode.Compatibility,
|
||||
);
|
||||
const client = createMockMatrixClient();
|
||||
|
||||
@@ -388,6 +392,7 @@ describe("DeveloperSettingsTab", () => {
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
@@ -397,13 +402,11 @@ describe("DeveloperSettingsTab", () => {
|
||||
);
|
||||
|
||||
const radios = getModeRadios();
|
||||
expect(radios.legacy).toBeDisabled();
|
||||
expect(radios.compatibility).toBeDisabled();
|
||||
expect(radios.matrix20).toBeDisabled();
|
||||
|
||||
const checkedValue = (
|
||||
{
|
||||
[MatrixRTCMode.Legacy]: radios.legacy,
|
||||
[MatrixRTCMode.Compatibility]: radios.compatibility,
|
||||
[MatrixRTCMode.Matrix_2_0]: radios.matrix20,
|
||||
} as const
|
||||
@@ -412,4 +415,74 @@ describe("DeveloperSettingsTab", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("KeyRotationStatus", () => {
|
||||
it("displays active status when key rotation is not suppressed", async () => {
|
||||
const client = createMockMatrixClient();
|
||||
const vm = createStaticViewModel<DeveloperSettingsSnapshot>({
|
||||
keyRotation: { suppressed: false, participantCount: 5 },
|
||||
});
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={vm}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/Media key rotation: active \(5 participants\)/),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("displays suppressed status when key rotation is suppressed", async () => {
|
||||
const client = createMockMatrixClient();
|
||||
const vm = createStaticViewModel<DeveloperSettingsSnapshot>({
|
||||
keyRotation: { suppressed: true, participantCount: 50 },
|
||||
});
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={vm}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Media key rotation: suppressed, participant limit reached \(50 participants\)/,
|
||||
),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not render KeyRotationStatus when not in a call", async () => {
|
||||
const client = createMockMatrixClient();
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DeveloperSettingsTab
|
||||
client={client}
|
||||
env={{} as unknown as ImportMetaEnv}
|
||||
vm={outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText(/Media key rotation:/),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,39 @@ import settingsStyles from "./SettingsModal.module.css";
|
||||
import { Slider } from "../Slider";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type ViewModel } from "../state/ViewModel.ts";
|
||||
|
||||
/**
|
||||
* The state of MatrixRTC's media key rotation.
|
||||
*/
|
||||
export interface KeyRotationInfo {
|
||||
/** Whether the call is large enough that MatrixRTC has stopped rotating the media key. */
|
||||
suppressed: boolean;
|
||||
participantCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Snapshot combines all fields the developer settings tab needs from the
|
||||
* surrounding call. Everything else in this tab is read from the settings store
|
||||
* or the environment directly.
|
||||
*/
|
||||
export interface DeveloperSettingsSnapshot {
|
||||
/** The media key rotation state, or `null` when we are not in a call. */
|
||||
keyRotation: KeyRotationInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows whether the call is large enough that MatrixRTC has stopped rotating the media key.
|
||||
*/
|
||||
const KeyRotationStatus: FC<{ info: KeyRotationInfo }> = ({ info }) => (
|
||||
<p>
|
||||
Media key rotation:{" "}
|
||||
{info.suppressed
|
||||
? `suppressed, participant limit reached (${info.participantCount} participants)`
|
||||
: `active (${info.participantCount} participants)`}
|
||||
</p>
|
||||
);
|
||||
|
||||
interface Props {
|
||||
client: MatrixClient;
|
||||
@@ -79,6 +112,7 @@ interface Props {
|
||||
livekitAlias?: string;
|
||||
}[];
|
||||
env: ImportMetaEnv;
|
||||
vm: ViewModel<DeveloperSettingsSnapshot>;
|
||||
}
|
||||
|
||||
export const DeveloperSettingsTab: FC<Props> = ({
|
||||
@@ -86,8 +120,10 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
livekitRooms,
|
||||
roomId,
|
||||
env,
|
||||
vm,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const keyRotation = useBehavior(vm.keyRotation$);
|
||||
const [duplicateTiles, setDuplicateTiles] = useSetting(duplicateTilesSetting);
|
||||
const [debugTileLayout, setDebugTileLayout] = useSetting(
|
||||
debugTileLayoutSetting,
|
||||
@@ -364,6 +400,7 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
id: client.getDeviceId() || "unknown",
|
||||
})}
|
||||
</p>
|
||||
{keyRotation !== null && <KeyRotationStatus info={keyRotation} />}
|
||||
<Separator />
|
||||
<FieldRow>
|
||||
<InputField
|
||||
@@ -520,22 +557,6 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
</Heading>
|
||||
{matrixRTCModeForced && <p>Your deployment overrides the mode.</p>}
|
||||
<Form>
|
||||
<InlineField
|
||||
name={matrixRTCModeRadioGroup}
|
||||
control={
|
||||
<RadioControl
|
||||
checked={effectiveMatrixRTCMode === MatrixRTCMode.Legacy}
|
||||
value={MatrixRTCMode.Legacy}
|
||||
disabled={matrixRTCModeForced}
|
||||
onChange={onMatrixRTCModeChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Label>{t("developer_mode.matrixRTCMode.Legacy.label")}</Label>
|
||||
<HelpMessage>
|
||||
{t("developer_mode.matrixRTCMode.Legacy.description")}
|
||||
</HelpMessage>
|
||||
</InlineField>
|
||||
<InlineField
|
||||
name={matrixRTCModeRadioGroup}
|
||||
control={
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { testScope } from "../utils/test";
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
createDeveloperSettingsTabViewModel,
|
||||
outOfCallDeveloperSettingsTabViewModel,
|
||||
} from "./DeveloperSettingsTabViewModel";
|
||||
|
||||
describe("createDeveloperSettingsTabViewModel", () => {
|
||||
it("projects the key rotation state of the call", () => {
|
||||
const keyRotationSuppressed$ = new BehaviorSubject(false);
|
||||
const participantCount$ = new BehaviorSubject(5);
|
||||
const vm = createDeveloperSettingsTabViewModel(testScope(), {
|
||||
keyRotationSuppressed$,
|
||||
participantCount$,
|
||||
} as unknown as CallViewModel);
|
||||
|
||||
expect(vm.keyRotation$.value).toEqual({
|
||||
suppressed: false,
|
||||
participantCount: 5,
|
||||
});
|
||||
|
||||
participantCount$.next(50);
|
||||
keyRotationSuppressed$.next(true);
|
||||
|
||||
expect(vm.keyRotation$.value).toEqual({
|
||||
suppressed: true,
|
||||
participantCount: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("outOfCallDeveloperSettingsTabViewModel", () => {
|
||||
it("has no key rotation state", () => {
|
||||
expect(outOfCallDeveloperSettingsTabViewModel.keyRotation$.value).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { combineLatest } from "rxjs";
|
||||
|
||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel.ts";
|
||||
import { type ObservableScope } from "../state/ObservableScope.ts";
|
||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel.ts";
|
||||
import { type DeveloperSettingsSnapshot } from "./DeveloperSettingsTab.tsx";
|
||||
|
||||
/**
|
||||
* Creates the ViewModel for the developer settings tab while in a call.
|
||||
*
|
||||
* Only the call state the tab actually renders is projected here, so that the
|
||||
* tab does not need to know about the CallViewModel.
|
||||
*
|
||||
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
|
||||
* @param callModel - The root CallViewModel; provides the key rotation state.
|
||||
*/
|
||||
export function createDeveloperSettingsTabViewModel(
|
||||
scope: ObservableScope,
|
||||
callModel: CallViewModel,
|
||||
): ViewModel<DeveloperSettingsSnapshot> {
|
||||
return {
|
||||
keyRotation$: scope.behavior(
|
||||
combineLatest(
|
||||
[callModel.keyRotationSuppressed$, callModel.participantCount$],
|
||||
(suppressed, participantCount) => ({ suppressed, participantCount }),
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The ViewModel for the developer settings tab outside of a call (lobby, user
|
||||
* menu), where no call state exists. All call specific fields are `null`.
|
||||
*/
|
||||
export const outOfCallDeveloperSettingsTabViewModel: ViewModel<DeveloperSettingsSnapshot> =
|
||||
createStaticViewModel<DeveloperSettingsSnapshot>({ keyRotation: null });
|
||||
@@ -18,7 +18,7 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||
import { iosDeviceMenu$ } from "../state/MediaDevices";
|
||||
import { useMediaDevices } from "../MediaDevicesContext";
|
||||
import { widget } from "../widget";
|
||||
import { useHostBridge } from "../HostBridge";
|
||||
import {
|
||||
useSetting,
|
||||
soundEffectVolume as soundEffectVolumeSetting,
|
||||
@@ -29,11 +29,16 @@ import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
|
||||
import { Slider } from "../Slider";
|
||||
import { DeviceSelection } from "./DeviceSelection";
|
||||
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
|
||||
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
|
||||
import {
|
||||
DeveloperSettingsTab,
|
||||
type DeveloperSettingsSnapshot,
|
||||
} from "./DeveloperSettingsTab";
|
||||
import { FieldRow, InputField } from "../input/Input";
|
||||
import { useSubmitRageshake } from "./submit-rageshake";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
import { useBehavior } from "../useBehavior";
|
||||
import { type ViewModel } from "../state/ViewModel.ts";
|
||||
import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel";
|
||||
|
||||
type SettingsTab =
|
||||
| "audio"
|
||||
@@ -56,6 +61,8 @@ interface Props {
|
||||
url: string;
|
||||
isLocal?: boolean;
|
||||
}[];
|
||||
/** Only available while in a call. Used by the developer tab. */
|
||||
developerSettingsVm?: ViewModel<DeveloperSettingsSnapshot>;
|
||||
}
|
||||
|
||||
export const defaultSettingsTab: SettingsTab = "audio";
|
||||
@@ -68,6 +75,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
client,
|
||||
roomId,
|
||||
livekitRooms,
|
||||
developerSettingsVm,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -115,6 +123,7 @@ export const SettingsModal: FC<Props> = ({
|
||||
// On EC, we decided that it is less confusing for the user if they see those options in the output section
|
||||
// rather than the input section.
|
||||
const { controlledAudioDevices } = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
// If we are on iOS we will show a button to open the native audio device picker.
|
||||
const iosDeviceMenu = useBehavior(iosDeviceMenu$);
|
||||
|
||||
@@ -220,12 +229,15 @@ export const SettingsModal: FC<Props> = ({
|
||||
client={client}
|
||||
livekitRooms={livekitRooms}
|
||||
roomId={roomId}
|
||||
vm={developerSettingsVm ?? outOfCallDeveloperSettingsTabViewModel}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const tabs = [audioTab, videoTab];
|
||||
if (widget === null) tabs.push(profileTab);
|
||||
// A host that can dismiss us is a host that owns the user's account, so their
|
||||
// profile is not ours to edit.
|
||||
if (hostBridge.close === undefined) tabs.push(profileTab);
|
||||
tabs.push(preferencesTab);
|
||||
if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") {
|
||||
// for full package we want to show the analytics consent checkbox
|
||||
|
||||
@@ -285,50 +285,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_9_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
value="legacy"
|
||||
/>
|
||||
<div
|
||||
class="_ui_1ug7n_19"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field-body_1o4d9_39"
|
||||
>
|
||||
<label
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_9_"
|
||||
>
|
||||
Legacy: state events & oldest membership SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_a_"
|
||||
>
|
||||
Compatible with old versions of EC that do not support multi SFU
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="_inline-field_1o4d9_33"
|
||||
>
|
||||
<div
|
||||
class="_inline-field-control_1o4d9_45"
|
||||
>
|
||||
<div
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_"
|
||||
checked=""
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_b_"
|
||||
id="radix-_r_9_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -344,13 +304,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_b_"
|
||||
for="radix-_r_9_"
|
||||
>
|
||||
Compatibility: state events & multi SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_c_"
|
||||
id="radix-_r_a_"
|
||||
>
|
||||
Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later)
|
||||
</span>
|
||||
@@ -366,9 +326,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_container_1ug7n_10"
|
||||
>
|
||||
<input
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_ radix-_r_e_"
|
||||
aria-describedby="radix-_r_a_ radix-_r_c_"
|
||||
class="_input_1ug7n_18"
|
||||
id="radix-_r_d_"
|
||||
id="radix-_r_b_"
|
||||
name="_r_0_"
|
||||
title=""
|
||||
type="radio"
|
||||
@@ -384,13 +344,13 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
>
|
||||
<label
|
||||
class="_label_1o4d9_60"
|
||||
for="radix-_r_d_"
|
||||
for="radix-_r_b_"
|
||||
>
|
||||
Matrix 2.0: sticky events & multi SFU
|
||||
</label>
|
||||
<span
|
||||
class="_message_1o4d9_86 _help-message_1o4d9_92"
|
||||
id="radix-_r_e_"
|
||||
id="radix-_r_c_"
|
||||
>
|
||||
Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later
|
||||
</span>
|
||||
@@ -491,7 +451,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_k_"
|
||||
aria-describedby="_r_i_"
|
||||
id="cameraToggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -521,7 +481,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</label>
|
||||
<p
|
||||
class="_description_1bd8c0"
|
||||
id="_r_k_"
|
||||
id="_r_i_"
|
||||
>
|
||||
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
|
||||
</p>
|
||||
@@ -543,7 +503,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_l_"
|
||||
aria-describedby="_r_j_"
|
||||
id="screenShareToggle"
|
||||
type="checkbox"
|
||||
/>
|
||||
@@ -573,7 +533,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
</label>
|
||||
<p
|
||||
class="_description_1bd8c0"
|
||||
id="_r_l_"
|
||||
id="_r_j_"
|
||||
>
|
||||
Configure resolution, framerate, bitrate, and codec for screen sharing
|
||||
</p>
|
||||
@@ -598,7 +558,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_m_"
|
||||
aria-describedby="_r_k_"
|
||||
checked=""
|
||||
id="echoCancellation"
|
||||
type="checkbox"
|
||||
@@ -636,7 +596,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_n_"
|
||||
aria-describedby="_r_l_"
|
||||
checked=""
|
||||
id="noiseSuppression"
|
||||
type="checkbox"
|
||||
@@ -674,7 +634,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
|
||||
class="_field_1bd8c0 _checkboxField_1bd8c0"
|
||||
>
|
||||
<input
|
||||
aria-describedby="_r_o_"
|
||||
aria-describedby="_r_m_"
|
||||
checked=""
|
||||
id="autoGainControl"
|
||||
type="checkbox"
|
||||
|
||||
@@ -5,17 +5,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { MediaDevices } from "./MediaDevices";
|
||||
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
|
||||
/**
|
||||
* The top-level state holder for the application.
|
||||
*/
|
||||
export class AppViewModel {
|
||||
public readonly mediaDevices = new MediaDevices(this.scope);
|
||||
public readonly mediaDevices = new MediaDevices(
|
||||
this.scope,
|
||||
this.audioOutputOptions,
|
||||
);
|
||||
|
||||
// TODO: Move more application logic here. The CallViewModel, at the very
|
||||
// least, ought to be accessible from this object.
|
||||
|
||||
public constructor(private readonly scope: ObservableScope) {}
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
private readonly audioOutputOptions: AudioOutputOptions,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ import {
|
||||
} from "./CallViewModelTestUtils.ts";
|
||||
import { MatrixRTCMode } from "../../config/ConfigOptions.ts";
|
||||
import { initializeWidget } from "../../widget.ts";
|
||||
import { computeUrlParams } from "../../UrlParams.ts";
|
||||
import { callViewModelOptionsFromParams } from "./CallViewModel.ts";
|
||||
|
||||
initializeWidget();
|
||||
|
||||
@@ -83,9 +85,6 @@ vi.mock("livekit-client/e2ee-worker?worker");
|
||||
|
||||
vi.mock("../e2ee/matrixKeyProvider");
|
||||
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../UrlParams", () => ({ getUrlParams }));
|
||||
|
||||
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../../Platform", () => ({
|
||||
get platform(): string {
|
||||
@@ -258,11 +257,9 @@ function mockRingEvent(
|
||||
} as unknown as { event_id: string } & IRTCNotificationContent;
|
||||
}
|
||||
|
||||
describe.each([
|
||||
[MatrixRTCMode.Legacy],
|
||||
[MatrixRTCMode.Compatibility],
|
||||
[MatrixRTCMode.Matrix_2_0],
|
||||
])("CallViewModel (%s mode)", (mode) => {
|
||||
const modes = [[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]];
|
||||
|
||||
describe.each(modes)("CallViewModel (%s mode)", (mode) => {
|
||||
const withCallViewModel = withCallViewModelInMode(mode);
|
||||
|
||||
test("participants are retained during a focus switch", () => {
|
||||
@@ -1595,12 +1592,13 @@ describe.each([
|
||||
|
||||
it.skip("audio output changes when toggling earpiece mode", () => {
|
||||
withTestScheduler(({ schedule, expectObservable }) => {
|
||||
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
|
||||
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
|
||||
of([]),
|
||||
);
|
||||
|
||||
const devices = new MediaDevices(testScope());
|
||||
const devices = new MediaDevices(testScope(), {
|
||||
controlledAudioDevices: true,
|
||||
});
|
||||
|
||||
window.controls.setAvailableAudioDevices([
|
||||
{ id: "speaker", name: "Speaker", isSpeaker: true },
|
||||
@@ -1702,3 +1700,42 @@ describe.each([
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("callViewModelOptionsFromParams", () => {
|
||||
// The defaults on CallViewModelOptions describe a standalone Element Call, so
|
||||
// a widget caller that drops one of these gets standalone behaviour rather
|
||||
// than an error. These check the whole chain from URL to options, which is
|
||||
// where that went wrong for the SDK.
|
||||
const widgetUrl = (extra: string): string =>
|
||||
`#?widgetId=id&parentUrl=${encodeURIComponent("http://parent")}&${extra}`;
|
||||
|
||||
it("carries an explicitly requested notification type", () => {
|
||||
const params = computeUrlParams("", widgetUrl("sendNotificationType=ring"));
|
||||
expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe(
|
||||
"ring",
|
||||
);
|
||||
});
|
||||
|
||||
it("carries the notification type an intent implies", () => {
|
||||
const params = computeUrlParams("", widgetUrl("intent=start_call_dm"));
|
||||
expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe(
|
||||
"ring",
|
||||
);
|
||||
});
|
||||
|
||||
it("carries hideScreensharing", () => {
|
||||
const params = computeUrlParams("", widgetUrl("hideScreensharing=true"));
|
||||
expect(callViewModelOptionsFromParams(params).hideScreensharing).toBe(true);
|
||||
});
|
||||
|
||||
it("carries controlledAudioDevices and the call intent", () => {
|
||||
const params = computeUrlParams(
|
||||
"",
|
||||
widgetUrl("controlledAudioDevices=true&intent=start_call_voice"),
|
||||
);
|
||||
expect(callViewModelOptionsFromParams(params)).toMatchObject({
|
||||
controlledAudioDevices: true,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,8 +45,9 @@ import {
|
||||
MembershipManagerEvent,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
|
||||
@@ -79,16 +80,17 @@ import {
|
||||
type ReactionInfo,
|
||||
type ReactionOption,
|
||||
} from "../../reactions";
|
||||
import { shallowEquals } from "../../utils/array";
|
||||
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { constant, type Behavior } from "../Behavior";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
|
||||
import { type MuteStates } from "../MuteStates";
|
||||
import { getUrlParams, HeaderStyle } from "../../UrlParams";
|
||||
import { HeaderStyle, type UrlParams } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { ElementWidgetActions, widget } from "../../widget";
|
||||
import { type HostBridge, nullHostBridge } from "../../HostBridge";
|
||||
import {
|
||||
layoutShallowEquals,
|
||||
type Alignment,
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
@@ -113,6 +115,7 @@ import {
|
||||
type LocalTransport,
|
||||
} from "./localMember/LocalTransport.ts";
|
||||
import {
|
||||
createKeyRotationSuppressed$,
|
||||
createMemberships$,
|
||||
membershipsAndTransports$,
|
||||
} from "../SessionBehaviors.ts";
|
||||
@@ -169,6 +172,28 @@ import { type GridTileViewModel } from "../TileViewModel.ts";
|
||||
// callMembership -> rtcMembership
|
||||
export interface CallViewModelOptions {
|
||||
encryptionSystem: EncryptionSystem;
|
||||
/**
|
||||
* The application hosting Element Call, which can ask it to hang up and wants
|
||||
* to know when the user joins or leaves. Defaults to no host.
|
||||
*/
|
||||
hostBridge?: HostBridge;
|
||||
/**
|
||||
* Whether the app hosting Element Call controls the audio output devices,
|
||||
* rather than the browser. Defaults to false.
|
||||
*/
|
||||
controlledAudioDevices?: boolean;
|
||||
/** The style of header to show. Defaults to {@link HeaderStyle.Standard}. */
|
||||
header?: HeaderStyle;
|
||||
/** Whether the call controls should be shown. Defaults to true. */
|
||||
showControls?: boolean;
|
||||
/** Whether to hide the screen-sharing button. Defaults to false. */
|
||||
hideScreensharing?: boolean;
|
||||
/**
|
||||
* Whether and what kind of notification to send when joining the call.
|
||||
*/
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/** The kind of call being placed. */
|
||||
callIntent?: RTCCallIntent;
|
||||
autoLeaveWhenOthersLeft?: boolean;
|
||||
/**
|
||||
* If the call is started in a way where we want it to behave like a telephone usecase
|
||||
@@ -191,6 +216,40 @@ export interface CallViewModelOptions {
|
||||
toggleScreensharing?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The options {@link createCallViewModel$} takes from the parameters Element
|
||||
* Call was started with.
|
||||
*
|
||||
* Callers share this rather than picking the fields out themselves. The
|
||||
* defaults on {@link CallViewModelOptions} describe a standalone Element Call,
|
||||
* so a widget or embedded caller that misses one does not get an error — it
|
||||
* quietly gets standalone behaviour instead.
|
||||
*
|
||||
* Note `autoLeaveWhenOthersLeft` and `waitForCallPickup` are deliberately not
|
||||
* here: unlike these, the view model never read them from the parameters
|
||||
* itself, so they remain the caller's decision.
|
||||
*/
|
||||
export function callViewModelOptionsFromParams(
|
||||
params: UrlParams,
|
||||
): Pick<
|
||||
CallViewModelOptions,
|
||||
| "controlledAudioDevices"
|
||||
| "header"
|
||||
| "showControls"
|
||||
| "hideScreensharing"
|
||||
| "sendNotificationType"
|
||||
| "callIntent"
|
||||
> {
|
||||
return {
|
||||
controlledAudioDevices: params.controlledAudioDevices,
|
||||
header: params.header,
|
||||
showControls: params.showControls,
|
||||
hideScreensharing: params.hideScreensharing,
|
||||
sendNotificationType: params.sendNotificationType,
|
||||
callIntent: params.callIntent,
|
||||
};
|
||||
}
|
||||
|
||||
// Do not play any sounds if the participant count has exceeded this
|
||||
// number.
|
||||
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
|
||||
@@ -301,6 +360,12 @@ export interface CallViewModel {
|
||||
* multiple devices.
|
||||
*/
|
||||
participantCount$: Behavior<number>;
|
||||
/**
|
||||
* Whether the call has grown large enough that MatrixRTC has stopped rotating the media
|
||||
* encryption key. While this is true the key in use is still shared with new joiners, but no new
|
||||
* key is generated when someone joins or leaves.
|
||||
*/
|
||||
keyRotationSuppressed$: Behavior<boolean>;
|
||||
allConnections$: Behavior<ConnectionManagerData>;
|
||||
/** Participants sorted by livekit room so they can be used in the audio rendering */
|
||||
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
|
||||
@@ -365,6 +430,11 @@ export interface CallViewModel {
|
||||
*/
|
||||
overflowing$: Behavior<boolean>;
|
||||
|
||||
/**
|
||||
* Whether modals such as settings and reactions should be accessible at all.
|
||||
*/
|
||||
showModals$: Behavior<boolean>;
|
||||
|
||||
settingsOpen$: Behavior<boolean>;
|
||||
setSettingsOpen$: Behavior<(open: boolean) => void>;
|
||||
|
||||
@@ -422,6 +492,18 @@ export function createCallViewModel$(
|
||||
if (!(userId && deviceId))
|
||||
throw new UnknownCallError(new Error("userId and deviceId are required"));
|
||||
|
||||
// Defaults match what the URL parameters resolve to outside of widget mode,
|
||||
// so that callers which don't care (chiefly tests) behave as they always have.
|
||||
const {
|
||||
hostBridge = nullHostBridge,
|
||||
controlledAudioDevices = false,
|
||||
header = HeaderStyle.Standard,
|
||||
showControls = true,
|
||||
hideScreensharing = false,
|
||||
sendNotificationType,
|
||||
callIntent,
|
||||
} = options;
|
||||
|
||||
const livekitKeyProvider = getE2eeKeyProvider(
|
||||
options.encryptionSystem,
|
||||
matrixRTCSession,
|
||||
@@ -435,7 +517,7 @@ export function createCallViewModel$(
|
||||
const matrixRTCMode$ =
|
||||
configMatrixRTCMode !== undefined
|
||||
? constant(configMatrixRTCMode)
|
||||
: (options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy));
|
||||
: (options.matrixRTCMode$ ?? constant(MatrixRTCMode.Compatibility));
|
||||
|
||||
// Each hbar seperates a block of input variables required for the CallViewModel to function.
|
||||
// The outputs of this block is written under the hbar.
|
||||
@@ -497,7 +579,6 @@ export function createCallViewModel$(
|
||||
mode === MatrixRTCMode.Matrix_2_0
|
||||
? JwtEndpointVersion.Matrix_2_0
|
||||
: JwtEndpointVersion.Legacy,
|
||||
useOldestMember: mode === MatrixRTCMode.Legacy,
|
||||
}),
|
||||
),
|
||||
),
|
||||
@@ -511,7 +592,7 @@ export function createCallViewModel$(
|
||||
mediaDevices,
|
||||
trackProcessorState$,
|
||||
livekitKeyProvider,
|
||||
getUrlParams().controlledAudioDevices,
|
||||
controlledAudioDevices,
|
||||
options.livekitRoomFactory,
|
||||
);
|
||||
|
||||
@@ -551,6 +632,8 @@ export function createCallViewModel$(
|
||||
encryptMedia: livekitKeyProvider !== undefined,
|
||||
// TODO. This might need to get called again on each change of matrixRTCMode...
|
||||
matrixRTCMode: mode,
|
||||
sendNotificationType,
|
||||
callIntent,
|
||||
})),
|
||||
),
|
||||
);
|
||||
@@ -580,12 +663,15 @@ export function createCallViewModel$(
|
||||
logger.getChild(
|
||||
"[Publisher " + connection.transport.livekit_service_url + "]",
|
||||
),
|
||||
controlledAudioDevices,
|
||||
);
|
||||
},
|
||||
connectionManager,
|
||||
matrixRTCSession,
|
||||
localTransport$,
|
||||
roomId: matrixRoom.roomId,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
logger: logger.getChild(`[${Date.now()}]`),
|
||||
});
|
||||
|
||||
@@ -856,6 +942,11 @@ export function createCallViewModel$(
|
||||
matrixLivekitMembers$.pipe(map((ms) => ms.length)),
|
||||
);
|
||||
|
||||
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
|
||||
scope,
|
||||
matrixRTCSession,
|
||||
);
|
||||
|
||||
const leaveSoundEffect$ = userMedia$.pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
@@ -872,24 +963,16 @@ export function createCallViewModel$(
|
||||
|
||||
const userHangup$ = new Subject<void>();
|
||||
|
||||
const widgetHangup$ =
|
||||
widget === null
|
||||
? NEVER
|
||||
: (
|
||||
fromEvent(
|
||||
widget.lazyActions,
|
||||
ElementWidgetActions.HangupCall,
|
||||
) as Observable<CustomEvent<IWidgetApiRequest>>
|
||||
).pipe(
|
||||
tap((ev) => {
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
}),
|
||||
);
|
||||
const hostHangup$ = hostBridge.hangUp$.pipe(
|
||||
tap((request) => {
|
||||
request.reply();
|
||||
}),
|
||||
);
|
||||
|
||||
const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> =
|
||||
merge(
|
||||
autoLeave$,
|
||||
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
|
||||
merge(userHangup$, hostHangup$).pipe(map(() => "user" as const)),
|
||||
).pipe(scope.share);
|
||||
|
||||
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
|
||||
@@ -941,7 +1024,7 @@ export function createCallViewModel$(
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
distinctUntilChanged(shallowArrayEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1000,7 +1083,7 @@ export function createCallViewModel$(
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
spotlightAndPip$.pipe(
|
||||
map(({ spotlight }) => spotlight),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1209,6 +1292,7 @@ export function createCallViewModel$(
|
||||
}
|
||||
return layout;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((orientation) => {
|
||||
@@ -1439,15 +1523,19 @@ export function createCallViewModel$(
|
||||
),
|
||||
);
|
||||
|
||||
const urlParams = getUrlParams();
|
||||
const showFooterUrlParams = !(
|
||||
urlParams.header === HeaderStyle.None && urlParams.showControls === false
|
||||
header === HeaderStyle.None && showControls === false
|
||||
);
|
||||
const showFooter$ = scope.behavior(
|
||||
naturallyShowFooter$.pipe(
|
||||
map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams),
|
||||
),
|
||||
);
|
||||
|
||||
const showModals$ = scope.behavior(
|
||||
windowMode$.pipe(map((mode) => mode !== "pip")),
|
||||
);
|
||||
|
||||
const settingsOpen$ = new BehaviorSubject(false);
|
||||
const setSettingsOpen$ = constant((open: boolean) => {
|
||||
settingsOpen$.next(open);
|
||||
@@ -1574,7 +1662,11 @@ export function createCallViewModel$(
|
||||
* The layout of tiles in the call interface.
|
||||
*/
|
||||
const layout$ = scope.behavior<Layout>(
|
||||
layoutInternals$.pipe(map(({ layout }) => layout)),
|
||||
layoutInternals$.pipe(
|
||||
map(({ layout }) => layout),
|
||||
// Drop redundant layout updates before they would hit React.
|
||||
distinctUntilChanged<Layout>(layoutShallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
const overflowing$ = scope.behavior<boolean>(
|
||||
@@ -1751,8 +1843,7 @@ export function createCallViewModel$(
|
||||
return {
|
||||
autoLeave$: autoLeave$,
|
||||
ringingVm$: ringingMedia$,
|
||||
ringingStatusLocation:
|
||||
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
|
||||
ringingStatusLocation: header === HeaderStyle.AppBar ? "app_bar" : "tile",
|
||||
leave$: leave$,
|
||||
hangup: (): void => userHangup$.next(),
|
||||
join: localMembership.requestJoinAndPublish,
|
||||
@@ -1782,6 +1873,7 @@ export function createCallViewModel$(
|
||||
),
|
||||
allConnections$,
|
||||
participantCount$: participantCount$,
|
||||
keyRotationSuppressed$: keyRotationSuppressed$,
|
||||
handsRaised$: handsRaised$,
|
||||
reactions$: reactions$,
|
||||
joinSoundEffect$: joinSoundEffect$,
|
||||
@@ -1820,6 +1912,7 @@ export function createCallViewModel$(
|
||||
showNameTags$,
|
||||
showHeader$: showHeader$,
|
||||
showFooter$: showFooter$,
|
||||
showModals$,
|
||||
settingsOpen$: settingsOpen$,
|
||||
setSettingsOpen$: setSettingsOpen$,
|
||||
edgeToEdge$,
|
||||
|
||||
@@ -52,6 +52,7 @@ import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
|
||||
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
|
||||
import { type Publisher } from "./Publisher";
|
||||
import { initializeWidget } from "../../../widget";
|
||||
import { nullHostBridge } from "../../../HostBridge";
|
||||
import {
|
||||
type LocalTransport,
|
||||
type LocalTransportWithSFUConfig,
|
||||
@@ -59,7 +60,7 @@ import {
|
||||
|
||||
initializeWidget();
|
||||
|
||||
const MATRIX_RTC_MODE = MatrixRTCMode.Legacy;
|
||||
const MATRIX_RTC_MODE = MatrixRTCMode.Compatibility;
|
||||
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
|
||||
vi.mock("../../../UrlParams", () => ({ getUrlParams }));
|
||||
vi.mock("@livekit/components-core", () => ({
|
||||
@@ -71,12 +72,6 @@ vi.mock("@livekit/components-core", () => ({
|
||||
describe("LocalMembership", () => {
|
||||
describe("enterRTCSession", () => {
|
||||
it("It joins the correct Session", () => {
|
||||
const focusFromOlderMembership = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "http://my-oldest-member-service-url.com",
|
||||
livekit_alias: "my-oldest-member-service-alias",
|
||||
};
|
||||
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
});
|
||||
@@ -95,10 +90,6 @@ describe("LocalMembership", () => {
|
||||
},
|
||||
},
|
||||
memberships: [],
|
||||
getFocusInUse: vi.fn().mockReturnValue(focusFromOlderMembership),
|
||||
getOldestMembership: vi.fn().mockReturnValue({
|
||||
getPreferredFoci: vi.fn().mockReturnValue([focusFromOlderMembership]),
|
||||
}),
|
||||
joinRTCSession: vi.fn(),
|
||||
}) as unknown as MatrixRTCSession;
|
||||
|
||||
@@ -122,17 +113,63 @@ describe("LocalMembership", () => {
|
||||
memberId: "@alice:example.org:DEVICE",
|
||||
userId: "@alice:example.org",
|
||||
},
|
||||
[
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-livekit-service-url.com",
|
||||
type: "livekit",
|
||||
[],
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-livekit-service-url.com",
|
||||
type: "livekit",
|
||||
},
|
||||
expect.objectContaining({ manageMediaKeys: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes keyRotationParticipantLimit from config to joinRTCSession", () => {
|
||||
mockConfig({
|
||||
livekit: { livekit_service_url: "http://my-default-service-url.com" },
|
||||
matrix_rtc_session: {
|
||||
delayed_leave_event_delay_ms: 0,
|
||||
network_error_retry_ms: 0,
|
||||
key_rotation_participant_limit: 50,
|
||||
},
|
||||
});
|
||||
|
||||
const mockedSession = vi.mocked({
|
||||
room: {
|
||||
roomId: "roomId",
|
||||
client: {
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
getOpenIdToken: vi.fn().mockResolvedValue({
|
||||
access_token: "ACCCESS_TOKEN",
|
||||
token_type: "Bearer",
|
||||
matrix_server_name: "localhost",
|
||||
expires_in: 10000,
|
||||
}),
|
||||
},
|
||||
],
|
||||
undefined,
|
||||
},
|
||||
memberships: [],
|
||||
joinRTCSession: vi.fn(),
|
||||
}) as unknown as MatrixRTCSession;
|
||||
|
||||
enterRTCSession(
|
||||
mockedSession,
|
||||
ownMemberMock,
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-livekit-service-url.com",
|
||||
type: "livekit",
|
||||
},
|
||||
{
|
||||
encryptMedia: true,
|
||||
matrixRTCMode: MATRIX_RTC_MODE,
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
|
||||
expect.any(Object),
|
||||
[],
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
manageMediaKeys: true,
|
||||
useLegacyMemberEvents: false,
|
||||
keyRotationParticipantLimit: 50,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -163,6 +200,8 @@ describe("LocalMembership", () => {
|
||||
rtsSession$: constant(RTCMemberStatus.Connected),
|
||||
},
|
||||
roomId: "!test-room-id:example.org",
|
||||
hideScreensharing: false,
|
||||
hostBridge: nullHostBridge,
|
||||
};
|
||||
|
||||
it("throws error on missing RTC config error", () => {
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
type LivekitTransport,
|
||||
type LivekitTransportConfig,
|
||||
type MatrixRTCSession,
|
||||
type RTCCallIntent,
|
||||
type RTCNotificationType,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
@@ -51,8 +53,8 @@ import {
|
||||
MembershipManagerError,
|
||||
UnknownCallError,
|
||||
} from "../../../utils/errors.ts";
|
||||
import { ElementWidgetActions, widget } from "../../../widget.ts";
|
||||
import { getUrlParams } from "../../../UrlParams.ts";
|
||||
import { type HostBridge } from "../../../HostBridge.ts";
|
||||
|
||||
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
|
||||
import {
|
||||
advancedScreenShare,
|
||||
@@ -117,7 +119,6 @@ export type LocalMemberState =
|
||||
};
|
||||
|
||||
/*
|
||||
* - get oldest membership
|
||||
* - get transport to use
|
||||
* - get openId + jwt token
|
||||
* - wait for createTrack() call
|
||||
@@ -142,6 +143,10 @@ interface Props {
|
||||
MatrixRTCSession,
|
||||
"updateCallIntent" | "leaveRoomSession"
|
||||
>;
|
||||
/** Whether to hide the screen-sharing button. */
|
||||
hideScreensharing: boolean;
|
||||
/** The application hosting Element Call, to be kept informed of join/leave. */
|
||||
hostBridge: HostBridge;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
@@ -161,6 +166,8 @@ interface Props {
|
||||
* @param props.muteStates The mute states for video and audio.
|
||||
* @param props.matrixRTCSession The matrix RTC session to join.
|
||||
* @param props.roomId The room ID used as the call identifier in analytics events.
|
||||
* @param props.hideScreensharing Whether to hide the screen-sharing button.
|
||||
* @param props.hostBridge The application hosting Element Call.
|
||||
* @returns
|
||||
* - publisher: The handle to create tracks and publish them to the room.
|
||||
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
|
||||
@@ -179,6 +186,8 @@ export const createLocalMembership$ = ({
|
||||
muteStates,
|
||||
matrixRTCSession,
|
||||
roomId,
|
||||
hideScreensharing,
|
||||
hostBridge,
|
||||
}: Props): {
|
||||
/**
|
||||
* This request to start audio and video tracks.
|
||||
@@ -571,29 +580,24 @@ export const createLocalMembership$ = ({
|
||||
}
|
||||
});
|
||||
|
||||
// inform the widget about the connect and disconnect intent from the user.
|
||||
// inform the host about the connect and disconnect intent from the user.
|
||||
scope
|
||||
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
|
||||
undefined,
|
||||
joinAndPublishRequested$.value,
|
||||
])
|
||||
.subscribe(([prev, current]) => {
|
||||
if (!widget) return;
|
||||
// JOIN prev=false (was left) => current-true (now joiend)
|
||||
if (!prev && current) {
|
||||
widget.api.transport
|
||||
.send(ElementWidgetActions.JoinCall, {})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to send join action", e);
|
||||
});
|
||||
hostBridge.notifyJoined().catch((e) => {
|
||||
logger.error("Failed to notify the host that we joined", e);
|
||||
});
|
||||
}
|
||||
// LEAVE prev=false (was joined) => current-true (now left)
|
||||
if (prev && !current) {
|
||||
widget.api.transport
|
||||
.send(ElementWidgetActions.HangupCall, {})
|
||||
.catch((e) => {
|
||||
logger.error("Failed to send hangup action", e);
|
||||
});
|
||||
hostBridge.notifyHungUp().catch((e) => {
|
||||
logger.error("Failed to notify the host that we hung up", e);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -710,7 +714,7 @@ export const createLocalMembership$ = ({
|
||||
let toggleScreenSharing: (() => void) | null = null;
|
||||
if (
|
||||
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
|
||||
!getUrlParams().hideScreensharing
|
||||
!hideScreensharing
|
||||
) {
|
||||
toggleScreenSharing = (): void => {
|
||||
const screenshareSettings: ScreenShareCaptureOptions = {
|
||||
@@ -821,6 +825,10 @@ export function observeSharingScreen$(p: Participant): Observable<boolean> {
|
||||
interface EnterRTCSessionOptions {
|
||||
encryptMedia: boolean;
|
||||
matrixRTCMode: MatrixRTCMode;
|
||||
/** Whether and what kind of notification to send when joining. */
|
||||
sendNotificationType?: RTCNotificationType;
|
||||
/** The kind of call being placed. */
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -833,8 +841,10 @@ interface EnterRTCSessionOptions {
|
||||
* @param rtcSession - The MatrixRTCSession to join.
|
||||
* @param ownMembershipIdentity - Options for entering the RTC session.
|
||||
* @param transport - The LivekitTransport to use for this session.
|
||||
* @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use.
|
||||
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
|
||||
* @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The
|
||||
* Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of
|
||||
* notification to send on join. `callIntent`: The kind of call being placed.
|
||||
* @throws If the host could not be told that we are joining.
|
||||
*/
|
||||
// Exported for unit testing
|
||||
export function enterRTCSession(
|
||||
@@ -843,7 +853,12 @@ export function enterRTCSession(
|
||||
transport: LivekitTransportConfig,
|
||||
options: EnterRTCSessionOptions,
|
||||
): void {
|
||||
const { encryptMedia, matrixRTCMode } = options;
|
||||
const {
|
||||
encryptMedia,
|
||||
matrixRTCMode,
|
||||
sendNotificationType: notificationType,
|
||||
callIntent,
|
||||
} = options;
|
||||
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
|
||||
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
|
||||
|
||||
@@ -851,10 +866,7 @@ export function enterRTCSession(
|
||||
// have started tracking by the time calls start getting created.
|
||||
// groupCallOTelMembership?.onJoinCall();
|
||||
|
||||
const { features, matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
||||
const useDeviceSessionMemberEvents =
|
||||
features?.feature_use_device_session_member_events;
|
||||
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
|
||||
const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
|
||||
const multiSFU =
|
||||
matrixRTCMode === MatrixRTCMode.Compatibility ||
|
||||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
|
||||
@@ -895,9 +907,6 @@ export function enterRTCSession(
|
||||
notificationType,
|
||||
callIntent,
|
||||
manageMediaKeys: encryptMedia,
|
||||
...(useDeviceSessionMemberEvents !== undefined && {
|
||||
useLegacyMemberEvents: !useDeviceSessionMemberEvents,
|
||||
}),
|
||||
delayedLeaveEventRestartMs:
|
||||
matrixRtcSessionConfig?.delayed_leave_event_restart_ms,
|
||||
delayedLeaveEventDelayMs:
|
||||
@@ -908,6 +917,8 @@ export function enterRTCSession(
|
||||
makeKeyDelay: matrixRtcSessionConfig?.wait_for_key_rotation_ms,
|
||||
membershipEventExpiryMs:
|
||||
matrixRtcSessionConfig?.membership_event_expiry_ms,
|
||||
keyRotationParticipantLimit:
|
||||
matrixRtcSessionConfig?.key_rotation_participant_limit,
|
||||
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
|
||||
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
|
||||
},
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
it,
|
||||
type MockedObject,
|
||||
vi,
|
||||
type MockInstance,
|
||||
} from "vitest";
|
||||
import {
|
||||
type CallMembership,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
mockConfig,
|
||||
flushPromises,
|
||||
ownMemberMock,
|
||||
mockRtcMembership,
|
||||
testScope,
|
||||
} from "../../../utils/test";
|
||||
import {
|
||||
@@ -35,7 +33,7 @@ import {
|
||||
type LocalTransportWithSFUConfig,
|
||||
} from "./LocalTransport";
|
||||
import { constant } from "../../Behavior";
|
||||
import { Epoch, ObservableScope, trackEpoch } from "../../ObservableScope";
|
||||
import { Epoch, ObservableScope } from "../../ObservableScope";
|
||||
import {
|
||||
MatrixRTCTransportMissingError,
|
||||
FailToGetOpenIdToken,
|
||||
@@ -58,7 +56,6 @@ describe("LocalTransport", () => {
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
roomId: "!room:example.org",
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -101,7 +98,6 @@ describe("LocalTransport", () => {
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
baseUrl: "https://example.org",
|
||||
@@ -144,7 +140,6 @@ describe("LocalTransport", () => {
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope: testScope(),
|
||||
roomId: "!room:example.org",
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -185,127 +180,6 @@ describe("LocalTransport", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("oldest member mode", () => {
|
||||
const aliceTransport: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://alice.example.org",
|
||||
};
|
||||
const bobTransport: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://bob.example.org",
|
||||
};
|
||||
const aliceMembership = mockRtcMembership("@alice:example.org", "AAA", {
|
||||
fociPreferred: [aliceTransport],
|
||||
});
|
||||
const bobMembership = mockRtcMembership("@bob:example.org", "BBB", {
|
||||
fociPreferred: [bobTransport],
|
||||
});
|
||||
|
||||
let openIdSpy: MockInstance<(typeof openIDSFU)["getSFUConfigWithOpenID"]>;
|
||||
beforeEach(() => {
|
||||
openIdSpy = vi
|
||||
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
|
||||
.mockResolvedValue(openIdResponse);
|
||||
});
|
||||
|
||||
it("updates active transport when oldest member changes", async () => {
|
||||
// Initially, Alice is the only member
|
||||
const memberships$ = new BehaviorSubject([aliceMembership]);
|
||||
|
||||
const scope = testScope();
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember: true,
|
||||
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// SFU config should've been fetched
|
||||
expect(openIdSpy).toHaveBeenCalled();
|
||||
// Alice's transport should be active and advertised
|
||||
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
|
||||
// Now Bob joins the call, but Alice is still the oldest member
|
||||
openIdSpy.mockClear();
|
||||
memberships$.next([aliceMembership, bobMembership]);
|
||||
await flushPromises();
|
||||
// No new SFU config should've been fetched
|
||||
expect(openIdSpy).not.toHaveBeenCalled();
|
||||
// Alice's transport should still be active and advertised
|
||||
expect(active$.value?.transport).toStrictEqual(aliceTransport);
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
|
||||
// Now Bob takes Alice's place as the oldest member
|
||||
openIdSpy.mockClear();
|
||||
memberships$.next([bobMembership, aliceMembership]);
|
||||
// Active transport should reset to null until we have Bob's SFU config
|
||||
expect(active$.value).toStrictEqual(null);
|
||||
await flushPromises();
|
||||
// Bob's SFU config should've been fetched
|
||||
expect(openIdSpy).toHaveBeenCalled();
|
||||
// Bob's transport should be active, but Alice's should remain advertised
|
||||
// (since we don't want the change in oldest member to cause a wave of new
|
||||
// state events)
|
||||
expect(active$.value?.transport).toStrictEqual(bobTransport);
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
});
|
||||
|
||||
it("advertises preferred transport when no other member exists", async () => {
|
||||
// Initially, there are no members
|
||||
const memberships$ = new BehaviorSubject<CallMembership[]>([]);
|
||||
|
||||
const scope = testScope();
|
||||
const { advertised$, active$ } = createLocalTransport$({
|
||||
scope,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember: true,
|
||||
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||
client: {
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () =>
|
||||
Promise.resolve([aliceTransport]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant("delay_id_mock"),
|
||||
});
|
||||
|
||||
expect(active$.value).toBe(null);
|
||||
await flushPromises();
|
||||
// Our own preferred transport should be advertised
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
// No transport should be active however (there is still no oldest member)
|
||||
expect(active$.value).toBe(null);
|
||||
|
||||
// Now Bob joins the call and becomes the oldest member
|
||||
memberships$.next([bobMembership]);
|
||||
await flushPromises();
|
||||
// We should still advertise our own preferred transport (to avoid
|
||||
// unnecessary state changes)
|
||||
expect(advertised$.value).toStrictEqual(aliceTransport);
|
||||
// Bob's transport should become active
|
||||
expect(active$.value?.transport).toBe(bobTransport);
|
||||
});
|
||||
});
|
||||
|
||||
type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];
|
||||
|
||||
describe("transport configuration mechanisms", () => {
|
||||
@@ -320,7 +194,6 @@ describe("LocalTransport", () => {
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
scope: testScope(),
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember: false,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant(null),
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
@@ -433,7 +306,6 @@ describe("LocalTransport", () => {
|
||||
scope: testScope(),
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!example_room_id",
|
||||
useOldestMember: false,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: constant(null),
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
@@ -473,7 +345,6 @@ describe("LocalTransport", () => {
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
roomId: "!example_room_id",
|
||||
// We want multi-sdu
|
||||
useOldestMember: false,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
delayId$: delayId$,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
|
||||
@@ -7,23 +7,16 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
type CallMembership,
|
||||
isLivekitTransportConfig,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import {
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
first,
|
||||
from,
|
||||
map,
|
||||
merge,
|
||||
type Observable,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
|
||||
@@ -47,8 +40,7 @@ import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
|
||||
|
||||
/*
|
||||
* It figures out “which LiveKit focus URL/alias the local user should use,”
|
||||
* optionally aligning with the oldest member, and ensures the SFU path is primed
|
||||
* before advertising that choice.
|
||||
* and ensures the SFU path is primed before advertising that choice.
|
||||
*/
|
||||
interface Props {
|
||||
scope: ObservableScope;
|
||||
@@ -61,7 +53,6 @@ interface Props {
|
||||
OpenIDClientParts;
|
||||
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
||||
roomId: string;
|
||||
useOldestMember: boolean;
|
||||
forceJwtEndpoint: JwtEndpointVersion;
|
||||
delayId$: Behavior<string | null>;
|
||||
}
|
||||
@@ -119,8 +110,6 @@ export interface LocalTransport {
|
||||
/**
|
||||
* Connects to the JWT service and determines the transports that the local member should use.
|
||||
*
|
||||
* @prop useOldestMember Whether to use the same transport as the oldest member.
|
||||
* This will only update once the first oldest member appears. Will not recompute if the oldest member leaves.
|
||||
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
|
||||
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
|
||||
* (which is expected for non sticky event based rtc member events)
|
||||
@@ -133,18 +122,10 @@ export const createLocalTransport$ = ({
|
||||
ownMembershipIdentity,
|
||||
client,
|
||||
roomId,
|
||||
useOldestMember,
|
||||
forceJwtEndpoint,
|
||||
delayId$,
|
||||
}: Props): LocalTransport => {
|
||||
const logger = rootLogger.getChild("[LocalTransport]");
|
||||
// The LiveKit transport in use by the oldest RTC membership. `null` when the
|
||||
// oldest member has no such transport.
|
||||
const oldestMemberTransport$ = observerOldestMembership$(
|
||||
scope,
|
||||
memberships$,
|
||||
logger,
|
||||
);
|
||||
|
||||
const transportDiscovery = new RtcTransportAutoDiscovery({
|
||||
client: client,
|
||||
@@ -203,19 +184,6 @@ export const createLocalTransport$ = ({
|
||||
}),
|
||||
);
|
||||
|
||||
if (useOldestMember) {
|
||||
return observeLocalTransportForOldestMembership(
|
||||
scope,
|
||||
oldestMemberTransport$,
|
||||
preferredTransport$,
|
||||
client,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
logger,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Multi-SFU mode ---
|
||||
// Always publish on and advertise the preferred transport.
|
||||
return {
|
||||
advertised$: scope.behavior(
|
||||
@@ -243,47 +211,6 @@ export const createLocalTransport$ = ({
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Observes the oldest member in the room and returns the transport that it uses if it is a livekit transport.
|
||||
* @param scope - The observable scope.
|
||||
* @param memberships$ - The observable of the call's memberships.'
|
||||
*/
|
||||
function observerOldestMembership$(
|
||||
scope: ObservableScope,
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>,
|
||||
logger: Logger,
|
||||
): Behavior<LivekitTransportConfig | null> {
|
||||
return scope.behavior<LivekitTransportConfig | null>(
|
||||
memberships$.pipe(
|
||||
map((memberships) => {
|
||||
const oldestMember = memberships.value[0];
|
||||
if (oldestMember === undefined) {
|
||||
logger.info("Oldest member: not found");
|
||||
return null;
|
||||
}
|
||||
const transport = oldestMember.getTransport(oldestMember);
|
||||
if (transport === undefined) {
|
||||
logger.warn(
|
||||
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has no transport`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!isLivekitTransportConfig(transport)) {
|
||||
logger.warn(
|
||||
`Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has invalid transport`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
logger.info(
|
||||
"Oldest member: ${oldestMember.userId}|${oldestMember.deviceId}|${oldestMember.memberId} has valid transport",
|
||||
);
|
||||
return transport;
|
||||
}),
|
||||
distinctUntilChanged(areLivekitTransportsEqual),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to ensure the user can authenticate with the SFU.
|
||||
* We will call `getSFUConfigWithOpenID` once per transport here as it's our
|
||||
@@ -331,85 +258,6 @@ async function doOpenIdAndJWTFromUrl(
|
||||
};
|
||||
}
|
||||
|
||||
function observeLocalTransportForOldestMembership(
|
||||
scope: ObservableScope,
|
||||
oldestMemberTransport$: Behavior<LivekitTransportConfig | null>,
|
||||
preferredTransport$: Observable<LocalTransportWithSFUConfig>,
|
||||
client: Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
||||
> &
|
||||
OpenIDClientParts,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
roomId: string,
|
||||
logger: Logger,
|
||||
): LocalTransport {
|
||||
// Ensure we can authenticate with the SFU.
|
||||
const authenticatedOldestMemberTransport$ = oldestMemberTransport$.pipe(
|
||||
switchMap((transport) => {
|
||||
// Oldest member not available -we are first- (or invalid SFU config).
|
||||
if (transport === null) return of(null);
|
||||
|
||||
// Whenever there is transport change we want to revert
|
||||
// to no transport while we do the authentication.
|
||||
// So do a from(promise) here to be able to startWith(null)
|
||||
return from(
|
||||
doOpenIdAndJWTFromUrl(
|
||||
transport,
|
||||
JwtEndpointVersion.Legacy,
|
||||
ownMembershipIdentity,
|
||||
roomId,
|
||||
client,
|
||||
undefined,
|
||||
logger,
|
||||
),
|
||||
).pipe(
|
||||
catchError((e: unknown) => {
|
||||
logger.error(
|
||||
`Failed to authenticate to transport ${transport.livekit_service_url}`,
|
||||
e,
|
||||
);
|
||||
throw mapAuthErrorToUserFriendlyError(e);
|
||||
}),
|
||||
startWith(null),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// --- Oldest member mode ---
|
||||
return {
|
||||
// Never update the transport that we advertise in our membership. Just
|
||||
// take the first valid oldest member or preferred transport that we learn
|
||||
// about, and stick with that. This avoids unnecessary SFU hops and room
|
||||
// state changes.
|
||||
advertised$: scope.behavior(
|
||||
merge(
|
||||
authenticatedOldestMemberTransport$.pipe(
|
||||
map((t) => t?.transport ?? null),
|
||||
),
|
||||
preferredTransport$.pipe(map((t) => t.transport)),
|
||||
).pipe(
|
||||
first((t) => t !== null),
|
||||
tap((t) =>
|
||||
logger.info(`Advertise transport: ${t.livekit_service_url}`),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
// Publish on the transport used by the oldest member.
|
||||
active$: scope.behavior(
|
||||
authenticatedOldestMemberTransport$.pipe(
|
||||
tap((t) =>
|
||||
logger.info(
|
||||
`Publish on transport: ${t?.transport.livekit_service_url}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
null,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
|
||||
if (
|
||||
e instanceof FailToGetOpenIdToken ||
|
||||
|
||||
@@ -192,6 +192,7 @@ describe("Publisher", () => {
|
||||
muteStates,
|
||||
constant({ supported: false, processor: undefined }),
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -309,6 +310,7 @@ describe("Publisher", () => {
|
||||
muteStates,
|
||||
constant({ supported: false, processor: undefined }),
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
});
|
||||
afterEach(async () => {
|
||||
@@ -364,6 +366,7 @@ describe("Bug fix", () => {
|
||||
muteStates,
|
||||
constant({ supported: false, processor: undefined }),
|
||||
logger,
|
||||
false,
|
||||
);
|
||||
audioEnabled$.next(true);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type ProcessorState,
|
||||
trackProcessorSync,
|
||||
} from "../../../livekit/TrackProcessorContext.tsx";
|
||||
import { getUrlParams } from "../../../UrlParams.ts";
|
||||
|
||||
import { observeTrackReference$ } from "../../observeTrackReference";
|
||||
import { type Connection } from "../remoteMembers/Connection.ts";
|
||||
import { ObservableScope } from "../../ObservableScope.ts";
|
||||
@@ -56,6 +56,8 @@ export class Publisher {
|
||||
* @param muteStates - The mute states for audio and video.
|
||||
* @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur).
|
||||
* @param logger - The logger to use for logging :D.
|
||||
* @param controlledAudioDevices - Whether the app hosting Element Call
|
||||
* controls the audio output devices, rather than the browser.
|
||||
*/
|
||||
public constructor(
|
||||
private connection: Pick<Connection, "livekitRoom" | "state$">, //setE2EEEnabled,
|
||||
@@ -63,8 +65,8 @@ export class Publisher {
|
||||
private readonly muteStates: MuteStates,
|
||||
trackerProcessorState$: Behavior<ProcessorState>,
|
||||
private logger: Logger,
|
||||
controlledAudioDevices: boolean,
|
||||
) {
|
||||
const { controlledAudioDevices } = getUrlParams();
|
||||
const room = connection.livekitRoom;
|
||||
|
||||
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
SFURoomCreationRestrictedError,
|
||||
UnknownCallError,
|
||||
} from "../../../utils/errors.ts";
|
||||
import { type JwtEndpointVersion } from "../localMember/LocalTransport.ts";
|
||||
|
||||
export interface ConnectionOpts {
|
||||
/**
|
||||
@@ -44,11 +43,6 @@ export interface ConnectionOpts {
|
||||
* On top the local transport will send additional data to the jwt server to use delayed event delegation.
|
||||
*/
|
||||
existingSFUConfig?: SFUConfig;
|
||||
/**
|
||||
* For local connections that use the oldest member pattern. here we have not prefetched the sfuConfig
|
||||
* and hence we need to let the connection do the jwt token fetching.
|
||||
*/
|
||||
forceJwtEndpoint?: JwtEndpointVersion;
|
||||
/** The identity parts to use on this connection */
|
||||
ownMembershipIdentity: CallMembershipIdentityParts;
|
||||
/** The media transport to connect to. */
|
||||
|
||||
@@ -6,43 +6,31 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { it, vi, expect } from "vitest";
|
||||
import EventEmitter from "events";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
// import * as ComponentsCore from "@livekit/components-core";
|
||||
import { withCallViewModel } from "./CallViewModel/CallViewModelTestUtils.ts";
|
||||
import { type CallViewModel } from "./CallViewModel/CallViewModel.ts";
|
||||
import { constant } from "./Behavior.ts";
|
||||
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
|
||||
import { ElementWidgetActions, widget } from "../widget.ts";
|
||||
import {
|
||||
type HostBridge,
|
||||
type HostRequest,
|
||||
nullHostBridge,
|
||||
} from "../HostBridge.ts";
|
||||
import { E2eeType } from "../e2ee/e2eeType.ts";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions.ts";
|
||||
|
||||
vi.mock("@livekit/components-core", { spy: true });
|
||||
|
||||
vi.mock("../widget", () => ({
|
||||
ElementWidgetActions: {
|
||||
HangupCall: "HangupCall",
|
||||
// Add other actions if needed
|
||||
},
|
||||
widget: {
|
||||
api: {
|
||||
transport: {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
},
|
||||
lazyActions: new EventEmitter(),
|
||||
},
|
||||
}));
|
||||
|
||||
it.each([
|
||||
[MatrixRTCMode.Legacy],
|
||||
[MatrixRTCMode.Compatibility],
|
||||
[MatrixRTCMode.Matrix_2_0],
|
||||
])(
|
||||
"expect leave when ElementWidgetActions.HangupCall is called (%s mode)",
|
||||
it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
|
||||
"expect leave when the host asks us to hang up (%s mode)",
|
||||
async (mode) => {
|
||||
const pr = Promise.withResolvers<string>();
|
||||
const hangUp$ = new Subject<HostRequest<Record<string, never>>>();
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, hangUp$ };
|
||||
const reply = vi.fn();
|
||||
|
||||
withCallViewModel(mode)(
|
||||
{
|
||||
remoteParticipants$: constant([aliceParticipant]),
|
||||
@@ -53,25 +41,17 @@ it.each([
|
||||
pr.resolve(s);
|
||||
});
|
||||
|
||||
widget!.lazyActions!.emit(
|
||||
ElementWidgetActions.HangupCall,
|
||||
new CustomEvent(ElementWidgetActions.HangupCall, {
|
||||
detail: {
|
||||
action: "im.vector.hangup",
|
||||
api: "toWidget",
|
||||
data: {},
|
||||
requestId: "widgetapi-1761237395918",
|
||||
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
|
||||
},
|
||||
}),
|
||||
);
|
||||
hangUp$.next({ data: {}, reply });
|
||||
},
|
||||
{
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
hostBridge,
|
||||
},
|
||||
);
|
||||
|
||||
const source = await pr.promise;
|
||||
expect(source).toBe("user");
|
||||
// The host expects to hear back that we acted on its request
|
||||
expect(reply).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { of } from "rxjs";
|
||||
|
||||
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
get platform(): string {
|
||||
return getPlatform();
|
||||
},
|
||||
isFirefox: (): boolean => false,
|
||||
}));
|
||||
vi.mock("@livekit/components-core", () => ({
|
||||
createMediaDeviceObserver: () => of([]),
|
||||
}));
|
||||
|
||||
import { AudioOutput, MediaDevices } from "./MediaDevices";
|
||||
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
|
||||
import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput";
|
||||
import { ObservableScope } from "./ObservableScope";
|
||||
|
||||
// Which audio output implementation is used is decided by what the app hosting
|
||||
// Element Call told it, rather than being discovered from the environment.
|
||||
describe("MediaDevices audio output", () => {
|
||||
test("uses the browser's own output when nobody else is controlling it", () => {
|
||||
const devices = new MediaDevices(new ObservableScope(), {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
expect(devices.audioOutput).toBeInstanceOf(AudioOutput);
|
||||
});
|
||||
|
||||
test("hands control to the host on Android", () => {
|
||||
getPlatform.mockReturnValue("android");
|
||||
|
||||
const devices = new MediaDevices(new ObservableScope(), {
|
||||
controlledAudioDevices: true,
|
||||
callIntent: "audio",
|
||||
});
|
||||
|
||||
expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput);
|
||||
});
|
||||
|
||||
test("hands control to the host elsewhere too", () => {
|
||||
getPlatform.mockReturnValue("ios");
|
||||
|
||||
const devices = new MediaDevices(new ObservableScope(), {
|
||||
controlledAudioDevices: true,
|
||||
callIntent: "video",
|
||||
});
|
||||
|
||||
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "rxjs";
|
||||
import { createMediaDeviceObserver } from "@livekit/components-core";
|
||||
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
|
||||
import {
|
||||
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
|
||||
@@ -25,7 +26,6 @@ import {
|
||||
} from "../settings/settings";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { switchWhen } from "../utils/observable";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
@@ -338,6 +338,22 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How Element Call should manage audio output.
|
||||
*/
|
||||
export interface AudioOutputOptions {
|
||||
/**
|
||||
* Whether the list of output devices is controlled by the app hosting Element
|
||||
* Call, through the global JS controls, rather than by the browser.
|
||||
*/
|
||||
controlledAudioDevices: boolean;
|
||||
/**
|
||||
* The kind of call being placed, which decides the initial output route when
|
||||
* the host controls the devices.
|
||||
*/
|
||||
callIntent?: RTCCallIntent;
|
||||
}
|
||||
|
||||
export class MediaDevices {
|
||||
private readonly deviceNamesRequest$ = new Subject<void>();
|
||||
/**
|
||||
@@ -368,23 +384,28 @@ export class MediaDevices {
|
||||
public readonly audioOutput: MediaDevice<
|
||||
AudioOutputDeviceLabel,
|
||||
SelectedAudioOutputDevice
|
||||
> = getUrlParams().controlledAudioDevices
|
||||
> = this.audioOutputOptions.controlledAudioDevices
|
||||
? platform == "android"
|
||||
? new AndroidControlledAudioOutput(
|
||||
controlledAvailableOutputDevices$,
|
||||
this.scope,
|
||||
getUrlParams().callIntent,
|
||||
this.audioOutputOptions.callIntent,
|
||||
window.controls,
|
||||
)
|
||||
: new IOSControlledAudioOutput(
|
||||
this.usingNames$,
|
||||
this.scope,
|
||||
getUrlParams().callIntent,
|
||||
this.audioOutputOptions.callIntent,
|
||||
)
|
||||
: new AudioOutput(this.usingNames$, this.scope);
|
||||
|
||||
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
|
||||
new VideoInput(this.usingNames$, this.scope);
|
||||
|
||||
public constructor(private readonly scope: ObservableScope) {}
|
||||
// Note: both parameters are read by the field initializers above, which is
|
||||
// safe because TypeScript assigns parameter properties before running them.
|
||||
public constructor(
|
||||
private readonly scope: ObservableScope,
|
||||
private readonly audioOutputOptions: AudioOutputOptions,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { nullHostBridge } from "../HostBridge";
|
||||
import { MuteStates, MuteState } from "./MuteStates";
|
||||
import {
|
||||
type AudioOutputDeviceLabel,
|
||||
@@ -228,10 +229,12 @@ describe("MuteStates", () => {
|
||||
videoInput: aVideoInput(),
|
||||
// other devices are not relevant for this test
|
||||
});
|
||||
const muteStates = new MuteStates(testScope, mediaDevices, {
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
});
|
||||
const muteStates = new MuteStates(
|
||||
testScope,
|
||||
mediaDevices,
|
||||
{ audioEnabled: false, videoEnabled: false },
|
||||
nullHostBridge,
|
||||
);
|
||||
|
||||
let latestSyncedState: boolean | null = null;
|
||||
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
|
||||
|
||||
+45
-54
@@ -6,14 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
fromEvent,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
@@ -24,7 +22,7 @@ import {
|
||||
} from "rxjs";
|
||||
|
||||
import { type MediaDevices, type MediaDevice } from "../state/MediaDevices";
|
||||
import { ElementWidgetActions, widget } from "../widget";
|
||||
import { type DeviceMuteState, type HostBridge } from "../HostBridge";
|
||||
import { type ObservableScope } from "./ObservableScope";
|
||||
import { type Behavior, constant } from "./Behavior";
|
||||
|
||||
@@ -216,58 +214,51 @@ export class MuteStates {
|
||||
audioEnabled: boolean;
|
||||
videoEnabled: boolean;
|
||||
},
|
||||
hostBridge: HostBridge,
|
||||
) {
|
||||
if (widget !== null) {
|
||||
// Sync our mute states with the hosting client
|
||||
const widgetApiState$ = combineLatest(
|
||||
[this.audio.enabled$, this.video.enabled$],
|
||||
(audio, video) => ({ audio_enabled: audio, video_enabled: video }),
|
||||
);
|
||||
widgetApiState$.pipe(this.scope.bind()).subscribe((state) => {
|
||||
widget!.api.transport
|
||||
.send(ElementWidgetActions.DeviceMute, state)
|
||||
.catch((e) =>
|
||||
logger.warn("Could not send DeviceMute action to widget", e),
|
||||
);
|
||||
});
|
||||
// Keep the host informed of our mute state
|
||||
const muteState$ = combineLatest(
|
||||
[this.audio.enabled$, this.video.enabled$],
|
||||
(audio, video): DeviceMuteState => ({
|
||||
audio_enabled: audio,
|
||||
video_enabled: video,
|
||||
}),
|
||||
);
|
||||
muteState$.pipe(this.scope.bind()).subscribe((state) => {
|
||||
hostBridge
|
||||
.notifyDeviceMute(state)
|
||||
.catch((e) => logger.warn("Could not send mute state to the host", e));
|
||||
});
|
||||
|
||||
// Also sync the hosting client's mute states back with ours
|
||||
const muteActions$ = fromEvent(
|
||||
widget.lazyActions,
|
||||
ElementWidgetActions.DeviceMute,
|
||||
) as Observable<CustomEvent<IWidgetApiRequest>>;
|
||||
muteActions$
|
||||
.pipe(
|
||||
withLatestFrom(
|
||||
widgetApiState$,
|
||||
this.audio.setEnabled$,
|
||||
this.video.setEnabled$,
|
||||
),
|
||||
this.scope.bind(),
|
||||
)
|
||||
.subscribe(([ev, state, setAudioEnabled, setVideoEnabled]) => {
|
||||
// First copy the current state into our new state
|
||||
const newState = { ...state };
|
||||
// Update new state if there are any requested changes from the widget
|
||||
// action in `ev.detail.data`.
|
||||
if (
|
||||
ev.detail.data.audio_enabled != null &&
|
||||
typeof ev.detail.data.audio_enabled === "boolean" &&
|
||||
setAudioEnabled !== null
|
||||
) {
|
||||
newState.audio_enabled = ev.detail.data.audio_enabled;
|
||||
setAudioEnabled(newState.audio_enabled);
|
||||
}
|
||||
if (
|
||||
ev.detail.data.video_enabled != null &&
|
||||
typeof ev.detail.data.video_enabled === "boolean" &&
|
||||
setVideoEnabled !== null
|
||||
) {
|
||||
newState.video_enabled = ev.detail.data.video_enabled;
|
||||
setVideoEnabled(newState.video_enabled);
|
||||
}
|
||||
widget!.api.transport.reply(ev.detail, newState);
|
||||
});
|
||||
}
|
||||
// And apply the changes the host asks for
|
||||
hostBridge.deviceMute$
|
||||
.pipe(
|
||||
withLatestFrom(
|
||||
muteState$,
|
||||
this.audio.setEnabled$,
|
||||
this.video.setEnabled$,
|
||||
),
|
||||
this.scope.bind(),
|
||||
)
|
||||
.subscribe(([request, state, setAudioEnabled, setVideoEnabled]) => {
|
||||
// First copy the current state into our new state
|
||||
const newState = { ...state };
|
||||
// Then apply whichever changes the host asked for
|
||||
if (
|
||||
typeof request.data.audio_enabled === "boolean" &&
|
||||
setAudioEnabled !== null
|
||||
) {
|
||||
newState.audio_enabled = request.data.audio_enabled;
|
||||
setAudioEnabled(newState.audio_enabled);
|
||||
}
|
||||
if (
|
||||
typeof request.data.video_enabled === "boolean" &&
|
||||
setVideoEnabled !== null
|
||||
) {
|
||||
newState.video_enabled = request.data.video_enabled;
|
||||
setVideoEnabled(newState.video_enabled);
|
||||
}
|
||||
request.reply(newState);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
Copyright 2025 Element Creations Ltd.
|
||||
|
||||
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, vi } from "vitest";
|
||||
import { MatrixRTCSessionEvent } from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
import { createKeyRotationSuppressed$ } from "./SessionBehaviors";
|
||||
import { testScope } from "../utils/test";
|
||||
|
||||
describe("SessionBehaviors", () => {
|
||||
describe("createKeyRotationSuppressed$", () => {
|
||||
it("emits initial value from isKeyRotationSuppressed and updates when KeyRotationSuppressedChanged event is emitted", () => {
|
||||
const scope = testScope();
|
||||
const emitter = new EventEmitter();
|
||||
const mockSession = Object.assign(emitter, {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
isKeyRotationSuppressed: false,
|
||||
});
|
||||
|
||||
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
|
||||
scope,
|
||||
mockSession as any,
|
||||
);
|
||||
|
||||
expect(keyRotationSuppressed$.value).toBe(false);
|
||||
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, true);
|
||||
|
||||
expect(keyRotationSuppressed$.value).toBe(true);
|
||||
|
||||
emitter.emit(MatrixRTCSessionEvent.KeyRotationSuppressedChanged, false);
|
||||
|
||||
expect(keyRotationSuppressed$.value).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -88,3 +88,22 @@ export const createMemberships$ = (
|
||||
new Epoch(matrixRTCSession.memberships),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the session has grown large enough that MatrixRTC has stopped rotating the media
|
||||
* encryption key. While this is true the key in use is still shared with new joiners, but no new
|
||||
* key is generated when someone joins or leaves.
|
||||
*/
|
||||
export const createKeyRotationSuppressed$ = (
|
||||
scope: ObservableScope,
|
||||
matrixRTCSession: MatrixRTCSession,
|
||||
): Behavior<boolean> => {
|
||||
return scope.behavior(
|
||||
fromEvent(
|
||||
matrixRTCSession,
|
||||
MatrixRTCSessionEvent.KeyRotationSuppressedChanged,
|
||||
(suppressed: boolean) => suppressed,
|
||||
),
|
||||
matrixRTCSession.isKeyRotationSuppressed,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { test, expect } from "vitest";
|
||||
import {
|
||||
type Alignment,
|
||||
layoutShallowEquals,
|
||||
type Layout,
|
||||
} from "./layout-types";
|
||||
import {
|
||||
type SpotlightTileViewModel,
|
||||
type GridTileViewModel,
|
||||
} from "./TileViewModel";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
const spotlightTile = {} as unknown as SpotlightTileViewModel;
|
||||
const gridTile = {} as unknown as GridTileViewModel;
|
||||
const pipAlignment$ = new BehaviorSubject<Alignment>({
|
||||
inline: "end",
|
||||
block: "end",
|
||||
});
|
||||
|
||||
const spotlightExpanded: Layout = {
|
||||
type: "spotlight-expanded",
|
||||
spotlight: spotlightTile,
|
||||
pipAlignment$,
|
||||
};
|
||||
|
||||
const spotlightPortrait: Layout = {
|
||||
type: "spotlight-portrait",
|
||||
spotlight: spotlightTile,
|
||||
grid: [gridTile],
|
||||
setVisibleTiles: () => {},
|
||||
};
|
||||
|
||||
test("layoutShallowEquals considers a layout to be equal to its shallow clone", () =>
|
||||
expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe(
|
||||
true,
|
||||
));
|
||||
|
||||
test("layoutShallowEquals detects a missing key", () => {
|
||||
expect(
|
||||
layoutShallowEquals(spotlightExpanded, {
|
||||
...spotlightExpanded,
|
||||
pip: gridTile,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
layoutShallowEquals(
|
||||
{ ...spotlightExpanded, pip: gridTile },
|
||||
spotlightExpanded,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("layoutShallowEquals considers grid arrays with equal contents to be equal", () =>
|
||||
expect(
|
||||
layoutShallowEquals(spotlightPortrait, {
|
||||
...spotlightPortrait,
|
||||
grid: [...spotlightPortrait.grid],
|
||||
}),
|
||||
).toBe(true));
|
||||
|
||||
test("layoutShallowEquals detects grid arrays with different contents", () =>
|
||||
expect(
|
||||
layoutShallowEquals(spotlightPortrait, {
|
||||
...spotlightPortrait,
|
||||
grid: [...spotlightPortrait.grid, gridTile],
|
||||
}),
|
||||
).toBe(false));
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type SpotlightTileViewModel,
|
||||
} from "./TileViewModel.ts";
|
||||
import { type Behavior } from "./Behavior.ts";
|
||||
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
|
||||
|
||||
export interface GridLayoutMedia {
|
||||
type: "grid";
|
||||
@@ -140,3 +141,30 @@ export type Layout =
|
||||
| OneOnOneDesktopLayout
|
||||
| OneOnOneMobileLayout
|
||||
| PipLayout;
|
||||
|
||||
/**
|
||||
* Tests whether the top-level properties and array elements of layout `a` are
|
||||
* equal to those of layout `b`. Useful for deduping redundant layout updates.
|
||||
*/
|
||||
export function layoutShallowEquals(a: Layout, b: Layout): boolean {
|
||||
// If a and b have the same number of keys and every key in a is also in b,
|
||||
// then they have the same keys.
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
|
||||
for (const key of aKeys) {
|
||||
if (!(key in b)) return false;
|
||||
|
||||
// Now check that they have the same values.
|
||||
const aValue = (a as any)[key];
|
||||
const bValue = (b as any)[key];
|
||||
if (Array.isArray(aValue) && Array.isArray(bValue)) {
|
||||
// Special case for arrays so we can detect when the grid tiles arrays are
|
||||
// essentially the same.
|
||||
if (!arrayShallowEquals(aValue, bValue)) return false;
|
||||
} else if (aValue !== bValue) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import { Slider } from "../Slider";
|
||||
import { platform } from "../Platform";
|
||||
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
|
||||
import { RingingStatus } from "./RingingStatus";
|
||||
import { useRootElement } from "../RootElementContext";
|
||||
|
||||
interface SpotlightItemBaseProps {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
@@ -414,6 +415,7 @@ export const SpotlightTile: FC<Props> = ({
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const rootElement = useRootElement();
|
||||
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
|
||||
const ref = useMergedRefs(ourRef, theirRef);
|
||||
const maximised = useBehavior(vm.maximised$);
|
||||
@@ -428,24 +430,22 @@ export const SpotlightTile: FC<Props> = ({
|
||||
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
|
||||
|
||||
const isFullscreen = useCallback((): boolean => {
|
||||
const rootElement = document.body;
|
||||
if (rootElement && document.fullscreenElement) return true;
|
||||
return false;
|
||||
}, []);
|
||||
}, [rootElement]);
|
||||
|
||||
const FullScreenIcon = isFullscreen()
|
||||
? FullScreenMinimiseIcon
|
||||
: FullScreenMaximiseIcon;
|
||||
|
||||
const onToggleFullscreen = useCallback(() => {
|
||||
const rootElement = document.body;
|
||||
if (!rootElement) return;
|
||||
if (isFullscreen()) {
|
||||
void document?.exitFullscreen();
|
||||
} else {
|
||||
void rootElement.requestFullscreen();
|
||||
}
|
||||
}, [isFullscreen]);
|
||||
}, [isFullscreen, rootElement]);
|
||||
|
||||
// To keep track of which item is visible, we need an intersection observer
|
||||
// hooked up to the root element and the items. Because the items will run
|
||||
|
||||
+26
-25
@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { createElement, type FC, type PropsWithChildren } from "react";
|
||||
import { Subject } from "rxjs";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
@@ -15,31 +17,35 @@ import {
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import EventEmitter from "events";
|
||||
import { WidgetApiToWidgetAction } from "matrix-widget-api";
|
||||
|
||||
import { useTheme } from "./useTheme";
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { widget } from "./widget";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
type HostRequest,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
|
||||
vi.mock("./UrlParams", () => ({ getUrlParams: vi.fn() }));
|
||||
vi.mock("./widget", () => ({
|
||||
widget: {
|
||||
api: { transport: { reply: vi.fn() } },
|
||||
lazyActions: new EventEmitter(),
|
||||
},
|
||||
}));
|
||||
vi.mock("./UrlParams", () => ({ useUrlParams: vi.fn() }));
|
||||
|
||||
describe("useTheme", () => {
|
||||
let originalClassList: DOMTokenList;
|
||||
let themeChange$: Subject<HostRequest<{ name?: string }>>;
|
||||
let wrapper: FC<PropsWithChildren>;
|
||||
|
||||
beforeEach(() => {
|
||||
themeChange$ = new Subject();
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, themeChange$ };
|
||||
wrapper = ({ children }) =>
|
||||
createElement(HostBridgeProvider, { value: hostBridge }, children);
|
||||
// Save the original classList to setup spies
|
||||
originalClassList = document.body.classList;
|
||||
|
||||
vi.spyOn(originalClassList, "add");
|
||||
vi.spyOn(originalClassList, "remove");
|
||||
vi.spyOn(originalClassList, "item").mockReturnValue(null);
|
||||
(getUrlParams as Mock).mockReturnValue({ theme: "dark" });
|
||||
(useUrlParams as Mock).mockReturnValue({ theme: "dark" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -53,9 +59,9 @@ describe("useTheme", () => {
|
||||
{ setTheme: "light-high-contrast", add: ["cpd-theme-light-hc"] },
|
||||
])("apply procedure", ({ setTheme, add }) => {
|
||||
test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => {
|
||||
(getUrlParams as Mock).mockReturnValue({ theme: setTheme });
|
||||
(useUrlParams as Mock).mockReturnValue({ theme: setTheme });
|
||||
|
||||
renderHook(() => useTheme());
|
||||
renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(originalClassList.remove).toHaveBeenCalledWith(
|
||||
"cpd-theme-light",
|
||||
@@ -71,7 +77,7 @@ describe("useTheme", () => {
|
||||
// Simulate a previous theme
|
||||
originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark");
|
||||
|
||||
renderHook(() => useTheme());
|
||||
renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(document.body.classList.add).not.toHaveBeenCalledWith(
|
||||
"cpd-theme-dark",
|
||||
@@ -82,18 +88,13 @@ describe("useTheme", () => {
|
||||
expect(originalClassList.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("theme changes in response to widget actions", async () => {
|
||||
renderHook(() => useTheme());
|
||||
test("theme changes in response to host requests", () => {
|
||||
renderHook(() => useTheme(), { wrapper });
|
||||
|
||||
expect(originalClassList.add).toHaveBeenCalledWith("cpd-theme-dark");
|
||||
await act(() =>
|
||||
widget!.lazyActions.emit(
|
||||
WidgetApiToWidgetAction.ThemeChange,
|
||||
new CustomEvent(WidgetApiToWidgetAction.ThemeChange, {
|
||||
detail: { data: { name: "light" } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const reply = vi.fn();
|
||||
act(() => themeChange$.next({ data: { name: "light" }, reply }));
|
||||
expect(reply).toHaveBeenCalledOnce();
|
||||
expect(originalClassList.remove).toHaveBeenCalledWith(
|
||||
"cpd-theme-light",
|
||||
"cpd-theme-dark",
|
||||
|
||||
+20
-31
@@ -6,38 +6,27 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { WidgetApiToWidgetAction } from "matrix-widget-api";
|
||||
import { type IThemeChangeActionRequest } from "matrix-widget-api";
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { widget } from "./widget";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export const useTheme = (): void => {
|
||||
const [requestedTheme, setRequestedTheme] = useState(
|
||||
() => getUrlParams().theme,
|
||||
);
|
||||
const previousTheme = useRef<string | null>(document.body.classList.item(0));
|
||||
const rootElement = useRootElement();
|
||||
const hostBridge = useHostBridge();
|
||||
const { theme } = useUrlParams();
|
||||
const [requestedTheme, setRequestedTheme] = useState(theme);
|
||||
const previousTheme = useRef<string | null>(rootElement.classList.item(0));
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) {
|
||||
const onThemeChange = (
|
||||
ev: CustomEvent<IThemeChangeActionRequest>,
|
||||
): void => {
|
||||
ev.preventDefault();
|
||||
if ("name" in ev.detail.data && typeof ev.detail.data.name === "string")
|
||||
setRequestedTheme(ev.detail.data.name);
|
||||
widget!.api.transport.reply(ev.detail, {});
|
||||
};
|
||||
|
||||
widget.lazyActions.on(WidgetApiToWidgetAction.ThemeChange, onThemeChange);
|
||||
return (): void => {
|
||||
widget!.lazyActions.off(
|
||||
WidgetApiToWidgetAction.ThemeChange,
|
||||
onThemeChange,
|
||||
);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
const subscription = hostBridge.themeChange$.subscribe(
|
||||
({ data, reply }) => {
|
||||
if (typeof data.name === "string") setRequestedTheme(data.name);
|
||||
reply();
|
||||
},
|
||||
);
|
||||
return (): void => subscription.unsubscribe();
|
||||
}, [hostBridge]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// If no theme has been explicitly requested we default to dark
|
||||
@@ -47,15 +36,15 @@ export const useTheme = (): void => {
|
||||
: "";
|
||||
const themeString = "cpd-theme-" + theme + themeHighContrast;
|
||||
if (themeString !== previousTheme.current) {
|
||||
document.body.classList.remove(
|
||||
rootElement.classList.remove(
|
||||
"cpd-theme-light",
|
||||
"cpd-theme-dark",
|
||||
"cpd-theme-light-hc",
|
||||
"cpd-theme-dark-hc",
|
||||
);
|
||||
document.body.classList.add(themeString);
|
||||
rootElement.classList.add(themeString);
|
||||
previousTheme.current = themeString;
|
||||
}
|
||||
document.body.classList.remove("no-theme");
|
||||
}, [previousTheme, requestedTheme]);
|
||||
rootElement.classList.remove("no-theme");
|
||||
}, [previousTheme, requestedTheme, rootElement]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
ErrorCategory,
|
||||
ErrorCode,
|
||||
FailToStartLivekitConnection,
|
||||
MembershipManagerError,
|
||||
NoMatrix2AuthorizationService,
|
||||
SFURoomCreationRestrictedError,
|
||||
} from "./errors";
|
||||
|
||||
// These errors take their wording from Element Call's own i18next instance
|
||||
// rather than the global one, so each needs to come out translated rather than
|
||||
// as a raw key.
|
||||
describe("localised errors", () => {
|
||||
test("MembershipManagerError describes the failure and keeps its cause", () => {
|
||||
const cause = new Error("the underlying problem");
|
||||
const error = new MembershipManagerError(cause);
|
||||
|
||||
expect(error.code).toBe(ErrorCode.INTERNAL_MEMBERSHIP_MANAGER);
|
||||
expect(error.category).toBe(ErrorCategory.SYSTEM_FAILURE);
|
||||
expect(error.localisedTitle).not.toContain("error.");
|
||||
expect(error.localisedMessage).not.toContain("error.");
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
test("NoMatrix2AuthorizationService is a configuration problem", () => {
|
||||
const cause = new Error("404");
|
||||
const error = new NoMatrix2AuthorizationService(cause);
|
||||
|
||||
expect(error.code).toBe(ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE);
|
||||
expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE);
|
||||
expect(error.localisedTitle).not.toContain("error.");
|
||||
expect(error.localisedMessage).not.toContain("error.");
|
||||
expect(error.cause).toBe(cause);
|
||||
});
|
||||
|
||||
test("FailToStartLivekitConnection passes its detail through", () => {
|
||||
const error = new FailToStartLivekitConnection("could not publish");
|
||||
|
||||
expect(error.code).toBe(ErrorCode.FAILED_TO_START_LIVEKIT);
|
||||
expect(error.category).toBe(ErrorCategory.NETWORK_CONNECTIVITY);
|
||||
expect(error.localisedTitle).not.toContain("error.");
|
||||
expect(error.localisedMessage).toBe("could not publish");
|
||||
});
|
||||
|
||||
test("SFURoomCreationRestrictedError explains the restriction", () => {
|
||||
const error = new SFURoomCreationRestrictedError();
|
||||
|
||||
expect(error.code).toBe(ErrorCode.SFU_ERROR);
|
||||
expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE);
|
||||
expect(error.localisedTitle).not.toContain("error.");
|
||||
expect(error.localisedMessage).not.toContain("error.");
|
||||
});
|
||||
});
|
||||
+22
-23
@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { t } from "i18next";
|
||||
import { type ConnectionError } from "livekit-client";
|
||||
|
||||
import { i18nKey } from "./i18n";
|
||||
import { i18n, i18nKey } from "./i18n";
|
||||
|
||||
export enum ErrorCode {
|
||||
/**
|
||||
@@ -78,10 +77,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
|
||||
*/
|
||||
public constructor(domain: string) {
|
||||
super(
|
||||
t("error.call_is_not_supported"),
|
||||
i18n.t("error.call_is_not_supported"),
|
||||
ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
t("error.matrix_rtc_transport_missing", {
|
||||
i18n.t("error.matrix_rtc_transport_missing", {
|
||||
domain,
|
||||
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
||||
errorCode: ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
|
||||
@@ -97,10 +96,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
|
||||
export class ConnectionLostError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
t("error.connection_lost"),
|
||||
i18n.t("error.connection_lost"),
|
||||
ErrorCode.CONNECTION_LOST_ERROR,
|
||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||
t("error.connection_lost_description"),
|
||||
i18n.t("error.connection_lost_description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -117,10 +116,10 @@ export class MembershipManagerError extends ElementCallError {
|
||||
*/
|
||||
public constructor(error: Error) {
|
||||
super(
|
||||
t("error.membership_manager"),
|
||||
i18n.t("error.membership_manager"),
|
||||
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
|
||||
ErrorCategory.SYSTEM_FAILURE,
|
||||
t("error.membership_manager_description"),
|
||||
i18n.t("error.membership_manager_description"),
|
||||
error,
|
||||
);
|
||||
}
|
||||
@@ -134,10 +133,10 @@ export class MembershipManagerError extends ElementCallError {
|
||||
export class StickyEventsRequiredError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
t("error.sticky_events_required"),
|
||||
i18n.t("error.sticky_events_required"),
|
||||
ErrorCode.STICKY_EVENTS_NOT_SUPPORTED,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
t("error.sticky_events_required_description"),
|
||||
i18n.t("error.sticky_events_required_description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -148,10 +147,10 @@ export class StickyEventsRequiredError extends ElementCallError {
|
||||
export class E2EENotSupportedError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
t("error.e2ee_unsupported"),
|
||||
i18n.t("error.e2ee_unsupported"),
|
||||
ErrorCode.E2EE_NOT_SUPPORTED,
|
||||
ErrorCategory.CLIENT_CONFIGURATION,
|
||||
t("error.e2ee_unsupported_description"),
|
||||
i18n.t("error.e2ee_unsupported_description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -166,7 +165,7 @@ export class UnknownCallError extends ElementCallError {
|
||||
*/
|
||||
public constructor(error: Error) {
|
||||
super(
|
||||
t("error.generic"),
|
||||
i18n.t("error.generic"),
|
||||
ErrorCode.UNKNOWN_ERROR,
|
||||
ErrorCategory.UNKNOWN,
|
||||
undefined,
|
||||
@@ -186,7 +185,7 @@ export class FailToGetOpenIdToken extends ElementCallError {
|
||||
*/
|
||||
public constructor(error: Error) {
|
||||
super(
|
||||
t("error.generic"),
|
||||
i18n.t("error.generic"),
|
||||
ErrorCode.OPEN_ID_ERROR,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
undefined,
|
||||
@@ -203,10 +202,10 @@ export class NoMatrix2AuthorizationService extends ElementCallError {
|
||||
*/
|
||||
public constructor(error: Error) {
|
||||
super(
|
||||
t("error.generic"),
|
||||
i18n.t("error.generic"),
|
||||
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
t("error.no_matrix_2_authorization_service"),
|
||||
i18n.t("error.no_matrix_2_authorization_service"),
|
||||
// Properly set it as a cause for a better reporting on sentry
|
||||
error,
|
||||
);
|
||||
@@ -223,7 +222,7 @@ export class FailToStartLivekitConnection extends ElementCallError {
|
||||
*/
|
||||
public constructor(e?: string) {
|
||||
super(
|
||||
t("error.failed_to_start_livekit"),
|
||||
i18n.t("error.failed_to_start_livekit"),
|
||||
ErrorCode.FAILED_TO_START_LIVEKIT,
|
||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||
e,
|
||||
@@ -237,10 +236,10 @@ export class FailToStartLivekitConnection extends ElementCallError {
|
||||
export class InsufficientCapacityError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
t("error.insufficient_capacity"),
|
||||
i18n.t("error.insufficient_capacity"),
|
||||
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
|
||||
ErrorCategory.UNKNOWN,
|
||||
t("error.insufficient_capacity_description"),
|
||||
i18n.t("error.insufficient_capacity_description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -252,10 +251,10 @@ export class InsufficientCapacityError extends ElementCallError {
|
||||
export class SFURoomCreationRestrictedError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
t("error.room_creation_restricted"),
|
||||
i18n.t("error.room_creation_restricted"),
|
||||
ErrorCode.SFU_ERROR,
|
||||
ErrorCategory.CONFIGURATION_ISSUE,
|
||||
t("error.room_creation_restricted_description"),
|
||||
i18n.t("error.room_creation_restricted_description"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -266,7 +265,7 @@ export class SFURoomCreationRestrictedError extends ElementCallError {
|
||||
export class PeerConnectionTimeoutError extends ElementCallError {
|
||||
public constructor() {
|
||||
super(
|
||||
t("error.peer_connection_timeout"),
|
||||
i18n.t("error.peer_connection_timeout"),
|
||||
ErrorCode.SFU_ERROR,
|
||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||
);
|
||||
@@ -283,7 +282,7 @@ export class PeerConnectionTimeoutError extends ElementCallError {
|
||||
export class LivekitConnectionError extends ElementCallError {
|
||||
public constructor(cause: ConnectionError) {
|
||||
super(
|
||||
t("error.livekit_connection_error"),
|
||||
i18n.t("error.livekit_connection_error"),
|
||||
ErrorCode.SFU_ERROR,
|
||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||
);
|
||||
|
||||
@@ -5,5 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import i18next, { type i18n as I18nInstance } from "i18next";
|
||||
|
||||
// Custom marker function to allow i18next extraction
|
||||
export const i18nKey = (key: string): string => key;
|
||||
|
||||
/**
|
||||
* Element Call's own i18next instance.
|
||||
*
|
||||
* We deliberately do not use the global i18next singleton: when Element Call is
|
||||
* embedded in a host application (rather than running as its own page), that
|
||||
* singleton belongs to the host, and configuring it would clobber the host's
|
||||
* translations.
|
||||
*
|
||||
* It is configured by `Initializer.initBeforeReact` and made available to
|
||||
* components via `<I18nextProvider>`; tests and stories configure it directly.
|
||||
*
|
||||
* Non-React code should call `i18n.t(...)` on this instance. Components should
|
||||
* use `useTranslation()` instead, so that they re-render on a language change.
|
||||
* Note that `t` must be reached through the instance at call time — i18next
|
||||
* only assigns it during `init()`, so destructuring it at module scope would
|
||||
* capture an uninitialised function.
|
||||
*/
|
||||
export const i18n: I18nInstance = i18next.createInstance();
|
||||
|
||||
@@ -40,7 +40,6 @@ export async function initSPA(
|
||||
try {
|
||||
const client = await initClient(initClientParams, true);
|
||||
return {
|
||||
widgetApi: null,
|
||||
client,
|
||||
passwordlessUser,
|
||||
};
|
||||
|
||||
@@ -40,8 +40,11 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
|
||||
import { constant } from "../state/Behavior";
|
||||
import { MatrixRTCMode } from "../config/ConfigOptions";
|
||||
import { createCallFooterViewModel } from "../components/CallFooterViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { type FooterSnapshot } from "../components/CallFooter";
|
||||
import { type ViewModel } from "../state/ViewModel";
|
||||
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel";
|
||||
import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab";
|
||||
|
||||
mockConfig({ livekit: { livekit_service_url: "https://example.com" } });
|
||||
|
||||
@@ -140,6 +143,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
): {
|
||||
vm: CallViewModel;
|
||||
footerVm: ViewModel<FooterSnapshot>;
|
||||
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
|
||||
rtcMemberships$: BehaviorSubject<CallMembership[]>;
|
||||
rtcSession: MockRTCSession;
|
||||
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
|
||||
@@ -171,7 +175,7 @@ export function getBasicCallViewModelEnvironment(
|
||||
setE2EEEnabled: async () => Promise.resolve(),
|
||||
}),
|
||||
connectionState$: constant(ConnectionState.Connected),
|
||||
matrixRTCMode$: constant(MatrixRTCMode.Legacy),
|
||||
matrixRTCMode$: constant(MatrixRTCMode.Compatibility),
|
||||
...callViewModelOptions,
|
||||
},
|
||||
handRaisedSubject$,
|
||||
@@ -184,10 +188,12 @@ export function getBasicCallViewModelEnvironment(
|
||||
muteStates,
|
||||
mediaDevices,
|
||||
"reactionId",
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
return {
|
||||
vm,
|
||||
footerVm,
|
||||
developerSettingsVm: createDeveloperSettingsTabViewModel(testScope(), vm),
|
||||
rtcMemberships$,
|
||||
rtcSession,
|
||||
handRaisedSubject$: handRaisedSubject$,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user