diff --git a/package.json b/package.json
index 18877823..b830b6da 100644
--- a/package.json
+++ b/package.json
@@ -108,7 +108,7 @@
"livekit-client": "^2.13.0",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
- "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=develop",
+ "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#head=hs/rtc-notify-hint",
"matrix-widget-api": "^1.13.0",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
diff --git a/src/UrlParams.ts b/src/UrlParams.ts
index c8aaf7e6..5c2cb762 100644
--- a/src/UrlParams.ts
+++ b/src/UrlParams.ts
@@ -235,13 +235,20 @@ export interface UrlConfiguration {
mediaHint?: RTCMediaHint;
}
+interface IntentAndPlatformDerivedConfiguration {
+ defaultAudioEnabled?: boolean;
+ defaultVideoEnabled?: boolean;
+}
// If you need to add a new flag to this interface, prefer a name that describes
// a specific behavior (such as 'confineToRoom'), rather than one that describes
// the situations that call for this behavior ('isEmbedded'). This makes it
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
-export interface UrlParams extends UrlProperties, UrlConfiguration {}
+export interface UrlParams
+ extends UrlProperties,
+ UrlConfiguration,
+ IntentAndPlatformDerivedConfiguration {}
// This is here as a stopgap, but what would be far nicer is a function that
// takes a UrlParams and returns a query string. That would enable us to
@@ -317,6 +324,11 @@ class ParamParser {
}
}
+let urlParamCache: {
+ search?: string;
+ hash?: string;
+ params?: UrlParams;
+} = {};
/**
* Gets the app parameters for the current URL.
* @param search The URL search string
@@ -326,7 +338,18 @@ class ParamParser {
export const getUrlParams = (
search = window.location.search,
hash = window.location.hash,
+ /** Skipping the cache might be needed in tests, to allow recomputing based on mocked platform changes. */
+ skipCache = false,
): UrlParams => {
+ // Only run the param configuration if we do not yet have it cached for this url.
+ if (
+ urlParamCache.search === search &&
+ urlParamCache.hash === hash &&
+ urlParamCache.params &&
+ !skipCache
+ ) {
+ return urlParamCache.params;
+ }
const parser = new ParamParser(search, hash);
const fontScale = parseFloat(parser.getParam("fontScale") ?? "");
@@ -350,8 +373,7 @@ export const getUrlParams = (
? UserIntent.Unknown
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
// Here we only use constants and `platform` to determine the intent preset.
- let intentPreset: UrlConfiguration;
- const inAppDefault = {
+ let intentPreset: UrlConfiguration = {
confineToRoom: true,
appPrompt: false,
preload: false,
@@ -369,52 +391,25 @@ export const getUrlParams = (
};
switch (intent) {
case UserIntent.StartNewCall:
- intentPreset = {
- ...inAppDefault,
- skipLobby: true,
- mediaHint: "video",
- };
+ intentPreset.skipLobby = true;
break;
case UserIntent.JoinExistingCall:
- intentPreset = {
- ...inAppDefault,
- skipLobby: false,
- mediaHint: "video",
- };
- break;
- case UserIntent.StartNewCallDM:
- intentPreset = {
- ...inAppDefault,
- skipLobby: true,
- autoLeaveWhenOthersLeft: true,
- waitForCallPickup: true,
- mediaHint: "video",
- };
+ // On desktop this will be overridden based on which button was used to join the call
+ intentPreset.skipLobby = false;
break;
case UserIntent.StartNewCallDMVoice:
- intentPreset = {
- ...inAppDefault,
- skipLobby: true,
- autoLeaveWhenOthersLeft: true,
- waitForCallPickup: true,
- mediaHint: "audio",
- };
- break;
- case UserIntent.JoinExistingCallDM:
- intentPreset = {
- ...inAppDefault,
- skipLobby: true,
- autoLeaveWhenOthersLeft: true,
- mediaHint: "video",
- };
+ intentPreset.mediaHint = "audio";
+ case UserIntent.StartNewCallDM:
+ intentPreset.skipLobby = true;
+ intentPreset.autoLeaveWhenOthersLeft = true;
+ intentPreset.waitForCallPickup = true;
break;
case UserIntent.JoinExistingCallDMVoice:
- intentPreset = {
- ...inAppDefault,
- skipLobby: true,
- autoLeaveWhenOthersLeft: true,
- mediaHint: "audio",
- };
+ intentPreset.mediaHint = "audio";
+ 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;
break;
// Non widget usecase defaults
default:
@@ -436,6 +431,24 @@ export const getUrlParams = (
};
}
+ const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration =
+ {};
+ // Desktop also includes web. Its anything that is not mobile.
+ const desktopMobile = platform === "desktop" ? "desktop" : "mobile";
+ switch (desktopMobile) {
+ case "desktop":
+ case "mobile":
+ switch (intent) {
+ case UserIntent.StartNewCall:
+ case UserIntent.JoinExistingCall:
+ case UserIntent.StartNewCallDM:
+ case UserIntent.JoinExistingCallDM:
+ intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true;
+ intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true;
+ break;
+ }
+ }
+
const properties: UrlProperties = {
widgetId,
parentUrl,
@@ -488,11 +501,29 @@ export const getUrlParams = (
autoLeaveWhenOthersLeft: parser.getFlag("autoLeave"),
};
- return {
+ // Log the final configuration for debugging purposes.
+ // This will only log when the cache is not yet set.
+ logger.info(
+ "UrlParams: final set of url params\n",
+ "intent:",
+ intent,
+ "\nproperties:",
+ properties,
+ "configuration:",
+ configuration,
+ "intentAndPlatformDerivedConfiguration:",
+ intentAndPlatformDerivedConfiguration,
+ );
+
+ const params = {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
+ ...intentAndPlatformDerivedConfiguration,
};
+ urlParamCache = { search, hash, params };
+
+ return params;
};
/**
diff --git a/src/room/MuteStates.test.tsx b/src/room/MuteStates.test.tsx
index d349a5c6..eb08217d 100644
--- a/src/room/MuteStates.test.tsx
+++ b/src/room/MuteStates.test.tsx
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import {
afterAll,
afterEach,
+ beforeEach,
describe,
expect,
it,
@@ -26,7 +27,6 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
import { mockConfig } from "../utils/test";
import { MediaDevices } from "../state/MediaDevices";
import { ObservableScope } from "../state/ObservableScope";
-
vi.mock("@livekit/components-core");
interface TestComponentProps {
@@ -110,9 +110,10 @@ function mockMediaDevices(
return new MediaDevices(scope);
}
-describe("useMuteStates", () => {
+describe("useMuteStates VITE_PACKAGE='full' (SPA) mode", () => {
afterEach(() => {
vi.clearAllMocks();
+ vi.stubEnv("VITE_PACKAGE", "full");
});
afterAll(() => {
@@ -256,3 +257,67 @@ describe("useMuteStates", () => {
expect(screen.getByTestId("video-enabled").textContent).toBe("true");
});
});
+
+describe("useMuteStates in VITE_PACKAGE='embedded' (widget) mode", () => {
+ beforeEach(() => {
+ vi.stubEnv("VITE_PACKAGE", "embedded");
+ });
+
+ it("uses defaults from config", () => {
+ mockConfig({
+ media_devices: {
+ enable_audio: false,
+ enable_video: false,
+ },
+ });
+
+ render(
+
+
+
+
+ ,
+ );
+ expect(screen.getByTestId("audio-enabled").textContent).toBe("false");
+ expect(screen.getByTestId("video-enabled").textContent).toBe("false");
+ });
+
+ it("skipLobby does not mute inputs", () => {
+ mockConfig();
+
+ render(
+
+
+
+
+ ,
+ );
+ expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
+ expect(screen.getByTestId("video-enabled").textContent).toBe("true");
+ });
+
+ it("url params win over config", () => {
+ // The config sets audio and video to disabled
+ mockConfig({ media_devices: { enable_audio: false, enable_video: false } });
+
+ render(
+
+
+
+
+ ,
+ );
+ // At the end we expect the url param to take precedence, resulting in true
+ expect(screen.getByTestId("audio-enabled").textContent).toBe("true");
+ expect(screen.getByTestId("video-enabled").textContent).toBe("true");
+ });
+});
diff --git a/src/room/MuteStates.ts b/src/room/MuteStates.ts
index 1f4b8e82..e89d13d9 100644
--- a/src/room/MuteStates.ts
+++ b/src/room/MuteStates.ts
@@ -81,11 +81,15 @@ function useMuteState(
export function useMuteStates(isJoined: boolean): MuteStates {
const devices = useMediaDevices();
- const { skipLobby } = useUrlParams();
+ const { skipLobby, defaultAudioEnabled, defaultVideoEnabled } =
+ useUrlParams();
- const audio = useMuteState(devices.audioInput, () => {
- return Config.get().media_devices.enable_audio && !skipLobby && !isJoined;
- });
+ const audio = useMuteState(
+ devices.audioInput,
+ () =>
+ (defaultAudioEnabled ?? Config.get().media_devices.enable_audio) &&
+ allowJoinUnmuted(skipLobby, isJoined),
+ );
useEffect(() => {
// If audio is enabled, we need to request the device names again,
// because iOS will not be able to switch to the correct device after un-muting.
@@ -97,7 +101,9 @@ export function useMuteStates(isJoined: boolean): MuteStates {
const isEarpiece = useIsEarpiece();
const video = useMuteState(
devices.videoInput,
- () => Config.get().media_devices.enable_video && !skipLobby && !isJoined,
+ () =>
+ (defaultVideoEnabled ?? Config.get().media_devices.enable_video) &&
+ allowJoinUnmuted(skipLobby, isJoined),
isEarpiece, // Force video to be unavailable if using earpiece
);
@@ -164,3 +170,9 @@ export function useMuteStates(isJoined: boolean): MuteStates {
return useMemo(() => ({ audio, video }), [audio, video]);
}
+
+function allowJoinUnmuted(skipLobby: boolean, isJoined: boolean): boolean {
+ return (
+ (!skipLobby && !isJoined) || import.meta.env.VITE_PACKAGE === "embedded"
+ );
+}