Compare commits

...
Author SHA1 Message Date
Timo f88d8d5830 Merge pull request #4456 from element-hq/toger5/getRTCTrasnport_and_getDomain
Use non deprecated non unstable names for getRTCTransports.
2026-11-12 16:12:02 +01:00
Timo K. 6c66f9a3e7 use non deprecated non unstable names for getRTCTransports.
Also remove getDomain where possible
2026-11-12 16:01:11 +01:00
Johannes Marbach 99f48ff987 Merge pull request #4454 from element-hq/johannes/lk-jwt-service-0.10.0
Bump lk-jwt-service to 0.10.0
2026-11-12 13:01:09 +01:00
Timo 6846c8dd3e Merge pull request #4431 from element-hq/toger5/component-no-store-worker
Keep the store worker out of the component package
2026-11-12 12:27:47 +01:00
Johannes Marbach 28ee707ace Bump lk-jwt-service to 0.10.0 2026-11-12 08:29:26 +01:00
Timo K.andClaude Fable 5.1 3100c1c410 Cover the standalone client's initialisation and session restore
Neither had a test before the move, so the moved lines showed up as
uncovered. The client initialisation is tested through a fake client
and mocked stores: which store it syncs into with and without
IndexedDB, with and without the worker, the fallback to memory when
the IndexedDB store cannot start, clearing the stores only when not
restoring, carrying on without crypto, and counting as ready only
after a sync from the network rather than the one restored from the
store. The session restore is tested for no stored session, a
restored one, one the homeserver no longer knows, and any other
failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 19:24:21 +01:00
Timo K.andClaude Fable 5.1 fc1e91f54c Keep the store worker out of the component package
The package carried the standalone app's IndexedDB store worker: 11 MB
with a copy of the Matrix SDK and its crypto wasm, referenced by an
absolute path no host could serve, and never used by the component,
which takes the host's client. The bundler emits the worker file as
soon as it loads the `?worker` import, used or not, and the import sat
in the Matrix utilities the component reaches for the room URLs.

