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.
*/
import { NEVER, Subject } from "rxjs";
import {
type DeviceMuteRequest,
type DeviceMuteState,
type ElementCallHostBridge,
type HostRequest,
} from "../index";
import { type ElementCallHostBridge } from "../index";
/**
* A host bridge that reports everything it is told and can be driven by hand,
* so that the harness can watch both directions of the conversation between
* Element Call and its host.
* A host bridge that reports everything it is told, so that the harness can
* watch what Element Call says to its host. (What the host says to Element
* 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(
log: (message: string) => void,
/** What the host does when Element Call asks to be closed. */
onClose: () => void,
): DevHostBridge {
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)}`}`,
),
});
};
): ElementCallHostBridge {
/**
* 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.
@@ -85,19 +42,5 @@ export function createDevHostBridge(
await told("close");
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,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { ElementCall } from "../index";
import { ElementCall, type ElementCallHandle } from "../index";
import { createDevHostBridge } from "./DevHostBridge";
import { createSession, joinRoom } from "./session";
import styles from "./Harness.module.css";
@@ -102,6 +103,29 @@ const Pane: FC<{
[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 (
<section className={styles.pane} data-testid="call-pane">
<div className={styles.paneBar}>
@@ -110,24 +134,42 @@ const Pane: FC<{
<button onClick={(): void => setMounted((m) => !m)}>
{mounted ? "Unmount" : "Mount"}
</button>
<button onClick={(): void => bridge.requestTheme("light")}>
Light
</button>
<button onClick={(): void => bridge.requestTheme("dark")}>Dark</button>
<button
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
</button>
<button onClick={(): void => bridge.requestHangUp()}>Hang up</button>
<button
onClick={(): void => ask("hangUp", async (h) => await h.hangUp())}
>
Hang up
</button>
</div>
{/* 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 */}
<div className={styles.paneCall} data-testid="call-container">
{mounted && (
<ElementCall
ref={handle}
client={session.client}
roomId={roomId}
hostBridge={bridge}