/* 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; }