mirror of
https://github.com/element-hq/element-call.git
synced 2026-09-11 17:56:27 +00:00
Derive the component's defaults from an intent, not the host's URL
The component seeded its parameters with `computeUrlParams()`, which reads `window.location`. Element Call is not the page any more, so what it found there was the host's URL: no `widgetId`, therefore not a widget, therefore no intent, therefore the standalone app's preset. The visible symptom was the Element Call logo in the footer of a call embedded in someone else's application, since `showLogo` follows `header === HeaderStyle.Standard`. The rest of that preset mattered more. A hosted call defaulted to `perParticipantE2EE: false` and `confineToRoom: false` — so unencrypted, and willing to take the user out of the room a host had put them in — and a host could not correct either without naming every parameter itself, nor even name `header`, since the enums were not exported. So the intent presets move out of `computeUrlParams` into `configurationForIntent`, and the component builds its parameters from an intent plus the properties a host has no URL to supply. `intent` becomes a prop, defaulting to joining an existing group call: the lobby first, confined to the room, encrypted per participant, and no Element Call branding in someone else's interface. A host that knows which button the user pressed should say which. One deliberate difference from the widget presets: the background defaults to solid rather than the gradient, which is drawn by a `position: fixed` pseudo-element and would escape the container to cover the host. `UserIntent.Unknown` still means the standalone preset, so the app and widget are unchanged — including a widget that names no intent, which has always been given those defaults.
This commit is contained in:
+37
-6
@@ -48,9 +48,11 @@ import {
|
||||
} from "../src/HostBridge";
|
||||
import { RootElementProvider } from "../src/RootElementContext";
|
||||
import {
|
||||
computeUrlParams,
|
||||
configurationForIntent,
|
||||
hostedProperties,
|
||||
type UrlParams,
|
||||
UrlParamsProvider,
|
||||
UserIntent,
|
||||
} from "../src/UrlParams";
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
@@ -74,10 +76,17 @@ export { type JoinCallData } from "../src/widget";
|
||||
// The deployment-wide configuration, as distinct from ElementCallConfiguration
|
||||
// above, which is per call
|
||||
export { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
// The values that appear in ElementCallConfiguration and in the intent
|
||||
export {
|
||||
BackgroundStyle,
|
||||
HeaderStyle,
|
||||
UserIntent,
|
||||
type UrlConfiguration,
|
||||
} from "../src/UrlParams";
|
||||
|
||||
/**
|
||||
* How Element Call should behave. Everything is optional; anything left out
|
||||
* takes the same default it would in the standalone app.
|
||||
* takes the default that {@link ElementCallProps.intent} implies.
|
||||
*/
|
||||
export type ElementCallConfiguration = Partial<UrlParams>;
|
||||
|
||||
@@ -89,7 +98,21 @@ export interface ElementCallProps {
|
||||
client: MatrixClient;
|
||||
/** The room to call in. The host's client must already know about it. */
|
||||
roomId: string;
|
||||
/** How Element Call should behave. */
|
||||
/**
|
||||
* What the user asked for — whether they started the call or joined one that
|
||||
* was already running, and whether it is a call in a group or a DM. Element
|
||||
* Call decides what each of those means: whether to show the lobby first,
|
||||
* whether to ring, and so on.
|
||||
*
|
||||
* Defaults to joining an existing group call, which is the most conservative
|
||||
* reading, but a host that knows which button the user pressed should say so.
|
||||
*/
|
||||
intent?: UserIntent;
|
||||
/**
|
||||
* How Element Call should behave, overriding whatever {@link intent} implies.
|
||||
* A host that finds itself setting a lot of these probably wants a different
|
||||
* intent instead.
|
||||
*/
|
||||
config?: ElementCallConfiguration;
|
||||
/**
|
||||
* How to reach the host while the call is running — to be told the user has
|
||||
@@ -143,6 +166,7 @@ const Decoration: FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
export const ElementCall: FC<ElementCallProps> = ({
|
||||
client,
|
||||
roomId,
|
||||
intent = UserIntent.JoinExistingCall,
|
||||
config,
|
||||
hostBridge = nullHostBridge,
|
||||
}): ReactNode => {
|
||||
@@ -150,10 +174,17 @@ export const ElementCall: FC<ElementCallProps> = ({
|
||||
// inside can render until we have it.
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// The defaults are the standalone app's, with the host's wishes over the top
|
||||
// Element Call has no URL of its own to read any of this from, and the
|
||||
// host's URL is not Element Call's business, so the defaults come from the
|
||||
// intent with the host's wishes over the top.
|
||||
const params = useMemo(
|
||||
(): UrlParams => ({ ...computeUrlParams(), ...config }),
|
||||
[config],
|
||||
(): UrlParams => ({
|
||||
...hostedProperties,
|
||||
roomId,
|
||||
...configurationForIntent(intent),
|
||||
...config,
|
||||
}),
|
||||
[roomId, intent, config],
|
||||
);
|
||||
|
||||
const mediaDevices = useInitial(
|
||||
|
||||
@@ -11,10 +11,14 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import * as PlatformMod from "../src/Platform";
|
||||
import {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
getRoomIdentifierFromUrl,
|
||||
computeUrlParams,
|
||||
HeaderStyle,
|
||||
getUrlParams,
|
||||
hostedProperties,
|
||||
UserIntent,
|
||||
} from "../src/UrlParams";
|
||||
import { mockConfig } from "./utils/test";
|
||||
|
||||
@@ -424,4 +428,48 @@ describe("UrlParams", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// What Element Call runs with when a host embeds it as a component, which
|
||||
// has no URL of its own for any of this to come from
|
||||
describe("hosted defaults", () => {
|
||||
it("assume nothing about a session or a page", () => {
|
||||
expect(hostedProperties).toMatchObject({
|
||||
// The host is not a widget host, and supplies the client itself, so
|
||||
// none of the widget or session plumbing applies
|
||||
isWidget: false,
|
||||
widgetId: null,
|
||||
parentUrl: null,
|
||||
userId: null,
|
||||
deviceId: null,
|
||||
baseUrl: null,
|
||||
homeserver: null,
|
||||
// The gradient is drawn by a `position: fixed` pseudo-element, which
|
||||
// would escape the container and cover the host's own interface
|
||||
background: BackgroundStyle.Solid,
|
||||
});
|
||||
});
|
||||
|
||||
it("keep a hosted call inside its room", () => {
|
||||
const hosted = configurationForIntent(UserIntent.JoinExistingCall);
|
||||
expect(hosted).toMatchObject({
|
||||
// A host owns navigation, so Element Call must not offer a way out of
|
||||
// the room
|
||||
confineToRoom: true,
|
||||
perParticipantE2EE: true,
|
||||
// The lobby first, so that the user picks their devices rather than
|
||||
// being thrown into the call by the act of being rendered
|
||||
skipLobby: false,
|
||||
});
|
||||
// No Element Call branding inside someone else's application
|
||||
expect(hosted.header).not.toBe(HeaderStyle.Standard);
|
||||
});
|
||||
|
||||
it("fall back to the standalone app's when no intent is stated", () => {
|
||||
expect(configurationForIntent(UserIntent.Unknown)).toMatchObject({
|
||||
confineToRoom: false,
|
||||
header: HeaderStyle.Standard,
|
||||
perParticipantE2EE: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+130
-72
@@ -354,6 +354,135 @@ export const getUrlParams = (
|
||||
return params;
|
||||
};
|
||||
|
||||
/**
|
||||
* The configuration implied by what the user meant to do — if they pressed a
|
||||
* Start Call button this would be `start_call`, and if they pressed Join Call,
|
||||
* `join_existing`.
|
||||
*
|
||||
* These are platform-specific defaults, so that a host can start a call by
|
||||
* saying what the user asked for rather than by setting every parameter itself,
|
||||
* and so that what each intent means is Element Call's decision, made in one
|
||||
* place. A host that wants something else states it alongside the intent.
|
||||
*
|
||||
* {@link UserIntent.Unknown} means no intent was stated, and gives the
|
||||
* standalone app's defaults: Element Call owns the whole page, so it offers the
|
||||
* way out of the room that a hosted call must not.
|
||||
*/
|
||||
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
|
||||
// Only constants and `platform` here, so that this depends on nothing but
|
||||
// the intent.
|
||||
let preset: UrlConfiguration = {
|
||||
confineToRoom: true,
|
||||
preload: false,
|
||||
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = false;
|
||||
preset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
preset.skipLobby = true;
|
||||
preset.sendNotificationType = "ring";
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.waitForCallPickup = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
preset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
preset.skipLobby = true;
|
||||
preset.autoLeaveWhenOthersLeft = true;
|
||||
preset.callIntent = preset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
preset = {
|
||||
confineToRoom: false,
|
||||
preload: false,
|
||||
header: HeaderStyle.Standard,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: false,
|
||||
perParticipantE2EE: false,
|
||||
controlledAudioDevices: false,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
return preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link UrlProperties} for Element Call embedded in a host application.
|
||||
*
|
||||
* It has no URL of its own to read these from, and it does not need most of
|
||||
* them: the widget plumbing does not apply, the Matrix client and the analytics
|
||||
* configuration come from the host by other routes, and what is left is either
|
||||
* the host's to state through the component's props or Element Call's own
|
||||
* default.
|
||||
*/
|
||||
export const hostedProperties: UrlProperties = {
|
||||
widgetId: null,
|
||||
parentUrl: null,
|
||||
isWidget: false,
|
||||
roomId: null,
|
||||
userId: null,
|
||||
displayName: null,
|
||||
deviceId: null,
|
||||
baseUrl: null,
|
||||
lang: null,
|
||||
fonts: [],
|
||||
fontScale: null,
|
||||
posthogUserId: null,
|
||||
posthogApiHost: null,
|
||||
posthogApiKey: null,
|
||||
e2eEnabled: true,
|
||||
password: null,
|
||||
viaServers: null,
|
||||
homeserver: null,
|
||||
rageshakeSubmitUrl: null,
|
||||
sentryDsn: null,
|
||||
sentryEnvironment: null,
|
||||
theme: null,
|
||||
// Solid rather than the gradient the standalone app defaults to: the gradient
|
||||
// is drawn by a `position: fixed` pseudo-element, which would escape the
|
||||
// container Element Call was given and cover the host's own interface.
|
||||
background: BackgroundStyle.Solid,
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the app parameters for the current URL.
|
||||
* @param search The URL search string
|
||||
@@ -383,78 +512,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
const intent = !isWidget
|
||||
? UserIntent.Unknown
|
||||
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
|
||||
// Here we only use constants and `platform` to determine the intent preset.
|
||||
let intentPreset: UrlConfiguration = {
|
||||
confineToRoom: true,
|
||||
preload: false,
|
||||
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: true,
|
||||
perParticipantE2EE: true,
|
||||
controlledAudioDevices: platform === "desktop" ? false : true,
|
||||
skipLobby: true,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: "notification",
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
switch (intent) {
|
||||
case UserIntent.StartNewCall:
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
intentPreset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
intentPreset.skipLobby = true;
|
||||
intentPreset.sendNotificationType = "ring";
|
||||
intentPreset.autoLeaveWhenOthersLeft = true;
|
||||
intentPreset.waitForCallPickup = true;
|
||||
intentPreset.callIntent = intentPreset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
intentPreset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
intentPreset.skipLobby = true;
|
||||
intentPreset.autoLeaveWhenOthersLeft = true;
|
||||
intentPreset.callIntent = intentPreset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
intentPreset = {
|
||||
confineToRoom: false,
|
||||
preload: false,
|
||||
header: HeaderStyle.Standard,
|
||||
showControls: true,
|
||||
hideScreensharing: false,
|
||||
allowIceFallback: false,
|
||||
perParticipantE2EE: false,
|
||||
controlledAudioDevices: false,
|
||||
skipLobby: false,
|
||||
returnToLobby: false,
|
||||
sendNotificationType: undefined,
|
||||
autoLeaveWhenOthersLeft: false,
|
||||
waitForCallPickup: false,
|
||||
};
|
||||
}
|
||||
const intentPreset = configurationForIntent(intent);
|
||||
|
||||
const properties: UrlProperties = {
|
||||
widgetId,
|
||||
|
||||
Reference in New Issue
Block a user