diff --git a/component/host.test.ts b/component/host.test.ts index c73a034ce..696470cd9 100644 --- a/component/host.test.ts +++ b/component/host.test.ts @@ -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", () => { diff --git a/component/host.ts b/component/host.ts index 7e4a0afbb..2a83a5978 100644 --- a/component/host.ts +++ b/component/host.ts @@ -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. diff --git a/src/HostBridge.test.ts b/src/HostBridge.test.ts index 41410ecae..fb3779f41 100644 --- a/src/HostBridge.test.ts +++ b/src/HostBridge.test.ts @@ -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); + }); }); diff --git a/src/HostBridge.ts b/src/HostBridge.ts index 58e541f10..2ba50257c 100644 --- a/src/HostBridge.ts +++ b/src/HostBridge.ts @@ -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. diff --git a/src/state/initialMuteState.test.ts b/src/state/initialMuteState.test.ts index abbb52cde..020a80b8c 100644 --- a/src/state/initialMuteState.test.ts +++ b/src/state/initialMuteState.test.ts @@ -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, diff --git a/src/state/initialMuteState.ts b/src/state/initialMuteState.ts index 4d27cddad..51342e895 100644 --- a/src/state/initialMuteState.ts +++ b/src/state/initialMuteState.ts @@ -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", diff --git a/src/state/useMuteStates.ts b/src/state/useMuteStates.ts index ab35169f0..908a6b182 100644 --- a/src/state/useMuteStates.ts +++ b/src/state/useMuteStates.ts @@ -39,7 +39,7 @@ export function useMuteStates(): MuteStates | null { calculateInitialMuteState( urlParams.skipLobby, urlParams.callIntent, - urlParams.isWidget, + hostBridge.allowJoinUnmutedViaIntent, ), hostBridge, ),