add allowJoinUnmutedViaIntent to the bridge

This allows us to control to never start unmuted in spa but be able to
start unmuted in widget and component mode.
This commit is contained in:
Timo K.
2026-09-08 18:12:40 +02:00
parent 486ecbfda0
commit 273ee3b632
7 changed files with 79 additions and 23 deletions
+15
View File
@@ -54,6 +54,21 @@ describe("useComponentHostBridge", () => {
}),
).resolves.toBeUndefined();
expect(result.current.supportsReactions).toBe(true);
// Starting the user unmuted unasked is something a host has to opt into
expect(result.current.allowJoinUnmutedViaIntent).toBe(false);
});
test("lets the host allow joining unmuted on the intent", () => {
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined, undefined),
{ initialProps: { supplied: {} } },
);
expect(result.current.allowJoinUnmutedViaIntent).toBe(false);
// Read through to whatever the host most recently said
rerender({ supplied: { allowJoinUnmutedViaIntent: true } });
expect(result.current.allowJoinUnmutedViaIntent).toBe(true);
});
test("only has a close when the host has one, since that is a signal", () => {
+11
View File
@@ -64,6 +64,14 @@ export interface ElementCallHostBridge {
* Defaults to true.
*/
readonly supportsReactions?: boolean;
/**
* Whether the user may start unmuted when the intent skips the lobby, so
* that they never see their devices before joining. Defaults to false: the
* user starts muted and unmutes themselves. A host that chose the intent on
* the user's behalf, and is sure they expect to be heard and seen at once,
* says so here — as a Matrix client hosting Element Call as a widget does.
*/
readonly allowJoinUnmutedViaIntent?: boolean;
}
/**
@@ -164,6 +172,9 @@ export function useComponentHostBridge(
get supportsReactions(): boolean {
return latest.current.supportsReactions ?? true;
},
get allowJoinUnmutedViaIntent(): boolean {
return latest.current.allowJoinUnmutedViaIntent ?? false;
},
// 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.
+9
View File
@@ -252,6 +252,11 @@ describe("createWidgetHostBridge", () => {
expect(bridge.supportsProfileChanges).toBe(false);
});
test("allows joining unmuted on the intent, since the host asked for the call", () => {
const bridge = createWidgetHostBridge(mockWidget({}));
expect(bridge.allowJoinUnmutedViaIntent).toBe(true);
});
describe("supportsReactions", () => {
const capabilities = [
"org.matrix.msc2762.send.event:m.reaction",
@@ -294,4 +299,8 @@ describe("nullHostBridge", () => {
test("supports reactions, since nothing is mediating its homeserver access", () => {
expect(nullHostBridge.supportsReactions).toBe(true);
});
test("does not allow joining unmuted on the intent, since nobody vouched for it", () => {
expect(nullHostBridge.allowJoinUnmutedViaIntent).toBe(false);
});
});
+14
View File
@@ -108,6 +108,14 @@ export interface HostBridge {
readonly supportsProfileChanges: boolean;
/** Whether the host permits Element Call to send and receive reactions. */
readonly supportsReactions: boolean;
/**
* Whether the user may be put into a call unmuted on the strength of the
* intent alone, when the lobby is skipped and so they get no chance to check
* their devices first. A host that asked for the call on the user's behalf
* has that much of their trust; standalone Element Call does not, and starts
* them muted instead.
*/
readonly allowJoinUnmutedViaIntent: boolean;
/**
* Fetches media on Element Call's behalf, for hosts that do not give it
* direct access to the homeserver. Absent when Element Call should fetch
@@ -136,6 +144,9 @@ export const nullHostBridge: HostBridge = {
// Standalone Element Call reaches the homeserver itself, so nothing is
// withholding these from it.
supportsReactions: true,
// Standalone, nobody vouched for the intent: it came from a URL, which is
// not enough to switch the user's camera and microphone on unasked.
allowJoinUnmutedViaIntent: false,
};
/** Bridges to a host that Element Call is a widget of. */
@@ -190,6 +201,9 @@ export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
// The client we are a widget of signed the user in, so the profile is its
// to manage
supportsProfileChanges: false,
// The client we are a widget of asked for this call on the user's behalf,
// so its intent may be trusted to say whether they start unmuted
allowJoinUnmutedViaIntent: true,
// Element Call needs the host's permission to send reactions on its behalf.
// Read on access rather than up front: the widget API negotiates its
// capabilities asynchronously, and the bridge is built before that settles.
+12 -12
View File
@@ -12,21 +12,21 @@ import { calculateInitialMuteState } from "./initialMuteState";
test.each<{
callIntent: RTCCallIntent;
isWidgetMode: boolean;
allowJoinUnmutedViaIntent: boolean;
}>([
{ callIntent: "audio", isWidgetMode: false },
{ callIntent: "audio", isWidgetMode: true },
{ callIntent: "video", isWidgetMode: false },
{ callIntent: "video", isWidgetMode: true },
{ callIntent: "unknown", isWidgetMode: false },
{ callIntent: "unknown", isWidgetMode: true },
{ callIntent: "audio", allowJoinUnmutedViaIntent: false },
{ callIntent: "audio", allowJoinUnmutedViaIntent: true },
{ callIntent: "video", allowJoinUnmutedViaIntent: false },
{ callIntent: "video", allowJoinUnmutedViaIntent: true },
{ callIntent: "unknown", allowJoinUnmutedViaIntent: false },
{ callIntent: "unknown", allowJoinUnmutedViaIntent: true },
])(
"Should allow to unmute on start if not skipping lobby (callIntent: $callIntent, packageType: $packageType)",
({ callIntent, isWidgetMode }) => {
"Should allow to unmute on start if not skipping lobby (callIntent: $callIntent, allowJoinUnmutedViaIntent: $allowJoinUnmutedViaIntent)",
({ callIntent, allowJoinUnmutedViaIntent }) => {
const { audioEnabled, videoEnabled } = calculateInitialMuteState(
false,
callIntent,
isWidgetMode,
allowJoinUnmutedViaIntent,
);
expect(audioEnabled).toBe(true);
expect(videoEnabled).toBe(callIntent !== "audio");
@@ -40,7 +40,7 @@ test.each<{
{ callIntent: "video" },
{ callIntent: "unknown" },
])(
"Should always mute on start if skipping lobby on non widget mode (callIntent: $callIntent)",
"Should always mute on start if skipping lobby and the host does not vouch for the intent (callIntent: $callIntent)",
({ callIntent }) => {
const { audioEnabled, videoEnabled } = calculateInitialMuteState(
true,
@@ -59,7 +59,7 @@ test.each<{
{ callIntent: "video" },
{ callIntent: "unknown" },
])(
"Can start unmuted if skipping lobby on widget mode (callIntent: $callIntent)",
"Can start unmuted if skipping lobby and the host vouches for the intent (callIntent: $callIntent)",
({ callIntent }) => {
const { audioEnabled, videoEnabled } = calculateInitialMuteState(
true,
+17 -10
View File
@@ -11,30 +11,37 @@ import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
/**
* Calculates the initial mute state for media devices based on configuration.
*
* It is not always possible to start the widget with audio/video unmuted due to privacy concerns.
* This function encapsulates the logic to determine the appropriate initial state.
* It is not always possible to start the call with audio/video unmuted due to
* privacy concerns. This function encapsulates the logic to determine the
* appropriate initial state.
*
* @param allowJoinUnmutedViaIntent Whether the host vouches for the intent
* enough to start the user unmuted without a lobby (see
* `HostBridge.allowJoinUnmutedViaIntent`).
*/
export function calculateInitialMuteState(
skipLobby: boolean,
callIntent: RTCCallIntent | undefined,
isWidgetMode: boolean,
allowJoinUnmutedViaIntent: boolean,
): { audioEnabled: boolean; videoEnabled: boolean } {
logger.debug(
`calculateInitialMuteState: skipLobby=${skipLobby}, callIntent=${callIntent} isWidgetMode=${isWidgetMode}`,
`calculateInitialMuteState: skipLobby=${skipLobby}, callIntent=${callIntent} allowJoinUnmutedViaIntent=${allowJoinUnmutedViaIntent}`,
);
if (skipLobby && !isWidgetMode) {
// If not in widget mode and lobby is skipped, default to muted to protect user privacy.
// In the SPA context we don't want to unmute users without giving them a chance to adjust their settings first.
if (skipLobby && !allowJoinUnmutedViaIntent) {
// The lobby is skipped, so the user gets no chance to adjust their devices
// before joining, and nobody has vouched for the intent: default to muted
// to protect their privacy.
return {
audioEnabled: false,
videoEnabled: false,
};
}
// Embedded contexts are trusted environments, so they allow unmuted by default.
// Same for when showing a lobby, as users can adjust their settings there.
// Additionally, if the call intent is "audio", we disable video by default.
// A host that vouches for the intent is a trusted environment, so it allows
// unmuted by default. Same for when showing a lobby, as users can adjust
// their settings there. Additionally, if the call intent is "audio", we
// disable video by default.
return {
audioEnabled: true,
videoEnabled: callIntent != "audio",
+1 -1
View File
@@ -39,7 +39,7 @@ export function useMuteStates(): MuteStates | null {
calculateInitialMuteState(
urlParams.skipLobby,
urlParams.callIntent,
urlParams.isWidget,
hostBridge.allowJoinUnmutedViaIntent,
),
hostBridge,
),