The client initialisation moves into a module of its own, with the
worker import, which only the standalone login, registration and
session restore paths import. ClientProvider takes the session loader
through a `loadClient` prop instead of importing it itself, since even
a dynamic import keeps a module in the package: the standalone app
passes a lazy `initSPA` from App.tsx, the component passes nothing,
and given neither a client nor a loader no one is signed in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-11 19:24:21 +01:00
renovate[bot] 96949a57da Update ghcr.io/element-hq/element-web:develop Docker digest to 8dc3767 (#4447)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-11 14:47:22 +00:00
Johannes Marbach 2e05ef77a0 Merge pull request #4441 from element-hq/johannes/flakes
Try to fix flaky background tests
2026-11-11 15:37:31 +01:00
Johannes Marbach 73826ec4f9 Reformat 2026-11-11 15:29:03 +01:00
Johannes Marbach 1135c02e6f Merge branch 'main' into johannes/flakes 2026-11-11 13:53:30 +01:00
Johannes MarbachandTimo 9dc425b5f8 Apply batched suggestions from code review
Improve comments

Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2026-11-11 13:52:05 +01:00
Johannes Marbach cfd8312e46 Try to fix flaky background tests 2026-11-09 12:20:00 +01:00
23 changed files with 641 additions and 220 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
pull_policy: always
hostname: auth-server
environment:
@@ -33,7 +33,7 @@ services:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
image: ghcr.io/element-hq/lk-jwt-service:0.10.0
pull_policy: always
hostname: auth-server-1
environment:
+2 -2
View File
@@ -19,10 +19,10 @@ 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:37d88b4426ff93a884b31c63d5319a7bcee32580f189c724e6b09f793a6de390
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
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:37d88b4426ff93a884b31c63d5319a7bcee32580f189c724e6b09f793a6de390
image: ghcr.io/element-hq/element-web:develop@sha256:72791f0b2541208f8f7edd9c6b5a7eaf88accd8ed1f0c4db1bc62bfe18eb90fe
volumes:
# The other site opens the embedded package under test in place of the
# copy its image bundles (the first site opens the dev server's app.
+1 -1
View File
@@ -107,7 +107,7 @@
"livekit-client": "^2.18.1",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#10e4faabbb49baca655492d523b58faa9eea04b9",
"matrix-widget-api": "^1.18.0",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
+10 -10
View File
@@ -191,8 +191,8 @@ importers:
specifier: ^1.9.1
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#dc8610e796c92553c658300a4882dde961a0678a
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a
specifier: github:matrix-org/matrix-js-sdk#10e4faabbb49baca655492d523b58faa9eea04b9
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9
matrix-widget-api:
specifier: ^1.18.0
version: 1.20.0
@@ -1195,8 +1195,8 @@ packages:
'@types/dom-mediacapture-transform': ^0.1.9
livekit-client: ^1.12.0 || ^2.1.0
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
'@matrix-org/matrix-sdk-crypto-wasm@18.9.2':
resolution: {integrity: sha512-GHeuSeb5Fj6Wg2+NE/WoLJJs9U3nOzttO+2sj7lOaS5gNBpGIksc48N/Q/kK/Z+Lxh7YMhopB7M8b6HyASXnGQ==}
engines: {node: '>= 18'}
'@mdx-js/react@3.1.1':
@@ -4734,9 +4734,9 @@ 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/dc8610e796c92553c658300a4882dde961a0678a:
resolution: {gitHosted: true, integrity: sha512-Ldt/VkCzBtre6m1cn0FqgrzRV0GrgWIoARgQUQO58ef2Edu2ERFCINPzp/psFS5RwoN3RIy9Rm4oPVc1ATkEWA==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a}
version: 45.0.0
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9:
resolution: {gitHosted: true, integrity: sha512-7cbPN96wGt93B73jZieW2HBtTnasgKJ7wzTgiV0SjwkNyb50EK3vDa17LdSJpyOwEjFP5edyPRprk865HQJqww==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9}
version: 45.1.0
engines: {node: '>=22.0.0'}
matrix-widget-api@1.20.0:
@@ -7178,7 +7178,7 @@ snapshots:
'@types/dom-mediacapture-transform': 0.1.11
livekit-client: 2.23.0(@types/dom-mediacapture-record@1.0.22)
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
'@matrix-org/matrix-sdk-crypto-wasm@18.9.2': {}
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.3.1)':
dependencies:
@@ -10413,10 +10413,10 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/dc8610e796c92553c658300a4882dde961a0678a:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/10e4faabbb49baca655492d523b58faa9eea04b9:
dependencies:
'@babel/runtime': 8.0.8
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.2
another-json: 0.2.0
bs58: 6.0.0
content-type: 3.1.1
+1
View File
@@ -162,6 +162,7 @@ describe("createLocalMedia", () => {
media.release("camera");
expect(trackBehind(camera).stop).toHaveBeenCalled();
expect(media.tracks$.value).toEqual([microphone]);
expect(media.requests.map((r) => r.source)).toEqual(["microphone"]);
media.stop();
expect(trackBehind(microphone).stop).toHaveBeenCalled();
+1
View File
@@ -107,6 +107,7 @@ export function createLocalMedia(
if (source !== "microphone" && source !== "camera") return;
capturedTracks.get(source)?.stop();
capturedTracks.delete(source);
requests.delete(source);
publishCapturedTracks();
};
+1 -1
View File
@@ -140,7 +140,7 @@ function setup(
getDeviceId: () => deviceId,
getDomain: () => "example.org",
getSyncState: () => SyncState.Syncing,
_unstable_getRTCTransports: vi.fn().mockResolvedValue(homeserverTransports),
getRTCTransports: vi.fn().mockResolvedValue(homeserverTransports),
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: publisherJWT }),
} as unknown as MatrixClient;
const members$ = new BehaviorSubject<RTCMember[]>([]);
+2 -2
View File
@@ -31,7 +31,7 @@ function client(
): Parameters<typeof discoverLocalTransport>[0] {
return {
getDomain: () => "example.org",
_unstable_getRTCTransports: vi.fn().mockResolvedValue(advertised),
getRTCTransports: vi.fn().mockResolvedValue(advertised),
};
}
@@ -54,7 +54,7 @@ describe("discoverLocalTransport", () => {
logger,
),
).resolves.toBe(hostTransport);
expect(c._unstable_getRTCTransports).not.toHaveBeenCalled();
expect(c.getRTCTransports).not.toHaveBeenCalled();
});
it("prefers the homeserver's transport over the host's fallback", async () => {
+1 -1
View File
@@ -22,7 +22,7 @@ import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
* @throws {MatrixRTCTransportMissingError} where none of these exists.
*/
export async function discoverLocalTransport(
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
client: Pick<MatrixClient, "getDomain" | "getRTCTransports">,
transportTypes: readonly string[],
{
transport: override,
@@ -36,11 +36,7 @@ const configTransport: Transport = {
function makeClient(): MockedObject<DiscoveryClient> {
return {
getDomain: vi.fn().mockReturnValue("example.org"),
baseUrl: "https://matrix.example.org",
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
getRTCTransports: vi.fn().mockResolvedValue([]),
} as unknown as MockedObject<DiscoveryClient>;
}
@@ -57,7 +53,7 @@ describe("TransportAutoDiscovery", () => {
"prefers backend transport other app config $transports",
async ({ transports }) => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
client.getRTCTransports.mockResolvedValue(transports);
const discovery = new TransportAutoDiscovery({
client,
@@ -71,7 +67,7 @@ describe("TransportAutoDiscovery", () => {
expect(discoveredTransport).toStrictEqual(backendTransport);
expect(discoveredTransport).not.toStrictEqual(configTransport);
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
expect(client.getRTCTransports).toHaveBeenCalledTimes(1);
},
);
@@ -81,10 +77,7 @@ describe("TransportAutoDiscovery", () => {
url: "wss://sfu.example.org",
};
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue([
backendTransport,
mLivekit,
]);
client.getRTCTransports.mockResolvedValue([backendTransport, mLivekit]);
const discovery = new TransportAutoDiscovery({
client,
@@ -100,7 +93,7 @@ describe("TransportAutoDiscovery", () => {
it("Retries limit_exceeded backend transport", async () => {
const client = makeClient();
client._unstable_getRTCTransports
client.getRTCTransports
.mockRejectedValueOnce(
new MatrixError(
{
@@ -124,7 +117,7 @@ describe("TransportAutoDiscovery", () => {
backendTransport,
);
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
expect(client.getRTCTransports).toHaveBeenCalledTimes(2);
});
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
@@ -135,7 +128,7 @@ describe("TransportAutoDiscovery", () => {
"falls back to config when backend has no (valid) livekit transports $transports",
async ({ transports }) => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
client.getRTCTransports.mockResolvedValue(transports);
const discovery = new TransportAutoDiscovery({
client,
@@ -152,7 +145,7 @@ describe("TransportAutoDiscovery", () => {
it("returns null when backend and config are all unavailable", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue([]);
client.getRTCTransports.mockResolvedValue([]);
const discovery = new TransportAutoDiscovery({
client,
+2 -5
View File
@@ -10,10 +10,7 @@ import { type Logger } from "matrix-js-sdk/lib/logger";
import { doNetworkOperationWithRetry } from "../utils/network";
type TransportDiscoveryClient = Pick<
MatrixClient,
"getDomain" | "_unstable_getRTCTransports"
>;
type TransportDiscoveryClient = Pick<MatrixClient, "getRTCTransports">;
export interface TransportAutoDiscoveryProps {
client: TransportDiscoveryClient;
@@ -74,7 +71,7 @@ export class TransportAutoDiscovery {
this.logger.info("First try to use getRTCTransports end point ...");
try {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
client.getRTCTransports(),
);
for (const type of this.transportTypes) {
const first = transportList.find(
+12 -2
View File
@@ -30,7 +30,7 @@ import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
import { RegisterPage } from "./auth/RegisterPage";
import { RoomPage } from "./room/RoomPage";
import { ClientProvider } from "./ClientContext";
import { type ClientLoader, ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { Initializer } from "./initializer";
import { type WidgetHelpers } from "./widget";
@@ -57,6 +57,16 @@ import { LeaveToHomeProvider } from "./LeaveToHomeContext";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
// Element Call's own session, for when no host supplies a client.
// - Passed in here, not imported by ClientProvider: this module is only in the
// app builds, so the client setup and the store worker behind it stay out of
// the component package (see the `loadClient` prop).
// - Imported on demand, as it was before, so that a widget, which gets its
// client from the host, never downloads it.
// - Module-level, so that the provider's effect sees one stable loader.
const loadStandaloneClient: ClientLoader = async (...args) =>
(await import("./utils/spa")).initSPA(...args);
interface SimpleProviderProps {
children: JSX.Element;
}
@@ -146,7 +156,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
const content =
loaded && clientReady ? (
<ClientProvider client={widgetClient}>
<ClientProvider client={widgetClient} loadClient={loadStandaloneClient}>
<MediaDevicesContext value={vm.mediaDevices}>
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
<Sentry.ErrorBoundary
+25 -6
View File
@@ -11,7 +11,11 @@ import { BrowserRouter } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { type FC } from "react";
import { ClientProvider, useClientState } from "./ClientContext";
import {
type ClientLoader,
ClientProvider,
useClientState,
} from "./ClientContext";
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
({
@@ -95,13 +99,13 @@ test("follows the client when the host swaps it", () => {
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 loadClient = vi
.fn<ClientLoader>()
.mockResolvedValue({ client, passwordlessUser: true });
const { container } = render(
<BrowserRouter>
<ClientProvider>
<ClientProvider loadClient={loadClient}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
@@ -110,6 +114,21 @@ test("finds a client of its own when the host supplies none", async () => {
// 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"));
// Given the session store to restore from and to clear on a stale session
expect(loadClient).toHaveBeenCalledWith(
expect.any(Function),
expect.any(Function),
);
});
vi.doUnmock("./utils/spa");
test("has no one signed in when given neither a client nor a way to load one", async () => {
const { container } = render(
<BrowserRouter>
<ClientProvider>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
await waitFor(() => expect(container.textContent).toBe("unauthenticated"));
});
+32 -12
View File
@@ -143,9 +143,34 @@ interface Props {
* own.
*/
client?: MatrixClient;
/**
* How Element Call finds a client when none is supplied: the standalone app
* restores or creates a session of its own. Stable, as a hook dependency.
* Left out along with the client, no one is signed in.
*
* A prop, rather than this module importing `utils/spa` itself, because:
* - This module is in the component build, and so is everything it imports,
* dynamically or not: a dynamic import becomes a chunk in the package,
* not a module left out of it.
* - Behind `utils/spa` sits `utils/initClient` and the client store worker,
* which the bundler emits as soon as it loads the import (see there).
* - `App.tsx`, which the component never imports, is where the standalone
* app passes the loader in; the component passes nothing, having the
* host's client.
*/
loadClient?: ClientLoader;
}
export const ClientProvider: FC<Props> = ({ children, client }) => {
/**
* Restores or creates a session of Element Call's own, kept where the given
* functions keep it. Resolves to null when no one is signed in.
*/
export type ClientLoader = (
loadSession: () => Session | undefined,
clearSession: () => void,
) => Promise<InitResult | null>;
export const ClientProvider: FC<Props> = ({ children, client, loadClient }) => {
const leaveToHome = useLeaveToHome();
const hostBridge = useHostBridge();
@@ -179,9 +204,13 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
// React does in strict mode), we need to make sure not to doubly initialize
// the client.
if (initializing.current) return;
if (loadClient === undefined) {
setInitClientState(null);
return;
}
initializing.current = true;
loadClient()
loadClient(loadSession, clearSession)
.then((initResult) => {
setInitClientState(initResult);
if (PosthogAnalytics.instance.isEnabled())
@@ -189,7 +218,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
})
.catch((err) => logger.error(err))
.finally(() => (initializing.current = false));
}, [client]);
}, [client, loadClient]);
const changePassword = useCallback(
async (password: string) => {
@@ -360,15 +389,6 @@ export type InitResult = {
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);
}
export interface Session {
user_id: string;
device_id: string;
+1 -1
View File
@@ -13,7 +13,7 @@ import {
type MatrixClient,
} from "matrix-js-sdk";
import { initClient } from "../utils/matrix";
import { initClient } from "../utils/initClient";
import { type Session } from "../ClientContext";
/**
* This provides the login method to login using user credentials.
+1 -1
View File
@@ -14,7 +14,7 @@ import {
} from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { initClient } from "../utils/matrix";
import { initClient } from "../utils/initClient";
import { type Session } from "../ClientContext";
import { Config } from "../config/Config";
import { useUrlParams } from "../UrlParams";
+34 -1
View File
@@ -20,6 +20,7 @@ import {
const frame = {} as VideoFrame;
const controller = {} as TransformStreamDefaultController<VideoFrame>;
const stats = { processingTimeMs: 1, segmentationTimeMs: 1, filterTimeMs: 0 };
describe("BackgroundEffectTransformer", () => {
afterEach(() => {
@@ -66,9 +67,10 @@ describe("BackgroundEffectTransformer", () => {
});
it("reports the first frame carrying an effect, once", async () => {
// The library reports every frame it draws.
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
async function (this: BackgroundTransformer) {
this.isFirstFrame = false;
this.options.onFrameProcessed?.(stats);
return Promise.resolve();
},
);
@@ -91,6 +93,37 @@ describe("BackgroundEffectTransformer", () => {
expect(transformer.onFirstFrame).not.toHaveBeenCalled();
});
it("keeps reporting drawn frames to whoever asked", async () => {
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
async function (this: BackgroundTransformer) {
this.options.onFrameProcessed?.(stats);
return Promise.resolve();
},
);
const onFrameProcessed = vi.fn();
const transformer = new BackgroundEffectTransformer({ onFrameProcessed });
await transformer.transform(frame, controller);
expect(onFrameProcessed).toHaveBeenCalledWith(stats);
});
it("never sends the first frame on untouched", async () => {
// The library copies its first frame through untouched, ahead of the
// drawn one, unless told the first frame is past
const seen: boolean[] = [];
vi.spyOn(BackgroundTransformer.prototype, "transform").mockImplementation(
async function (this: BackgroundTransformer) {
seen.push(this.isFirstFrame);
return Promise.resolve();
},
);
const transformer = new BackgroundEffectTransformer({});
expect(transformer.isFirstFrame).toBe(true);
await transformer.transform(frame, controller);
expect(seen).toEqual([false]);
});
it("passes frames on untouched once disabled, even after blurring", async () => {
const transformer = new BackgroundEffectTransformer({});
await transformer.update({ blurRadius: 15, backgroundDisabled: false });
+28 -5
View File
@@ -41,18 +41,41 @@ const wasmFileset: WasmFileset = { wasmLoaderPath, wasmBinaryPath };
* resource fetched from the public internet.
*/
export class BackgroundEffectTransformer extends BackgroundTransformer {
/** Called once, when the first frame carrying an effect has been drawn. */
/**
* Called once, when the first frame carrying an effect has been drawn.
* This is used for drewAFrame$ to show the correct loading state.
*/
public onFirstFrame: (() => void) | undefined;
private drewAFrame = false;
public constructor(opts: BackgroundOptions) {
super(opts);
// Called by the library for a frame it drew, never for one it passed on
// untouched.
this.options.onFrameProcessed = (stats): void => {
opts.onFrameProcessed?.(stats);
if (this.drewAFrame) return;
this.drewAFrame = true;
this.onFirstFrame?.();
};
}
public override async transform(
frame: VideoFrame,
controller: TransformStreamDefaultController<VideoFrame>,
): Promise<void> {
const priming = this.isFirstFrame;
// From the LK docs:
// > Render a copy of the first frame is rendered to the screen as soon as possible to act
// > as a less jarring initial state than a solid color while the synchronous work below
// > (segmentation + frame rendering) occurs.
// We do not want this copy (it leaks unblurred media).
// So we always set `isFirstFrame` to false -> never show an unprocessed frame.
// It does not have any impact other than setting the unprocessed frame while loading the processor.
//
// To not show the blank frame, we have our own mechanism for hiding the loading overlay:
// `onFirstFrame` is what the EC UI will listen to and only then show the video feed.
this.isFirstFrame = false;
await super.transform(frame, controller);
// Cleared only once a frame has been through the segmenter; a frame
// passed through untouched leaves it set.
if (priming && !this.isFirstFrame) this.onFirstFrame?.();
}
/**
+214
View File
@@ -0,0 +1,214 @@
/*
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 { EventEmitter } from "events";
import {
afterEach,
beforeEach,
describe,
expect,
it,
type Mock,
vi,
} from "vitest";
import {
ClientEvent,
createClient,
type ICreateClientOpts,
IndexedDBStore,
MemoryStore,
} from "matrix-js-sdk";
import type * as MatrixSdk from "matrix-js-sdk";
import { initClient } from "./initClient";
import { flushPromises } from "./test";
interface Store {
startup: Mock<() => Promise<void>>;
getSavedSyncToken: Mock<() => Promise<string | null>>;
}
const store = (): Store => ({
startup: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
getSavedSyncToken: vi
.fn<() => Promise<string | null>>()
.mockResolvedValue(null),
});
// The client is the SDK's business; what these tests are about is how it is
// set up: its stores, its crypto, and when it counts as ready.
vi.mock("matrix-js-sdk", async (importOriginal) => ({
...(await importOriginal<typeof MatrixSdk>()),
createClient: vi.fn(),
IndexedDBStore: vi.fn(function (this: Store) {
Object.assign(this, store());
}),
MemoryStore: vi.fn(function (this: Store) {
Object.assign(this, store());
}),
}));
interface FakeClient extends EventEmitter {
store: Store;
clearStores: Mock<() => Promise<void>>;
initRustCrypto: Mock<() => Promise<void>>;
startClient: Mock<() => Promise<void>>;
}
/**
* A client that, unless told otherwise, reports its first sync a tick after
* being started, as the real one does once the network answers.
*/
function fakeClient(opts: ICreateClientOpts, syncs = true): FakeClient {
const client = Object.assign(new EventEmitter(), {
store: opts.store as unknown as Store,
clearStores: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
initRustCrypto: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
startClient: vi.fn(async () => {
if (!syncs) return;
await new Promise((resolve) => setTimeout(resolve, 0));
client.emit(ClientEvent.Sync, "PREPARED", null);
}),
});
return client;
}
const options: ICreateClientOpts = { baseUrl: "https://hs.example.org" };
const clients = vi.mocked(createClient) as unknown as Mock<
(opts: ICreateClientOpts) => FakeClient
>;
const indexedDBStores = vi.mocked(IndexedDBStore);
const memoryStores = vi.mocked(MemoryStore);
describe("initClient", () => {
beforeEach(() => {
clients.mockImplementation((opts) => fakeClient(opts));
});
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});
it("hands back the client once its first sync is done", async () => {
const client = await initClient(options, true);
expect(client).toBe(clients.mock.results[0].value);
expect(client.startClient).toHaveBeenCalledWith({
clientWellKnownPollPeriod: 600,
});
expect(clients).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://hs.example.org",
useAuthorizationHeader: true,
localTimeoutMs: 5000,
}),
);
});
it("keeps the stores when restoring a session", async () => {
const client = (await initClient(options, true)) as unknown as FakeClient;
expect(client.clearStores).not.toHaveBeenCalled();
});
it("clears the stores before starting them when not restoring", async () => {
const client = (await initClient(options, false)) as unknown as FakeClient;
expect(client.clearStores).toHaveBeenCalled();
expect(client.clearStores.mock.invocationCallOrder[0]).toBeLessThan(
client.store.startup.mock.invocationCallOrder[0],
);
});
it("syncs into memory where there is no IndexedDB", async () => {
// jsdom has no indexedDB
const client = await initClient(options, true);
expect(client.store).toBe(memoryStores.mock.instances[0]);
expect(indexedDBStores).not.toHaveBeenCalled();
});
it("syncs into IndexedDB through a worker, where it was bundled", async () => {
vi.stubGlobal("indexedDB", {});
vi.stubEnv("DEV", false);
const client = await initClient(options, true);
expect(client.store).toBe(indexedDBStores.mock.instances[0]);
const [storeOptions] = indexedDBStores.mock.calls[0];
expect(storeOptions.dbName).toBe("element-call-sync");
expect(storeOptions.workerFactory).toBeTypeOf("function");
});
it("syncs into IndexedDB without a worker in development", async () => {
vi.stubGlobal("indexedDB", {});
vi.stubEnv("DEV", true);
await initClient(options, true);
// The dev server does not bundle the worker
expect(indexedDBStores.mock.calls[0][0].workerFactory).toBeUndefined();
});
it("falls back to memory when the IndexedDB store cannot start", async () => {
vi.stubGlobal("indexedDB", {});
const failing = store();
failing.startup.mockRejectedValue(new Error("quota exceeded"));
indexedDBStores.mockImplementationOnce(function (this: IndexedDBStore) {
Object.assign(this, failing);
});
const client = await initClient(options, true);
expect(client.store).toBe(memoryStores.mock.instances[0]);
expect(client.store.startup).toHaveBeenCalled();
});
it("carries on without crypto when it cannot be initialised", async () => {
clients.mockImplementation((opts) => {
const client = fakeClient(opts);
client.initRustCrypto.mockRejectedValue(new Error("stores in use"));
return client;
});
await expect(initClient(options, true)).resolves.toBeDefined();
});
it("waits for a sync from the network, past one restored from the store", async () => {
let client!: FakeClient;
clients.mockImplementation((opts) => {
client = fakeClient(opts, false);
client.store.getSavedSyncToken.mockResolvedValue("saved");
return client;
});
let ready = false;
const result = initClient(options, true).then(() => (ready = true));
await flushPromises();
// The first sync the client reports is the one restored from the store
client.emit(ClientEvent.Sync, "PREPARED", null);
await flushPromises();
expect(ready).toBe(false);
client.emit(ClientEvent.Sync, "PREPARED", null);
await result;
expect(ready).toBe(true);
});
it("fails when the first sync does", async () => {
let client!: FakeClient;
clients.mockImplementation((opts) => (client = fakeClient(opts, false)));
const result = initClient(options, true);
await flushPromises();
client.emit(ClientEvent.Sync, "ERROR", null, {
error: new Error("offline"),
});
await expect(result).rejects.toThrow("offline");
});
});
+170
View File
@@ -0,0 +1,170 @@
/*
Copyright 2022-2024 New Vector Ltd.
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 {
ClientEvent,
createClient,
type ICreateClientOpts,
IndexedDBStore,
type MatrixClient,
MemoryStore,
} from "matrix-js-sdk";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { logger } from "matrix-js-sdk/lib/logger";
// This import is why `initClient` has a module of its own:
// - The bundler emits the worker file (11 MB: the Matrix SDK and its crypto
// wasm) the moment it loads this import — whether or not anything ever calls
// `new IndexedDBWorker()`. Tree shaking drops the call, not the file.
// - So it may only sit in a module the component build never loads. It used
// to be in `./matrix`, which the component reaches for the room URL helpers,
// and the worker shipped in the package, unused and unusable by any host.
import IndexedDBWorker from "../IndexedDBWorker?worker";
import { getUrlParams } from "../UrlParams";
import { Config } from "../config/Config";
import { fallbackICEServerAllowed } from "./matrix";
const SYNC_STORE_NAME = "element-call-sync";
/**
* Initialises and returns a new standalone Matrix Client.
* This can only be called safely if no other client is running
* otherwise rust crypto will throw since it is not ready to initialize a new session.
* If another client is running make sure `.logout()` is called before executing this function.
* @param clientOptions Object of options passed through to the client
* @param restore If the rust crypto should be reset before the client initialization or
* if the initialization should try to restore the crypto state from the indexDB.
* @returns The MatrixClient instance
*/
export async function initClient(
clientOptions: ICreateClientOpts,
restore: boolean,
): Promise<MatrixClient> {
let indexedDB: IDBFactory | undefined;
try {
indexedDB = window.indexedDB;
} catch (e) {
logger.warn("Could not get indexDB from window.", e);
}
// options we always pass to the client (stuff that we need in order to work)
const baseOpts = {
fallbackICEServerAllowed: fallbackICEServerAllowed,
isVoipWithNoMediaAllowed:
Config.get().features?.feature_group_calls_without_video_and_audio,
} as ICreateClientOpts;
if (indexedDB && localStorage) {
baseOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage,
dbName: SYNC_STORE_NAME,
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
// in dev mode: it expects them to use native modules. Ours don't, and even then only
// Chrome supports it. (It bundles them fine in production mode.)
workerFactory: import.meta.env.DEV
? undefined
: (): Worker => new IndexedDBWorker(),
});
} else if (localStorage) {
baseOpts.store = new MemoryStore({ localStorage });
}
// XXX: we read from the URL params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const { e2eEnabled } = getUrlParams();
if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
const client = createClient({
...baseOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime app
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed,
});
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
if (!restore) {
await client.clearStores();
}
// Start client store.
// Note: The `client.store` is used to store things like sync results. It's independent of
// the cryptostore, and uses a separate indexeddb database.
try {
await client.store.startup();
} catch (error) {
logger.error(
"Error starting matrix client indexDB store. Falling back to memory store.",
error,
);
client.store = new MemoryStore({ localStorage });
await client.store.startup();
}
// Also creates and starts any crypto related stores.
try {
await client.initRustCrypto();
} catch (err) {
logger.warn(
err,
"Make sure to clear client stores before initializing the rust crypto.",
);
}
// Once startClient is called, syncs are run asynchronously.
// Also, sync completion is communicated only via events.
// So, apply the event listener *before* starting the client.
// Otherwise, a sync may complete before the listener gets applied,
// and we will miss it.
const syncPromise = waitForSync(client);
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
await syncPromise;
return client;
}
async function waitForSync(client: MatrixClient): Promise<void> {
// If there is a saved sync, the client will fire an additional sync event
// for restoring it before it runs the first network sync.
// However, the sync we want to wait for is the network sync,
// as the saved sync may be missing some state.
// Thus, don't resolve on the first sync when we know it's for the saved sync.
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
return new Promise<void>((resolve, reject) => {
const onSync = (
state: SyncState,
_old: SyncState | null,
data?: ISyncStateData,
): void => {
if (state === "PREPARED") {
if (waitForSavedSync) {
waitForSavedSync = false;
} else {
client.removeListener(ClientEvent.Sync, onSync);
resolve();
}
} else if (state === "ERROR") {
client.removeListener(ClientEvent.Sync, onSync);
reject(data?.error);
}
};
client.on(ClientEvent.Sync, onSync);
});
}
+8 -152
View File
@@ -5,21 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ClientEvent,
createClient,
IndexedDBStore,
MemoryStore,
Preset,
Visibility,
} from "matrix-js-sdk";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { ClientEvent, Preset, Visibility } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { secureRandomBase64Url } from "matrix-js-sdk/lib/randomstring";
import type { ICreateClientOpts, MatrixClient, Room } from "matrix-js-sdk";
import IndexedDBWorker from "../IndexedDBWorker?worker";
import { generateUrlSearchParams, getUrlParams } from "../UrlParams";
import type { MatrixClient, Room } from "matrix-js-sdk";
import { generateUrlSearchParams } from "../UrlParams";
import { Config } from "../config/Config";
import { E2eeType } from "../e2ee/e2eeType";
import {
@@ -27,149 +18,14 @@ import {
saveKeyForRoom,
} from "../e2ee/sharedKeyManagement";
// Shared with the component, which reaches this module for the room URLs. So
// nothing in here may import the client store worker: the bundler emits the
// worker file on loading the import, used or not. The one function that needs
// it, `initClient`, lives in `./initClient` for that reason.
export const fallbackICEServerAllowed =
import.meta.env.VITE_FALLBACK_STUN_ALLOWED === "true";
const SYNC_STORE_NAME = "element-call-sync";
async function waitForSync(client: MatrixClient): Promise<void> {
// If there is a saved sync, the client will fire an additional sync event
// for restoring it before it runs the first network sync.
// However, the sync we want to wait for is the network sync,
// as the saved sync may be missing some state.
// Thus, don't resolve on the first sync when we know it's for the saved sync.
let waitForSavedSync = !!(await client.store.getSavedSyncToken());
return new Promise<void>((resolve, reject) => {
const onSync = (
state: SyncState,
_old: SyncState | null,
data?: ISyncStateData,
): void => {
if (state === "PREPARED") {
if (waitForSavedSync) {
waitForSavedSync = false;
} else {
client.removeListener(ClientEvent.Sync, onSync);
resolve();
}
} else if (state === "ERROR") {
client.removeListener(ClientEvent.Sync, onSync);
reject(data?.error);
}
};
client.on(ClientEvent.Sync, onSync);
});
}
/**
* Initialises and returns a new standalone Matrix Client.
* This can only be called safely if no other client is running
* otherwise rust crypto will throw since it is not ready to initialize a new session.
* If another client is running make sure `.logout()` is called before executing this function.
* @param clientOptions Object of options passed through to the client
* @param restore If the rust crypto should be reset before the client initialization or
* if the initialization should try to restore the crypto state from the indexDB.
* @returns The MatrixClient instance
*/
export async function initClient(
clientOptions: ICreateClientOpts,
restore: boolean,
): Promise<MatrixClient> {
let indexedDB: IDBFactory | undefined;
try {
indexedDB = window.indexedDB;
} catch (e) {
logger.warn("Could not get indexDB from window.", e);
}
// options we always pass to the client (stuff that we need in order to work)
const baseOpts = {
fallbackICEServerAllowed: fallbackICEServerAllowed,
isVoipWithNoMediaAllowed:
Config.get().features?.feature_group_calls_without_video_and_audio,
} as ICreateClientOpts;
if (indexedDB && localStorage) {
baseOpts.store = new IndexedDBStore({
indexedDB: window.indexedDB,
localStorage,
dbName: SYNC_STORE_NAME,
// We can't use the worker in dev mode because Vite simply doesn't bundle workers
// in dev mode: it expects them to use native modules. Ours don't, and even then only
// Chrome supports it. (It bundles them fine in production mode.)
workerFactory: import.meta.env.DEV
? undefined
: (): Worker => new IndexedDBWorker(),
});
} else if (localStorage) {
baseOpts.store = new MemoryStore({ localStorage });
}
// XXX: we read from the URL params in RoomPage too:
// it would be much better to read them in one place and pass
// the values around, but we initialise the matrix client in
// many different places so we'd have to pass it into all of
// them.
const { e2eEnabled } = getUrlParams();
if (!e2eEnabled) {
logger.info("Disabling E2E: group call signalling will NOT be encrypted.");
}
const client = createClient({
...baseOpts,
...clientOptions,
useAuthorizationHeader: true,
// Use a relatively low timeout for API calls: this is a realtime app
// so we don't want API calls taking ages, we'd rather they just fail.
localTimeoutMs: 5000,
useE2eForGroupCall: e2eEnabled,
fallbackICEServerAllowed: fallbackICEServerAllowed,
});
// In case of logging in a new matrix account but there is still crypto local store. This is needed for:
// - We lost the auth tokens and cannot restore the client resulting in registering a new user.
// - We start the sign in flow but are registered with a guest user. (It should additionally log out the guest before)
// - A new account is created because of missing LocalStorage: "matrix-auth-store", but the crypto IndexDB is still available.
if (!restore) {
await client.clearStores();
}
// Start client store.
// Note: The `client.store` is used to store things like sync results. It's independent of
// the cryptostore, and uses a separate indexeddb database.
try {
await client.store.startup();
} catch (error) {
logger.error(
"Error starting matrix client indexDB store. Falling back to memory store.",
error,
);
client.store = new MemoryStore({ localStorage });
await client.store.startup();
}
// Also creates and starts any crypto related stores.
try {
await client.initRustCrypto();
} catch (err) {
logger.warn(
err,
"Make sure to clear client stores before initializing the rust crypto.",
);
}
// Once startClient is called, syncs are run asynchronously.
// Also, sync completion is communicated only via events.
// So, apply the event listener *before* starting the client.
// Otherwise, a sync may complete before the listener gets applied,
// and we will miss it.
const syncPromise = waitForSync(client);
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
await syncPromise;
return client;
}
export function roomAliasLocalpartFromRoomName(roomName: string): string {
return roomName
.trim()
+83
View File
@@ -0,0 +1,83 @@
/*
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, beforeEach, describe, expect, it, vi } from "vitest";
import { type MatrixClient, MatrixError } from "matrix-js-sdk";
import { initClient } from "./initClient";
import { initSPA } from "./spa";
import { Config } from "../config/Config";
import { type Session } from "../ClientContext";
vi.mock("./initClient", () => ({ initClient: vi.fn() }));
const session: Session = {
user_id: "@alice:example.org",
device_id: "AAAA",
access_token: "secret",
passwordlessUser: true,
};
const client = {} as MatrixClient;
describe("initSPA", () => {
const clearSession = vi.fn();
beforeEach(() => {
vi.spyOn(Config, "defaultHomeserverUrl").mockReturnValue(
"https://hs.example.org",
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
it("continues without a client when no session is stored", async () => {
await expect(initSPA(() => undefined, clearSession)).resolves.toBeNull();
expect(initClient).not.toHaveBeenCalled();
expect(clearSession).not.toHaveBeenCalled();
});
it("restores the stored session into a client", async () => {
vi.mocked(initClient).mockResolvedValue(client);
await expect(initSPA(() => session, clearSession)).resolves.toEqual({
client,
passwordlessUser: true,
});
expect(initClient).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: "https://hs.example.org",
accessToken: "secret",
userId: "@alice:example.org",
deviceId: "AAAA",
}),
// Restoring: the crypto store is the session's, not to be wiped
true,
);
});
it("drops a session the homeserver no longer knows", async () => {
vi.mocked(initClient).mockRejectedValue(
new MatrixError({ errcode: "M_UNKNOWN_TOKEN", error: "Unknown token" }),
);
await expect(initSPA(() => session, clearSession)).resolves.toBeNull();
expect(clearSession).toHaveBeenCalled();
});
it("drops the session and fails on any other error", async () => {
vi.mocked(initClient).mockRejectedValue(new Error("offline"));
await expect(initSPA(() => session, clearSession)).rejects.toThrow(
"offline",
);
expect(clearSession).toHaveBeenCalled();
});
});
+2 -1
View File
@@ -9,7 +9,8 @@ import { type ICreateClientOpts, MatrixError } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { Config } from "../config/Config";
import { fallbackICEServerAllowed, initClient } from "./matrix";
import { fallbackICEServerAllowed } from "./matrix";
import { initClient } from "./initClient";
import type { InitResult, Session } from "../ClientContext";
export async function initSPA(