From fbb7570210a0abf3a70526436072b74d56104f12 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:21:12 +0200 Subject: [PATCH] default mute states (unmuted!) in widget mode (embedded + intent) (#3494) * default mute states (unmuted!) in widget mode (embedded + intent) Signed-off-by: Timo K * review Signed-off-by: Timo K * introduce a cache for the url params. Signed-off-by: Timo K * Add an option to skip the cache. Signed-off-by: Timo K --------- Signed-off-by: Timo K --- package.json | 2 +- src/UrlParams.ts | 119 ++++++++++++++++++++++------------- src/room/MuteStates.test.tsx | 69 +++++++++++++++++++- src/room/MuteStates.ts | 22 +++++-- 4 files changed, 160 insertions(+), 52 deletions(-) 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" + ); +}