diff --git a/component/index.tsx b/component/index.tsx index 1d3155d30..55e2f996f 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -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; @@ -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 = ({ client, roomId, + intent = UserIntent.JoinExistingCall, config, hostBridge = nullHostBridge, }): ReactNode => { @@ -150,10 +174,17 @@ export const ElementCall: FC = ({ // inside can render until we have it. const [container, setContainer] = useState(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( diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index 3a61a76b9..63c4e2c86 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -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, + }); + }); + }); }); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 422163fc4..25b60dbd9 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -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,