merge: upstream Element Call v0.28.0 (cinny #201) #61
@@ -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", () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useMuteStates(): MuteStates | null {
|
||||
calculateInitialMuteState(
|
||||
urlParams.skipLobby,
|
||||
urlParams.callIntent,
|
||||
urlParams.isWidget,
|
||||
hostBridge.allowJoinUnmutedViaIntent,
|
||||
),
|
||||
hostBridge,
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user