diff --git a/sdk/main.ts b/sdk/main.ts index de15a5759..4a7926a16 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -54,11 +54,7 @@ import { MediaDevices } from "../src/state/MediaDevices"; import { E2eeType } from "../src/e2ee/e2eeType"; import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper"; import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; -import { - ElementWidgetActions, - widget as _widget, - initializeWidget, -} from "../src/widget"; +import { initializeWidget } from "../src/widget"; import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; import { createWidgetHostBridge } from "../src/HostBridge"; @@ -110,8 +106,7 @@ export async function createMatrixRTCSdk( const scope = new ObservableScope(); // widget client - initializeWidget(application, true); - const widget = _widget; + const widget = initializeWidget(application, true); if (!widget) throw Error("No widget. This webapp can only start as a widget"); const client = await widget.client; const hostBridge = createWidgetHostBridge(widget); @@ -294,18 +289,17 @@ export async function createMatrixRTCSdk( }); await leaveResolver.promise; logger.info("send Unstick"); - await widget.api + await hostBridge .setAlwaysOnScreen(false) - .catch((e) => - logger.error( - "Failed to set call widget `alwaysOnScreen` to false", - e, - ), + .catch((e: unknown) => + logger.error("Failed to set `alwaysOnScreen` to false", e), ); logger.info("send Close"); - await widget.api.transport - .send(ElementWidgetActions.Close, {}) - .catch((e) => logger.error("Failed to send close action", e)); + await hostBridge + .close?.() + .catch((e: unknown) => + logger.error("Failed to ask the host to close", e), + ); }; // schedule close first and then leave (scope.end) diff --git a/src/App.tsx b/src/App.tsx index 1d51f9598..8d272989e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/lib/logger"; +import { type MatrixClient } from "matrix-js-sdk"; import { I18nextProvider } from "react-i18next"; import { HomePage } from "./home/HomePage"; @@ -19,7 +20,7 @@ import { RoomPage } from "./room/RoomPage"; import { ClientProvider } from "./ClientContext"; import { ErrorPage, LoadingPage } from "./FullScreenView"; import { Initializer } from "./initializer"; -import { widget } from "./widget"; +import { type WidgetHelpers } from "./widget"; import { useTheme } from "./useTheme"; import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; @@ -81,9 +82,11 @@ const MaybeAppBar: FC = ({ children }) => { interface Props { vm: AppViewModel; + /** A point of access to the widget API, if running as a widget. */ + widget: WidgetHelpers | null; } -export const App: FC = ({ vm }) => { +export const App: FC = ({ vm, widget }) => { // The standalone build has no host; the widget build's host is the client it // is a widget of. const hostBridge = useInitial(() => @@ -100,26 +103,40 @@ export const App: FC = ({ vm }) => { .catch(logger.error); }); - const content = loaded ? ( - - - - } - > - - } /> - } /> - } /> - } /> - - - - - - ) : ( - + // As a widget, the client comes from the host over the widget API. Standalone, + // Element Call finds one itself, so there is nothing to wait for here. + const [widgetClient, setWidgetClient] = useState( + undefined, ); + useEffect(() => { + if (widget === null) return; + widget.client + .then(setWidgetClient) + .catch((e) => logger.error("Failed to obtain the host's client", e)); + }, [widget]); + const clientReady = widget === null || widgetClient !== undefined; + + const content = + loaded && clientReady ? ( + + + + } + > + + } /> + } /> + } /> + } /> + + + + + + ) : ( + + ); return ( diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index 6ad017f15..388beb8b1 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -22,7 +22,6 @@ import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync"; import { ClientEvent, type MatrixClient } from "matrix-js-sdk"; import { ErrorPage } from "./FullScreenView"; -import { widget } from "./widget"; import { useHostBridge } from "./HostBridge"; import { PosthogAnalytics, @@ -163,7 +162,12 @@ export const ClientProvider: FC = ({ children, client }) => { const initializing = useRef(false); useEffect(() => { - if (client !== undefined) return; + if (client !== undefined) { + // Nothing to load, but analytics still need to follow the user's choices. + if (PosthogAnalytics.instance.isEnabled()) + PosthogAnalytics.instance.startListeningToSettingsChanges(); + return; + } // In case the component is mounted, unmounted, and remounted quickly (as // React does in strict mode), we need to make sure not to doubly initialize // the client. @@ -246,9 +250,9 @@ export const ClientProvider: FC = ({ children, client }) => { // To protect against multiple sessions writing to the same storage // simultaneously, we send a broadcast message that shuts down all other // running instances of the app. Element Call only has storage of its own to - // protect when it created the session itself; given a client, or running as a - // widget, it is mostly stateless. - const ownsSession = client === undefined && widget === null; + // protect when it created the session itself; given a client — by a host, or + // over the widget API — it is mostly stateless. + const ownsSession = client === undefined; useEffect(() => { if (ownsSession) loadChannel?.postMessage({}); }, [ownsSession]); @@ -349,19 +353,13 @@ export type InitResult = { passwordlessUser: boolean; }; +/** + * Restores or creates a session of Element Call's own. Only reached when no + * client was supplied for it to use. + */ async function loadClient(): Promise { - if (widget) { - // We're inside a widget, so let's engage *matryoshka mode* - logger.log("Using a matryoshka client"); - const client = await widget.client; - return { - client, - passwordlessUser: false, - }; - } else { - const { initSPA } = await import("./utils/spa"); - return initSPA(loadSession, clearSession); - } + const { initSPA } = await import("./utils/spa"); + return initSPA(loadSession, clearSession); } export interface Session { diff --git a/src/initializer.tsx b/src/initializer.tsx index 0927d7aba..ee3ee9bd4 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -32,7 +32,7 @@ import { Config } from "./config/Config"; import { seedSettingsFromConfig } from "./settings/settings"; import { platform } from "./Platform"; import { isFailure } from "./utils/fetch"; -import { initializeWidget } from "./widget"; +import { initializeWidget, type WidgetHelpers } from "./widget"; import { enableExtendedLivekitLogs } from "./settings/settings.ts"; import { type AnalyticsConfig, @@ -155,8 +155,8 @@ export class Initializer { return !!Initializer.internalInstance?.isInitialized; } - public static async initBeforeReact(): Promise { - initializeWidget(); + public static async initBeforeReact(): Promise { + const widget = initializeWidget(); const polyfills: Promise[] = []; if (shouldPolyfillSegmenter()) { @@ -243,6 +243,8 @@ export class Initializer { }); window.setLKLogLevel = setLKLogLevel; + + return widget; } public static init(): Promise | null { diff --git a/src/main.tsx b/src/main.tsx index 03c74c94c..b2f79f34e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -49,7 +49,7 @@ if (fatalError !== null) { } Initializer.initBeforeReact() - .then(() => { + .then((widget) => { const { controlledAudioDevices, callIntent } = getUrlParams(); root.render( @@ -60,6 +60,7 @@ Initializer.initBeforeReact() callIntent, }) } + widget={widget} /> , ); diff --git a/src/widget.test.ts b/src/widget.test.ts index 3618fb505..c645c3b34 100644 --- a/src/widget.test.ts +++ b/src/widget.test.ts @@ -9,7 +9,7 @@ import { describe, expect, vi, it, beforeEach } from "vitest"; import { createRoomWidgetClient, EventType } from "matrix-js-sdk"; import { getUrlParams } from "./UrlParams"; -import { initializeWidget, widget } from "./widget"; +import { initializeWidget } from "./widget"; import { Config } from "./config/Config"; import { ElementCallReactionEventType } from "./reactions"; @@ -42,7 +42,7 @@ beforeEach(() => { describe("widget", () => { it("should create an embedded client with the correct params", () => { - initializeWidget("ANYRTCAPP"); + const widget = initializeWidget("ANYRTCAPP"); expect(getUrlParams()).toStrictEqual({ widgetId: "id", diff --git a/src/widget.ts b/src/widget.ts index af6f28b6d..b35e335bc 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -58,21 +58,21 @@ export interface WidgetHelpers { } /** - * A point of access to the widget API, if the app is running as a widget. This - * is initialized with `initializeWidget`. This should happen at the top level because the widget messaging - * needs to be set up ASAP on load to ensure it doesn't miss any requests. - */ -export let widget: WidgetHelpers | null = null; - -/** - * Should be called as soon as possible on app start. (In the initilizer before react) + * Connects to the widget API, if Element Call is running as a widget. + * + * Should be called as soon as possible on app start (in the initializer, before + * React), because the widget messaging needs to be set up ASAP on load to + * ensure it doesn't miss any requests. + * + * @returns A point of access to the widget API, or null if Element Call is not + * running as a widget. */ // this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before // execution. export const initializeWidget = ( rtcApplication: string = "m.call", sendRoomEvents = false, -): void => { +): WidgetHelpers | null => { try { const { widgetId, @@ -202,14 +202,14 @@ export const initializeWidget = ( return client; }; - widget = { api, lazyActions, client: clientPromise() }; + return { api, lazyActions, client: clientPromise() }; } else { if (import.meta.env.MODE !== "test") logger.info("No widget API available"); - widget = null; + return null; } } catch (e) { logger.warn("Continuing without the widget API", e); - widget = null; + return null; } };