merge: upstream Element Call v0.28.0 (cinny #201) #61

Merged
jared merged 197 commits from lotus-upstream-v0.28.0 into lotus 2026-10-24 01:14:32 -04:00
7 changed files with 79 additions and 23 deletions
Showing only changes of commit 273ee3b632 - Show all commits
+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,
),