Talk to a component host in callbacks, not observables

The component exposed the internal HostBridge to hosts as-is, which
carried the host's requests as rxjs observables. That made rxjs part of
the public API of a package that bundles its own copy of it, so a host
would build bridges with a different rxjs than the one Element Call
consumed them with — and asked every host to learn rxjs to change the
theme.

A component host now implements plain async callbacks for what Element
Call tells it (`ElementCallHostBridge`, all optional), and makes its own
requests through an imperative handle on the component's `ref`
(`ElementCallHandle`: setTheme, join, hangUp, setDeviceMute), each
resolving once Element Call has acted and rejecting when nothing in
Element Call can. `component/host.ts` adapts that to the HostBridge the
rest of Element Call still speaks, with a bridge whose identity never
changes, so a host re-creating its callbacks on render restarts nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Timo K.
2026-09-08 15:38:59 +02:00
co-authored by Claude Fable 5.1
parent d18e82c546
commit 8e8bc5ddde
5 changed files with 390 additions and 99 deletions
+5 -62
View File
@@ -5,61 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { NEVER, Subject } from "rxjs"; import { type ElementCallHostBridge } from "../index";
import {
type DeviceMuteRequest,
type DeviceMuteState,
type ElementCallHostBridge,
type HostRequest,
} from "../index";
/** /**
* A host bridge that reports everything it is told and can be driven by hand, * A host bridge that reports everything it is told, so that the harness can
* so that the harness can watch both directions of the conversation between * watch what Element Call says to its host. (What the host says to Element
* Element Call and its host. * Call goes through the component's handle, and is logged by the pane.)
*/ */
export interface DevHostBridge extends ElementCallHostBridge {
/** Tells Element Call the host has changed theme. */
requestTheme(name: string): void;
/** Tells Element Call to leave the call. */
requestHangUp(): void;
/** Asks Element Call to change, and report back, its mute state. */
requestDeviceMute(request: DeviceMuteRequest): void;
}
export function createDevHostBridge( export function createDevHostBridge(
log: (message: string) => void, log: (message: string) => void,
/** What the host does when Element Call asks to be closed. */ /** What the host does when Element Call asks to be closed. */
onClose: () => void, onClose: () => void,
): DevHostBridge { ): ElementCallHostBridge {
const themeChange$ = new Subject<HostRequest<{ name?: string }>>();
const hangUp$ = new Subject<HostRequest<Record<string, never>>>();
const deviceMute$ = new Subject<
HostRequest<DeviceMuteRequest, DeviceMuteState>
>();
const ask = <Data, Reply>(
subject: Subject<HostRequest<Data, Reply>>,
name: string,
data: Data,
): void => {
// Worth saying out loud: a request nobody is subscribed to is silently
// dropped, and that is exactly the sort of thing the harness is for.
if (!subject.observed) {
log(`${name}: nothing is listening`);
return;
}
log(`${name}`);
subject.next({
data,
reply: (reply): void =>
log(
`${name} acknowledged${reply === undefined ? "" : `: ${JSON.stringify(reply)}`}`,
),
});
};
/** /**
* Records something Element Call told the host. Nothing is sent anywhere, so * Records something Element Call told the host. Nothing is sent anywhere, so
* this is only asynchronous because a real host's answer would have to be. * this is only asynchronous because a real host's answer would have to be.
@@ -85,19 +42,5 @@ export function createDevHostBridge(
await told("close"); await told("close");
onClose(); onClose();
}, },
themeChange$,
// The harness does not preload a call, so this is never asked for
join$: NEVER,
hangUp$,
deviceMute$,
supportsReactions: true,
requestTheme: (name): void =>
ask(themeChange$, `themeChange(${name})`, { name }),
requestHangUp: (): void => ask(hangUp$, "hangUp", {}),
requestDeviceMute: (request): void =>
ask(deviceMute$, `deviceMute(${JSON.stringify(request)})`, request),
}; };
} }
+49 -7
View File
@@ -11,12 +11,13 @@ import {
type ReactNode, type ReactNode,
useCallback, useCallback,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { ElementCall } from "../index"; import { ElementCall, type ElementCallHandle } from "../index";
import { createDevHostBridge } from "./DevHostBridge"; import { createDevHostBridge } from "./DevHostBridge";
import { createSession, joinRoom } from "./session"; import { createSession, joinRoom } from "./session";
import styles from "./Harness.module.css"; import styles from "./Harness.module.css";
@@ -102,6 +103,29 @@ const Pane: FC<{
[log, session.label], [log, session.label],
); );
// What the host asks of Element Call goes through the component's handle.
// Worth saying out loud when a request is refused — asking to hang up when
// there is no call, say — since that is the sort of thing the harness is for.
const handle = useRef<ElementCallHandle>(null);
const ask = (
name: string,
make: (handle: ElementCallHandle) => Promise<unknown>,
): void => {
if (handle.current === null) {
log(session.label, `${name}: not mounted`);
return;
}
log(session.label, `${name}`);
make(handle.current).then(
(reply) =>
log(
session.label,
`${name} acknowledged${reply === undefined ? "" : `: ${JSON.stringify(reply)}`}`,
),
(e: unknown) => log(session.label, `${name} refused: ${e}`),
);
};
return ( return (
<section className={styles.pane} data-testid="call-pane"> <section className={styles.pane} data-testid="call-pane">
<div className={styles.paneBar}> <div className={styles.paneBar}>
@@ -110,24 +134,42 @@ const Pane: FC<{
<button onClick={(): void => setMounted((m) => !m)}> <button onClick={(): void => setMounted((m) => !m)}>
{mounted ? "Unmount" : "Mount"} {mounted ? "Unmount" : "Mount"}
</button> </button>
<button onClick={(): void => bridge.requestTheme("light")}>
Light
</button>
<button onClick={(): void => bridge.requestTheme("dark")}>Dark</button>
<button <button
onClick={(): void => onClick={(): void =>
bridge.requestDeviceMute({ audio_enabled: false }) ask("setTheme(light)", async (h) => await h.setTheme("light"))
}
>
Light
</button>
<button
onClick={(): void =>
ask("setTheme(dark)", async (h) => await h.setTheme("dark"))
}
>
Dark
</button>
<button
onClick={(): void =>
ask(
"setDeviceMute(audio: false)",
async (h) => await h.setDeviceMute({ audio_enabled: false }),
)
} }
> >
Mute Mute
</button> </button>
<button onClick={(): void => bridge.requestHangUp()}>Hang up</button> <button
onClick={(): void => ask("hangUp", async (h) => await h.hangUp())}
>
Hang up
</button>
</div> </div>
{/* Resizable, because how Element Call copes with the size it is given is {/* Resizable, because how Element Call copes with the size it is given is
one of the things we cannot find out from the standalone app */} one of the things we cannot find out from the standalone app */}
<div className={styles.paneCall} data-testid="call-container"> <div className={styles.paneCall} data-testid="call-container">
{mounted && ( {mounted && (
<ElementCall <ElementCall
ref={handle}
client={session.client} client={session.client}
roomId={roomId} roomId={roomId}
hostBridge={bridge} hostBridge={bridge}
+122
View File
@@ -0,0 +1,122 @@
/*
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 { renderHook } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, test, vi } from "vitest";
import {
type ElementCallHandle,
type ElementCallHostBridge,
useComponentHostBridge,
} from "./host";
describe("useComponentHostBridge", () => {
test("keeps one identity while the host supplies new objects", () => {
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined),
{ initialProps: { supplied: {} } },
);
const first = result.current;
rerender({ supplied: { notifyJoined: async () => {} } });
expect(result.current).toBe(first);
});
test("forwards to whatever the host most recently supplied", async () => {
const before = vi.fn().mockResolvedValue(undefined);
const after = vi.fn().mockResolvedValue(undefined);
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined),
{ initialProps: { supplied: { notifyJoined: before } } },
);
rerender({ supplied: { notifyJoined: after } });
await result.current.notifyJoined();
expect(before).not.toHaveBeenCalled();
expect(after).toHaveBeenCalledOnce();
});
test("is quiet about what the host did not implement", async () => {
const { result } = renderHook(() =>
useComponentHostBridge(undefined, undefined),
);
await expect(result.current.contentLoaded()).resolves.toBeUndefined();
await expect(
result.current.notifyDeviceMute({
audio_enabled: true,
video_enabled: false,
}),
).resolves.toBeUndefined();
expect(result.current.supportsReactions).toBe(true);
});
test("only has a close when the host has one, since that is a signal", () => {
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined),
{ initialProps: { supplied: {} } },
);
expect(result.current.close).toBeUndefined();
const close = vi.fn().mockResolvedValue(undefined);
rerender({ supplied: { close } });
expect(result.current.close).toBeDefined();
});
test("never offers profile changes, since the account is the host's", () => {
const { result } = renderHook(() =>
useComponentHostBridge(undefined, undefined),
);
expect(result.current.supportsProfileChanges).toBe(false);
});
describe("the handle", () => {
test("delivers a request to what is listening and resolves on its reply", async () => {
const ref = createRef<ElementCallHandle>();
const { result } = renderHook(() =>
useComponentHostBridge(undefined, ref),
);
const received = vi.fn();
result.current.deviceMute$.subscribe(({ data, reply }) => {
received(data);
reply({ audio_enabled: data.audio_enabled!, video_enabled: true });
});
await expect(
ref.current!.setDeviceMute({ audio_enabled: false }),
).resolves.toEqual({ audio_enabled: false, video_enabled: true });
expect(received).toHaveBeenCalledWith({ audio_enabled: false });
});
test("refuses a request nothing in Element Call is listening for", async () => {
const ref = createRef<ElementCallHandle>();
renderHook(() => useComponentHostBridge(undefined, ref));
await expect(ref.current!.hangUp()).rejects.toThrow(
"Nothing in Element Call can hang up right now",
);
});
test("passes the theme name through", async () => {
const ref = createRef<ElementCallHandle>();
const { result } = renderHook(() =>
useComponentHostBridge(undefined, ref),
);
const names: (string | undefined)[] = [];
result.current.themeChange$.subscribe(({ data, reply }) => {
names.push(data.name);
reply();
});
await ref.current!.setTheme("light");
expect(names).toEqual(["light"]);
});
});
});
+194
View File
@@ -0,0 +1,194 @@
/*
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.
*/
/**
* How a host application and the Element Call component talk to each other.
*
* Inside Element Call the host is a {@link HostBridge}, which carries the
* host's requests as rxjs observables because that is what the widget API and
* the view models work in. A host should not have to know about rxjs, or agree
* with us on a version of it, so a component host sees neither: it implements
* plain async callbacks for what Element Call tells it, and makes its own
* requests through an imperative handle on the component, the way it would
* call `play()` on a video element. This module adapts the one to the other.
*/
import { type Ref, useImperativeHandle } from "react";
import { Subject } from "rxjs";
import {
type DeviceMuteRequest,
type DeviceMuteState,
type HostBridge,
type HostRequest,
} from "../src/HostBridge";
import { type JoinCallData } from "../src/widget";
import { useInitial } from "../src/useInitial";
import { useLatest } from "../src/useLatest";
/**
* What Element Call tells the application embedding it. Everything is
* optional: a host implements what it wants to hear about.
*
* Compared by nothing — Element Call always calls whichever one it was most
* recently given, so this may be written inline.
*/
export interface ElementCallHostBridge {
/**
* 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: to unmount the component. Its
* presence is what makes Element Call offer a close button on its error
* screens, and leave the host to decide what is shown once a call has ended.
* Without it, Element Call shows its own post-call screen, if it has one for
* the situation, or nothing.
*/
close?(): Promise<void>;
/**
* Whether Element Call may send and receive reactions in this room.
* Defaults to true.
*/
readonly supportsReactions?: boolean;
/**
* Fetches media on Element Call's behalf, for hosts that do not want it
* touching the homeserver's media endpoints itself. Absent, Element Call
* fetches media with the client it was given.
*/
downloadMedia?(mxcUri: string): Promise<Blob>;
}
/**
* What a host can ask of a mounted Element Call, reached through the
* component's `ref`. Each request resolves once Element Call has acted on it,
* and rejects if nothing in Element Call is in a position to act: hanging up
* when there is no call, say.
*/
export interface ElementCallHandle {
/** Switches Element Call to the named theme, `light` or `dark`. */
setTheme(name: string): Promise<void>;
/**
* Joins the call, when Element Call was configured to `preload` and is
* waiting to be told to. Says which devices to join with.
*/
join(devices: JoinCallData): Promise<void>;
/** Leaves the call. */
hangUp(): Promise<void>;
/**
* Changes the mute state, for whichever of audio and video is given, and
* reports the state that results.
*/
setDeviceMute(request: DeviceMuteRequest): Promise<DeviceMuteState>;
}
/** Hands a request to Element Call and waits for it to be acknowledged. */
async function request<Data, Reply>(
listeners: Subject<HostRequest<Data, Reply>>,
what: string,
data: Data,
): Promise<Reply> {
if (!listeners.observed)
throw new Error(`Nothing in Element Call can ${what} right now`);
return await new Promise((resolve) =>
listeners.next({ data, reply: resolve }),
);
}
/**
* The {@link HostBridge} the rest of Element Call sees, built from what a
* component host supplies and wired to the handle it is given.
*
* The bridge is created once and never changes identity — everything that
* depends on it would otherwise restart when the host re-rendered with a new
* object — and forwards each call to whatever the host most recently passed.
*/
export function useComponentHostBridge(
supplied: ElementCallHostBridge | undefined,
ref: Ref<ElementCallHandle> | undefined,
): HostBridge {
const latest = useLatest(supplied ?? {});
const requests = useInitial(() => ({
themeChange$: new Subject<HostRequest<{ name?: string }>>(),
join$: new Subject<HostRequest<JoinCallData>>(),
hangUp$: new Subject<HostRequest<Record<string, never>>>(),
deviceMute$: new Subject<HostRequest<DeviceMuteRequest, DeviceMuteState>>(),
}));
const bridge = useInitial(
(): HostBridge => ({
setAlwaysOnScreen: async (alwaysOnScreen) => {
await latest.current.setAlwaysOnScreen?.(alwaysOnScreen);
},
contentLoaded: async () => {
await latest.current.contentLoaded?.();
},
notifyJoined: async () => {
await latest.current.notifyJoined?.();
},
notifyHungUp: async () => {
await latest.current.notifyHungUp?.();
},
notifyDeviceMute: async (state) => {
await latest.current.notifyDeviceMute?.(state);
},
// Whether these exist is itself information, so they are read through
// rather than wrapped unconditionally
get close() {
const close = latest.current.close;
return close === undefined
? undefined
: async (): Promise<void> => await close();
},
get downloadMedia() {
const downloadMedia = latest.current.downloadMedia;
return downloadMedia === undefined
? undefined
: async (mxcUri: string): Promise<Blob> =>
await downloadMedia(mxcUri);
},
get supportsReactions(): boolean {
return latest.current.supportsReactions ?? true;
},
// Whatever the host says or does not say, the account is its own: it
// signed the user in and handed us the client. So Element Call never
// offers to edit the profile from inside a component.
supportsProfileChanges: false,
...requests,
}),
);
useImperativeHandle(
ref,
(): ElementCallHandle => ({
setTheme: async (name) =>
await request(requests.themeChange$, "change theme", { name }),
join: async (devices) =>
await request(requests.join$, "join a call", devices),
hangUp: async () => await request(requests.hangUp$, "hang up", {}),
setDeviceMute: async (muteRequest) =>
await request(
requests.deviceMute$,
"change the mute state",
muteRequest,
),
}),
[requests],
);
return bridge;
}
+20 -30
View File
@@ -36,6 +36,7 @@ import {
type FC, type FC,
type JSX, type JSX,
type ReactNode, type ReactNode,
type Ref,
useEffect, useEffect,
useMemo, useMemo,
useState, useState,
@@ -53,11 +54,7 @@ import EN from "../locales/en/app.json";
import { ElementCallView } from "../src/ElementCallView"; import { ElementCallView } from "../src/ElementCallView";
import { ErrorPage } from "../src/FullScreenView"; import { ErrorPage } from "../src/FullScreenView";
import { ClientProvider } from "../src/ClientContext"; import { ClientProvider } from "../src/ClientContext";
import { import { HostBridgeProvider } from "../src/HostBridge";
type HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "../src/HostBridge";
import { RootElementProvider } from "../src/RootElementContext"; import { RootElementProvider } from "../src/RootElementContext";
import { import {
configurationForIntent, configurationForIntent,
@@ -76,13 +73,17 @@ import { i18n } from "../src/utils/i18n";
import { useTheme } from "../src/useTheme"; import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue"; import { useStableValue } from "../src/useStableValue";
import styles from "./ElementCall.module.css"; import styles from "./ElementCall.module.css";
import {
type ElementCallHandle,
type ElementCallHostBridge,
useComponentHostBridge,
} from "./host";
// Everything needed to implement a HostBridge, not just the interface itself // How the host and Element Call talk to each other, and what they say
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
export { export {
type DeviceMuteRequest, type DeviceMuteRequest,
type DeviceMuteState, type DeviceMuteState,
type HostBridge,
type HostRequest,
} from "../src/HostBridge"; } from "../src/HostBridge";
export { type JoinCallData } from "../src/widget"; export { type JoinCallData } from "../src/widget";
// The deployment-wide configuration, as distinct from ElementCallConfiguration // The deployment-wide configuration, as distinct from ElementCallConfiguration
@@ -102,14 +103,6 @@ export {
*/ */
export type ElementCallConfiguration = Partial<UrlParams>; export type ElementCallConfiguration = Partial<UrlParams>;
/**
* What a host embedding Element Call implements to talk to it. This is the
* {@link HostBridge} less what Element Call already knows about such a host:
* the account is the host's, since the client is, so the profile is not
* Element Call's to change.
*/
export type ElementCallHostBridge = Omit<HostBridge, "supportsProfileChanges">;
export interface ElementCallProps { export interface ElementCallProps {
/** /**
* The client to place the call with. Element Call does not authenticate * The client to place the call with. Element Call does not authenticate
@@ -138,11 +131,16 @@ export interface ElementCallProps {
*/ */
config?: ElementCallConfiguration; config?: ElementCallConfiguration;
/** /**
* How to reach the host while the call is running — to be told the user has * What Element Call tells the host while the call is running: that the user
* joined or hung up, to be asked to keep the call on screen, and so on. * has joined or hung up, that it would like to be kept on screen, and so on.
* Without one, Element Call assumes it has no host to talk to. * Without one, Element Call assumes nobody is listening.
*/ */
hostBridge?: ElementCallHostBridge; hostBridge?: ElementCallHostBridge;
/**
* What the host tells Element Call: to change theme, to hang up, to mute.
* Available once the component has rendered.
*/
ref?: Ref<ElementCallHandle>;
} }
/** /**
@@ -191,18 +189,10 @@ export const ElementCall: FC<ElementCallProps> = ({
roomId, roomId,
intent = UserIntent.JoinExistingCall, intent = UserIntent.JoinExistingCall,
config, config,
hostBridge: suppliedHostBridge = nullHostBridge, hostBridge: suppliedHostBridge,
ref,
}): ReactNode => { }): ReactNode => {
// Whatever the host says or does not say, the account is its own: it signed const hostBridge = useComponentHostBridge(suppliedHostBridge, ref);
// the user in and handed us the client. So Element Call never offers to edit
// the profile from inside a component.
const hostBridge = useMemo(
(): HostBridge => ({
...suppliedHostBridge,
supportsProfileChanges: false,
}),
[suppliedHostBridge],
);
// The container is what Element Call decorates and portals into, so nothing // The container is what Element Call decorates and portals into, so nothing
// inside can render until we have it. // inside can render until we have it.