diff --git a/component/dev/DevHostBridge.ts b/component/dev/DevHostBridge.ts index 837079203..b69779a94 100644 --- a/component/dev/DevHostBridge.ts +++ b/component/dev/DevHostBridge.ts @@ -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>(); - const hangUp$ = new Subject>>(); - const deviceMute$ = new Subject< - HostRequest - >(); - - const ask = ( - subject: Subject>, - 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), }; } diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx index 141f94922..ba68c410c 100644 --- a/component/dev/Harness.tsx +++ b/component/dev/Harness.tsx @@ -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(null); + const ask = ( + name: string, + make: (handle: ElementCallHandle) => Promise, + ): 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 (
@@ -110,24 +134,42 @@ const Pane: FC<{ - - + + - +
{/* 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 */}
{mounted && ( { + 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(); + 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(); + 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(); + 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"]); + }); + }); +}); diff --git a/component/host.ts b/component/host.ts new file mode 100644 index 000000000..640aaf22d --- /dev/null +++ b/component/host.ts @@ -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; + /** Tells the host that Element Call has finished loading. */ + contentLoaded?(): Promise; + /** Tells the host that the user has joined the call. */ + notifyJoined?(): Promise; + /** Tells the host that the user has hung up. */ + notifyHungUp?(): Promise; + /** Tells the host the user's current audio and video mute state. */ + notifyDeviceMute?(state: DeviceMuteState): Promise; + /** + * 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; + /** + * 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; +} + +/** + * 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; + /** + * 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; + /** Leaves the call. */ + hangUp(): Promise; + /** + * Changes the mute state, for whichever of audio and video is given, and + * reports the state that results. + */ + setDeviceMute(request: DeviceMuteRequest): Promise; +} + +/** Hands a request to Element Call and waits for it to be acknowledged. */ +async function request( + listeners: Subject>, + what: string, + data: Data, +): Promise { + 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 | undefined, +): HostBridge { + const latest = useLatest(supplied ?? {}); + + const requests = useInitial(() => ({ + themeChange$: new Subject>(), + join$: new Subject>(), + hangUp$: new Subject>>(), + deviceMute$: new Subject>(), + })); + + 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 => await close(); + }, + get downloadMedia() { + const downloadMedia = latest.current.downloadMedia; + return downloadMedia === undefined + ? undefined + : async (mxcUri: string): Promise => + 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; +} diff --git a/component/index.tsx b/component/index.tsx index 5a0be5d12..7e89f89da 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -36,6 +36,7 @@ import { type FC, type JSX, type ReactNode, + type Ref, useEffect, useMemo, useState, @@ -53,11 +54,7 @@ import EN from "../locales/en/app.json"; import { ElementCallView } from "../src/ElementCallView"; import { ErrorPage } from "../src/FullScreenView"; import { ClientProvider } from "../src/ClientContext"; -import { - type HostBridge, - HostBridgeProvider, - nullHostBridge, -} from "../src/HostBridge"; +import { HostBridgeProvider } from "../src/HostBridge"; import { RootElementProvider } from "../src/RootElementContext"; import { configurationForIntent, @@ -76,13 +73,17 @@ import { i18n } from "../src/utils/i18n"; import { useTheme } from "../src/useTheme"; import { useStableValue } from "../src/useStableValue"; 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 { type DeviceMuteRequest, type DeviceMuteState, - type HostBridge, - type HostRequest, } from "../src/HostBridge"; export { type JoinCallData } from "../src/widget"; // The deployment-wide configuration, as distinct from ElementCallConfiguration @@ -102,14 +103,6 @@ export { */ export type ElementCallConfiguration = Partial; -/** - * 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; - export interface ElementCallProps { /** * The client to place the call with. Element Call does not authenticate @@ -138,11 +131,16 @@ export interface ElementCallProps { */ config?: ElementCallConfiguration; /** - * How to reach the host while the call is running — to be told the user has - * joined or hung up, to be asked to keep the call on screen, and so on. - * Without one, Element Call assumes it has no host to talk to. + * What Element Call tells the host while the call is running: that the user + * has joined or hung up, that it would like to be kept on screen, and so on. + * Without one, Element Call assumes nobody is listening. */ hostBridge?: ElementCallHostBridge; + /** + * What the host tells Element Call: to change theme, to hang up, to mute. + * Available once the component has rendered. + */ + ref?: Ref; } /** @@ -191,18 +189,10 @@ export const ElementCall: FC = ({ roomId, intent = UserIntent.JoinExistingCall, config, - hostBridge: suppliedHostBridge = nullHostBridge, + hostBridge: suppliedHostBridge, + ref, }): ReactNode => { - // 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. - const hostBridge = useMemo( - (): HostBridge => ({ - ...suppliedHostBridge, - supportsProfileChanges: false, - }), - [suppliedHostBridge], - ); + const hostBridge = useComponentHostBridge(suppliedHostBridge, ref); // The container is what Element Call decorates and portals into, so nothing // inside can render until we have it.