mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
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:
co-authored by
Claude Fable 5.1
parent
d18e82c546
commit
8e8bc5ddde
@@ -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),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user