Files
element-call-Github/component/dev/DevHostBridge.ts
T
Timo K.andClaude Fable 5.1 7cd21476f7 Say outright whether the profile is ours to change
Whether to offer the profile settings was inferred from whether the host
could close Element Call. For a component with no host bridge — the
default — nothing could, so an embedded Element Call let the user edit
the profile of an account that belongs to the host application.

`HostBridge.supportsProfileChanges` states it directly: true standalone,
where Element Call signed the user in itself; false for a widget's host
and for anything embedding the component (which sets it itself, since
the client it hands over is its own). The profile tab and the profile
shortcut follow that. What a host's ability to close us still decides —
what to show after the call ends — is a question about who owns our
lifetime, and stays keyed on `close`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:38:59 +02:00

104 lines
3.3 KiB
TypeScript

/*
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 { NEVER, Subject } from "rxjs";
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,
* so that the harness can watch both directions of the conversation between
* Element Call and its host.
*/
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)}`}`,
),
});
};
/**
* 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.
*/
const told = async (message: string): Promise<void> => {
log(`→ ${message}`);
await Promise.resolve();
};
return {
setAlwaysOnScreen: async (alwaysOnScreen): Promise<void> =>
await told(`setAlwaysOnScreen(${alwaysOnScreen})`),
contentLoaded: async (): Promise<void> => await told("contentLoaded"),
notifyJoined: async (): Promise<void> => await told("notifyJoined"),
notifyHungUp: async (): Promise<void> => await told("notifyHungUp"),
notifyDeviceMute: async (state): Promise<void> =>
await told(
`notifyDeviceMute(audio: ${state.audio_enabled}, video: ${state.video_enabled})`,
),
// Present because this host really can dismiss Element Call, which is what
// makes it offer a close affordance at all
close: async (): Promise<void> => {
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),
};
}