Compare commits

..
Author SHA1 Message Date
Robin 3c4379ce9f Log when delegation succeeds 2026-08-27 10:29:39 +02:00
Robin 5399d2bec1 Delegate delayed events using dedicated endpoint 2026-08-26 19:51:43 +02:00
78 changed files with 983 additions and 2772 deletions
+2 -4
View File
@@ -7,16 +7,14 @@ 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.
// Unlike the app, stories register the instance as react-i18next's default
// rather than wrapping every story in an <I18nextProvider>.
// Bare-minimum i18n config
i18n
.use(initReactI18next)
.init({
+2 -2
View File
@@ -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:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
image: ghcr.io/element-hq/element-web:develop@sha256:0332028836603e91689053f157847b4deef9342bd19e3195eeb81793b7e4046e
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:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
image: ghcr.io/element-hq/element-web:develop@sha256:0332028836603e91689053f157847b4deef9342bd19e3195eeb81793b7e4046e
+25 -25
View File
@@ -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@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))
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))
'@sentry/react':
specifier: ^8.0.0
version: 8.55.2(react@19.2.8)
@@ -138,7 +138,7 @@ importers:
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@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))
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))
'@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)
@@ -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/24929be0e741be6a5d0a7226f1c682e245263b8a
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0
matrix-widget-api:
specifier: ^1.18.0
version: 1.19.0
version: 1.18.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.5.0':
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
'@matrix-org/matrix-sdk-crypto-wasm@18.4.0':
resolution: {integrity: sha512-osxkU1DQ+05+anGHapjWyvZqdHUb94Id37gy54mCKn1Cq/D7iGT5oEUEhjp4oTnCLo4TOtI6ULJ/LHsapaIptQ==}
engines: {node: '>= 18'}
'@mdx-js/react@3.1.1':
@@ -3670,9 +3670,9 @@ packages:
constants-browserify@1.0.0:
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
content-type@3.0.0:
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
engines: {node: '>=22'}
content-type@2.0.0:
resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==}
engines: {node: '>=18'}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -4685,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/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
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
engines: {node: '>=22.0.0'}
matrix-widget-api@1.19.0:
resolution: {integrity: sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==}
matrix-widget-api@1.18.0:
resolution: {integrity: sha512-4T2f2koWmx05p1BLcT/9YGGGPSXpPT+PA4Oap/5fjhXsWPxMGJiGL97YpANMYLKsnE1sScYFeuyxIgdc1Qo+Ew==}
md5.js@1.3.5:
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
@@ -7102,7 +7102,7 @@ snapshots:
'@types/dom-mediacapture-transform': 0.1.11
livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22)
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
'@matrix-org/matrix-sdk-crypto-wasm@18.4.0': {}
'@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.8)':
dependencies:
@@ -8053,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@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))':
'@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))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
picomatch: 4.0.4
rolldown: 1.1.5
optionalDependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 7.29.7
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': {}
@@ -8744,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@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))':
'@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))':
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@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))
'@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
'@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)':
@@ -9195,7 +9195,7 @@ snapshots:
constants-browserify@1.0.0: {}
content-type@3.0.0: {}
content-type@2.0.0: {}
convert-source-map@2.0.0: {}
@@ -10275,21 +10275,21 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/178dc6528ebc6a15e2f6fab38410127f16b500d0:
dependencies:
'@babel/runtime': 8.0.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.5.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.4.0
another-json: 0.2.0
bs58: 6.0.0
content-type: 3.0.0
content-type: 2.0.0
loglevel: 1.9.2
matrix-events-sdk: 0.0.1
matrix-widget-api: 1.19.0
matrix-widget-api: 1.18.0
p-retry: 8.0.0
sdp-transform: 3.0.0
unhomoglyph: 1.0.6
matrix-widget-api@1.19.0:
matrix-widget-api@1.18.0:
dependencies:
'@types/events': 3.0.3
events: 3.3.0
+23 -32
View File
@@ -46,10 +46,7 @@ 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 {
callViewModelOptionsFromParams,
createCallViewModel$,
} from "../src/state/CallViewModel/CallViewModel";
import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel";
import { ObservableScope } from "../src/state/ObservableScope";
import { getUrlParams } from "../src/UrlParams";
import { MuteStates } from "../src/state/MuteStates";
@@ -57,9 +54,12 @@ 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 { initializeWidget } from "../src/widget";
import {
ElementWidgetActions,
widget as _widget,
initializeWidget,
} from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
import { createWidgetHostBridge } from "../src/HostBridge";
interface MatrixRTCSdk {
/**
@@ -109,15 +109,14 @@ export async function createMatrixRTCSdk(
const scope = new ObservableScope();
// widget client
const widget = initializeWidget(application, true);
initializeWidget(application, true);
const widget = _widget;
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 urlParams = getUrlParams();
const { roomId, controlledAudioDevices, callIntent } = urlParams;
const { roomId } = getUrlParams();
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");
@@ -129,16 +128,11 @@ export async function createMatrixRTCSdk(
const rtcSession = rtcSessionManager.getRoomSession(room);
// media devices
const mediaDevices = new MediaDevices(scope, {
controlledAudioDevices,
callIntent,
const mediaDevices = new MediaDevices(scope);
const muteStates = new MuteStates(scope, mediaDevices, {
audioEnabled: false,
videoEnabled: false,
});
const muteStates = new MuteStates(
scope,
mediaDevices,
{ audioEnabled: false, videoEnabled: false },
hostBridge,
);
// call view model
const callViewModel = createCallViewModel$(
@@ -147,11 +141,7 @@ export async function createMatrixRTCSdk(
room,
mediaDevices,
muteStates,
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
hostBridge,
},
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
of({}),
of({}),
constant({ supported: false, processor: undefined }),
@@ -292,17 +282,18 @@ export async function createMatrixRTCSdk(
});
await leaveResolver.promise;
logger.info("send Unstick");
await hostBridge
await widget.api
.setAlwaysOnScreen(false)
.catch((e: unknown) =>
logger.error("Failed to set `alwaysOnScreen` to false", e),
.catch((e) =>
logger.error(
"Failed to set call widget `alwaysOnScreen` to false",
e,
),
);
logger.info("send Close");
await hostBridge
.close?.()
.catch((e: unknown) =>
logger.error("Failed to ask the host to close", e),
);
await widget.api.transport
.send(ElementWidgetActions.Close, {})
.catch((e) => logger.error("Failed to send close action", e));
};
// schedule close first and then leave (scope.end)
+53 -96
View File
@@ -5,13 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
import {
type FC,
type JSX,
Suspense,
useEffect,
useMemo,
useState,
} from "react";
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web";
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";
@@ -20,26 +25,13 @@ import { RoomPage } from "./room/RoomPage";
import { ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { Initializer } from "./initializer";
import { type WidgetHelpers } from "./widget";
import { widget } from "./widget";
import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
import {
HeaderStyle,
UrlParamsProvider,
useUrlParams,
useUrlParamsFromLocation,
} from "./UrlParams";
import { getUrlParams, HeaderStyle, useUrlParams } 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);
@@ -47,24 +39,15 @@ 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(() => {
rootElement.setAttribute("data-background", background);
}, [pathname, background, rootElement]);
document
.getElementsByTagName("body")[0]
.setAttribute("data-background", background);
}, [pathname, background]);
return children;
};
@@ -74,87 +57,61 @@ 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, 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),
);
export const App: FC<Props> = ({ vm }) => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
Initializer.init()
?.then(async () => {
if (loaded) return;
setLoaded(true);
await hostBridge.contentLoaded();
await widget?.api.sendContentLoaded();
})
.catch(logger.error);
});
// 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;
// Since we are outside the router component, we cannot use useUrlParams here
const { header } = useMemo(getUrlParams, []);
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 />
);
const content = loaded ? (
<ClientProvider>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} widget={widget} />}
>
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute path="/register" element={<RegisterPage />} />
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
) : (
<LoadingPage />
);
return (
<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>
<BrowserRouter>
<BackgroundProvider>
<ThemeProvider>
<TooltipProvider>
<Suspense fallback={null}>
{header === HeaderStyle.AppBar ? (
<AppBar>{content}</AppBar>
) : (
content
)}
</Suspense>
</TooltipProvider>
</ThemeProvider>
</BackgroundProvider>
</BrowserRouter>
);
};
+49 -14
View File
@@ -9,22 +9,18 @@ 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 } from "./Avatar";
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
import {
type HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "./HostBridge";
import { widget } from "./widget";
const TestComponent: FC<
PropsWithChildren<{
client: MatrixClient;
hostBridge?: HostBridge;
}>
> = ({ client, hostBridge = nullHostBridge, children }) => {
> = ({ client, children }) => {
return (
<ClientContextProvider
value={{
@@ -42,11 +38,17 @@ const TestComponent: FC<
},
}}
>
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
{children}
</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();
});
@@ -133,7 +135,7 @@ test("should attempt to fetch authenticated media from the server", async () =>
});
});
test("should download media through the host when it offers to", async () => {
test("should attempt to use widget API if running as a widget", async () => {
const expectedMXCUrl = "mxc://example.org/alice-avatar";
const expectedObjectURL = "my-object-url";
const theBlob = new Blob([]);
@@ -149,8 +151,8 @@ test("should download media through the host when it offers to", async () => {
getAccessToken: () => undefined,
} as unknown as MatrixClient);
const downloadMedia = vi.fn().mockResolvedValue(theBlob);
const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia };
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
const member = mockMatrixRoomMember(
mockRtcMembership("@alice:example.org", "AAAA"),
{
@@ -159,7 +161,7 @@ test("should download media through the host when it offers to", async () => {
);
const displayName = "Alice";
render(
<TestComponent client={client} hostBridge={hostBridge}>
<TestComponent client={client}>
<Avatar
id={member.userId}
name={displayName}
@@ -174,5 +176,38 @@ test("should download media through the host when it offers to", async () => {
document.querySelector(`img[src='${expectedObjectURL}']`),
);
expect(downloadMedia).toBeCalledWith(expectedMXCUrl);
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);
});
+27 -7
View File
@@ -14,9 +14,10 @@ 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 { useHostBridge } from "./HostBridge";
import { widget } from "./widget";
export enum Size {
XS = "xs",
@@ -75,7 +76,6 @@ export const Avatar: FC<Props> = ({
...props
}) => {
const clientState = useClientState();
const hostBridge = useHostBridge();
const sizePx = useMemo(
() =>
@@ -87,8 +87,7 @@ export const Avatar: FC<Props> = ({
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
// 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.
// 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.
useEffect(() => {
if (!src) {
setAvatarUrl(undefined);
@@ -97,8 +96,8 @@ export const Avatar: FC<Props> = ({
let blob: Promise<Blob>;
if (hostBridge.downloadMedia) {
blob = hostBridge.downloadMedia(src);
if (widget?.api) {
blob = getAvatarFromWidgetAPI(widget.api, src);
} else if (
clientState?.state === "valid" &&
clientState.authenticated?.client &&
@@ -133,7 +132,7 @@ export const Avatar: FC<Props> = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [clientState, hostBridge, src, sizePx]);
}, [clientState, src, sizePx]);
return (
<CompoundAvatar
@@ -173,3 +172,24 @@ 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 + "",
);
}
-115
View File
@@ -1,115 +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, 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");
});
+49 -51
View File
@@ -21,8 +21,9 @@ 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 { useHostBridge } from "./HostBridge";
import { widget } from "./widget";
import {
PosthogAnalytics,
RegistrationType,
@@ -133,48 +134,18 @@ 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, client }) => {
export const ClientProvider: FC<Props> = ({ children }) => {
const navigate = useNavigate();
const hostBridge = useHostBridge();
// null = signed out, undefined = loading
const [initClientState, setInitClientState] = useState<
InitResult | null | 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 },
);
>(undefined);
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.
@@ -189,7 +160,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
})
.catch((err) => logger.error(err))
.finally(() => (initializing.current = false));
}, [client]);
}, []);
const changePassword = useCallback(
async (password: string) => {
@@ -230,6 +201,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
saveSession(session);
setInitClientState({
widgetApi: null,
client,
passwordlessUser: session.passwordlessUser,
});
@@ -256,13 +228,11 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
// 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. 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;
// 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.
useEffect(() => {
if (ownsSession) loadChannel?.postMessage({});
}, [ownsSession]);
if (!widget) loadChannel?.postMessage({});
}, []);
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
undefined,
@@ -337,36 +307,64 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
initClientState.client.on(ClientEvent.Sync, onSync);
}
if (!hostBridge.supportsReactions)
logger.warn("The host does not permit reactions");
setSupportsReactions(hostBridge.supportsReactions);
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);
}
return (): void => {
if (initClientState.client) {
initClientState.client.removeListener(ClientEvent.Sync, onSync);
}
};
}, [initClientState, onSync, hostBridge]);
}, [initClientState, onSync]);
if (alreadyOpenedErr) {
return <ErrorPage error={alreadyOpenedErr} />;
return <ErrorPage widget={widget} 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> {
const { initSPA } = await import("./utils/spa");
return initSPA(loadSession, clearSession);
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);
}
}
export interface Session {
+19 -13
View File
@@ -21,7 +21,7 @@ import { RageshakeButton } from "./settings/RageshakeButton";
import styles from "./ErrorView.module.css";
import { useUrlParams } from "./UrlParams";
import { LinkButton } from "./button";
import { useHostBridge } from "./HostBridge.ts";
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
interface Props {
Icon: ComponentType<SVGAttributes<SVGElement>>;
@@ -38,6 +38,7 @@ interface Props {
*/
fatal?: boolean;
children: ReactNode;
widget: WidgetHelpers | null;
}
export const ErrorView: FC<Props> = ({
@@ -46,27 +47,32 @@ 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 CloseButton: FC<{ close: () => Promise<void> }> = ({
close,
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
widget,
}): ReactElement => {
// 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);
});
// 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();
});
};
return (
<Button kind="primary" onClick={onClose}>
<Button kind="primary" onClick={closeWidget}>
{t("action.close")}
</Button>
);
@@ -102,8 +108,8 @@ export const ErrorView: FC<Props> = ({
{rageshake && (
<RageshakeButton description={`***Error View***: ${title}`} />
)}
{hostBridge.close ? (
<CloseButton close={hostBridge.close} />
{widget ? (
<CloseWidgetButton widget={widget} />
) : (
!confineToRoom && <ReturnToHomeButton />
)}
+4 -1
View File
@@ -17,6 +17,7 @@ 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;
@@ -47,11 +48,12 @@ 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 }: ErrorPageProps): ReactElement => {
export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
const { t } = useTranslation();
useEffect(() => {
logger.error(error);
@@ -64,6 +66,7 @@ export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
error.richMessage
) : (
<ErrorView
widget={widget}
Icon={ErrorSolidIcon}
title={t("error.generic")}
rageshake
-288
View File
@@ -1,288 +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 { 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);
});
});
-220
View File
@@ -1,220 +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 { 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;
+2 -4
View File
@@ -24,7 +24,6 @@ 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;
@@ -79,7 +78,6 @@ 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)");
@@ -102,7 +100,7 @@ export const Modal: FC<Props> = ({
onOpenChange={onOpenChange}
dismissible={onDismiss !== undefined}
>
<Drawer.Portal container={rootElement}>
<Drawer.Portal>
<Drawer.Overlay className={classNames(overlayStyles.bg)} />
<Drawer.Content
className={classNames(
@@ -157,7 +155,7 @@ export const Modal: FC<Props> = ({
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal container={rootElement}>
<DialogPortal>
<DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)}
/>
+1 -2
View File
@@ -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 { useTranslation } from "react-i18next";
import { t } from "i18next";
import styles from "./QrCode.module.css";
@@ -18,7 +18,6 @@ interface Props {
}
export const QrCode: FC<Props> = ({ data, className }) => {
const { t } = useTranslation();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
+6 -1
View File
@@ -10,6 +10,7 @@ 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
@@ -31,7 +32,11 @@ const OpenElsewhere: FC = () => {
const { t } = useTranslation();
return (
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
<ErrorView
widget={widget}
Icon={PopOutIcon}
title={t("error.open_elsewhere")}
>
<p>
{t("error.open_elsewhere_description", {
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
-39
View File
@@ -1,39 +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 { 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;
+1 -7
View File
@@ -25,7 +25,6 @@ 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 {
/**
@@ -65,7 +64,6 @@ export const Toast: FC<Props> = ({
Icon,
modal = true,
}) => {
const rootElement = useRootElement();
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) onDismiss();
@@ -106,11 +104,7 @@ export const Toast: FC<Props> = ({
return (
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
{modal ? (
<DialogPortal container={rootElement}>{content}</DialogPortal>
) : (
content
)}
{modal ? <DialogPortal>{content}</DialogPortal> : content}
</DialogRoot>
);
};
+4 -41
View File
@@ -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 { createContext, use, useMemo } from "react";
import { useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import {
@@ -59,17 +59,6 @@ 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
@@ -459,7 +448,6 @@ 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.
@@ -531,36 +519,11 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
};
};
const UrlParamsContext = createContext<UrlParams | null>(null);
/**
* 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.
* Hook to simplify use of getUrlParams.
* @returns The app parameters for the current URL
*/
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 => {
export const useUrlParams = (): UrlParams => {
const { search, hash } = useLocation();
return useMemo(() => getUrlParams(search, hash), [search, hash]);
};
+62 -184
View File
@@ -15,7 +15,6 @@ import {
afterAll,
} from "vitest";
import posthog, { type CaptureResult } from "posthog-js";
import { type MatrixClient } from "matrix-js-sdk";
import {
Anonymity,
@@ -23,122 +22,75 @@ import {
PosthogAnalytics,
} from "./PosthogAnalytics";
import { mockConfig } from "../utils/test";
import { analyticsConfigFromEnvironment } from "../initializer";
import { optInAnalytics } from "../settings/settings";
describe("PosthogAnalytics", () => {
describe("enablement", () => {
beforeEach(() => {
PosthogAnalytics.resetInstance();
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
it("stays off until it is configured", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
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("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);
});
});
// 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();
});
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",
},
};
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
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();
});
it("does not create instance without config value or URL params", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
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();
it("ignores config value and does not create instance", () => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
// 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", () => {
it("uses URL params if both set", () => {
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
});
});
describe("full package", () => {
beforeAll(() => {
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");
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
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",
},
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
});
});
@@ -252,13 +204,22 @@ 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(() => {
PosthogAnalytics.resetInstance();
PosthogAnalytics.configure({
matrixBackend: "jssdk",
apiKey: "api_key",
apiHost: "https://api.example.com.localhost",
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("passes events through the privacy filter via before_send", () => {
@@ -283,86 +244,3 @@ 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" }),
),
);
});
});
+27 -58
View File
@@ -15,6 +15,7 @@ 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,
@@ -28,6 +29,8 @@ import {
CallConnectDurationTracker,
CallReconnectingTracker,
} from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
import { optInAnalytics } from "../settings/settings";
/* Posthog analytics tracking.
@@ -137,27 +140,6 @@ 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
@@ -185,32 +167,13 @@ 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,
PosthogAnalytics.analyticsConfig,
);
this.internalInstance = new PosthogAnalytics(posthog);
}
return this.internalInstance;
}
@@ -218,14 +181,20 @@ export class PosthogAnalytics {
public static resetInstance(): void {
// Reset the singleton instance
this.internalInstance = null;
this.analyticsConfig = analyticsDisabled;
}
private constructor(
private readonly posthog: PostHog,
private readonly config: AnalyticsConfig,
) {
const { apiKey, apiHost } = config;
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;
}
if (apiKey && apiHost) {
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
@@ -256,15 +225,15 @@ export class PosthogAnalytics {
}
}
private getPlatformProperties(): PlatformProperties {
private static getPlatformProperties(): PlatformProperties {
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
return {
appVersion,
matrixBackend: this.config.matrixBackend,
matrixBackend: widget ? "embedded" : "jssdk",
callBackend: "livekit",
// 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(),
cryptoVersion: widget
? undefined
: window.matrixclient?.getCrypto()?.getVersion(),
};
}
@@ -314,8 +283,8 @@ export class PosthogAnalytics {
// different devices to send the same ID.
let analyticsID = await this.getAnalyticsId();
try {
if (!analyticsID && this.config.matrixBackend !== "embedded") {
// only mint an analytics ID when we are the ones storing it.
if (!analyticsID && !widget) {
// only try setting up a new analytics ID in the standalone app.
// 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
@@ -344,8 +313,8 @@ export class PosthogAnalytics {
private async getAnalyticsId(): Promise<string | null> {
const client: MatrixClient = window.matrixclient;
if (this.config.matrixBackend === "embedded") {
return this.config.hostAnalyticsId ?? null;
if (widget) {
return getUrlParams().posthogUserId;
} else {
const accountData = await client.getAccountDataFromServer(
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
@@ -355,7 +324,7 @@ export class PosthogAnalytics {
}
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
if (this.config.matrixBackend !== "embedded") {
if (!widget) {
const client = window.matrixclient;
// the analytics ID only needs to be set in the standalone version.
@@ -393,7 +362,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 = this.getPlatformProperties();
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
this.registerSuperProperties({
...this.platformSuperProperties,
registrationType:
+3 -4
View File
@@ -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 { useUrlParams } from "../UrlParams";
import { widget } from "../widget";
export const useInteractiveRegistration = (
oldClient?: MatrixClient,
@@ -32,7 +32,6 @@ export const useInteractiveRegistration = (
passwordlessUser: boolean,
) => Promise<[MatrixClient, Session]>;
} => {
const { isWidget } = useUrlParams();
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
undefined,
);
@@ -48,7 +47,7 @@ export const useInteractiveRegistration = (
}
useEffect(() => {
if (isWidget) return;
if (widget) return;
// An empty registerRequest is used to get the privacy policy and recaptcha key.
authClient.current!.registerRequest({}).catch((error) => {
setPrivacyPolicyUrl(
@@ -56,7 +55,7 @@ export const useInteractiveRegistration = (
);
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
});
}, [isWidget]);
}, []);
const register = useCallback(
async (
+3 -4
View File
@@ -12,7 +12,7 @@ import { useClient } from "../ClientContext";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { generateRandomName } from "../auth/generateRandomName";
import { useRecaptcha } from "../auth/useRecaptcha";
import { useUrlParams } from "../UrlParams";
import { widget } from "../widget";
interface UseRegisterPasswordlessUserType {
privacyPolicyUrl?: string;
@@ -22,7 +22,6 @@ interface UseRegisterPasswordlessUserType {
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
const { setClient } = useClient();
const { isWidget } = useUrlParams();
const { privacyPolicyUrl, recaptchaKey, register } =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
@@ -32,7 +31,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
if (!setClient) {
throw new Error("No client context");
}
if (isWidget) {
if (widget) {
throw new Error(
"Registration was skipped: We should never try to register password-less user in embedded mode.",
);
@@ -54,7 +53,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
throw e;
}
},
[execute, reset, register, setClient, isWidget],
[execute, reset, register, setClient],
);
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
+1 -3
View File
@@ -29,9 +29,7 @@ const reactionData = {
reactions$: new BehaviorSubject({}),
};
const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
const mediaDevices = new MediaDevices(globalScope);
/**
* A wrapper component that is used for:
@@ -15,7 +15,6 @@ 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", () => ({
@@ -106,7 +105,6 @@ describe("createCallFooterViewModel", () => {
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.audioOptions$.value).toEqual([]);
@@ -128,7 +126,6 @@ describe("createCallFooterViewModel", () => {
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.audioOptions$?.value).toEqual([
+2 -5
View File
@@ -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 { HeaderStyle } from "../UrlParams";
import { getUrlParams, HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
@@ -138,8 +138,6 @@ 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,
@@ -147,9 +145,8 @@ export function createCallFooterViewModel(
muteStates: MuteStates,
mediaDevices: MediaDevices,
reactionIdentifier: string | undefined,
options: { showControls: boolean; header: HeaderStyle },
): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = options;
const { showControls, header: headerStyle } = getUrlParams();
const showLogo = headerStyle === HeaderStyle.Standard;
const isPip$ = scope.behavior(
@@ -14,9 +14,7 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
const mediaDevices = new MediaDevices(globalScope);
const meta = {
component: MediaMuteAndSwitchButton,
+2 -51
View File
@@ -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 { Config, validateConfig } from "./Config";
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
import { validateConfig } from "./Config";
import { MatrixRTCMode } from "./ConfigOptions";
describe("validateConfig", () => {
afterEach(() => {
@@ -52,52 +52,3 @@ 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");
});
});
+7 -42
View File
@@ -30,14 +30,6 @@ 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();
@@ -58,36 +50,17 @@ export class Config {
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
(config) => {
internalInstance.config = resolveConfig(config);
internalInstance.config = merge(
{},
DEFAULT_CONFIG,
validateConfig(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
@@ -96,7 +69,8 @@ export class Config {
* It is supposed to only be used in tests. (It is executed in `vite.setup.js`)
*/
public static initDefault(): void {
Config.initWith({});
Config.internalInstance = new Config();
Config.internalInstance.config = { ...DEFAULT_CONFIG };
}
// Convenience accessors
@@ -120,15 +94,6 @@ 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)) {
-13
View File
@@ -219,18 +219,6 @@ 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;
};
}
@@ -264,7 +252,6 @@ 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;
};
}
-36
View File
@@ -1,36 +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 { 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();
});
});
+6 -23
View File
@@ -12,7 +12,7 @@ import {
setLocalStorageItemReactive,
useLocalStorage,
} from "../useLocalStorage";
import { getUrlParams, useUrlParams } from "../UrlParams";
import { getUrlParams } from "../UrlParams";
import { E2eeType } from "./e2eeType";
import { useClient } from "../ClientContext";
@@ -57,31 +57,19 @@ const useRoomSharedKey = (
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
};
/**
* 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)
export function getKeyForRoom(roomId: string): string | null {
const { roomId: urlRoomId, password } = getUrlParams();
if (roomId !== urlRoomId)
logger.warn(
"requested key for a roomId which is not the current call room id (from the URL)",
roomId,
paramsRoomId,
urlRoomId,
);
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 };
@@ -89,15 +77,10 @@ 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),
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
getKeyForRoom(roomId) ?? undefined,
);
const room = client?.getRoom(roomId);
+2 -1
View File
@@ -13,6 +13,7 @@ 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();
@@ -23,7 +24,7 @@ export const HomePage: FC = () => {
if (!clientState) {
return <LoadingPage />;
} else if (clientState.state === "error") {
return <ErrorPage error={clientState.error} />;
return <ErrorPage widget={widget} error={clientState.error} />;
} else {
return clientState.authenticated ? (
<RegisteredView client={clientState.authenticated.client} />
+6 -45
View File
@@ -6,11 +6,12 @@ Please see LICENSE in the repository root for full details.
*/
import React from "react";
import {
import i18n, {
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";
@@ -32,13 +33,8 @@ import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings";
import { platform } from "./Platform";
import { isFailure } from "./utils/fetch";
import { initializeWidget, type WidgetHelpers } from "./widget";
import { initializeWidget } 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:
// {
@@ -102,36 +98,6 @@ 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,
@@ -155,8 +121,8 @@ export class Initializer {
return !!Initializer.internalInstance?.isInitialized;
}
public static async initBeforeReact(): Promise<WidgetHelpers | null> {
const widget = initializeWidget();
public static async initBeforeReact(): Promise<void> {
initializeWidget();
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillSegmenter()) {
@@ -182,12 +148,10 @@ 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",
@@ -243,8 +207,6 @@ export class Initializer {
});
window.setLKLogLevel = setLKLogLevel;
return widget;
}
public static init(): Promise<void> | null {
@@ -277,7 +239,6 @@ export class Initializer {
Config.init().then(
() => {
seedSettingsFromConfig(Config.get().media_quality);
PosthogAnalytics.configure(analyticsConfigFromEnvironment());
this.loadStates.config = LoadState.Loaded;
this.initStep(resolve);
},
+14 -80
View File
@@ -18,7 +18,6 @@ import {
NoMatrix2AuthorizationService,
} from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix";
import { Config } from "../config/Config";
import { JwtEndpointVersion } from "../state/CallViewModel/localMember/LocalTransport";
/**
@@ -85,8 +84,6 @@ export type OpenIDClientParts = Pick<
* This function by default uses whatever is possible with the current jwt service installed next to the SFU.
* For remote connections this does not matter, since we will not publish there we can rely on the newest option.
* For our own connection we can only use the hashed version if we also send the new matrix2.0 sticky events.
* @param opts.delayEndpointBaseUrl The URL of the matrix homeserver.
* @param opts.delayId The delay id used for the jwt service to manage.
* @param logger optional logger.
* @returns Object containing the token information
* @throws FailToGetOpenIdToken
@@ -98,8 +95,6 @@ export async function getSFUConfigWithOpenID(
roomId: string,
opts?: {
forceJwtEndpoint?: JwtEndpointVersion;
delayEndpointBaseUrl?: string;
delayId?: string;
},
logger?: Logger,
): Promise<SFUConfig> {
@@ -121,20 +116,18 @@ export async function getSFUConfigWithOpenID(
const forceMatrix2Jwt =
opts?.forceJwtEndpoint === JwtEndpointVersion.Matrix_2_0;
// We want to start using the new endpoint (with optional delay delegation)
// if we can use both or if we are forced to use the new one.
// We want to start using the new endpoint if we can use both or if we are
// forced to use the new one.
if (tryBothJwtEndpoints || forceMatrix2Jwt) {
try {
logger?.info(
`Trying to get JWT with delegation for focus ${serviceUrl}...`,
);
const sfuConfig = await getLiveKitJWTWithDelayDelegation(
const sfuConfig = await getLiveKitJWT(
membership,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
return extractFullConfigFromToken(sfuConfig);
@@ -154,13 +147,11 @@ export async function getSFUConfigWithOpenID(
logger?.info(
`Trying to get JWT with legacy endpoint for focus ${serviceUrl}...`,
);
sfuConfig = await getLiveKitJWT(
sfuConfig = await getLiveKitJWTLegacy(
membership.deviceId,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
@@ -188,33 +179,14 @@ function extractFullConfigFromToken(sfuConfig: {
};
}
async function getLiveKitJWT(
async function getLiveKitJWTLegacy(
deviceId: string,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
interface IDelayParams {
delay_id?: string;
delay_timeout?: number;
delay_cs_api_url?: string;
}
let bodyDalayParts: IDelayParams = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
const delayTimeoutMs =
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
bodyDalayParts = {
delay_id: delayId,
delay_timeout: delayTimeoutMs,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
return await fetch(livekitServiceURL + "/sfu/get", {
const res = await doNetworkOperationWithRetry(async () =>
fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -225,31 +197,9 @@ async function getLiveKitJWT(
room: matrixRoomId,
openid_token: openIDToken,
device_id: deviceId,
...delayParts,
}),
});
};
const res = await doNetworkOperationWithRetry(async () => {
let response = await makeRequest(bodyDalayParts);
// Old service compatibility check
const oldServiceDoesNotSupportDelayParts =
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
if (oldServiceDoesNotSupportDelayParts) {
try {
const errorBody = await response.json();
if (errorBody.errcode === "M_BAD_JSON") {
response = await makeRequest({});
}
} catch {
// If we can't parse the error, treat as real error
}
}
return response;
});
}),
);
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
@@ -264,17 +214,15 @@ class NotSupportedError extends Error {
}
}
export async function getLiveKitJWTWithDelayDelegation(
export async function getLiveKitJWT(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const body = {
const body = JSON.stringify({
room_id: matrixRoomId,
slot_id: "m.call#ROOM",
openid_token: openIDToken,
@@ -283,27 +231,13 @@ export async function getLiveKitJWTWithDelayDelegation(
claimed_user_id: userId,
claimed_device_id: deviceId,
},
};
let bodyDalayParts = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
const delayTimeoutMs =
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms;
bodyDalayParts = {
delay_id: delayId,
delay_timeout: delayTimeoutMs,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
});
const res = await doNetworkOperationWithRetry(async () => {
return await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, ...bodyDalayParts }),
headers: { "Content-Type": "application/json" },
body,
});
});
+2 -12
View File
@@ -21,7 +21,6 @@ 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);
@@ -49,19 +48,10 @@ if (fatalError !== null) {
}
Initializer.initBeforeReact()
.then((widget) => {
const { controlledAudioDevices, callIntent } = getUrlParams();
.then(() => {
root.render(
<StrictMode>
<App
vm={
new AppViewModel(globalScope, {
controlledAudioDevices,
callIntent,
})
}
widget={widget}
/>
<App vm={new AppViewModel(globalScope)} />
</StrictMode>,
);
})
+38 -20
View File
@@ -36,11 +36,7 @@ import {
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
import {
type HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "../HostBridge.ts";
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
test.each([
{
@@ -83,6 +79,7 @@ test.each([
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
@@ -111,6 +108,7 @@ test("should render the error page with link back to home", async () => {
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
@@ -156,7 +154,10 @@ test("ConnectionLostError: Action handling should reset error state", async () =
return (
<BrowserRouter>
<GroupCallErrorBoundary recoveryActionHandler={reconnectCallback}>
<GroupCallErrorBoundary
recoveryActionHandler={reconnectCallback}
widget={null}
>
<TestComponent fail={failState} />
</GroupCallErrorBoundary>
</BrowserRouter>
@@ -198,6 +199,7 @@ describe("Rageshake button", () => {
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
@@ -222,27 +224,29 @@ describe("Rageshake button", () => {
});
});
test("should have a close button when the host can dismiss us", async () => {
test("should have a close button in widget mode", async () => {
const error = new MatrixRTCTransportMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};
const close = vi.fn().mockResolvedValue(undefined);
const hostBridge: HostBridge = { ...nullHostBridge, close };
const mockWidget = {
api: {
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
},
} as unknown as WidgetHelpers;
const user = userEvent.setup();
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<HostBridgeProvider value={hostBridge}>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
>
<TestComponent />
</GroupCallErrorBoundary>
</HostBridgeProvider>
<GroupCallErrorBoundary
widget={mockWidget}
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
);
@@ -254,7 +258,11 @@ test("should have a close button when the host can dismiss us", async () => {
await user.click(screen.getByRole("button", { name: "Close" }));
expect(close).toHaveBeenCalled();
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
ElementWidgetActions.Close,
expect.anything(),
);
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
});
test("should show technical details when error has a matrixError cause", async () => {
@@ -274,7 +282,11 @@ test("should show technical details when error has a matrixError cause", async (
render(
<BrowserRouter>
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
@@ -303,7 +315,11 @@ test("should not show technical details when error has no matrix error cause", a
render(
<BrowserRouter>
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
@@ -360,6 +376,7 @@ describe("LiveKit ConnectionError variants", () => {
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
@@ -389,6 +406,7 @@ describe("LiveKit ConnectionError variants", () => {
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
+8 -1
View File
@@ -34,6 +34,7 @@ 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" ;
@@ -46,11 +47,13 @@ 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);
@@ -86,6 +89,7 @@ const ErrorPage: FC<ErrorPageProps> = ({
Icon={icon}
title={error.localisedTitle}
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
widget={widget}
>
<p>
{error.localisedMessageKey ? (
@@ -144,12 +148,14 @@ 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 => {
@@ -159,6 +165,7 @@ export const GroupCallErrorBoundary = ({
: new UnknownCallError(error instanceof Error ? error : new Error());
return (
<ErrorPage
widget={widget ?? null}
error={callError}
resetError={resetError}
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
@@ -168,7 +175,7 @@ export const GroupCallErrorBoundary = ({
/>
);
},
[recoveryActionHandler],
[recoveryActionHandler, widget],
);
return (
+83 -49
View File
@@ -36,6 +36,7 @@ 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";
@@ -51,11 +52,8 @@ import {
} from "../utils/test";
import { GroupCallView } from "./GroupCallView";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
import {
type HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "../HostBridge";
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import { MatrixRTCTransportMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
@@ -136,7 +134,7 @@ beforeEach(() => {
});
function createGroupCallView(
hostBridge: HostBridge,
widget: WidgetHelpers | null,
joined = true,
options: {
withErrorBoundary?: boolean;
@@ -186,6 +184,7 @@ 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 {}}
@@ -193,21 +192,22 @@ function createGroupCallView(
);
const { getByText } = render(
<BrowserRouter>
<HostBridgeProvider value={hostBridge}>
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{groupCallView}
</GroupCallErrorBoundary>
) : (
groupCallView
)}
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
</HostBridgeProvider>
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary
recoveryActionHandler={vi.fn()}
widget={null}
>
{groupCallView}
</GroupCallErrorBoundary>
) : (
groupCallView
)}
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
</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(nullHostBridge);
const { getByText, rtcSession } = createGroupCallView(null);
const leaveButton = getByText("Leave");
await user.click(leaveButton);
expect(playSound).toHaveBeenCalledWith("left");
@@ -235,7 +235,12 @@ 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 hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() };
const widget = {
api: {
setAlwaysOnScreen: async () => Promise.resolve(true),
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
let resolvePlaySound: () => void;
playSound = vi
.fn()
@@ -248,7 +253,9 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
soundDuration: {},
});
const { getByText, rtcSession } = createGroupCallView(hostBridge);
const { getByText, rtcSession } = createGroupCallView(
widget as WidgetHelpers,
);
const leaveButton = getByText("Leave");
await user.click(leaveButton);
await flushPromises();
@@ -265,13 +272,28 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
expect(leaveRTCSession).toHaveBeenCalledOnce();
});
test("Should ask the host to close when all other left and play a sound", async () => {
test("Should close widget when all other left and play a sound", async () => {
const user = userEvent.setup();
const close = vi.fn().mockResolvedValue(undefined);
const hostBridge: HostBridge = {
...nullHostBridge,
setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined),
close,
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 resolvePlaySound = Promise.withResolvers<void>();
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
@@ -281,38 +303,50 @@ test("Should ask the host to close when all other left and play a sound", async
soundDuration: {},
});
const { getByText } = createGroupCallView(hostBridge);
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const leaveButton = getByText("SimulateOtherLeft");
await user.click(leaveButton);
await flushPromises();
expect(close).not.toHaveBeenCalled();
expect(widgetClosedCalled).toBeFalsy();
resolvePlaySound.resolve();
expect(playSound).toHaveBeenCalledWith("left", 0);
await waitFor(() => expect(close).toHaveBeenCalledOnce());
await widgetClosedPromise;
await flushPromises();
expect(widgetClosedCalled).toBeTruthy();
expect(widgetStopMock).toHaveBeenCalledOnce();
}, 80000);
test("Should not ask the host to close when auto leave due to error", async () => {
test("Should not close widget when auto leave due to error", async () => {
const user = userEvent.setup();
const close = vi.fn().mockResolvedValue(undefined);
const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined);
const hostBridge: HostBridge = {
...nullHostBridge,
setAlwaysOnScreen,
close,
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 { getByText } = createGroupCallView(hostBridge);
const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen");
const { getByText } = createGroupCallView(widget as WidgetHelpers);
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(setAlwaysOnScreen).toHaveBeenCalledWith(false));
await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false));
await flushPromises();
// But then we do not ask to be closed automatically
expect(close).not.toHaveBeenCalled();
// But then we do not close the widget automatically
expect(widgetStopMock).not.toHaveBeenCalledOnce();
expect(widgetSendMock).not.toHaveBeenCalledOnce();
});
test.skip("GroupCallView leaves the session when an error occurs", async () => {
@@ -326,7 +360,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
);
});
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(nullHostBridge);
const { rtcSession } = createGroupCallView(null);
await user.click(screen.getByRole("button", { name: "Panic!" }));
screen.getByText("Something went wrong");
expect(leaveRTCSession).toHaveBeenCalledWith(
@@ -343,7 +377,7 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
onTestFinished(() => {
enterRTCSession.mockReset();
});
createGroupCallView(nullHostBridge, false);
createGroupCallView(null, false);
await user.click(screen.getByRole("button", { name: "Join call" }));
screen.getByText("Call is not supported");
});
@@ -362,7 +396,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
{ cause: stickyError },
);
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
const { rtcSession } = createGroupCallView(null, true, {
withErrorBoundary: true,
});
@@ -374,7 +408,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
});
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
const { rtcSession } = createGroupCallView(null, true, {
withErrorBoundary: true,
});
@@ -390,7 +424,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(nullHostBridge, true);
const { rtcSession } = createGroupCallView(null, true);
await act(() =>
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
);
+73 -63
View File
@@ -30,7 +30,12 @@ import {
} from "matrix-js-sdk/lib/matrixrtc";
import { useNavigate } from "react-router-dom";
import { type JoinCallData } from "../widget";
import type { IWidgetApiRequest } from "matrix-widget-api";
import {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "../widget";
import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView";
@@ -49,7 +54,12 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import {
getUrlParams,
HeaderStyle,
type UrlParams,
useUrlParams,
} from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext";
import {
@@ -70,8 +80,6 @@ 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
@@ -95,6 +103,7 @@ interface Props {
joined: boolean;
setJoined: (value: boolean) => void;
muteStates: MuteStates;
widget: WidgetHelpers | null;
}
export const GroupCallView: FC<Props> = ({
@@ -107,21 +116,13 @@ 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(
@@ -149,11 +150,11 @@ export const GroupCallView: FC<Props> = ({
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
// tested on different devices...)
useEffect(() => {
rootElement.classList.add("no-scroll-body");
document.body.classList.add("no-scroll-body");
return (): void => {
rootElement.classList.remove("no-scroll-body");
document.body.classList.remove("no-scroll-body");
};
}, [rootElement]);
}, []);
useEffect(() => {
window.rtcSession = rtcSession;
@@ -296,26 +297,28 @@ export const GroupCallView: FC<Props> = ({
};
if (skipLobby) {
// `preload` is only ever set when we have a host to be preloaded by.
if (preload) {
if (widget && preload) {
// In preload mode without lobby we wait for a join action before entering
const subscription = hostBridge.join$.subscribe(({ data, reply }) => {
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
(async (): Promise<void> => {
await defaultDeviceSetup(data);
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
setJoined(true);
reply();
widget.api.transport.reply(ev.detail, {});
})().catch((e) => {
logger.error("Error joining RTC session on preload", e);
});
});
return (): void => subscription.unsubscribe();
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
} else {
// No lobby and no preload: we enter the rtc session right away
setJoined(true);
}
}
}, [
hostBridge,
widget,
rtcSession,
preload,
skipLobby,
@@ -341,7 +344,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.
// (which is what delays asking the host to close us)
// (used to destory the widget)
audioPromise = leaveSoundContext.current?.playSound("left", 0);
break;
case "timeout":
@@ -356,12 +359,12 @@ export const GroupCallView: FC<Props> = ({
setLeft(true);
// We need to wait until the callEnded event is tracked on PostHog,
// otherwise we may be torn down first.
// otherwise the iframe may get killed first.
const posthogRequest = new Promise((resolve) => {
// 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;
// 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;
PosthogAnalytics.instance.eventCallEnded.track(
room.roomId,
rtcSession.memberships.length,
@@ -369,8 +372,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 our
// disposal that long anyway, so giving it 10 ms will do.
// 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.
window.setTimeout(resolve, 10);
});
@@ -389,19 +392,25 @@ export const GroupCallView: FC<Props> = ({
)
void navigate("/");
// 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) {
if (widget) {
// After this point the iframe could die at any moment!
try {
await hostBridge.close?.();
await widget.api.setAlwaysOnScreen(false);
} catch (e) {
logger.error("Failed to ask the host to close Element Call", 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();
}
}
});
@@ -409,24 +418,22 @@ export const GroupCallView: FC<Props> = ({
[
setJoined,
leaveSoundContext,
hostBridge,
hostControlsLifetime,
widget,
room.roomId,
rtcSession,
isPasswordlessUser,
confineToRoom,
returnToLobby,
navigate,
],
);
useEffect(() => {
if (joined)
// ask to be kept on screen once joined.
hostBridge.setAlwaysOnScreen(true).catch((e) => {
if (widget && joined)
// set widget to sticky once joined.
widget.api.setAlwaysOnScreen(true).catch((e) => {
logger.error("Error calling setAlwaysOnScreen(true)", e);
});
}, [hostBridge, joined, rtcSession]);
}, [widget, joined, rtcSession]);
const joinRule = useJoinRule(room);
@@ -496,16 +503,19 @@ export const GroupCallView: FC<Props> = ({
/>
</>
);
} else if (left && !hostControlsLifetime) {
// Left, and it is up to us what to show next:
} else if (left && widget === null) {
// Left in SPA mode:
// 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 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()) {
// 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)
) {
body = (
<CallEndedView
endedCallId={rtcSession.room.roomId}
@@ -521,8 +531,8 @@ export const GroupCallView: FC<Props> = ({
// LobbyView again which would open capture devices again.
body = null;
}
} else if (left && hostControlsLifetime) {
// Left, and the host decides what happens next:
} else if (left && widget !== null) {
// Left in widget mode:
body = returnToLobby ? lobbyView : null;
} else if (preload || skipLobby) {
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
@@ -533,6 +543,7 @@ export const GroupCallView: FC<Props> = ({
return (
<GroupCallErrorBoundary
widget={widget}
recoveryActionHandler={async (action) => {
setExternalError(null);
if (action == "reconnect") {
@@ -544,10 +555,9 @@ export const GroupCallView: FC<Props> = ({
}}
onError={(_error) => {
if (rtcSession.isJoined()) onLeft("error");
// 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);
// 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);
}}
>
{body}
+20 -58
View File
@@ -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 { ActiveCall, InCallView } from "./InCallView";
import { InCallView } from "./InCallView";
import {
mockLivekitRoom,
mockLocalParticipant,
@@ -33,10 +33,7 @@ import {
type MockRTCSession,
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import {
getBasicCallViewModelEnvironment,
getBasicRTCSession,
} from "../utils/test-viewmodel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import {
type CallViewModel,
type CallViewModelOptions,
@@ -48,8 +45,6 @@ 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();
@@ -83,17 +78,6 @@ 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(() => {
@@ -138,13 +122,12 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
remoteParticipants$: of([remoteParticipant]),
},
);
const { vm, footerVm, developerSettingsVm, rtcSession } =
getBasicCallViewModelEnvironment(
[local, alice],
undefined,
mediaDevices,
args.callViewModelOptions,
);
const { vm, footerVm, rtcSession } = getBasicCallViewModelEnvironment(
[local, alice],
undefined,
mediaDevices,
args.callViewModelOptions,
);
rtcSession.joined = true;
const room = rtcSession.room;
@@ -157,8 +140,18 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
muteStates={muteState}
vm={vm}
footerVm={footerVm}
developerSettingsVm={developerSettingsVm}
matrixInfo={matrixInfo}
matrixInfo={{
userId: "",
displayName: "",
avatarUrl: "",
roomId: "",
roomName: "",
roomAlias: null,
roomAvatar: null,
e2eeSystem: {
kind: E2eeType.NONE,
},
}}
matrixRoom={room}
onShareClick={null}
/>
@@ -233,34 +226,3 @@ 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();
});
});
+6 -31
View File
@@ -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 { useHostBridge } from "../HostBridge.ts";
import { widget } from "../widget";
import styles from "./InCallView.module.css";
import { GridTile } from "../tile/GridTile";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
@@ -41,7 +41,6 @@ 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";
@@ -84,8 +83,6 @@ 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";
@@ -99,7 +96,7 @@ declare module "react" {
export interface ActiveCallProps extends Omit<
InCallViewProps,
"vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
"vm" | "livekitRoom" | "connState" | "footerVm"
> {
e2eeSystem: EncryptionSystem;
// TODO refactor those reasons into an enum
@@ -113,11 +110,7 @@ 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(() => {
@@ -134,9 +127,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices,
props.muteStates,
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
matrixRTCMode$: matrixRTCModeSetting.value$,
@@ -161,7 +152,6 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.e2eeSystem,
props.onLeft,
urlParams,
hostBridge,
mediaDevices,
trackProcessorState$,
props.client,
@@ -177,10 +167,8 @@ 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();
@@ -200,16 +188,10 @@ 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}
developerSettingsVm={developerSettingsVm}
/>
<InCallView {...props} vm={vm} footerVm={footerVm} />
</ReactionsSenderProvider>
);
};
@@ -218,7 +200,6 @@ export interface InCallViewProps {
client: MatrixClient;
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
matrixInfo: MatrixInfo;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
@@ -230,7 +211,6 @@ export const InCallView: FC<InCallViewProps> = ({
client,
vm,
footerVm,
developerSettingsVm,
matrixInfo,
matrixRoom,
muteStates,
@@ -238,7 +218,6 @@ export const InCallView: FC<InCallViewProps> = ({
}) => {
const logger = rootLogger.getChild("[InCallView]");
const { t } = useTranslation();
const hostBridge = useHostBridge();
const { sendReaction, toggleRaisedHand } = useReactionsSender();
useWakeLock();
@@ -322,17 +301,14 @@ export const InCallView: FC<InCallViewProps> = ({
const openProfile = useMemo(
() =>
// 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
// Profile settings are unavailable in widget mode
widget === null
? (): void => {
setSettingsTab("profile");
setSettingsOpen(true);
}
: null,
[setSettingsTab, setSettingsOpen, hostBridge],
[setSettingsTab, setSettingsOpen],
);
const [headerRef, headerBounds] = useMeasure();
@@ -657,7 +633,6 @@ export const InCallView: FC<InCallViewProps> = ({
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
onTabChange={setSettingsTab}
developerSettingsVm={developerSettingsVm}
livekitRooms={allConnections
.getConnections()
.map((connectionItem) => ({
+9 -9
View File
@@ -29,7 +29,7 @@ import { GroupCallView } from "./GroupCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { HomePage } from "../home/HomePage";
import { useHostBridge } from "../HostBridge.ts";
import { widget } from "../widget";
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
@@ -44,7 +44,6 @@ 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();
@@ -76,18 +75,17 @@ export const RoomPage: FC = (): ReactNode => {
calculateInitialMuteState(
urlParams.skipLobby,
urlParams.callIntent,
urlParams.isWidget,
widget !== null,
),
hostBridge,
),
);
return (): void => scope.end();
}, [devices, urlParams, hostBridge]);
}, [devices, urlParams]);
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 && !urlParams.isWidget) {
if (!loading && !authenticated && displayName && !widget) {
setIsRegistering(true);
registerPasswordlessUser(displayName)
.catch((e) => {
@@ -101,7 +99,6 @@ export const RoomPage: FC = (): ReactNode => {
loading,
authenticated,
displayName,
urlParams.isWidget,
setIsRegistering,
registerPasswordlessUser,
]);
@@ -126,6 +123,7 @@ export const RoomPage: FC = (): ReactNode => {
return (
muteStates && (
<GroupCallView
widget={widget}
client={client!}
rtcSession={groupCallState.rtcSession}
joined={joined}
@@ -200,6 +198,7 @@ export const RoomPage: FC = (): ReactNode => {
<ErrorView
Icon={UnknownSolidIcon}
title={t("error.call_not_found")}
widget={widget}
>
<Trans i18nKey="error.call_not_found_description">
<p>
@@ -217,6 +216,7 @@ 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 error={groupCallState.error} />;
return <ErrorPage widget={widget} error={groupCallState.error} />;
}
default:
return <> </>;
@@ -238,7 +238,7 @@ export const RoomPage: FC = (): ReactNode => {
};
if (loading || isRegistering) return <LoadingPage />;
if (error) return <ErrorPage error={error} />;
if (error) return <ErrorPage widget={widget} error={error} />;
if (!client) return <RoomAuthView />;
// TODO: This doesn't belong here, the app routes need to be reworked
if (!roomIdOrAlias) return <HomePage />;
@@ -1100,7 +1100,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
</DocumentFragment>
`;
exports[`should have a close button when the host can dismiss us 1`] = `
exports[`should have a close button in widget mode 1`] = `
<DocumentFragment>
<div
class="_page_4be5c0"
+2 -4
View File
@@ -34,7 +34,7 @@ import {
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useUrlParams } from "../UrlParams";
import { widget } from "../widget";
export type GroupCallLoaded = {
kind: "loaded";
@@ -132,7 +132,6 @@ 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 =>
@@ -250,7 +249,7 @@ export const useLoadGroupCall = (
// room already joined so we are done here already.
return room!;
}
if (isWidget)
if (widget)
// 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.",
@@ -374,7 +373,6 @@ export const useLoadGroupCall = (
}, [
bannedError,
client,
isWidget,
knockRejectError,
removeNoticeError,
roomIdOrAlias,
+1 -86
View File
@@ -12,12 +12,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
import type { MatrixClient } from "matrix-js-sdk";
import type { Room as LivekitRoom } from "livekit-client";
import {
DeveloperSettingsTab,
type DeveloperSettingsSnapshot,
} from "./DeveloperSettingsTab";
import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel";
import { createStaticViewModel } from "../state/ViewModel";
import { DeveloperSettingsTab } from "./DeveloperSettingsTab";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
import {
customLivekitUrl as customLivekitUrlSetting,
@@ -112,7 +107,6 @@ describe("DeveloperSettingsTab", () => {
roomId={"#room:example.org"}
livekitRooms={livekitRooms}
env={{ MY_MOCK_ENV: 10, ENV: "test" } as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>,
);
@@ -142,7 +136,6 @@ describe("DeveloperSettingsTab", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -166,7 +159,6 @@ describe("DeveloperSettingsTab", () => {
client={client}
roomId="#testRoom"
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -189,7 +181,6 @@ describe("DeveloperSettingsTab", () => {
client={client}
roomId="#testRoom"
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -215,7 +206,6 @@ describe("DeveloperSettingsTab", () => {
client={client}
roomId="#testRoom"
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -246,7 +236,6 @@ describe("DeveloperSettingsTab", () => {
client={client}
roomId="#testRoom"
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -284,7 +273,6 @@ describe("DeveloperSettingsTab", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -317,7 +305,6 @@ describe("DeveloperSettingsTab", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -358,7 +345,6 @@ describe("DeveloperSettingsTab", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -392,7 +378,6 @@ describe("DeveloperSettingsTab", () => {
<DeveloperSettingsTab
client={client}
env={{} as unknown as ImportMetaEnv}
vm={outOfCallDeveloperSettingsTabViewModel}
/>
</TooltipProvider>,
);
@@ -415,74 +400,4 @@ 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(),
);
});
});
});
-37
View File
@@ -68,39 +68,6 @@ 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;
@@ -112,7 +79,6 @@ interface Props {
livekitAlias?: string;
}[];
env: ImportMetaEnv;
vm: ViewModel<DeveloperSettingsSnapshot>;
}
export const DeveloperSettingsTab: FC<Props> = ({
@@ -120,10 +86,8 @@ 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,
@@ -400,7 +364,6 @@ export const DeveloperSettingsTab: FC<Props> = ({
id: client.getDeviceId() || "unknown",
})}
</p>
{keyRotation !== null && <KeyRotationStatus info={keyRotation} />}
<Separator />
<FieldRow>
<InputField
@@ -1,48 +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 { 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,
);
});
});
@@ -1,43 +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 { 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 });
+3 -15
View File
@@ -18,7 +18,7 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import { iosDeviceMenu$ } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { useHostBridge } from "../HostBridge";
import { widget } from "../widget";
import {
useSetting,
soundEffectVolume as soundEffectVolumeSetting,
@@ -29,16 +29,11 @@ import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider";
import { DeviceSelection } from "./DeviceSelection";
import { useTrackProcessor } from "../livekit/TrackProcessorContext";
import {
DeveloperSettingsTab,
type DeveloperSettingsSnapshot,
} from "./DeveloperSettingsTab";
import { DeveloperSettingsTab } 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"
@@ -61,8 +56,6 @@ 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";
@@ -75,7 +68,6 @@ export const SettingsModal: FC<Props> = ({
client,
roomId,
livekitRooms,
developerSettingsVm,
}) => {
const { t } = useTranslation();
@@ -123,7 +115,6 @@ 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$);
@@ -229,15 +220,12 @@ export const SettingsModal: FC<Props> = ({
client={client}
livekitRooms={livekitRooms}
roomId={roomId}
vm={developerSettingsVm ?? outOfCallDeveloperSettingsTabViewModel}
/>
),
};
const tabs = [audioTab, videoTab];
// 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);
if (widget === null) 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
+3 -9
View File
@@ -5,23 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
import { 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,
this.audioOutputOptions,
);
public readonly mediaDevices = new MediaDevices(this.scope);
// TODO: Move more application logic here. The CallViewModel, at the very
// least, ought to be accessible from this object.
public constructor(
private readonly scope: ObservableScope,
private readonly audioOutputOptions: AudioOutputOptions,
) {}
public constructor(private readonly scope: ObservableScope) {}
}
+5 -44
View File
@@ -68,8 +68,6 @@ 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();
@@ -85,6 +83,9 @@ 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 {
@@ -1592,13 +1593,12 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
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(), {
controlledAudioDevices: true,
});
const devices = new MediaDevices(testScope());
window.controls.setAvailableAudioDevices([
{ id: "speaker", name: "Speaker", isSpeaker: true },
@@ -1700,42 +1700,3 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
});
});
});
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",
});
});
});
+33 -110
View File
@@ -45,12 +45,10 @@ 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";
import {
createToggle$,
@@ -86,9 +84,9 @@ import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType";
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates";
import { HeaderStyle, type UrlParams } from "../../UrlParams";
import { getUrlParams, HeaderStyle } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type HostBridge, nullHostBridge } from "../../HostBridge";
import { ElementWidgetActions, widget } from "../../widget";
import {
layoutShallowEquals,
type Alignment,
@@ -115,7 +113,6 @@ import {
type LocalTransport,
} from "./localMember/LocalTransport.ts";
import {
createKeyRotationSuppressed$,
createMemberships$,
membershipsAndTransports$,
} from "../SessionBehaviors.ts";
@@ -172,28 +169,6 @@ 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
@@ -216,40 +191,6 @@ 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;
@@ -360,12 +301,6 @@ 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[]>;
@@ -492,18 +427,6 @@ 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,
@@ -564,16 +487,6 @@ export function createCallViewModel$(
memberships$: memberships$,
ownMembershipIdentity,
client,
delayId$: scope.behavior(
(
fromEvent(
matrixRTCSession,
MembershipManagerEvent.DelayIdChanged,
// The type of reemitted event includes the original emitted as the second arg.
) as Observable<[string | undefined, IMembershipManager]>
).pipe(map(([delayId]) => delayId ?? null)),
matrixRTCSession.delayId ?? null,
),
roomId: matrixRoom.roomId,
forceJwtEndpoint:
mode === MatrixRTCMode.Matrix_2_0
@@ -592,7 +505,7 @@ export function createCallViewModel$(
mediaDevices,
trackProcessorState$,
livekitKeyProvider,
controlledAudioDevices,
getUrlParams().controlledAudioDevices,
options.livekitRoomFactory,
);
@@ -632,8 +545,6 @@ export function createCallViewModel$(
encryptMedia: livekitKeyProvider !== undefined,
// TODO. This might need to get called again on each change of matrixRTCMode...
matrixRTCMode: mode,
sendNotificationType,
callIntent,
})),
),
);
@@ -663,15 +574,23 @@ export function createCallViewModel$(
logger.getChild(
"[Publisher " + connection.transport.livekit_service_url + "]",
),
controlledAudioDevices,
);
},
connectionManager,
client,
matrixRTCSession,
localTransport$,
roomId: matrixRoom.roomId,
hideScreensharing,
hostBridge,
ownMembershipIdentity,
delayId$: scope.behavior(
(
fromEvent(
matrixRTCSession,
MembershipManagerEvent.DelayIdChanged,
) as Observable<[string | undefined]>
).pipe(map(([delayId]) => delayId ?? null)),
matrixRTCSession.delayId ?? null,
),
logger: logger.getChild(`[${Date.now()}]`),
});
@@ -942,11 +861,6 @@ export function createCallViewModel$(
matrixLivekitMembers$.pipe(map((ms) => ms.length)),
);
const keyRotationSuppressed$ = createKeyRotationSuppressed$(
scope,
matrixRTCSession,
);
const leaveSoundEffect$ = userMedia$.pipe(
pairwise(),
filter(
@@ -963,16 +877,24 @@ export function createCallViewModel$(
const userHangup$ = new Subject<void>();
const hostHangup$ = hostBridge.hangUp$.pipe(
tap((request) => {
request.reply();
}),
);
const widgetHangup$ =
widget === null
? NEVER
: (
fromEvent(
widget.lazyActions,
ElementWidgetActions.HangupCall,
) as Observable<CustomEvent<IWidgetApiRequest>>
).pipe(
tap((ev) => {
widget!.api.transport.reply(ev.detail, {});
}),
);
const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> =
merge(
autoLeave$,
merge(userHangup$, hostHangup$).pipe(map(() => "user" as const)),
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
).pipe(scope.share);
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
@@ -1523,8 +1445,9 @@ export function createCallViewModel$(
),
);
const urlParams = getUrlParams();
const showFooterUrlParams = !(
header === HeaderStyle.None && showControls === false
urlParams.header === HeaderStyle.None && urlParams.showControls === false
);
const showFooter$ = scope.behavior(
naturallyShowFooter$.pipe(
@@ -1843,7 +1766,8 @@ export function createCallViewModel$(
return {
autoLeave$: autoLeave$,
ringingVm$: ringingMedia$,
ringingStatusLocation: header === HeaderStyle.AppBar ? "app_bar" : "tile",
ringingStatusLocation:
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
leave$: leave$,
hangup: (): void => userHangup$.next(),
join: localMembership.requestJoinAndPublish,
@@ -1873,7 +1797,6 @@ export function createCallViewModel$(
),
allConnections$,
participantCount$: participantCount$,
keyRotationSuppressed$: keyRotationSuppressed$,
handsRaised$: handsRaised$,
reactions$: reactions$,
joinSoundEffect$: joinSoundEffect$,
@@ -52,7 +52,6 @@ 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,
@@ -122,57 +121,6 @@ describe("LocalMembership", () => {
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,
}),
},
},
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({
keyRotationParticipantLimit: 50,
}),
);
});
});
const defaultCreateLocalMemberValues = {
@@ -200,8 +148,6 @@ describe("LocalMembership", () => {
rtsSession$: constant(RTCMemberStatus.Connected),
},
roomId: "!test-room-id:example.org",
hideScreensharing: false,
hostBridge: nullHostBridge,
};
it("throws error on missing RTC config error", () => {
@@ -15,13 +15,16 @@ import {
MediaDeviceFailure,
} from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core";
import {
type IOpenIDToken,
parseErrorResponse,
type MatrixClient,
} from "matrix-js-sdk";
import {
Status as RTCSessionStatus,
type LivekitTransport,
type LivekitTransportConfig,
type MatrixRTCSession,
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
@@ -53,8 +56,8 @@ import {
MembershipManagerError,
UnknownCallError,
} from "../../../utils/errors.ts";
import { type HostBridge } from "../../../HostBridge.ts";
import { ElementWidgetActions, widget } from "../../../widget.ts";
import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import {
advancedScreenShare,
@@ -74,6 +77,7 @@ import {
import { type HomeserverConnected } from "./HomeserverConnected.ts";
import { type LocalTransport } from "./LocalTransport.ts";
import { areLivekitTransportsEqual } from "../remoteMembers/MatrixLivekitMembers.ts";
import { doNetworkOperationWithRetry } from "../../../utils/matrix.ts";
export enum TransportState {
/** Not even a transport is available to the LocalMembership */
@@ -138,15 +142,14 @@ interface Props {
joinMatrixRTC: (transport: LivekitTransportConfig) => void;
homeserverConnected: HomeserverConnected;
roomId: string;
ownMembershipIdentity: CallMembershipIdentityParts;
localTransport$: Behavior<LocalTransport>;
client: Pick<MatrixClient, "getOpenIdToken" | "baseUrl">;
matrixRTCSession: Pick<
MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession"
"slotId" | "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;
delayId$: Behavior<string | null>;
logger: Logger;
}
@@ -166,8 +169,6 @@ 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)
@@ -184,10 +185,11 @@ export const createLocalMembership$ = ({
joinMatrixRTC,
logger: parentLogger,
muteStates,
client,
matrixRTCSession,
roomId,
hideScreensharing,
hostBridge,
ownMembershipIdentity,
delayId$,
}: Props): {
/**
* This request to start audio and video tracks.
@@ -580,24 +582,29 @@ export const createLocalMembership$ = ({
}
});
// inform the host about the connect and disconnect intent from the user.
// inform the widget 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) {
hostBridge.notifyJoined().catch((e) => {
logger.error("Failed to notify the host that we joined", e);
});
widget.api.transport
.send(ElementWidgetActions.JoinCall, {})
.catch((e) => {
logger.error("Failed to send join action", e);
});
}
// LEAVE prev=false (was joined) => current-true (now left)
if (prev && !current) {
hostBridge.notifyHungUp().catch((e) => {
logger.error("Failed to notify the host that we hung up", e);
});
widget.api.transport
.send(ElementWidgetActions.HangupCall, {})
.catch((e) => {
logger.error("Failed to send hangup action", e);
});
}
});
@@ -644,6 +651,60 @@ export const createLocalMembership$ = ({
},
);
scope.reconcile(
scope.behavior(combineLatest([delayId$, activeTransport$])),
async ([delayId, transport]) => {
if (delayId !== null && transport !== null) {
logger.info(
`Delegating delayed leave ${delayId} to ${transport.livekit_service_url}…`,
);
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (e) {
logger.error("Failed to get OpenID token for delegation", e);
return;
}
const body = JSON.stringify({
room_id: roomId,
slot_id: matrixRTCSession.slotId,
member: {
id: ownMembershipIdentity.memberId,
claimed_user_id: ownMembershipIdentity.userId,
claimed_device_id: ownMembershipIdentity.deviceId,
},
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session?.delayed_leave_event_delay_ms,
delay_cs_api_url: client.baseUrl,
openid_token: openIdToken,
});
try {
const res = await doNetworkOperationWithRetry(async () =>
fetch(transport.livekit_service_url + "/delegate_delayed_leave", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
}),
);
if (res.ok) logger.info(`Delayed leave delegation successful`);
else if (res.status === 404)
logger.warn(
`Delayed event delegation not available on ${transport.livekit_service_url}`,
);
else throw parseErrorResponse(res, await res.text());
} catch (e) {
logger.error(
`Failed to delegate ${delayId} to ${transport.livekit_service_url}`,
e,
);
}
}
},
);
const participant$ = scope.behavior(
localConnection$.pipe(
map((c) => c?.livekitRoom?.localParticipant ?? null),
@@ -714,7 +775,7 @@ export const createLocalMembership$ = ({
let toggleScreenSharing: (() => void) | null = null;
if (
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!hideScreensharing
!getUrlParams().hideScreensharing
) {
toggleScreenSharing = (): void => {
const screenshareSettings: ScreenShareCaptureOptions = {
@@ -825,10 +886,6 @@ 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;
}
/**
@@ -841,10 +898,8 @@ 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. `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.
* @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use.
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
*/
// Exported for unit testing
export function enterRTCSession(
@@ -853,12 +908,7 @@ export function enterRTCSession(
transport: LivekitTransportConfig,
options: EnterRTCSessionOptions,
): void {
const {
encryptMedia,
matrixRTCMode,
sendNotificationType: notificationType,
callIntent,
} = options;
const { encryptMedia, matrixRTCMode } = options;
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
@@ -867,6 +917,7 @@ export function enterRTCSession(
// groupCallOTelMembership?.onJoinCall();
const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
const multiSFU =
matrixRTCMode === MatrixRTCMode.Compatibility ||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
@@ -917,8 +968,6 @@ 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,
},
@@ -14,11 +14,8 @@ import {
type MockedObject,
vi,
} from "vitest";
import {
type CallMembership,
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, filter, lastValueFrom } from "rxjs";
import { type CallMembership } from "matrix-js-sdk/lib/matrixrtc";
import { lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock";
import {
@@ -27,11 +24,7 @@ import {
ownMemberMock,
testScope,
} from "../../../utils/test";
import {
createLocalTransport$,
JwtEndpointVersion,
type LocalTransportWithSFUConfig,
} from "./LocalTransport";
import { createLocalTransport$, JwtEndpointVersion } from "./LocalTransport";
import { constant } from "../../Behavior";
import { Epoch, ObservableScope } from "../../ObservableScope";
import {
@@ -68,7 +61,6 @@ describe("LocalTransport", () => {
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"),
});
await flushPromises();
@@ -109,7 +101,6 @@ describe("LocalTransport", () => {
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"),
});
active$.subscribe(
(o) => observations.push(o),
@@ -151,7 +142,6 @@ describe("LocalTransport", () => {
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant("delay_id_mock"),
});
openIdResolver.resolve?.({
@@ -195,7 +185,6 @@ describe("LocalTransport", () => {
scope: testScope(),
roomId: "!example_room_id",
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
baseUrl: "https://example.org",
@@ -307,7 +296,6 @@ describe("LocalTransport", () => {
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
@@ -329,84 +317,4 @@ describe("LocalTransport", () => {
);
});
});
it("should not update advertised/active transport on delayID changes, but delay Id delegation should be called", async () => {
// For simplicity, we'll just use the config livekit
customLivekitUrl.setValue("https://lk.example.org");
const authCallSpy = vi
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
.mockResolvedValue(openIdResponse);
const delayId$ = new BehaviorSubject<string | null>(null);
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
// We want multi-sdu
forceJwtEndpoint: JwtEndpointVersion.Legacy,
delayId$: delayId$,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
baseUrl: "https://example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
});
const advertisedValues: LivekitTransportConfig[] = [];
const activeValues: LocalTransportWithSFUConfig[] = [];
advertised$
.pipe(filter((v) => v !== null))
.subscribe((t) => advertisedValues.push(t));
active$
.pipe(filter((v) => v !== null))
.subscribe((t) => activeValues.push(t));
await flushPromises();
// we have now an active and an advertised
expect(advertisedValues.length).toEqual(1);
expect(activeValues.length).toEqual(1);
expect(advertisedValues[0]!.livekit_service_url).toEqual(
"https://lk.example.org",
);
expect(activeValues[0]!.transport.livekit_service_url).toEqual(
"https://lk.example.org",
);
expect(authCallSpy).toHaveBeenCalledTimes(2);
// Now emits 3 new delays id
delayId$.next("delay_id_1");
await flushPromises();
delayId$.next("delay_id_2");
await flushPromises();
delayId$.next("delay_id_3");
await flushPromises();
// No new emissions should've happened, it is the same transport.
expect(advertisedValues.length).toEqual(1);
expect(activeValues.length).toEqual(1);
// Still we should have updated the delayID to auth
expect(authCallSpy).toHaveBeenCalledTimes(
4 * 2 /* 2 calls for each delayId ?? why */,
);
expect(authCallSpy).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
delayId: "delay_id_3",
}),
expect.anything(),
);
});
});
@@ -10,14 +10,7 @@ import {
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import {
combineLatest,
distinctUntilChanged,
from,
map,
of,
switchMap,
} from "rxjs";
import { distinctUntilChanged, from, map, of, switchMap } from "rxjs";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -54,7 +47,6 @@ interface Props {
// Used by the jwt service to create the livekit room and compute the livekit alias.
roomId: string;
forceJwtEndpoint: JwtEndpointVersion;
delayId$: Behavior<string | null>;
}
export enum JwtEndpointVersion {
@@ -123,7 +115,6 @@ export const createLocalTransport$ = ({
client,
roomId,
forceJwtEndpoint,
delayId$,
}: Props): LocalTransport => {
const logger = rootLogger.getChild("[LocalTransport]");
@@ -162,8 +153,8 @@ export const createLocalTransport$ = ({
distinctUntilChanged(areLivekitTransportsEqual),
);
const preferredTransport$ = combineLatest([preferredConfig$, delayId$]).pipe(
switchMap(async ([transport, delayId]) => {
const preferredTransport$ = preferredConfig$.pipe(
switchMap(async (transport) => {
try {
return await doOpenIdAndJWTFromUrl(
transport,
@@ -171,7 +162,6 @@ export const createLocalTransport$ = ({
ownMembershipIdentity,
roomId,
client,
delayId ?? undefined,
logger,
);
} catch (e) {
@@ -223,7 +213,6 @@ export const createLocalTransport$ = ({
* @param membership The identity of the local member.
* @param roomId The room ID to use for the JWT.
* @param client The client to use for the OpenID token.
* @param delayId The delayId to use for the JWT.
*
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
*/
@@ -232,12 +221,7 @@ async function doOpenIdAndJWTFromUrl(
forceJwtEndpoint: JwtEndpointVersion,
membership: CallMembershipIdentityParts,
roomId: string,
client: Pick<
MatrixClient,
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
> &
OpenIDClientParts,
delayId?: string,
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
logger?: Logger,
): Promise<LocalTransportWithSFUConfig> {
const sfuConfig = await getSFUConfigWithOpenID(
@@ -247,8 +231,6 @@ async function doOpenIdAndJWTFromUrl(
roomId,
{
forceJwtEndpoint: forceJwtEndpoint,
delayEndpointBaseUrl: client.baseUrl,
delayId,
},
logger,
);
@@ -192,7 +192,6 @@ describe("Publisher", () => {
muteStates,
constant({ supported: false, processor: undefined }),
logger,
false,
);
});
@@ -310,7 +309,6 @@ describe("Publisher", () => {
muteStates,
constant({ supported: false, processor: undefined }),
logger,
false,
);
});
afterEach(async () => {
@@ -366,7 +364,6 @@ 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,8 +56,6 @@ 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,
@@ -65,8 +63,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) => {
+31 -15
View File
@@ -6,31 +6,39 @@ Please see LICENSE in the repository root for full details.
*/
import { it, vi, expect } from "vitest";
import { Subject } from "rxjs";
import EventEmitter from "events";
// 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 {
type HostBridge,
type HostRequest,
nullHostBridge,
} from "../HostBridge.ts";
import { ElementWidgetActions, widget } from "../widget.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.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
"expect leave when the host asks us to hang up (%s mode)",
"expect leave when ElementWidgetActions.HangupCall is called (%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]),
@@ -41,17 +49,25 @@ it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
pr.resolve(s);
});
hangUp$.next({ data: {}, reply });
widget!.lazyActions!.emit(
ElementWidgetActions.HangupCall,
new CustomEvent(ElementWidgetActions.HangupCall, {
detail: {
action: "im.vector.hangup",
api: "toWidget",
data: {},
requestId: "widgetapi-1761237395918",
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
},
}),
);
},
{
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();
},
);
-59
View File
@@ -1,59 +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 { 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);
});
});
+5 -26
View File
@@ -16,7 +16,6 @@ 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,
@@ -26,6 +25,7 @@ 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,22 +338,6 @@ 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>();
/**
@@ -384,28 +368,23 @@ export class MediaDevices {
public readonly audioOutput: MediaDevice<
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> = this.audioOutputOptions.controlledAudioDevices
> = getUrlParams().controlledAudioDevices
? platform == "android"
? new AndroidControlledAudioOutput(
controlledAvailableOutputDevices$,
this.scope,
this.audioOutputOptions.callIntent,
getUrlParams().callIntent,
window.controls,
)
: new IOSControlledAudioOutput(
this.usingNames$,
this.scope,
this.audioOutputOptions.callIntent,
getUrlParams().callIntent,
)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
new VideoInput(this.usingNames$, this.scope);
// 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,
) {}
public constructor(private readonly scope: ObservableScope) {}
}
+4 -7
View File
@@ -9,7 +9,6 @@ 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,
@@ -229,12 +228,10 @@ describe("MuteStates", () => {
videoInput: aVideoInput(),
// other devices are not relevant for this test
});
const muteStates = new MuteStates(
testScope,
mediaDevices,
{ audioEnabled: false, videoEnabled: false },
nullHostBridge,
);
const muteStates = new MuteStates(testScope, mediaDevices, {
audioEnabled: false,
videoEnabled: false,
});
let latestSyncedState: boolean | null = null;
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
+54 -45
View File
@@ -6,12 +6,14 @@ 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,
@@ -22,7 +24,7 @@ import {
} from "rxjs";
import { type MediaDevices, type MediaDevice } from "../state/MediaDevices";
import { type DeviceMuteState, type HostBridge } from "../HostBridge";
import { ElementWidgetActions, widget } from "../widget";
import { type ObservableScope } from "./ObservableScope";
import { type Behavior, constant } from "./Behavior";
@@ -214,51 +216,58 @@ export class MuteStates {
audioEnabled: boolean;
videoEnabled: boolean;
},
hostBridge: HostBridge,
) {
// 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));
});
// 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);
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),
);
});
// 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);
});
}
}
}
-41
View File
@@ -1,41 +0,0 @@
/*
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);
});
});
});
-19
View File
@@ -88,22 +88,3 @@ 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,
);
};
+4 -4
View File
@@ -54,7 +54,6 @@ 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>;
@@ -415,7 +414,6 @@ 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$);
@@ -430,22 +428,24 @@ 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, rootElement]);
}, [isFullscreen]);
// 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
+25 -26
View File
@@ -6,8 +6,6 @@ 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,
@@ -17,35 +15,31 @@ import {
test,
vi,
} from "vitest";
import EventEmitter from "events";
import { WidgetApiToWidgetAction } from "matrix-widget-api";
import { useTheme } from "./useTheme";
import { useUrlParams } from "./UrlParams";
import {
type HostBridge,
HostBridgeProvider,
type HostRequest,
nullHostBridge,
} from "./HostBridge";
import { getUrlParams } from "./UrlParams";
import { widget } from "./widget";
vi.mock("./UrlParams", () => ({ useUrlParams: vi.fn() }));
vi.mock("./UrlParams", () => ({ getUrlParams: vi.fn() }));
vi.mock("./widget", () => ({
widget: {
api: { transport: { reply: vi.fn() } },
lazyActions: new EventEmitter(),
},
}));
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);
(useUrlParams as Mock).mockReturnValue({ theme: "dark" });
(getUrlParams as Mock).mockReturnValue({ theme: "dark" });
});
afterEach(() => {
@@ -59,9 +53,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`, () => {
(useUrlParams as Mock).mockReturnValue({ theme: setTheme });
(getUrlParams as Mock).mockReturnValue({ theme: setTheme });
renderHook(() => useTheme(), { wrapper });
renderHook(() => useTheme());
expect(originalClassList.remove).toHaveBeenCalledWith(
"cpd-theme-light",
@@ -77,7 +71,7 @@ describe("useTheme", () => {
// Simulate a previous theme
originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark");
renderHook(() => useTheme(), { wrapper });
renderHook(() => useTheme());
expect(document.body.classList.add).not.toHaveBeenCalledWith(
"cpd-theme-dark",
@@ -88,13 +82,18 @@ describe("useTheme", () => {
expect(originalClassList.add).not.toHaveBeenCalled();
});
test("theme changes in response to host requests", () => {
renderHook(() => useTheme(), { wrapper });
test("theme changes in response to widget actions", async () => {
renderHook(() => useTheme());
expect(originalClassList.add).toHaveBeenCalledWith("cpd-theme-dark");
const reply = vi.fn();
act(() => themeChange$.next({ data: { name: "light" }, reply }));
expect(reply).toHaveBeenCalledOnce();
await act(() =>
widget!.lazyActions.emit(
WidgetApiToWidgetAction.ThemeChange,
new CustomEvent(WidgetApiToWidgetAction.ThemeChange, {
detail: { data: { name: "light" } },
}),
),
);
expect(originalClassList.remove).toHaveBeenCalledWith(
"cpd-theme-light",
"cpd-theme-dark",
+31 -20
View File
@@ -6,27 +6,38 @@ 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 { useUrlParams } from "./UrlParams";
import { useRootElement } from "./RootElementContext";
import { useHostBridge } from "./HostBridge";
import { getUrlParams } from "./UrlParams";
import { widget } from "./widget";
export const useTheme = (): void => {
const rootElement = useRootElement();
const hostBridge = useHostBridge();
const { theme } = useUrlParams();
const [requestedTheme, setRequestedTheme] = useState(theme);
const previousTheme = useRef<string | null>(rootElement.classList.item(0));
const [requestedTheme, setRequestedTheme] = useState(
() => getUrlParams().theme,
);
const previousTheme = useRef<string | null>(document.body.classList.item(0));
useEffect(() => {
const subscription = hostBridge.themeChange$.subscribe(
({ data, reply }) => {
if (typeof data.name === "string") setRequestedTheme(data.name);
reply();
},
);
return (): void => subscription.unsubscribe();
}, [hostBridge]);
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,
);
};
}
}, []);
useLayoutEffect(() => {
// If no theme has been explicitly requested we default to dark
@@ -36,15 +47,15 @@ export const useTheme = (): void => {
: "";
const themeString = "cpd-theme-" + theme + themeHighContrast;
if (themeString !== previousTheme.current) {
rootElement.classList.remove(
document.body.classList.remove(
"cpd-theme-light",
"cpd-theme-dark",
"cpd-theme-light-hc",
"cpd-theme-dark-hc",
);
rootElement.classList.add(themeString);
document.body.classList.add(themeString);
previousTheme.current = themeString;
}
rootElement.classList.remove("no-theme");
}, [previousTheme, requestedTheme, rootElement]);
document.body.classList.remove("no-theme");
}, [previousTheme, requestedTheme]);
};
-62
View File
@@ -1,62 +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 { 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.");
});
});
+23 -22
View File
@@ -5,9 +5,10 @@ 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 { i18n, i18nKey } from "./i18n";
import { i18nKey } from "./i18n";
export enum ErrorCode {
/**
@@ -77,10 +78,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
*/
public constructor(domain: string) {
super(
i18n.t("error.call_is_not_supported"),
t("error.call_is_not_supported"),
ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
ErrorCategory.CONFIGURATION_ISSUE,
i18n.t("error.matrix_rtc_transport_missing", {
t("error.matrix_rtc_transport_missing", {
domain,
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
errorCode: ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
@@ -96,10 +97,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
export class ConnectionLostError extends ElementCallError {
public constructor() {
super(
i18n.t("error.connection_lost"),
t("error.connection_lost"),
ErrorCode.CONNECTION_LOST_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
i18n.t("error.connection_lost_description"),
t("error.connection_lost_description"),
);
}
}
@@ -116,10 +117,10 @@ export class MembershipManagerError extends ElementCallError {
*/
public constructor(error: Error) {
super(
i18n.t("error.membership_manager"),
t("error.membership_manager"),
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
ErrorCategory.SYSTEM_FAILURE,
i18n.t("error.membership_manager_description"),
t("error.membership_manager_description"),
error,
);
}
@@ -133,10 +134,10 @@ export class MembershipManagerError extends ElementCallError {
export class StickyEventsRequiredError extends ElementCallError {
public constructor() {
super(
i18n.t("error.sticky_events_required"),
t("error.sticky_events_required"),
ErrorCode.STICKY_EVENTS_NOT_SUPPORTED,
ErrorCategory.CONFIGURATION_ISSUE,
i18n.t("error.sticky_events_required_description"),
t("error.sticky_events_required_description"),
);
}
}
@@ -147,10 +148,10 @@ export class StickyEventsRequiredError extends ElementCallError {
export class E2EENotSupportedError extends ElementCallError {
public constructor() {
super(
i18n.t("error.e2ee_unsupported"),
t("error.e2ee_unsupported"),
ErrorCode.E2EE_NOT_SUPPORTED,
ErrorCategory.CLIENT_CONFIGURATION,
i18n.t("error.e2ee_unsupported_description"),
t("error.e2ee_unsupported_description"),
);
}
}
@@ -165,7 +166,7 @@ export class UnknownCallError extends ElementCallError {
*/
public constructor(error: Error) {
super(
i18n.t("error.generic"),
t("error.generic"),
ErrorCode.UNKNOWN_ERROR,
ErrorCategory.UNKNOWN,
undefined,
@@ -185,7 +186,7 @@ export class FailToGetOpenIdToken extends ElementCallError {
*/
public constructor(error: Error) {
super(
i18n.t("error.generic"),
t("error.generic"),
ErrorCode.OPEN_ID_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
undefined,
@@ -202,10 +203,10 @@ export class NoMatrix2AuthorizationService extends ElementCallError {
*/
public constructor(error: Error) {
super(
i18n.t("error.generic"),
t("error.generic"),
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
ErrorCategory.CONFIGURATION_ISSUE,
i18n.t("error.no_matrix_2_authorization_service"),
t("error.no_matrix_2_authorization_service"),
// Properly set it as a cause for a better reporting on sentry
error,
);
@@ -222,7 +223,7 @@ export class FailToStartLivekitConnection extends ElementCallError {
*/
public constructor(e?: string) {
super(
i18n.t("error.failed_to_start_livekit"),
t("error.failed_to_start_livekit"),
ErrorCode.FAILED_TO_START_LIVEKIT,
ErrorCategory.NETWORK_CONNECTIVITY,
e,
@@ -236,10 +237,10 @@ export class FailToStartLivekitConnection extends ElementCallError {
export class InsufficientCapacityError extends ElementCallError {
public constructor() {
super(
i18n.t("error.insufficient_capacity"),
t("error.insufficient_capacity"),
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
ErrorCategory.UNKNOWN,
i18n.t("error.insufficient_capacity_description"),
t("error.insufficient_capacity_description"),
);
}
}
@@ -251,10 +252,10 @@ export class InsufficientCapacityError extends ElementCallError {
export class SFURoomCreationRestrictedError extends ElementCallError {
public constructor() {
super(
i18n.t("error.room_creation_restricted"),
t("error.room_creation_restricted"),
ErrorCode.SFU_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
i18n.t("error.room_creation_restricted_description"),
t("error.room_creation_restricted_description"),
);
}
}
@@ -265,7 +266,7 @@ export class SFURoomCreationRestrictedError extends ElementCallError {
export class PeerConnectionTimeoutError extends ElementCallError {
public constructor() {
super(
i18n.t("error.peer_connection_timeout"),
t("error.peer_connection_timeout"),
ErrorCode.SFU_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
);
@@ -282,7 +283,7 @@ export class PeerConnectionTimeoutError extends ElementCallError {
export class LivekitConnectionError extends ElementCallError {
public constructor(cause: ConnectionError) {
super(
i18n.t("error.livekit_connection_error"),
t("error.livekit_connection_error"),
ErrorCode.SFU_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
);
-21
View File
@@ -5,26 +5,5 @@ 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();
+1
View File
@@ -40,6 +40,7 @@ export async function initSPA(
try {
const client = await initClient(initClientParams, true);
return {
widgetApi: null,
client,
passwordlessUser,
};
-6
View File
@@ -40,11 +40,8 @@ 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" } });
@@ -143,7 +140,6 @@ export function getBasicCallViewModelEnvironment(
): {
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
rtcMemberships$: BehaviorSubject<CallMembership[]>;
rtcSession: MockRTCSession;
handRaisedSubject$: BehaviorSubject<Record<string, RaisedHandInfo>>;
@@ -188,12 +184,10 @@ export function getBasicCallViewModelEnvironment(
muteStates,
mediaDevices,
"reactionId",
{ showControls: true, header: HeaderStyle.Standard },
);
return {
vm,
footerVm,
developerSettingsVm: createDeveloperSettingsTabViewModel(testScope(), vm),
rtcMemberships$,
rtcSession,
handRaisedSubject$: handRaisedSubject$,
+4 -9
View File
@@ -63,7 +63,6 @@ import { type MediaDevices } from "../state/MediaDevices";
import { type Behavior, constant } from "../state/Behavior";
import { ObservableScope } from "../state/ObservableScope";
import { MuteStates } from "../state/MuteStates";
import { nullHostBridge } from "../HostBridge";
import {
createLocalUserMedia,
type LocalUserMediaViewModel,
@@ -488,8 +487,6 @@ export class MockRTCSession extends TypedEventEmitter<
return this.joined;
}
public isKeyRotationSuppressed = false;
public withMemberships(
rtcMembers$: Behavior<Partial<CallMembership>[]>,
): MockRTCSession {
@@ -578,12 +575,10 @@ export function mockMuteStates(
joined$: Observable<boolean> = of(true),
): MuteStates {
const observableScope = new ObservableScope();
return new MuteStates(
observableScope,
mockMediaDevices({}),
{ audioEnabled: false, videoEnabled: false },
nullHostBridge,
);
return new MuteStates(observableScope, mockMediaDevices({}), {
audioEnabled: false,
videoEnabled: false,
});
}
export class MockConnection extends Connection {
+2 -4
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import "@formatjs/intl-durationformat/polyfill.js";
import "@formatjs/intl-segmenter/polyfill";
import i18n from "i18next";
import posthog from "posthog-js";
import { initReactI18next } from "react-i18next";
import { afterEach } from "vitest";
@@ -17,11 +18,8 @@ import "@testing-library/jest-dom/vitest";
import EN from "../locales/en/app.json";
import { Config } from "./config/Config";
import { i18n } from "./utils/i18n";
// Bare-minimum i18n config.
// Unlike the app, tests register the instance as react-i18next's default rather
// than wrapping every render in an <I18nextProvider>.
// Bare-minimum i18n config
i18n
.use(initReactI18next)
.init({
+2 -2
View File
@@ -9,7 +9,7 @@ import { describe, expect, vi, it, beforeEach } from "vitest";
import { createRoomWidgetClient, EventType } from "matrix-js-sdk";
import { getUrlParams } from "./UrlParams";
import { initializeWidget } from "./widget";
import { initializeWidget, widget } from "./widget";
import { Config } from "./config/Config";
import { ElementCallReactionEventType } from "./reactions";
@@ -42,7 +42,7 @@ beforeEach(() => {
describe("widget", () => {
it("should create an embedded client with the correct params", () => {
const widget = initializeWidget("ANYRTCAPP");
initializeWidget("ANYRTCAPP");
expect(getUrlParams()).toStrictEqual({
widgetId: "id",
+12 -12
View File
@@ -58,21 +58,21 @@ export interface WidgetHelpers {
}
/**
* Connects to the widget API, if Element Call is running as a widget.
*
* Should be called as soon as possible on app start (in the initializer, before
* React), because the widget messaging needs to be set up ASAP on load to
* ensure it doesn't miss any requests.
*
* @returns A point of access to the widget API, or null if Element Call is not
* running as a widget.
* A point of access to the widget API, if the app is running as a widget. This
* is initialized with `initializeWidget`. This should happen at the top level because the widget messaging
* needs to be set up ASAP on load to ensure it doesn't miss any requests.
*/
export let widget: WidgetHelpers | null = null;
/**
* Should be called as soon as possible on app start. (In the initilizer before react)
*/
// this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before
// execution.
export const initializeWidget = (
rtcApplication: string = "m.call",
sendRoomEvents = false,
): WidgetHelpers | null => {
): void => {
try {
const {
widgetId,
@@ -202,14 +202,14 @@ export const initializeWidget = (
return client;
};
return { api, lazyActions, client: clientPromise() };
widget = { api, lazyActions, client: clientPromise() };
} else {
if (import.meta.env.MODE !== "test")
logger.info("No widget API available");
return null;
widget = null;
}
} catch (e) {
logger.warn("Continuing without the widget API", e);
return null;
widget = null;
}
};