diff --git a/src/App.tsx b/src/App.tsx index 238815a7b..1d51f9598 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -33,6 +33,12 @@ import { import { AppBar } from "./AppBar"; import { i18n } from "./utils/i18n"; import { useRootElement } from "./RootElementContext"; +import { + createWidgetHostBridge, + HostBridgeProvider, + nullHostBridge, +} from "./HostBridge"; +import { useInitial } from "./useInitial"; const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route); @@ -78,13 +84,18 @@ interface Props { } export const App: FC = ({ vm }) => { + // The standalone build has no host; the widget build's host is the client it + // is a widget of. + const hostBridge = useInitial(() => + widget === null ? nullHostBridge : createWidgetHostBridge(widget), + ); const [loaded, setLoaded] = useState(false); useEffect(() => { Initializer.init() ?.then(async () => { if (loaded) return; setLoaded(true); - await widget?.api.sendContentLoaded(); + await hostBridge.contentLoaded(); }) .catch(logger.error); }); @@ -94,7 +105,7 @@ export const App: FC = ({ vm }) => { } + fallback={(error) => } > } /> @@ -112,19 +123,21 @@ export const App: FC = ({ vm }) => { return ( - - - - - - - {content} - - - - - - + + + + + + + + {content} + + + + + + + ); }; diff --git a/src/Avatar.test.tsx b/src/Avatar.test.tsx index 1e32de0e0..c5d5e25af 100644 --- a/src/Avatar.test.tsx +++ b/src/Avatar.test.tsx @@ -9,18 +9,22 @@ import { afterEach, expect, test, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { type MatrixClient } from "matrix-js-sdk"; import { type FC, type PropsWithChildren } from "react"; -import { type WidgetApi } from "matrix-widget-api"; import { ClientContextProvider } from "./ClientContext"; -import { Avatar, getAvatarFromWidgetAPI } from "./Avatar"; +import { Avatar } from "./Avatar"; import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test"; -import { widget } from "./widget"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "./HostBridge"; const TestComponent: FC< PropsWithChildren<{ client: MatrixClient; + hostBridge?: HostBridge; }> -> = ({ client, children }) => { +> = ({ client, hostBridge = nullHostBridge, children }) => { return ( - {children} + {children} ); }; -vi.mock("./widget", () => ({ - widget: { - api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough - }, -})); - afterEach(() => { vi.unstubAllGlobals(); }); @@ -135,7 +133,7 @@ test("should attempt to fetch authenticated media from the server", async () => }); }); -test("should attempt to use widget API if running as a widget", async () => { +test("should download media through the host when it offers to", async () => { const expectedMXCUrl = "mxc://example.org/alice-avatar"; const expectedObjectURL = "my-object-url"; const theBlob = new Blob([]); @@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => { getAccessToken: () => undefined, } as unknown as MatrixClient); - widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi; - vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob }); + const downloadMedia = vi.fn().mockResolvedValue(theBlob); + const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia }; const member = mockMatrixRoomMember( mockRtcMembership("@alice:example.org", "AAAA"), { @@ -161,7 +159,7 @@ test("should attempt to use widget API if running as a widget", async () => { ); const displayName = "Alice"; render( - + { document.querySelector(`img[src='${expectedObjectURL}']`), ); - expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl); -}); - -test("Supports download files as base64", async () => { - const expectedMXCUrl = "mxc://example.org/alice-avatar"; - const expectedBase64 = - "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" + - "+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" + - "ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" + - "UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" + - "Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" + - "oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" + - "A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" + - "nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" + - "ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" + - "Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" + - "2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" + - "M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" + - "kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" + - "uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" + - "XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" + - "YII="; - const mockWidgetAPI = { - downloadFile: vi.fn().mockImplementation(async (contentUri) => { - if (contentUri !== expectedMXCUrl) { - return Promise.reject(new Error("Unexpected content URI")); - } - return { file: expectedBase64 }; - }), - } as unknown as WidgetApi; - - const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl); - - expect(blob).toBeInstanceOf(Blob); + expect(downloadMedia).toBeCalledWith(expectedMXCUrl); }); diff --git a/src/Avatar.tsx b/src/Avatar.tsx index 99940540d..185ae97b4 100644 --- a/src/Avatar.tsx +++ b/src/Avatar.tsx @@ -14,10 +14,9 @@ import { } from "react"; import { Avatar as CompoundAvatar } from "@vector-im/compound-web"; import { type MatrixClient } from "matrix-js-sdk"; -import { type WidgetApi } from "matrix-widget-api"; import { useClientState } from "./ClientContext"; -import { widget } from "./widget"; +import { useHostBridge } from "./HostBridge"; export enum Size { XS = "xs", @@ -76,6 +75,7 @@ export const Avatar: FC = ({ ...props }) => { const clientState = useClientState(); + const hostBridge = useHostBridge(); const sizePx = useMemo( () => @@ -87,7 +87,8 @@ export const Avatar: FC = ({ const [avatarUrl, setAvatarUrl] = useState(undefined); - // In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs. + // In theory, a change in `clientState` or `sizePx` could run extra media + // downloads, but in practice they should be stable long before this code runs. useEffect(() => { if (!src) { setAvatarUrl(undefined); @@ -96,8 +97,8 @@ export const Avatar: FC = ({ let blob: Promise; - if (widget?.api) { - blob = getAvatarFromWidgetAPI(widget.api, src); + if (hostBridge.downloadMedia) { + blob = hostBridge.downloadMedia(src); } else if ( clientState?.state === "valid" && clientState.authenticated?.client && @@ -132,7 +133,7 @@ export const Avatar: FC = ({ URL.revokeObjectURL(objectUrl); } }; - }, [clientState, src, sizePx]); + }, [clientState, hostBridge, src, sizePx]); return ( { - const response = await api.downloadFile(src); - const file = response.file; - - // element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that - if (file instanceof Blob) { - return file; - } else if (typeof file === "string") { - // it is a base64 string - const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0)); - return new Blob([bytes]); - } - throw new Error( - "Downloaded file format is not supported: " + typeof file + "", - ); -} diff --git a/src/ClientContext.tsx b/src/ClientContext.tsx index f2ff3dd4b..288665c59 100644 --- a/src/ClientContext.tsx +++ b/src/ClientContext.tsx @@ -339,7 +339,7 @@ export const ClientProvider: FC = ({ children }) => { }, [initClientState, onSync]); if (alreadyOpenedErr) { - return ; + return ; } return {children}; diff --git a/src/ErrorView.tsx b/src/ErrorView.tsx index 1309ae046..519089831 100644 --- a/src/ErrorView.tsx +++ b/src/ErrorView.tsx @@ -21,7 +21,7 @@ import { RageshakeButton } from "./settings/RageshakeButton"; import styles from "./ErrorView.module.css"; import { useUrlParams } from "./UrlParams"; import { LinkButton } from "./button"; -import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts"; +import { useHostBridge } from "./HostBridge.ts"; interface Props { Icon: ComponentType>; @@ -38,7 +38,6 @@ interface Props { */ fatal?: boolean; children: ReactNode; - widget: WidgetHelpers | null; } export const ErrorView: FC = ({ @@ -47,32 +46,27 @@ export const ErrorView: FC = ({ rageshake, fatal, children, - widget, }) => { const { t } = useTranslation(); const { confineToRoom } = useUrlParams(); + const hostBridge = useHostBridge(); const onReload = useCallback(() => { window.location.href = "/"; }, []); - const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({ - widget, + const CloseButton: FC<{ close: () => Promise }> = ({ + close, }): ReactElement => { - // in widget mode we don't want to show the return home button but a close button - const closeWidget = (): void => { - widget.api.transport - .send(ElementWidgetActions.Close, {}) - .catch((e) => { - // What to do here? - logger.error("Failed to send close action", e); - }) - .finally(() => { - widget.api.transport.stop(); - }); + // When the host can dismiss us, offer that instead of a link home + const onClose = (): void => { + close().catch((e) => { + // What to do here? + logger.error("Failed to ask the host to close Element Call", e); + }); }; return ( - ); @@ -108,8 +102,8 @@ export const ErrorView: FC = ({ {rageshake && ( )} - {widget ? ( - + {hostBridge.close ? ( + ) : ( !confineToRoom && )} diff --git a/src/FullScreenView.tsx b/src/FullScreenView.tsx index eb84010e5..ea2a4a0d2 100644 --- a/src/FullScreenView.tsx +++ b/src/FullScreenView.tsx @@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css"; import { useUrlParams } from "./UrlParams"; import { RichError } from "./RichError"; import { ErrorView } from "./ErrorView"; -import { type WidgetHelpers } from "./widget.ts"; interface FullScreenViewProps { className?: string; @@ -48,12 +47,11 @@ export const FullScreenView: FC = ({ interface ErrorPageProps { error: unknown; - widget: WidgetHelpers | null; } // Due to this component being used as the crash fallback for Sentry, which has // weird type requirements, we can't just give this a type of FC -export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => { +export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => { const { t } = useTranslation(); useEffect(() => { logger.error(error); @@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => { error.richMessage ) : ( ): WidgetHelpers { + return { + api: api as WidgetApi, + lazyActions: new EventEmitter(), + client: Promise.resolve(), + } as unknown as WidgetHelpers; +} + +describe("createWidgetHostBridge", () => { + describe("downloadMedia", () => { + const mxcUri = "mxc://example.org/alice-avatar"; + + test("passes a Blob through unchanged", async () => { + const file = new Blob([]); + const bridge = createWidgetHostBridge( + mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file }) }), + ); + + await expect(bridge.downloadMedia!(mxcUri)).resolves.toBe(file); + }); + + test("decodes a base64 string into a Blob", async () => { + const bridge = createWidgetHostBridge( + mockWidget({ + // "hello" in base64 + downloadFile: vi.fn().mockResolvedValue({ file: "aGVsbG8=" }), + }), + ); + + const blob = await bridge.downloadMedia!(mxcUri); + + expect(blob).toBeInstanceOf(Blob); + // The five decoded bytes, rather than the eight characters of base64 — + // which is what we'd get if the string were stored verbatim. + expect(blob.size).toBe(5); + }); + + test("rejects a file format it does not understand", async () => { + const bridge = createWidgetHostBridge( + mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file: 42 }) }), + ); + + await expect(bridge.downloadMedia!(mxcUri)).rejects.toThrow( + "Downloaded file format is not supported", + ); + }); + }); + + describe("supportsReactions", () => { + const capabilities = [ + "org.matrix.msc2762.send.event:m.reaction", + "org.matrix.msc2762.send.event:m.room.redaction", + "org.matrix.msc2762.receive.event:m.reaction", + "org.matrix.msc2762.receive.event:m.room.redaction", + ]; + + test("is true when the host grants every reaction capability", () => { + const bridge = createWidgetHostBridge( + mockWidget({ hasCapability: () => true }), + ); + + expect(bridge.supportsReactions).toBe(true); + }); + + test.each(capabilities)("is false without %s", (missing) => { + const bridge = createWidgetHostBridge( + mockWidget({ hasCapability: (c) => c !== missing }), + ); + + expect(bridge.supportsReactions).toBe(false); + }); + }); +}); + +describe("nullHostBridge", () => { + test("offers no way to close, so the interface falls back to navigation", () => { + expect(nullHostBridge.close).toBeUndefined(); + }); + + test("offers no media download, so Element Call uses its own client", () => { + expect(nullHostBridge.downloadMedia).toBeUndefined(); + }); + + test("supports reactions, since nothing is mediating its homeserver access", () => { + expect(nullHostBridge.supportsReactions).toBe(true); + }); +}); diff --git a/src/HostBridge.ts b/src/HostBridge.ts new file mode 100644 index 000000000..f31b5a4af --- /dev/null +++ b/src/HostBridge.ts @@ -0,0 +1,214 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { createContext, use } from "react"; +import { fromEvent, map, NEVER, type Observable } from "rxjs"; +import { + type IWidgetApiRequest, + type IWidgetApiRequestData, + WidgetApiToWidgetAction, +} from "matrix-widget-api"; + +import { + ElementWidgetActions, + type JoinCallData, + type WidgetHelpers, +} from "./widget"; + +// Note: these are type aliases rather than interfaces so that they satisfy the +// widget API's index-signature payload types. + +/** The mute state Element Call and its host exchange. */ +export type DeviceMuteState = { + audio_enabled: boolean; + video_enabled: boolean; +}; + +/** + * A mute state change requested by the host. An absent field means "leave this + * one as it is". + */ +export type DeviceMuteRequest = { + audio_enabled?: boolean; + video_enabled?: boolean; +}; + +/** + * Something the host has asked of Element Call, which it is expected to + * acknowledge. + */ +export interface HostRequest { + data: Data; + /** Acknowledges the request. Should be called exactly once. */ + reply(reply: Reply): void; +} + +/** + * Element Call's view of the application hosting it. + * + * Element Call can run as its own page, as a widget inside a Matrix client, or + * embedded directly into one. Only the last two give it a host, and each of + * them reaches it by a different route — so everything Element Call needs from + * whatever is hosting it goes through this interface, rather than being + * expressed in terms of the widget API. + * + * This covers the interactions Element Call has with its host while running. + * The Matrix client it talks to is supplied separately, at startup. + */ +export interface HostBridge { + // What Element Call tells the host. + + /** + * Asks the host to keep Element Call on screen (or stop doing so), so that a + * call in progress is not torn down when the user navigates elsewhere. + */ + setAlwaysOnScreen(alwaysOnScreen: boolean): Promise; + /** Tells the host that Element Call has finished loading. */ + contentLoaded(): Promise; + /** Tells the host that the user has joined the call. */ + notifyJoined(): Promise; + /** Tells the host that the user has hung up. */ + notifyHungUp(): Promise; + /** Tells the host the user's current audio and video mute state. */ + notifyDeviceMute(state: DeviceMuteState): Promise; + /** + * Asks the host to close Element Call, and stops communicating with it. No + * further calls should be made on this bridge afterwards. + * + * Absent when the host has no way to dismiss Element Call — standalone, the + * user navigates away instead — so its presence is what tells the interface + * whether to offer a close affordance. + */ + close?(): Promise; + + // What the host asks of Element Call. + + /** The host has changed the theme Element Call should use. */ + themeChange$: Observable>; + /** The host wants a preloaded Element Call to join the call now. */ + join$: Observable>; + /** The host wants Element Call to leave the call. */ + hangUp$: Observable>>; + /** The host wants to change, or read back, the device mute state. */ + deviceMute$: Observable>; + + // What the host is capable of. + + /** Whether the host permits Element Call to send and receive reactions. */ + readonly supportsReactions: boolean; + /** + * Fetches media on Element Call's behalf, for hosts that do not give it + * direct access to the homeserver. Absent when Element Call should fetch + * media itself using its own client. + */ + downloadMedia?(mxcUri: string): Promise; +} + +/** + * A bridge to nowhere, for when Element Call has no host — that is, when it is + * running as its own page and talks to the homeserver directly. + */ +export const nullHostBridge: HostBridge = { + setAlwaysOnScreen: async () => {}, + contentLoaded: async () => {}, + notifyJoined: async () => {}, + notifyHungUp: async () => {}, + notifyDeviceMute: async () => {}, + themeChange$: NEVER, + join$: NEVER, + hangUp$: NEVER, + deviceMute$: NEVER, + // Standalone Element Call reaches the homeserver itself, so nothing is + // withholding these from it. + supportsReactions: true, +}; + +/** Bridges to a host that Element Call is a widget of. */ +export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge { + const requests = ( + action: string, + ): Observable> => + ( + fromEvent(widget.lazyActions, action) as Observable< + CustomEvent + > + ).pipe( + map((ev) => ({ + data: ev.detail.data as Data, + // The widget API requires a reply for every request, and carries the + // payload as a plain object, so an empty reply becomes {}. + reply: (reply: Reply): void => + widget.api.transport.reply(ev.detail, reply ?? {}), + })), + ); + + const send = async ( + action: ElementWidgetActions, + data: IWidgetApiRequestData = {}, + ): Promise => { + await widget.api.transport.send(action, data); + }; + + return { + setAlwaysOnScreen: async (alwaysOnScreen) => { + await widget.api.setAlwaysOnScreen(alwaysOnScreen); + }, + contentLoaded: async () => widget.api.sendContentLoaded(), + notifyJoined: async () => send(ElementWidgetActions.JoinCall), + notifyHungUp: async () => send(ElementWidgetActions.HangupCall), + notifyDeviceMute: async (state) => + send(ElementWidgetActions.DeviceMute, state), + close: async () => { + await send(ElementWidgetActions.Close); + widget.api.transport.stop(); + }, + themeChange$: requests(WidgetApiToWidgetAction.ThemeChange), + join$: requests(ElementWidgetActions.JoinCall), + hangUp$: requests(ElementWidgetActions.HangupCall), + deviceMute$: requests(ElementWidgetActions.DeviceMute), + // Element Call needs the host's permission to send reactions on its behalf. + // Read on access rather than up front: the widget API negotiates its + // capabilities asynchronously, and the bridge is built before that settles. + get supportsReactions(): boolean { + return ( + widget.api.hasCapability("org.matrix.msc2762.send.event:m.reaction") && + widget.api.hasCapability( + "org.matrix.msc2762.send.event:m.room.redaction", + ) && + widget.api.hasCapability( + "org.matrix.msc2762.receive.event:m.reaction", + ) && + widget.api.hasCapability( + "org.matrix.msc2762.receive.event:m.room.redaction", + ) + ); + }, + downloadMedia: async (mxcUri) => { + const { file } = await widget.api.downloadFile(mxcUri); + if (file instanceof Blob) return file; + if (typeof file === "string") + // it is a base64 string + return new Blob([Uint8Array.from(atob(file), (c) => c.charCodeAt(0))]); + throw new Error( + `Downloaded file format is not supported: ${typeof file}`, + ); + }, + }; +} + +const HostBridgeContext = createContext(null); + +export const HostBridgeProvider = HostBridgeContext.Provider; + +/** + * The application hosting Element Call. + * + * Defaults to {@link nullHostBridge}, so that tests and stories, which have no + * host, need no provider. + */ +export const useHostBridge = (): HostBridge => + use(HostBridgeContext) ?? nullHostBridge; diff --git a/src/RichError.tsx b/src/RichError.tsx index 699486e25..abacf0b34 100644 --- a/src/RichError.tsx +++ b/src/RichError.tsx @@ -10,7 +10,6 @@ import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import type { FC, ReactNode } from "react"; import { ErrorView } from "./ErrorView"; -import { widget } from "./widget.ts"; /** * An error consisting of a terse message to be logged to the console and a @@ -32,11 +31,7 @@ const OpenElsewhere: FC = () => { const { t } = useTranslation(); return ( - +

{t("error.open_elsewhere_description", { brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call", diff --git a/src/home/HomePage.tsx b/src/home/HomePage.tsx index ca1f0ea83..e61368559 100644 --- a/src/home/HomePage.tsx +++ b/src/home/HomePage.tsx @@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView"; import { UnauthenticatedView } from "./UnauthenticatedView"; import { RegisteredView } from "./RegisteredView"; import { usePageTitle } from "../usePageTitle"; -import { widget } from "../widget.ts"; export const HomePage: FC = () => { const { t } = useTranslation(); @@ -24,7 +23,7 @@ export const HomePage: FC = () => { if (!clientState) { return ; } else if (clientState.state === "error") { - return ; + return ; } else { return clientState.authenticated ? ( diff --git a/src/room/GroupCallErrorBoundary.test.tsx b/src/room/GroupCallErrorBoundary.test.tsx index e10044ae1..a70b31805 100644 --- a/src/room/GroupCallErrorBoundary.test.tsx +++ b/src/room/GroupCallErrorBoundary.test.tsx @@ -36,7 +36,11 @@ import { UnknownCallError, } from "../utils/errors.ts"; import { mockConfig } from "../utils/test.ts"; -import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "../HostBridge.ts"; test.each([ { @@ -79,7 +83,6 @@ test.each([ @@ -108,7 +111,6 @@ test("should render the error page with link back to home", async () => { @@ -154,10 +156,7 @@ test("ConnectionLostError: Action handling should reset error state", async () = return ( - + @@ -199,7 +198,6 @@ describe("Rageshake button", () => { @@ -224,29 +222,27 @@ describe("Rageshake button", () => { }); }); -test("should have a close button in widget mode", async () => { +test("should have a close button when the host can dismiss us", async () => { const error = new MatrixRTCTransportMissingError("example.com"); const TestComponent = (): ReactNode => { throw error; }; - const mockWidget = { - api: { - transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() }, - }, - } as unknown as WidgetHelpers; + const close = vi.fn().mockResolvedValue(undefined); + const hostBridge: HostBridge = { ...nullHostBridge, close }; const user = userEvent.setup(); const onErrorMock = vi.fn(); const { asFragment } = render( - - - + + + + + , ); @@ -258,11 +254,7 @@ test("should have a close button in widget mode", async () => { await user.click(screen.getByRole("button", { name: "Close" })); - expect(mockWidget.api.transport.send).toHaveBeenCalledWith( - ElementWidgetActions.Close, - expect.anything(), - ); - expect(mockWidget.api.transport.stop).toHaveBeenCalled(); + expect(close).toHaveBeenCalled(); }); test("should show technical details when error has a matrixError cause", async () => { @@ -282,11 +274,7 @@ test("should show technical details when error has a matrixError cause", async ( render( - + , @@ -315,11 +303,7 @@ test("should not show technical details when error has no matrix error cause", a render( - + , @@ -376,7 +360,6 @@ describe("LiveKit ConnectionError variants", () => { @@ -406,7 +389,6 @@ describe("LiveKit ConnectionError variants", () => { diff --git a/src/room/GroupCallErrorBoundary.tsx b/src/room/GroupCallErrorBoundary.tsx index 390a5a8c2..6d43cf78f 100644 --- a/src/room/GroupCallErrorBoundary.tsx +++ b/src/room/GroupCallErrorBoundary.tsx @@ -34,7 +34,6 @@ import { } from "../utils/errors.ts"; import { FullScreenView } from "../FullScreenView.tsx"; import { ErrorView } from "../ErrorView.tsx"; -import { type WidgetHelpers } from "../widget.ts"; import styles from "../ErrorView.module.css"; export type CallErrorRecoveryAction = "reconnect"; // | "retry" ; @@ -47,13 +46,11 @@ interface ErrorPageProps { error: ElementCallError; recoveryActionHandler: RecoveryActionHandler; resetError: () => void; - widget: WidgetHelpers | null; } const ErrorPage: FC = ({ error, recoveryActionHandler, - widget, }: ErrorPageProps): ReactElement => { const { t } = useTranslation(); logger.error("Error boundary caught:", error); @@ -89,7 +86,6 @@ const ErrorPage: FC = ({ Icon={icon} title={error.localisedTitle} rageshake={error.code == ErrorCode.UNKNOWN_ERROR} - widget={widget} >

{error.localisedMessageKey ? ( @@ -148,14 +144,12 @@ interface BoundaryProps { children: ReactNode | (() => ReactNode); recoveryActionHandler: RecoveryActionHandler; onError?: (error: unknown) => void; - widget: WidgetHelpers | null; } export const GroupCallErrorBoundary = ({ recoveryActionHandler, onError, children, - widget, }: BoundaryProps): ReactElement => { const fallbackRenderer: FallbackRender = useCallback( ({ error, resetError }): ReactElement => { @@ -165,7 +159,6 @@ export const GroupCallErrorBoundary = ({ : new UnknownCallError(error instanceof Error ? error : new Error()); return ( { @@ -175,7 +168,7 @@ export const GroupCallErrorBoundary = ({ /> ); }, - [recoveryActionHandler, widget], + [recoveryActionHandler], ); return ( diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index a5c3b0d8e..a53288c2c 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -36,7 +36,6 @@ import userEvent, { import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { useState } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; -import { type ITransport } from "matrix-widget-api"; import { prefetchSounds } from "../soundUtils"; import { useAudioContext } from "../useAudioContext"; @@ -52,8 +51,11 @@ import { } from "../utils/test"; import { GroupCallView } from "./GroupCallView"; import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary"; -import { ElementWidgetActions, type WidgetHelpers } from "../widget"; -import { LazyEventEmitter } from "../LazyEventEmitter"; +import { + type HostBridge, + HostBridgeProvider, + nullHostBridge, +} from "../HostBridge"; import { MatrixRTCTransportMissingError } from "../utils/errors"; import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { MediaDevicesContext } from "../MediaDevicesContext"; @@ -134,7 +136,7 @@ beforeEach(() => { }); function createGroupCallView( - widget: WidgetHelpers | null, + hostBridge: HostBridge, joined = true, options: { withErrorBoundary?: boolean; @@ -184,7 +186,6 @@ function createGroupCallView( skipLobby={false} rtcSession={rtcSession.asMockedSession()} muteStates={muteState} - widget={widget} // TODO-MULTI-SFU: Make joined and setJoined work joined={true} setJoined={function (value: boolean): void {}} @@ -192,22 +193,21 @@ function createGroupCallView( ); const { getByText } = render( - - - - {options.withErrorBoundary ? ( - - {groupCallView} - - ) : ( - groupCallView - )} - - - + + + + + {options.withErrorBoundary ? ( + + {groupCallView} + + ) : ( + groupCallView + )} + + + + , ); return { @@ -218,7 +218,7 @@ function createGroupCallView( test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => { const user = userEvent.setup(); - const { getByText, rtcSession } = createGroupCallView(null); + const { getByText, rtcSession } = createGroupCallView(nullHostBridge); const leaveButton = getByText("Leave"); await user.click(leaveButton); expect(playSound).toHaveBeenCalledWith("left"); @@ -235,12 +235,7 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => { const user = userEvent.setup(); - const widget = { - api: { - setAlwaysOnScreen: async () => Promise.resolve(true), - } as Partial, - lazyActions: new LazyEventEmitter(), - }; + const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() }; let resolvePlaySound: () => void; playSound = vi .fn() @@ -253,9 +248,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn soundDuration: {}, }); - const { getByText, rtcSession } = createGroupCallView( - widget as WidgetHelpers, - ); + const { getByText, rtcSession } = createGroupCallView(hostBridge); const leaveButton = getByText("Leave"); await user.click(leaveButton); await flushPromises(); @@ -272,28 +265,13 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn expect(leaveRTCSession).toHaveBeenCalledOnce(); }); -test("Should close widget when all other left and play a sound", async () => { +test("Should ask the host to close when all other left and play a sound", async () => { const user = userEvent.setup(); - let widgetClosedCalled = false; - const { promise: widgetClosedPromise, resolve: widgetClosedResolver } = - Promise.withResolvers(); - const widgetSendMock = vi.fn().mockImplementation((action: string) => { - if (action === ElementWidgetActions.Close) { - widgetClosedCalled = true; - widgetClosedResolver(); - } - }); - const widgetStopMock = vi.fn().mockResolvedValue(undefined); - const widget = { - api: { - setAlwaysOnScreen: vi.fn().mockResolvedValue(true), - transport: { - send: widgetSendMock, - reply: vi.fn().mockResolvedValue(undefined), - stop: widgetStopMock, - } as unknown as ITransport, - } as Partial, - lazyActions: new LazyEventEmitter(), + const close = vi.fn().mockResolvedValue(undefined); + const hostBridge: HostBridge = { + ...nullHostBridge, + setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined), + close, }; const resolvePlaySound = Promise.withResolvers(); playSound = vi.fn().mockReturnValue(resolvePlaySound.promise); @@ -303,50 +281,38 @@ test("Should close widget when all other left and play a sound", async () => { soundDuration: {}, }); - const { getByText } = createGroupCallView(widget as WidgetHelpers); + const { getByText } = createGroupCallView(hostBridge); const leaveButton = getByText("SimulateOtherLeft"); await user.click(leaveButton); await flushPromises(); - expect(widgetClosedCalled).toBeFalsy(); + expect(close).not.toHaveBeenCalled(); resolvePlaySound.resolve(); expect(playSound).toHaveBeenCalledWith("left", 0); - await widgetClosedPromise; - await flushPromises(); - expect(widgetClosedCalled).toBeTruthy(); - expect(widgetStopMock).toHaveBeenCalledOnce(); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); }, 80000); -test("Should not close widget when auto leave due to error", async () => { +test("Should not ask the host to close when auto leave due to error", async () => { const user = userEvent.setup(); - const widgetStopMock = vi.fn().mockResolvedValue(undefined); - const widgetSendMock = vi.fn().mockResolvedValue(undefined); - const widget = { - api: { - setAlwaysOnScreen: vi.fn().mockResolvedValue(true), - transport: { - send: widgetSendMock, - reply: vi.fn().mockResolvedValue(undefined), - stop: widgetStopMock, - } as unknown as ITransport, - } as Partial, - lazyActions: new LazyEventEmitter(), + const close = vi.fn().mockResolvedValue(undefined); + const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined); + const hostBridge: HostBridge = { + ...nullHostBridge, + setAlwaysOnScreen, + close, }; - const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen"); - - const { getByText } = createGroupCallView(widget as WidgetHelpers); + const { getByText } = createGroupCallView(hostBridge); const leaveButton = getByText("SimulateErrorLeft"); await user.click(leaveButton); await flushPromises(); // When onLeft is called, we first set always on screen to false - await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false)); + await waitFor(() => expect(setAlwaysOnScreen).toHaveBeenCalledWith(false)); await flushPromises(); - // But then we do not close the widget automatically - expect(widgetStopMock).not.toHaveBeenCalledOnce(); - expect(widgetSendMock).not.toHaveBeenCalledOnce(); + // But then we do not ask to be closed automatically + expect(close).not.toHaveBeenCalled(); }); test.skip("GroupCallView leaves the session when an error occurs", async () => { @@ -360,7 +326,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => { ); }); const user = userEvent.setup(); - const { rtcSession } = createGroupCallView(null); + const { rtcSession } = createGroupCallView(nullHostBridge); await user.click(screen.getByRole("button", { name: "Panic!" })); screen.getByText("Something went wrong"); expect(leaveRTCSession).toHaveBeenCalledWith( @@ -377,7 +343,7 @@ test.skip("GroupCallView shows errors that occur during joining", async () => { onTestFinished(() => { enterRTCSession.mockReset(); }); - createGroupCallView(null, false); + createGroupCallView(nullHostBridge, false); await user.click(screen.getByRole("button", { name: "Join call" })); screen.getByText("Call is not supported"); }); @@ -396,7 +362,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent { cause: stickyError }, ); - const { rtcSession } = createGroupCallView(null, true, { + const { rtcSession } = createGroupCallView(nullHostBridge, true, { withErrorBoundary: true, }); @@ -408,7 +374,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent }); test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => { - const { rtcSession } = createGroupCallView(null, true, { + const { rtcSession } = createGroupCallView(nullHostBridge, true, { withErrorBoundary: true, }); @@ -424,7 +390,7 @@ test("falls back to ConnectionLostError for unrecognised membership manager erro test("user can reconnect after a membership manager error", async () => { const user = userEvent.setup(); - const { rtcSession } = createGroupCallView(null, true); + const { rtcSession } = createGroupCallView(nullHostBridge, true); await act(() => rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined), ); diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index 57e9205f1..623b8839c 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -30,12 +30,7 @@ import { } from "matrix-js-sdk/lib/matrixrtc"; import { useNavigate } from "react-router-dom"; -import type { IWidgetApiRequest } from "matrix-widget-api"; -import { - ElementWidgetActions, - type JoinCallData, - type WidgetHelpers, -} from "../widget"; +import { type JoinCallData } from "../widget"; import { LobbyView } from "./LobbyView"; import { type MatrixInfo } from "./VideoPreview"; import { CallEndedView } from "./CallEndedView"; @@ -76,6 +71,7 @@ import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts"; import { useAppBarTitle } from "../AppBar.tsx"; import { useBehavior } from "../useBehavior.ts"; import { useRootElement } from "../RootElementContext.ts"; +import { useHostBridge } from "../HostBridge.ts"; /** * If there already are this many participants in the call, we automatically mute @@ -99,7 +95,6 @@ interface Props { joined: boolean; setJoined: (value: boolean) => void; muteStates: MuteStates; - widget: WidgetHelpers | null; } export const GroupCallView: FC = ({ @@ -112,7 +107,6 @@ export const GroupCallView: FC = ({ joined, setJoined, muteStates, - widget, }) => { // Used to thread through any errors that occur outside the error boundary const [externalError, setExternalError] = useState( @@ -120,6 +114,14 @@ export const GroupCallView: FC = ({ ); const memberships = useMatrixRTCSessionMemberships(rtcSession); const rootElement = useRootElement(); + const hostBridge = useHostBridge(); + // A host that can close us is a host that decides when we stop existing, so + // we neither show our own post-call screens nor assume we have time to + // finish what we are doing. + // TODO: this reads a capability as a proxy for who owns our lifetime. Worth + // finding a more direct way to express it — see the guidance in UrlParams.ts + // on naming behaviours rather than situations. + const hostControlsLifetime = hostBridge.close !== undefined; const muteAllAudio = useBehavior(muteAllAudio$); const leaveSoundContext = useLatest( @@ -294,28 +296,26 @@ export const GroupCallView: FC = ({ }; if (skipLobby) { - if (widget && preload) { + // `preload` is only ever set when we have a host to be preloaded by. + if (preload) { // In preload mode without lobby we wait for a join action before entering - const onJoin = (ev: CustomEvent): void => { + const subscription = hostBridge.join$.subscribe(({ data, reply }) => { (async (): Promise => { - await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData); + await defaultDeviceSetup(data); setJoined(true); - widget.api.transport.reply(ev.detail, {}); + reply(); })().catch((e) => { logger.error("Error joining RTC session on preload", e); }); - }; - widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin); - return (): void => { - widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin); - }; + }); + return (): void => subscription.unsubscribe(); } else { // No lobby and no preload: we enter the rtc session right away setJoined(true); } } }, [ - widget, + hostBridge, rtcSession, preload, skipLobby, @@ -341,7 +341,7 @@ export const GroupCallView: FC = ({ // When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer // already plays the "left" sound when the remote participant's media // disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise. - // (used to destory the widget) + // (which is what delays asking the host to close us) audioPromise = leaveSoundContext.current?.playSound("left", 0); break; case "timeout": @@ -356,12 +356,12 @@ export const GroupCallView: FC = ({ setLeft(true); // We need to wait until the callEnded event is tracked on PostHog, - // otherwise the iframe may get killed first. + // otherwise we may be torn down first. const posthogRequest = new Promise((resolve) => { - // To increase the likelihood of the PostHog event being sent out in - // widget mode before the iframe is killed, we ask it to skip the - // usual queuing/batching of requests. - const sendInstantly = widget !== null; + // To increase the likelihood of the PostHog event being sent out + // before the host disposes of us, we ask it to skip the usual + // queuing/batching of requests. + const sendInstantly = hostControlsLifetime; PosthogAnalytics.instance.eventCallEnded.track( room.roomId, rtcSession.memberships.length, @@ -369,8 +369,8 @@ export const GroupCallView: FC = ({ rtcSession, ); // Unfortunately the PostHog library provides no way to await the - // tracking of an event, but we don't really want it to hold up the - // closing of the widget that long anyway, so giving it 10 ms will do. + // tracking of an event, but we don't really want it to hold up our + // disposal that long anyway, so giving it 10 ms will do. window.setTimeout(resolve, 10); }); @@ -389,25 +389,19 @@ export const GroupCallView: FC = ({ ) void navigate("/"); - if (widget) { - // After this point the iframe could die at any moment! + // After this point the host could dispose of us at any moment! + try { + await hostBridge.setAlwaysOnScreen(false); + } catch (e) { + logger.error("Failed to set `alwaysOnScreen` to false", e); + } + // On a normal user hangup we can shut down and ask to be closed. But + // if an error occurs we should stay open until the user reads it. + if (reason != "error" && !returnToLobby) { try { - await widget.api.setAlwaysOnScreen(false); + await hostBridge.close?.(); } catch (e) { - logger.error( - "Failed to set call widget `alwaysOnScreen` to false", - e, - ); - } - // On a normal user hangup we can shut down and close the widget. But if an - // error occurs we should keep the widget open until the user reads it. - if (reason != "error" && !returnToLobby) { - try { - await widget.api.transport.send(ElementWidgetActions.Close, {}); - } catch (e) { - logger.error("Failed to send close action", e); - } - widget.api.transport.stop(); + logger.error("Failed to ask the host to close Element Call", e); } } }); @@ -415,7 +409,8 @@ export const GroupCallView: FC = ({ [ setJoined, leaveSoundContext, - widget, + hostBridge, + hostControlsLifetime, room.roomId, rtcSession, isPasswordlessUser, @@ -426,12 +421,12 @@ export const GroupCallView: FC = ({ ); useEffect(() => { - if (widget && joined) - // set widget to sticky once joined. - widget.api.setAlwaysOnScreen(true).catch((e) => { + if (joined) + // ask to be kept on screen once joined. + hostBridge.setAlwaysOnScreen(true).catch((e) => { logger.error("Error calling setAlwaysOnScreen(true)", e); }); - }, [widget, joined, rtcSession]); + }, [hostBridge, joined, rtcSession]); const joinRule = useJoinRule(room); @@ -501,19 +496,16 @@ export const GroupCallView: FC = ({ /> ); - } else if (left && widget === null) { - // Left in SPA mode: + } else if (left && !hostControlsLifetime) { + // Left, and it is up to us what to show next: // The call ended view is shown for two reasons: prompting guests to create // an account, and prompting users that have opted into analytics to provide - // feedback. We don't show a feedback prompt to widget users however (at - // least for now), because we don't yet have designs that would allow widget - // users to dismiss the feedback prompt and close the call window without - // submitting anything. - if ( - isPasswordlessUser || - (PosthogAnalytics.instance.isEnabled() && widget === null) - ) { + // feedback. We don't show a feedback prompt when a host owns our lifetime + // however (at least for now), because we don't yet have designs that would + // allow those users to dismiss the feedback prompt and close the call + // window without submitting anything. + if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) { body = ( = ({ // LobbyView again which would open capture devices again. body = null; } - } else if (left && widget !== null) { - // Left in widget mode: + } else if (left && hostControlsLifetime) { + // Left, and the host decides what happens next: body = returnToLobby ? lobbyView : null; } else if (preload || skipLobby) { // The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called. @@ -541,7 +533,6 @@ export const GroupCallView: FC = ({ return ( { setExternalError(null); if (action == "reconnect") { @@ -553,9 +544,10 @@ export const GroupCallView: FC = ({ }} onError={(_error) => { if (rtcSession.isJoined()) onLeft("error"); - // If there is an error we need to be able to close the widget. This is done in `onLeft` as well - // We need it here explicitly in case rtcSession.isJoined is false. - void widget?.api.setAlwaysOnScreen(false); + // If there is an error we need to be dismissible again. This is done in + // `onLeft` as well; we need it here explicitly in case + // rtcSession.isJoined is false. + void hostBridge.setAlwaysOnScreen(false); }} > {body} diff --git a/src/room/RoomPage.tsx b/src/room/RoomPage.tsx index b1ffb3ba2..b5f943ce8 100644 --- a/src/room/RoomPage.tsx +++ b/src/room/RoomPage.tsx @@ -123,7 +123,6 @@ export const RoomPage: FC = (): ReactNode => { return ( muteStates && ( {

@@ -216,7 +214,6 @@ export const RoomPage: FC = (): ReactNode => {

{groupCallState.error.messageBody}

{groupCallState.error.reason && ( @@ -230,7 +227,7 @@ export const RoomPage: FC = (): ReactNode => { ); } else { - return ; + return ; } default: return <> ; @@ -238,7 +235,7 @@ export const RoomPage: FC = (): ReactNode => { }; if (loading || isRegistering) return ; - if (error) return ; + if (error) return ; if (!client) return ; // TODO: This doesn't belong here, the app routes need to be reworked if (!roomIdOrAlias) return ; diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap index 4239cee13..0194dfc4d 100644 --- a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap +++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap @@ -1100,7 +1100,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh `; -exports[`should have a close button in widget mode 1`] = ` +exports[`should have a close button when the host can dismiss us 1`] = `
({ useUrlParams: vi.fn() })); -vi.mock("./widget", () => ({ - widget: { - api: { transport: { reply: vi.fn() } }, - lazyActions: new EventEmitter(), - }, -})); describe("useTheme", () => { let originalClassList: DOMTokenList; + let themeChange$: Subject>; + let wrapper: FC; + beforeEach(() => { + themeChange$ = new Subject(); + const hostBridge: HostBridge = { ...nullHostBridge, themeChange$ }; + wrapper = ({ children }) => + createElement(HostBridgeProvider, { value: hostBridge }, children); // Save the original classList to setup spies originalClassList = document.body.classList; @@ -55,7 +61,7 @@ describe("useTheme", () => { test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => { (useUrlParams as Mock).mockReturnValue({ theme: setTheme }); - renderHook(() => useTheme()); + renderHook(() => useTheme(), { wrapper }); expect(originalClassList.remove).toHaveBeenCalledWith( "cpd-theme-light", @@ -71,7 +77,7 @@ describe("useTheme", () => { // Simulate a previous theme originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark"); - renderHook(() => useTheme()); + renderHook(() => useTheme(), { wrapper }); expect(document.body.classList.add).not.toHaveBeenCalledWith( "cpd-theme-dark", @@ -82,18 +88,13 @@ describe("useTheme", () => { expect(originalClassList.add).not.toHaveBeenCalled(); }); - test("theme changes in response to widget actions", async () => { - renderHook(() => useTheme()); + test("theme changes in response to host requests", () => { + renderHook(() => useTheme(), { wrapper }); expect(originalClassList.add).toHaveBeenCalledWith("cpd-theme-dark"); - await act(() => - widget!.lazyActions.emit( - WidgetApiToWidgetAction.ThemeChange, - new CustomEvent(WidgetApiToWidgetAction.ThemeChange, { - detail: { data: { name: "light" } }, - }), - ), - ); + const reply = vi.fn(); + act(() => themeChange$.next({ data: { name: "light" }, reply })); + expect(reply).toHaveBeenCalledOnce(); expect(originalClassList.remove).toHaveBeenCalledWith( "cpd-theme-light", "cpd-theme-dark", diff --git a/src/useTheme.ts b/src/useTheme.ts index 85dace2aa..3c02745a6 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -6,39 +6,27 @@ Please see LICENSE in the repository root for full details. */ import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { WidgetApiToWidgetAction } from "matrix-widget-api"; -import { type IThemeChangeActionRequest } from "matrix-widget-api"; import { useUrlParams } from "./UrlParams"; -import { widget } from "./widget"; import { useRootElement } from "./RootElementContext"; +import { useHostBridge } from "./HostBridge"; export const useTheme = (): void => { const rootElement = useRootElement(); + const hostBridge = useHostBridge(); const { theme } = useUrlParams(); const [requestedTheme, setRequestedTheme] = useState(theme); const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { - if (widget) { - const onThemeChange = ( - ev: CustomEvent, - ): void => { - ev.preventDefault(); - if ("name" in ev.detail.data && typeof ev.detail.data.name === "string") - setRequestedTheme(ev.detail.data.name); - widget!.api.transport.reply(ev.detail, {}); - }; - - widget.lazyActions.on(WidgetApiToWidgetAction.ThemeChange, onThemeChange); - return (): void => { - widget!.lazyActions.off( - WidgetApiToWidgetAction.ThemeChange, - onThemeChange, - ); - }; - } - }, []); + const subscription = hostBridge.themeChange$.subscribe( + ({ data, reply }) => { + if (typeof data.name === "string") setRequestedTheme(data.name); + reply(); + }, + ); + return (): void => subscription.unsubscribe(); + }, [hostBridge]); useLayoutEffect(() => { // If no theme has been explicitly requested we default to dark