diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index 33770fe4bf3..9e4544c37d4 100644 --- a/src/LegacyCallHandler.tsx +++ b/src/LegacyCallHandler.tsx @@ -1055,9 +1055,9 @@ export default class LegacyCallHandler extends TypedEventEmitter WidgetType.JITSI.matches(w.type)); jitsiWidgets.forEach((w) => { const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(w)); - if (!messaging) return; // more "should never happen" words + if (!messaging?.widgetApi) return; // more "should never happen" words - messaging.transport.send(ElementWidgetActions.HangupCall, {}); + messaging.widgetApi.transport.send(ElementWidgetActions.HangupCall, {}); }); } diff --git a/src/Livestream.ts b/src/Livestream.ts index 5fa315b4427..cc8812fc9d1 100644 --- a/src/Livestream.ts +++ b/src/Livestream.ts @@ -41,12 +41,12 @@ async function createLiveStream(matrixClient: MatrixClient, roomId: string): Pro export async function startJitsiAudioLivestream( matrixClient: MatrixClient, - widgetMessaging: ClientWidgetApi, + widgetApi: ClientWidgetApi, roomId: string, ): Promise { const streamId = await createLiveStream(matrixClient, roomId); - await widgetMessaging.transport.send(ElementWidgetActions.StartLiveStream, { + await widgetApi.transport.send(ElementWidgetActions.StartLiveStream, { rtmpStreamKey: AUDIOSTREAM_DUMMY_URL + streamId, }); } diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 5b03d54e175..ff74e25d38f 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX, type ComponentProps, useContext } from "react"; -import { type ClientWidgetApi, type IWidget, MatrixCapabilities } from "matrix-widget-api"; +import { type IWidget, MatrixCapabilities } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; import { type ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; @@ -28,7 +28,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../../Livestream"; import { ModuleRunner } from "../../../modules/ModuleRunner"; -import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; +import { ElementWidget, type WidgetMessaging } from "../../../stores/widgets/WidgetMessaging"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps extends Omit, "children"> { @@ -69,10 +69,10 @@ const showDeleteButton = (canModify: boolean, onDeleteClick: undefined | (() => return !!onDeleteClick || canModify; }; -const showSnapshotButton = (widgetMessaging: ClientWidgetApi | undefined): boolean => { +const showSnapshotButton = (widgetMessaging: WidgetMessaging | undefined): boolean => { return ( SettingsStore.getValue("enableWidgetScreenshots") && - !!widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots) + !!widgetMessaging?.widgetApi?.hasCapability(MatrixCapabilities.Screenshots) ); }; @@ -123,7 +123,7 @@ export const WidgetContextMenu: React.FC = ({ if (roomId && showStreamAudioStreamButton(app)) { const onStreamAudioClick = async (): Promise => { try { - await startJitsiAudioLivestream(cli, widgetMessaging!, roomId); + await startJitsiAudioLivestream(cli, widgetMessaging!.widgetApi!, roomId); } catch (err) { logger.error("Failed to start livestream", err); // XXX: won't i18n well, but looks like widget api only support 'message'? @@ -161,7 +161,7 @@ export const WidgetContextMenu: React.FC = ({ let snapshotButton: JSX.Element | undefined; if (showSnapshotButton(widgetMessaging)) { const onSnapshotClick = (): void => { - widgetMessaging + widgetMessaging?.widgetApi ?.takeScreenshot() .then((data) => { dis.dispatch({ diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index 250a438c135..c0af2b632b2 100644 --- a/src/components/views/dialogs/ModalWidgetDialog.tsx +++ b/src/components/views/dialogs/ModalWidgetDialog.tsx @@ -27,11 +27,11 @@ import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import BaseDialog from "./BaseDialog"; import { _t, getUserLanguage } from "../../../languageHandler"; import AccessibleButton, { type AccessibleButtonKind } from "../elements/AccessibleButton"; -import { StopGapWidgetDriver } from "../../../stores/widgets/StopGapWidgetDriver"; +import { ElementWidgetDriver } from "../../../stores/widgets/ElementWidgetDriver"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; -import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; +import { ElementWidget } from "../../../stores/widgets/WidgetMessaging"; import { ELEMENT_CLIENT_ID } from "../../../identifiers"; import ThemeWatcher, { ThemeWatcherEvent } from "../../../settings/watchers/ThemeWatcher"; @@ -72,7 +72,7 @@ export default class ModalWidgetDialog extends React.PureComponent { showLayoutButtons: true, }; + private readonly widget: ElementWidget; private contextMenuButton = createRef(); private iframeParent: HTMLElement | null = null; // parent div of the iframe private allowedWidgetsWatchRef?: string; private persistKey: string; - private sgWidget?: StopGapWidget; + private messaging?: WidgetMessaging; private dispatcherRef?: string; private unmounted = false; @@ -164,11 +165,16 @@ export default class AppTile extends React.Component { // The key used for PersistedElement this.persistKey = getPersistKey(WidgetUtils.getWidgetUid(this.props.app)); - try { - this.sgWidget = new StopGapWidget(this.props); - } catch (e) { - logger.log("Failed to construct widget", e); - this.sgWidget = undefined; + + this.widget = new ElementWidget(props.app); + this.messaging = WidgetMessagingStore.instance.getMessaging(this.widget, props.room?.roomId); + if (this.messaging === undefined) { + try { + this.messaging = new WidgetMessaging(this.widget, props); + WidgetMessagingStore.instance.storeMessaging(this.widget, props.room?.roomId, this.messaging); + } catch (e) { + logger.log("Failed to construct widget", e); + } } this.state = this.getNewState(props); @@ -235,11 +241,11 @@ export default class AppTile extends React.Component { private determineInitialRequiresClientState(): boolean { try { - const mockWidget = new ElementWidget(this.props.app); - const widgetApi = WidgetMessagingStore.instance.getMessaging(mockWidget, this.props.room?.roomId); - if (widgetApi) { + const widget = new ElementWidget(this.props.app); + const messaging = WidgetMessagingStore.instance.getMessaging(widget, this.props.room?.roomId); + if (messaging?.widgetApi) { // Load value from existing API to prevent resetting the requiresClient value on layout changes. - return widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient); + return messaging.widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient); } } catch { // fallback to true @@ -291,7 +297,7 @@ export default class AppTile extends React.Component { isAppWidget(this.props.app) ? this.props.app.roomId : null, ); PersistedElement.destroyElement(this.persistKey); - this.sgWidget?.stopMessaging(); + this.messaging?.stop(); } this.setState({ hasPermissionToLoad }); @@ -325,12 +331,12 @@ export default class AppTile extends React.Component { ); } - if (this.sgWidget) { - this.setupSgListeners(); + if (this.messaging) { + this.setupMessagingListeners(); } // Only fetch IM token on mount if we're showing and have permission to load - if (this.sgWidget && this.state.hasPermissionToLoad) { + if (this.messaging && this.state.hasPermissionToLoad) { this.startWidget(); } this.watchUserReady(); @@ -376,73 +382,56 @@ export default class AppTile extends React.Component { OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady); } - private setupSgListeners(): void { - this.sgWidget?.on("ready", this.onWidgetReady); - this.sgWidget?.on("error:preparing", this.updateRequiresClient); - // emits when the capabilities have been set up or changed - this.sgWidget?.on("capabilitiesNotified", this.updateRequiresClient); + private setupMessagingListeners(): void { + this.messaging?.on(WidgetMessagingEvent.Start, this.onMessagingStart); + this.messaging?.on(WidgetMessagingEvent.Stop, this.onMessagingStop); } - private stopSgListeners(): void { - if (!this.sgWidget) return; - this.sgWidget?.off("ready", this.onWidgetReady); - this.sgWidget.off("error:preparing", this.updateRequiresClient); - this.sgWidget.off("capabilitiesNotified", this.updateRequiresClient); + private stopMessagingListeners(): void { + this.messaging?.off(WidgetMessagingEvent.Start, this.onMessagingStart); + this.messaging?.off(WidgetMessagingEvent.Stop, this.onMessagingStop); } + private readonly onMessagingStart = (widgetApi: ClientWidgetApi): void => { + widgetApi.on("ready", this.onWidgetReady); + widgetApi.on("error:preparing", this.updateRequiresClient); + // emits when the capabilities have been set up or changed + widgetApi.on("capabilitiesNotified", this.updateRequiresClient); + }; + + private readonly onMessagingStop = (widgetApi: ClientWidgetApi): void => { + widgetApi.off("ready", this.onWidgetReady); + widgetApi.off("error:preparing", this.updateRequiresClient); + widgetApi.off("capabilitiesNotified", this.updateRequiresClient); + }; + private resetWidget(newProps: IProps): void { - this.sgWidget?.stopMessaging(); - this.stopSgListeners(); + this.messaging?.stop(); + this.stopMessagingListeners(); try { - this.sgWidget = new StopGapWidget(newProps); - this.setupSgListeners(); + WidgetMessagingStore.instance.stopMessaging(this.widget, this.props.room?.roomId); + this.messaging = new WidgetMessaging(this.widget, newProps); + WidgetMessagingStore.instance.storeMessaging(this.widget, this.props.room?.roomId, this.messaging); + this.setupMessagingListeners(); this.startWidget(); } catch (e) { logger.error("Failed to construct widget", e); - this.sgWidget = undefined; + this.messaging = undefined; } } private startWidget(): void { - this.sgWidget?.prepare().then(() => { + this.messaging?.prepare().then(() => { if (this.unmounted) return; this.setState({ initialising: false }); }); } /** - * Creates the widget iframe and opens communication with the widget. - */ - private startMessaging(): void { - // We create the iframe ourselves rather than leaving the job to React, - // because we need the lifetime of the messaging and the iframe to be - // the same; we don't want strict mode, for instance, to cause the - // messaging to restart (lose its state) without also killing the widget - const iframe = document.createElement("iframe"); - iframe.title = WidgetUtils.getWidgetName(this.props.app); - iframe.allow = iframeFeatures; - iframe.src = this.sgWidget!.embedUrl; - iframe.allowFullscreen = true; - iframe.sandbox = sandboxFlags; - this.iframeParent!.appendChild(iframe); - // In order to start the widget messaging we need iframe.contentWindow - // to exist. Waiting until the next layout gives the browser a chance to - // initialize it. - requestAnimationFrame(() => { - // Handle the race condition (seen in strict mode) where the element - // is added and then removed before we enter this callback - if (iframe.parentElement === null) return; - try { - this.sgWidget?.startMessaging(iframe); - } catch (e) { - logger.error("Failed to start widget", e); - } - }); - } - - /** - * Callback ref for the parent div of the iframe. + * A callback ref receiving the current parent div of the iframe. This is + * responsible for creating the iframe and starting or resetting + * communication with the widget. */ private iframeParentRef = (element: HTMLElement | null): void => { if (this.unmounted) return; @@ -451,10 +440,43 @@ export default class AppTile extends React.Component { this.iframeParent?.querySelector("iframe")?.remove(); this.iframeParent = element; - if (element && this.sgWidget) { - this.startMessaging(); - } else { + if (this.iframeParent === null) { + // The component is trying to unmount the iframe. We could reach + // this path if the widget definition was updated, for example. The + // iframe parent will later be remounted and widget communications + // reopened after this.state.initializing resets to false. this.resetWidget(this.props); + } else if ( + this.messaging && + // Check whether an iframe already exists (it totally could exist, + // seeing as it is a persisted element which might have hopped + // between React components) + this.iframeParent.querySelector("iframe") === null + ) { + // We create the iframe ourselves rather than leaving the job to React, + // because we need the lifetime of the messaging and the iframe to be + // the same; we don't want strict mode, for instance, to cause the + // messaging to restart (lose its state) without also killing the widget + const iframe = document.createElement("iframe"); + iframe.title = WidgetUtils.getWidgetName(this.props.app); + iframe.allow = iframeFeatures; + iframe.src = this.messaging.embedUrl; + iframe.allowFullscreen = true; + iframe.sandbox = sandboxFlags; + this.iframeParent.appendChild(iframe); + // In order to start the widget messaging we need iframe.contentWindow + // to exist. Waiting until the next layout gives the browser a chance to + // initialize it. + requestAnimationFrame(() => { + // Handle the race condition (seen in strict mode) where the element is + // added and then removed from the DOM before we enter this callback + if (iframe.parentElement === null) return; + try { + this.messaging?.start(iframe); + } catch (e) { + logger.error("Failed to start widget", e); + } + }); } }; @@ -484,7 +506,7 @@ export default class AppTile extends React.Component { isAppWidget(this.props.app) ? this.props.app.roomId : null, ); - this.sgWidget?.stopMessaging({ forceDestroy: true }); + this.messaging?.stop({ forceDestroy: true }); } private onWidgetReady = (): void => { @@ -493,7 +515,7 @@ export default class AppTile extends React.Component { private updateRequiresClient = (): void => { this.setState({ - requiresClient: !!this.sgWidget?.widgetApi?.hasCapability(ElementWidgetCapabilities.RequiresClient), + requiresClient: !!this.messaging?.widgetApi?.hasCapability(ElementWidgetCapabilities.RequiresClient), }); }; @@ -502,7 +524,7 @@ export default class AppTile extends React.Component { case "m.sticker": if ( payload.widgetId === this.props.app.id && - this.sgWidget?.widgetApi?.hasCapability(MatrixCapabilities.StickerSending) + this.messaging?.widgetApi?.hasCapability(MatrixCapabilities.StickerSending) ) { dis.dispatch({ action: "post_sticker_message", @@ -602,7 +624,7 @@ export default class AppTile extends React.Component { // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement("a"), { target: "_blank", - href: this.sgWidget?.popoutUrl, + href: this.messaging?.popoutUrl, rel: "noreferrer noopener", }).click(); }; @@ -665,13 +687,13 @@ export default class AppTile extends React.Component { ); - if (this.sgWidget === null) { + if (this.messaging === null) { appTileBody = (
); - } else if (!this.state.hasPermissionToLoad && this.props.room && this.sgWidget) { + } else if (!this.state.hasPermissionToLoad && this.props.room && this.messaging) { // only possible for room widgets, can assert this.props.room here const isEncrypted = this.context.isRoomEncrypted(this.props.room.roomId); appTileBody = ( @@ -679,7 +701,7 @@ export default class AppTile extends React.Component { @@ -698,7 +720,7 @@ export default class AppTile extends React.Component { ); - } else if (this.sgWidget) { + } else if (this.messaging) { appTileBody = ( <>
diff --git a/src/components/views/pips/WidgetPip.tsx b/src/components/views/pips/WidgetPip.tsx index ed49552cf06..5fee35ee0fd 100644 --- a/src/components/views/pips/WidgetPip.tsx +++ b/src/components/views/pips/WidgetPip.tsx @@ -90,7 +90,7 @@ export const WidgetPip: FC = ({ widgetId, room, viewingRoom, onStartMovin // Assumed to be a Jitsi widget WidgetMessagingStore.instance .getMessagingForUid(WidgetUtils.getWidgetUid(widget)) - ?.transport.send(ElementWidgetActions.HangupCall, {}) + ?.widgetApi?.transport.send(ElementWidgetActions.HangupCall, {}) .catch((e) => console.error("Failed to leave Jitsi", e)); } }, diff --git a/src/components/views/rooms/Stickerpicker.tsx b/src/components/views/rooms/Stickerpicker.tsx index 6281f287baa..bbf3f49685a 100644 --- a/src/components/views/rooms/Stickerpicker.tsx +++ b/src/components/views/rooms/Stickerpicker.tsx @@ -224,8 +224,8 @@ export default class Stickerpicker extends React.PureComponent { const messaging = WidgetMessagingStore.instance.getMessagingForUid( WidgetUtils.calcWidgetUid(this.state.stickerpickerWidget.id), ); - if (messaging && visible !== this.prevSentVisibility) { - messaging.updateVisibility(visible).catch((err) => { + if (messaging?.widgetApi && visible !== this.prevSentVisibility) { + messaging.widgetApi.updateVisibility(visible).catch((err) => { logger.error("Error updating widget visibility: ", err); }); this.prevSentVisibility = visible; diff --git a/src/models/Call.ts b/src/models/Call.ts index 1882eec9640..521eaff0288 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -43,6 +43,7 @@ import { FontWatcher } from "../settings/watchers/FontWatcher"; import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-types"; import SdkConfig from "../SdkConfig.ts"; import DMRoomMap from "../utils/DMRoomMap.ts"; +import { type WidgetMessaging, WidgetMessagingEvent } from "../stores/widgets/WidgetMessaging.ts"; const TIMEOUT_MS = 16000; @@ -122,15 +123,15 @@ export abstract class Call extends TypedEventEmitter { const messagingStore = WidgetMessagingStore.instance; - this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null; - if (!this.messaging) { - // The widget might still be initializing, so wait for it. - try { - await waitForEvent( - messagingStore, - WidgetMessagingStoreEvent.StoreMessaging, - (uid: string, widgetApi: ClientWidgetApi) => { - if (uid === this.widgetUid) { - this.messaging = widgetApi; - return true; - } - return false; - }, + let messaging = messagingStore.getMessagingForUid(this.widgetUid); + const startTime = performance.now(); + // The widget might still be initializing, so wait for it in an async + // event loop. We need the messaging to be both present and started, so + // we register listeners for both cases. Note that due to React strict + // mode, the messaging could even be aborted and replaced by an entirely + // new messaging while we are waiting here! + while (!messaging?.widgetApi) { + await new Promise((resolve, reject) => { + const onStart = (): void => resolve(); + messaging?.on(WidgetMessagingEvent.Start, onStart); + + const onStoreMessaging = (uid: string, m: WidgetMessaging): void => { + if (uid === this.widgetUid) { + messaging = m; + resolve(); + } + }; + messagingStore.on(WidgetMessagingStoreEvent.StoreMessaging, onStoreMessaging); + + setTimeout( + () => + reject( + new Error( + `Messaging for call in ${this.roomId} ${messaging ? "did not start" : "not present"}; timed out`, + ), + ), + startTime + TIMEOUT_MS - performance.now(), ); - } catch (e) { - throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`); - } + }); } + this.widgetApi = messaging.widgetApi; } protected setConnected(): void { @@ -267,7 +281,7 @@ export abstract class Call extends TypedEventEmitter { await super.start(); - this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); - this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.widgetApi!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + this.widgetApi!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock); } protected async performDisconnection(): Promise { const response = waitForEvent( - this.messaging!, + this.widgetApi!, `action:${ElementWidgetActions.HangupCall}`, (ev: CustomEvent) => { ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack + this.widgetApi!.transport.reply(ev.detail, {}); // ack return true; }, ); - const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); + const request = this.widgetApi!.transport.send(ElementWidgetActions.HangupCall, {}); try { await Promise.all([request, response]); } catch (e) { @@ -475,8 +489,8 @@ export class JitsiCall extends Call { } public close(): void { - this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); - this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.widgetApi!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + this.widgetApi!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock); super.close(); @@ -527,18 +541,18 @@ export class JitsiCall extends Call { private readonly onDock = async (): Promise => { // The widget is no longer a PiP, so let's restore the default layout - await this.messaging!.transport.send(ElementWidgetActions.TileLayout, {}); + await this.widgetApi!.transport.send(ElementWidgetActions.TileLayout, {}); }; private readonly onUndock = async (): Promise => { // The widget has become a PiP, so let's switch Jitsi to spotlight mode // to only show the active speaker and economize on space - await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {}); + await this.widgetApi!.transport.send(ElementWidgetActions.SpotlightLayout, {}); }; private readonly onJoin = (ev: CustomEvent): void => { ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack + this.widgetApi!.transport.reply(ev.detail, {}); // ack this.setConnected(); }; @@ -548,7 +562,7 @@ export class JitsiCall extends Call { if (this.connectionState === ConnectionState.Disconnecting) return; ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack + this.widgetApi!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); if (!isVideoRoom(this.room)) this.close(); }; @@ -899,23 +913,23 @@ export class ElementCall extends Call { this.widgetGenerationParameters, ).toString(); await super.start(); - this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); - this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.messaging!.on(`action:${ElementWidgetActions.Close}`, this.onClose); - this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); + this.widgetApi!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + this.widgetApi!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.widgetApi!.on(`action:${ElementWidgetActions.Close}`, this.onClose); + this.widgetApi!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); } protected async performDisconnection(): Promise { const response = waitForEvent( - this.messaging!, + this.widgetApi!, `action:${ElementWidgetActions.HangupCall}`, (ev: CustomEvent) => { ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack + this.widgetApi!.transport.reply(ev.detail, {}); // ack return true; }, ); - const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); + const request = this.widgetApi!.transport.send(ElementWidgetActions.HangupCall, {}); try { await Promise.all([request, response]); } catch (e) { @@ -924,10 +938,10 @@ export class ElementCall extends Call { } public close(): void { - this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); - this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); - this.messaging!.off(`action:${ElementWidgetActions.Close}`, this.onClose); - this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); + this.widgetApi!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + this.widgetApi!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.widgetApi!.off(`action:${ElementWidgetActions.Close}`, this.onClose); + this.widgetApi!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); super.close(); } @@ -975,12 +989,12 @@ export class ElementCall extends Call { private readonly onDeviceMute = (ev: CustomEvent): void => { ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack + this.widgetApi!.transport.reply(ev.detail, {}); // ack }; private readonly onJoin = (ev: CustomEvent): void => { ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack + this.widgetApi!.transport.reply(ev.detail, {}); // ack this.setConnected(); }; @@ -990,13 +1004,13 @@ export class ElementCall extends Call { if (this.connectionState === ConnectionState.Disconnecting) return; ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack + this.widgetApi!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); }; private readonly onClose = async (ev: CustomEvent): Promise => { ev.preventDefault(); - this.messaging!.transport.reply(ev.detail, {}); // ack + this.widgetApi!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); // Just in case the widget forgot to emit a hangup action (maybe it's in an error state) this.close(); // User is done with the call; tell the UI to close it }; diff --git a/src/stores/ModalWidgetStore.ts b/src/stores/ModalWidgetStore.ts index 2a2fbdd8da5..76db49e5dcf 100644 --- a/src/stores/ModalWidgetStore.ts +++ b/src/stores/ModalWidgetStore.ts @@ -88,11 +88,11 @@ export class ModalWidgetStore extends AsyncStoreWithClient { this.modalInstance = null; const sourceMessaging = WidgetMessagingStore.instance.getMessaging(sourceWidget, widgetRoomId); - if (!sourceMessaging) { - logger.error("No source widget messaging for modal widget"); + if (!sourceMessaging?.widgetApi) { + logger.error("No source widget API for modal widget"); return; } - sourceMessaging.notifyModalWidgetClose(data); + sourceMessaging.widgetApi.notifyModalWidgetClose(data); } }; } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/ElementWidgetDriver.ts similarity index 98% rename from src/stores/widgets/StopGapWidgetDriver.ts rename to src/stores/widgets/ElementWidgetDriver.ts index 42abdc801db..e341f7627c0 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/ElementWidgetDriver.ts @@ -65,8 +65,6 @@ import { ModuleRunner } from "../../modules/ModuleRunner"; import SettingsStore from "../../settings/SettingsStore"; import { mediaFromMxc } from "../../customisations/Media"; -// TODO: Purge this from the universe - function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] { return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]"); } @@ -81,12 +79,19 @@ const normalizeTurnServer = ({ urls, username, credential }: IClientTurnServer): password: credential, }); -export class StopGapWidgetDriver extends WidgetDriver { +/** + * Element Web's implementation of a widget driver (the object that + * matrix-widget-api uses to retrieve information from the client and carry out + * authorized actions on the widget's behalf). Essentially this is a glorified + * set of callbacks. + */ +// TODO: Consider alternative designs for matrix-widget-api? +// Replace with matrix-rust-sdk? +export class ElementWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; // TODO: Refactor widgetKind into the Widget class public constructor( - allowedCapabilities: Capability[], private forWidget: Widget, private forWidgetKind: WidgetKind, virtual: boolean, @@ -97,11 +102,7 @@ export class StopGapWidgetDriver extends WidgetDriver { // Always allow screenshots to be taken because it's a client-induced flow. The widget can't // spew screenshots at us and can't request screenshots of us, so it's up to us to provide the // button if the widget says it supports screenshots. - this.allowedCapabilities = new Set([ - ...allowedCapabilities, - MatrixCapabilities.Screenshots, - ElementWidgetCapabilities.RequiresClient, - ]); + this.allowedCapabilities = new Set([MatrixCapabilities.Screenshots, ElementWidgetCapabilities.RequiresClient]); // Grant the permissions that are specific to given widget types if (WidgetType.JITSI.matches(this.forWidget.type) && forWidgetKind === WidgetKind.Room) { diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/WidgetMessaging.ts similarity index 82% rename from src/stores/widgets/StopGapWidget.ts rename to src/stores/widgets/WidgetMessaging.ts index c8de3bf0f13..58b487bffb8 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/WidgetMessaging.ts @@ -14,6 +14,7 @@ import { ClientEvent, RoomStateEvent, type ReceivedToDeviceMessage, + TypedEventEmitter, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { @@ -34,11 +35,10 @@ import { WidgetApiFromWidgetAction, WidgetKind, } from "matrix-widget-api"; -import { EventEmitter } from "events"; import { logger } from "matrix-js-sdk/src/logger"; import { _t, getUserLanguage } from "../../languageHandler"; -import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; +import { ElementWidgetDriver } from "./ElementWidgetDriver"; import { WidgetMessagingStore } from "./WidgetMessagingStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { OwnProfileStore } from "../OwnProfileStore"; @@ -46,12 +46,11 @@ import WidgetUtils from "../../utils/WidgetUtils"; import { IntegrationManagers } from "../../integrations/IntegrationManagers"; import { WidgetType } from "../../widgets/WidgetType"; import ActiveWidgetStore from "../ActiveWidgetStore"; -import { objectShallowClone } from "../../utils/objects"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; import { ElementWidgetActions, type IHangupCallApiRequest, type IViewRoomApiRequest } from "./ElementWidgetActions"; import { ModalWidgetStore } from "../ModalWidgetStore"; -import { type IApp, isAppWidget } from "../WidgetStore"; +import { isAppWidget } from "../WidgetStore"; import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { getCustomTheme } from "../../theme"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; @@ -64,21 +63,10 @@ import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; import { SdkContextClass } from "../../contexts/SDKContext"; import { UPDATE_EVENT } from "../AsyncStore"; -// TODO: Destroy all of this code +// TODO: Purge this code of its overgrown hacks and compatibility shims. -interface IAppTileProps { - // Note: these are only the props we care about - app: IApp | IWidget; - room?: Room; // without a room it is a user widget - userId: string; - creatorUserId: string; - waitForIframeLoad: boolean; - whitelistCapabilities?: string[]; - userWidget: boolean; - stickyPromise?: () => Promise; -} - -// TODO: Don't use this because it's wrong +// TODO: Don't use this. We should avoid overriding/mocking matrix-widget-api +// behavior and instead strive to use widgets in more transparent ways. export class ElementWidget extends Widget { public constructor(private rawDefinition: IWidget) { super(rawDefinition); @@ -152,11 +140,38 @@ export class ElementWidget extends Widget { } } -export class StopGapWidget extends EventEmitter { +export enum WidgetMessagingEvent { + Start = "start", + Stop = "stop", +} + +interface WidgetMessagingEventMap { + [WidgetMessagingEvent.Start]: (widgetApi: ClientWidgetApi) => void; + [WidgetMessagingEvent.Stop]: (widgetApi: ClientWidgetApi) => void; +} + +interface WidgetMessagingOptions { + app: IWidget; + room?: Room; // without a room it is a user widget + userId: string; + creatorUserId: string; + waitForIframeLoad: boolean; + userWidget: boolean; + /** + * If defined this async method will be called when the widget requests to become sticky. + * It will only become sticky once the returned promise resolves. + * This is useful because: Widget B is sticky. Making widget A sticky will kill widget B immediately. + * This promise allows to do Widget B related cleanup before Widget A becomes sticky. (e.g. hangup a Voip call) + */ + stickyPromise?: () => Promise; +} + +/** + * A running instance of a widget, associated with an iframe and a messaging transport. + */ +export class WidgetMessaging extends TypedEventEmitter { private client: MatrixClient; private iframe: HTMLIFrameElement | null = null; - private messaging: ClientWidgetApi | null = null; - private mockWidget: ElementWidget; private scalarToken?: string; private roomId?: string; // The room that we're currently allowing the widget to interact with. Only @@ -171,26 +186,24 @@ export class StopGapWidget extends EventEmitter { // Holds events that should be fed to the widget once they finish decrypting private readonly eventsToFeed = new WeakSet(); - public constructor(private appTileProps: IAppTileProps) { + public constructor( + private readonly widget: ElementWidget, + options: WidgetMessagingOptions, + ) { super(); this.client = MatrixClientPeg.safeGet(); - - let app = appTileProps.app; - // Backwards compatibility: not all old widgets have a creatorUserId - if (!app.creatorUserId) { - app = objectShallowClone(app); // clone to prevent accidental mutation - app.creatorUserId = this.client.getUserId()!; - } - - this.mockWidget = new ElementWidget(app); - this.roomId = appTileProps.room?.roomId; - this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably - this.virtual = isAppWidget(app) && app.eventId === undefined; - this.stickyPromise = appTileProps.stickyPromise; + this.roomId = options.room?.roomId; + this.kind = options.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably + this.virtual = isAppWidget(options.app) && options.app.eventId === undefined; + this.stickyPromise = options.stickyPromise; } + private _widgetApi: ClientWidgetApi | null = null; + private set widgetApi(value: ClientWidgetApi | null) { + this._widgetApi = value; + } public get widgetApi(): ClientWidgetApi | null { - return this.messaging; + return this._widgetApi; } /** @@ -220,7 +233,7 @@ export class StopGapWidget extends EventEmitter { deviceId: this.client.getDeviceId() ?? undefined, baseUrl: this.client.baseUrl, }; - const templated = this.mockWidget.getCompleteUrl(Object.assign(defaults, fromCustomisation), opts?.asPopout); + const templated = this.widget.getCompleteUrl(Object.assign(defaults, fromCustomisation), opts?.asPopout); const parsed = new URL(templated); @@ -228,7 +241,7 @@ export class StopGapWidget extends EventEmitter { // TODO: Replace these with proper widget params // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833 if (!opts?.asPopout) { - parsed.searchParams.set("widgetId", this.mockWidget.id); + parsed.searchParams.set("widgetId", this.widget.id); parsed.searchParams.set("parentUrl", window.location.href.split("#", 2)[0]); // Give the widget a scalar token if we're supposed to (more legacy) @@ -244,16 +257,16 @@ export class StopGapWidget extends EventEmitter { } private onThemeChange = (theme: string): void => { - this.messaging?.updateTheme({ name: theme }); + this.widgetApi?.updateTheme({ name: theme }); }; private onOpenModal = async (ev: CustomEvent): Promise => { ev.preventDefault(); if (ModalWidgetStore.instance.canOpenModalWidget()) { - ModalWidgetStore.instance.openModalWidget(ev.detail.data, this.mockWidget, this.roomId); - this.messaging?.transport.reply(ev.detail, {}); // ack + ModalWidgetStore.instance.openModalWidget(ev.detail.data, this.widget, this.roomId); + this.widgetApi?.transport.reply(ev.detail, {}); // ack } else { - this.messaging?.transport.reply(ev.detail, { + this.widgetApi?.transport.reply(ev.detail, { error: { message: "Unable to open modal at this time", }, @@ -266,7 +279,7 @@ export class StopGapWidget extends EventEmitter { private onRoomViewStoreUpdate = (): void => { const roomId = SdkContextClass.instance.roomViewStore.getRoomId() ?? null; if (roomId !== this.viewedRoomId) { - this.messaging!.setViewedRoomId(roomId); + this.widgetApi!.setViewedRoomId(roomId); this.viewedRoomId = roomId; } }; @@ -275,60 +288,47 @@ export class StopGapWidget extends EventEmitter { * This starts the messaging for the widget if it is not in the state `started` yet. * @param iframe the iframe the widget should use */ - public startMessaging(iframe: HTMLIFrameElement): void { - if (this.messaging !== null) return; + public start(iframe: HTMLIFrameElement): void { + if (this.widgetApi !== null) return; this.iframe = iframe; - const allowedCapabilities = this.appTileProps.whitelistCapabilities || []; - const driver = new StopGapWidgetDriver( - allowedCapabilities, - this.mockWidget, - this.kind, - this.virtual, - this.roomId, - ); - - this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); - this.messaging.on("preparing", () => this.emit("preparing")); - this.messaging.on("error:preparing", (err: unknown) => this.emit("error:preparing", err)); - this.messaging.once("ready", () => { - WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging!); - this.emit("ready"); + const driver = new ElementWidgetDriver(this.widget, this.kind, this.virtual, this.roomId); + this.widgetApi = new ClientWidgetApi(this.widget, iframe, driver); + this.widgetApi.once("ready", () => { this.themeWatcher.start(); this.themeWatcher.on(ThemeWatcherEvent.Change, this.onThemeChange); // Theme may have changed while messaging was starting this.onThemeChange(this.themeWatcher.getEffectiveTheme()); }); - this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); - this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); + this.widgetApi.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); // When widgets are listening to events, we need to make sure they're only // receiving events for the right room if (this.roomId === undefined) { // Account widgets listen to the currently active room - this.messaging.setViewedRoomId(SdkContextClass.instance.roomViewStore.getRoomId() ?? null); + this.widgetApi.setViewedRoomId(SdkContextClass.instance.roomViewStore.getRoomId() ?? null); SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate); } else { // Room widgets get locked to the room they were added in - this.messaging.setViewedRoomId(this.roomId); + this.widgetApi.setViewedRoomId(this.roomId); } // Always attach a handler for ViewRoom, but permission check it internally - this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { + this.widgetApi.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { ev.preventDefault(); // stop the widget API from auto-rejecting this // Check up front if this is even a valid request const targetRoomId = (ev.detail.data || {}).room_id; if (!targetRoomId) { - return this.messaging?.transport.reply(ev.detail, { + return this.widgetApi?.transport.reply(ev.detail, { error: { message: "Room ID not supplied." }, }); } // Check the widget's permission - if (!this.messaging?.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) { - return this.messaging?.transport.reply(ev.detail, { + if (!this.widgetApi?.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) { + return this.widgetApi?.transport.reply(ev.detail, { error: { message: "This widget does not have permission for this action (denied)." }, }); } @@ -341,7 +341,7 @@ export class StopGapWidget extends EventEmitter { }); // acknowledge so the widget doesn't freak out - this.messaging.transport.reply(ev.detail, {}); + this.widgetApi.transport.reply(ev.detail, {}); }); // Populate the map of "read up to" events for this widget with the current event in every room. @@ -361,10 +361,10 @@ export class StopGapWidget extends EventEmitter { this.client.on(RoomStateEvent.Events, this.onStateUpdate); this.client.on(ClientEvent.ReceivedToDeviceMessage, this.onToDeviceMessage); - this.messaging.on( + this.widgetApi.on( `action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, async (ev: CustomEvent) => { - if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { + if (this.widgetApi?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { ev.preventDefault(); if (ev.detail.data.value) { // If the widget wants to become sticky we wait for the stickyPromise to resolve @@ -372,43 +372,43 @@ export class StopGapWidget extends EventEmitter { } // Stop being persistent can be done instantly ActiveWidgetStore.instance.setWidgetPersistence( - this.mockWidget.id, + this.widget.id, this.roomId ?? null, ev.detail.data.value, ); // Send the ack after the widget actually has become sticky. - this.messaging.transport.reply(ev.detail, {}); + this.widgetApi.transport.reply(ev.detail, {}); } }, ); // TODO: Replace this event listener with appropriate driver functionality once the API // establishes a sane way to send events back and forth. - this.messaging.on( + this.widgetApi.on( `action:${WidgetApiFromWidgetAction.SendSticker}`, (ev: CustomEvent) => { - if (this.messaging?.hasCapability(MatrixCapabilities.StickerSending)) { + if (this.widgetApi?.hasCapability(MatrixCapabilities.StickerSending)) { // Acknowledge first ev.preventDefault(); - this.messaging.transport.reply(ev.detail, {}); + this.widgetApi.transport.reply(ev.detail, {}); // Send the sticker defaultDispatcher.dispatch({ action: "m.sticker", data: ev.detail.data, - widgetId: this.mockWidget.id, + widgetId: this.widget.id, }); } }, ); - if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) { - this.messaging.on( + if (WidgetType.STICKERPICKER.matches(this.widget.type)) { + this.widgetApi.on( `action:${ElementWidgetActions.OpenIntegrationManager}`, (ev: CustomEvent) => { // Acknowledge first ev.preventDefault(); - this.messaging?.transport.reply(ev.detail, {}); + this.widgetApi?.transport.reply(ev.detail, {}); // First close the stickerpicker defaultDispatcher.dispatch({ action: "stickerpicker_close" }); @@ -429,8 +429,8 @@ export class StopGapWidget extends EventEmitter { ); } - if (WidgetType.JITSI.matches(this.mockWidget.type)) { - this.messaging.on(`action:${ElementWidgetActions.HangupCall}`, (ev: CustomEvent) => { + if (WidgetType.JITSI.matches(this.widget.type)) { + this.widgetApi.on(`action:${ElementWidgetActions.HangupCall}`, (ev: CustomEvent) => { ev.preventDefault(); if (ev.detail.data?.errorMessage) { Modal.createDialog(ErrorDialog, { @@ -440,9 +440,11 @@ export class StopGapWidget extends EventEmitter { }), }); } - this.messaging?.transport.reply(ev.detail, {}); + this.widgetApi?.transport.reply(ev.detail, {}); }); } + + this.emit(WidgetMessagingEvent.Start, this.widgetApi); } public async prepare(): Promise { @@ -450,10 +452,8 @@ export class StopGapWidget extends EventEmitter { await (WidgetVariableCustomisations?.isReady?.() ?? Promise.resolve()); if (this.scalarToken) return; - const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget, this.roomId); - if (existingMessaging) this.messaging = existingMessaging; try { - if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) { + if (WidgetUtils.isScalarUrl(this.widget.templateUrl)) { const managers = IntegrationManagers.sharedInstance(); if (managers.hasManager()) { // TODO: Pick the right manager for the widget @@ -475,8 +475,8 @@ export class StopGapWidget extends EventEmitter { * widget. * @param opts */ - public stopMessaging(opts = { forceDestroy: false }): void { - if (this.messaging === null || this.iframe === null) return; + public stop(opts = { forceDestroy: false }): void { + if (this.widgetApi === null || this.iframe === null) return; if (opts.forceDestroy) { // HACK: This is a really dirty way to ensure that Jitsi cleans up // its hold on the webcam. Without this, the widget holds a media @@ -487,15 +487,16 @@ export class StopGapWidget extends EventEmitter { // at a page that is reasonably safe to use in the event the iframe // doesn't wink away. this.iframe!.src = "about:blank"; - } else if (ActiveWidgetStore.instance.getWidgetPersistence(this.mockWidget.id, this.roomId ?? null)) { + } else if (ActiveWidgetStore.instance.getWidgetPersistence(this.widget.id, this.roomId ?? null)) { logger.log("Skipping destroy - persistent widget"); return; } - WidgetMessagingStore.instance.stopMessaging(this.mockWidget, this.roomId); - this.messaging?.removeAllListeners(); // Guard against the 'ready' event firing after stopping - this.messaging = null; + this.emit(WidgetMessagingEvent.Stop, this.widgetApi); + this.widgetApi?.removeAllListeners(); // Insurance against resource leaks + this.widgetApi = null; this.iframe = null; + WidgetMessagingStore.instance.stopMessaging(this.widget, this.roomId); SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate); @@ -515,9 +516,9 @@ export class StopGapWidget extends EventEmitter { }; private onStateUpdate = (ev: MatrixEvent): void => { - if (this.messaging === null) return; + if (this.widgetApi === null) return; const raw = ev.getEffectiveEvent(); - this.messaging.feedStateUpdate(raw as IRoomEvent).catch((e) => { + this.widgetApi.feedStateUpdate(raw as IRoomEvent).catch((e) => { logger.error("Error sending state update to widget: ", e); }); }; @@ -525,7 +526,7 @@ export class StopGapWidget extends EventEmitter { private onToDeviceMessage = async (payload: ReceivedToDeviceMessage): Promise => { const { message, encryptionInfo } = payload; // TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent - await this.messaging?.feedToDevice(message as IRoomEvent, encryptionInfo != null); + await this.widgetApi?.feedToDevice(message as IRoomEvent, encryptionInfo != null); }; /** @@ -592,7 +593,7 @@ export class StopGapWidget extends EventEmitter { } private feedEvent(ev: MatrixEvent): void { - if (this.messaging === null) return; + if (this.widgetApi === null) return; if ( // If we had decided earlier to feed this event to the widget, but // it just wasn't ready, give it another try @@ -621,7 +622,7 @@ export class StopGapWidget extends EventEmitter { this.eventsToFeed.add(ev); } else { const raw = ev.getEffectiveEvent(); - this.messaging.feedEvent(raw as IRoomEvent).catch((e) => { + this.widgetApi.feedEvent(raw as IRoomEvent).catch((e) => { logger.error("Error sending event to widget: ", e); }); } diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index 2dcf8c4fdc6..a9ec3d765f7 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { type ClientWidgetApi, type Widget } from "matrix-widget-api"; +import { type Widget } from "matrix-widget-api"; import { type EmptyObject } from "matrix-js-sdk/src/matrix"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -14,6 +14,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher"; import { type ActionPayload } from "../../dispatcher/payloads"; import { EnhancedMap } from "../../utils/maps"; import WidgetUtils from "../../utils/WidgetUtils"; +import { type WidgetMessaging } from "./WidgetMessaging"; export enum WidgetMessagingStoreEvent { StoreMessaging = "store_messaging", @@ -32,7 +33,7 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { return instance; })(); - private widgetMap = new EnhancedMap(); // + private widgetMap = new EnhancedMap(); // public constructor() { super(defaultDispatcher); @@ -51,19 +52,19 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { this.widgetMap.clear(); } - public storeMessaging(widget: Widget, roomId: string | undefined, widgetApi: ClientWidgetApi): void { + public storeMessaging(widget: Widget, roomId: string | undefined, messaging: WidgetMessaging): void { this.stopMessaging(widget, roomId); const uid = WidgetUtils.calcWidgetUid(widget.id, roomId); - this.widgetMap.set(uid, widgetApi); + this.widgetMap.set(uid, messaging); - this.emit(WidgetMessagingStoreEvent.StoreMessaging, uid, widgetApi); + this.emit(WidgetMessagingStoreEvent.StoreMessaging, uid, messaging); } public stopMessaging(widget: Widget, roomId: string | undefined): void { this.stopMessagingByUid(WidgetUtils.calcWidgetUid(widget.id, roomId)); } - public getMessaging(widget: Widget, roomId: string | undefined): ClientWidgetApi | undefined { + public getMessaging(widget: Widget, roomId: string | undefined): WidgetMessaging | undefined { return this.widgetMap.get(WidgetUtils.calcWidgetUid(widget.id, roomId)); } @@ -84,7 +85,7 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { * @param {string} widgetUid The widget UID. * @returns {ClientWidgetApi} The widget API, or a falsy value if not found. */ - public getMessagingForUid(widgetUid: string): ClientWidgetApi | undefined { + public getMessagingForUid(widgetUid: string): WidgetMessaging | undefined { return this.widgetMap.get(widgetUid); } } diff --git a/test/unit-tests/components/structures/PipContainer-test.tsx b/test/unit-tests/components/structures/PipContainer-test.tsx index e7b7e958627..61456ba2ee4 100644 --- a/test/unit-tests/components/structures/PipContainer-test.tsx +++ b/test/unit-tests/components/structures/PipContainer-test.tsx @@ -49,6 +49,7 @@ import WidgetStore from "../../../../src/stores/WidgetStore"; import { WidgetType } from "../../../../src/widgets/WidgetType"; import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions"; +import { type WidgetMessaging } from "../../../../src/stores/widgets/WidgetMessaging"; jest.mock("../../../../src/stores/OwnProfileStore", () => ({ OwnProfileStore: { @@ -148,30 +149,40 @@ describe("PipContainer", () => { await act(async () => { WidgetStore.instance.addVirtualWidget(call.widget, room.roomId); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + on: () => {}, + off: () => {}, + prepare: async () => {}, stop: () => {}, - hasCapability: jest.fn(), - feedStateUpdate: jest.fn().mockResolvedValue(undefined), - } as unknown as ClientWidgetApi); + widgetApi: { + hasCapability: jest.fn(), + feedStateUpdate: jest.fn().mockResolvedValue(undefined), + }, + } as unknown as WidgetMessaging); await call.start(); ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true); }); - await fn(call); - - cleanup(); - act(() => { - call.destroy(); - ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); - WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId); - }); + try { + await fn(call); + } finally { + cleanup(); + act(() => { + call.destroy(); + ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); + WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId); + }); + } }; const withWidget = async (fn: () => Promise): Promise => { act(() => ActiveWidgetStore.instance.setWidgetPersistence("1", room.roomId, true)); - await fn(); - cleanup(); - ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId); + try { + await fn(); + } finally { + cleanup(); + ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId); + } }; const setUpRoomViewStore = () => { @@ -275,9 +286,13 @@ describe("PipContainer", () => { >() .mockResolvedValue({}); const mockMessaging = { - transport: { send: sendSpy }, + on: () => {}, + off: () => {}, stop: () => {}, - } as unknown as ClientWidgetApi; + widgetApi: { + transport: { send: sendSpy }, + }, + } as unknown as WidgetMessaging; WidgetMessagingStore.instance.storeMessaging(new Widget(widget), room.roomId, mockMessaging); await user.click(screen.getByRole("button", { name: "Leave" })); expect(sendSpy).toHaveBeenCalledWith(ElementWidgetActions.HangupCall, {}); diff --git a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx index f48ad2e3537..caa5b23810d 100644 --- a/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx +++ b/test/unit-tests/components/views/beacon/RoomCallBanner-test.tsx @@ -14,7 +14,7 @@ import { type RoomMember, RoomStateEvent, } from "matrix-js-sdk/src/matrix"; -import { type ClientWidgetApi, Widget } from "matrix-widget-api"; +import { Widget } from "matrix-widget-api"; import { act, cleanup, render, screen } from "jest-matrix-react"; import { mocked, type Mocked } from "jest-mock"; @@ -32,6 +32,7 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { ConnectionState } from "../../../../../src/models/Call"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; import RoomContext, { type RoomContextType } from "../../../../../src/contexts/RoomContext"; +import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging"; describe("", () => { let client: Mocked; @@ -115,7 +116,7 @@ describe("", () => { widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, - } as unknown as ClientWidgetApi); + } as unknown as WidgetMessaging); }); afterEach(() => { cleanup(); // Unmount before we do any cleanup that might update the component diff --git a/test/unit-tests/components/views/elements/AppTile-test.tsx b/test/unit-tests/components/views/elements/AppTile-test.tsx index e62d1adab9e..bd469bc5743 100644 --- a/test/unit-tests/components/views/elements/AppTile-test.tsx +++ b/test/unit-tests/components/views/elements/AppTile-test.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { Room, type MatrixClient } from "matrix-js-sdk/src/matrix"; -import { type ClientWidgetApi, type IWidget, MatrixWidgetType } from "matrix-widget-api"; +import { type IWidget, MatrixWidgetType } from "matrix-widget-api"; import { type Optional } from "matrix-events-sdk"; import { act, render, type RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; @@ -35,7 +35,7 @@ import AppTile from "../../../../../src/components/views/elements/AppTile"; import { Container, WidgetLayoutStore } from "../../../../../src/stores/widgets/WidgetLayoutStore"; import AppsDrawer from "../../../../../src/components/views/rooms/AppsDrawer"; import { ElementWidgetCapabilities } from "../../../../../src/stores/widgets/ElementWidgetCapabilities"; -import { ElementWidget } from "../../../../../src/stores/widgets/StopGapWidget"; +import { ElementWidget, type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; import { ModuleRunner } from "../../../../../src/modules/ModuleRunner"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; @@ -425,16 +425,20 @@ describe("AppTile", () => { describe("with an existing widgetApi with requiresClient = false", () => { beforeEach(() => { - const api = { - hasCapability: (capability: ElementWidgetCapabilities): boolean => { - return !(capability === ElementWidgetCapabilities.RequiresClient); - }, - once: () => {}, + const messaging = { + on: () => {}, + off: () => {}, + prepare: async () => {}, stop: () => {}, - } as unknown as ClientWidgetApi; + widgetApi: { + hasCapability: (capability: ElementWidgetCapabilities): boolean => { + return !(capability === ElementWidgetCapabilities.RequiresClient); + }, + }, + } as unknown as WidgetMessaging; const mockWidget = new ElementWidget(app1); - WidgetMessagingStore.instance.storeMessaging(mockWidget, r1.roomId, api); + WidgetMessagingStore.instance.storeMessaging(mockWidget, r1.roomId, messaging); renderResult = render( diff --git a/test/unit-tests/components/views/messages/CallEvent-test.tsx b/test/unit-tests/components/views/messages/CallEvent-test.tsx index 688c9b190f1..4c5b3ce6920 100644 --- a/test/unit-tests/components/views/messages/CallEvent-test.tsx +++ b/test/unit-tests/components/views/messages/CallEvent-test.tsx @@ -16,7 +16,7 @@ import { PendingEventOrdering, type RoomMember, } from "matrix-js-sdk/src/matrix"; -import { type ClientWidgetApi, Widget } from "matrix-widget-api"; +import { Widget } from "matrix-widget-api"; import { useMockedCalls, @@ -35,6 +35,7 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import { CallStore } from "../../../../../src/stores/CallStore"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; import { ConnectionState } from "../../../../../src/models/Call"; +import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging"; const CallEvent = wrapInMatrixClientContext(UnwrappedCallEvent); @@ -86,7 +87,7 @@ describe("CallEvent", () => { widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, - } as unknown as ClientWidgetApi); + } as unknown as WidgetMessaging); }); afterEach(async () => { diff --git a/test/unit-tests/components/views/rooms/RoomTile-test.tsx b/test/unit-tests/components/views/rooms/RoomTile-test.tsx index a770b00bd41..f093840f1a4 100644 --- a/test/unit-tests/components/views/rooms/RoomTile-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomTile-test.tsx @@ -20,7 +20,6 @@ import { import { KnownMembership } from "matrix-js-sdk/src/types"; import { Widget } from "matrix-widget-api"; -import type { ClientWidgetApi } from "matrix-widget-api"; import { stubClient, mkRoomMember, @@ -47,6 +46,7 @@ import { MessagePreviewStore } from "../../../../../src/stores/room-list/Message import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { ConnectionState } from "../../../../../src/models/Call"; +import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging"; jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({ shouldShowComponent: jest.fn(), @@ -204,7 +204,7 @@ describe("RoomTile", () => { widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, - } as unknown as ClientWidgetApi); + } as unknown as WidgetMessaging); }); afterEach(() => { diff --git a/test/unit-tests/components/views/voip/CallView-test.tsx b/test/unit-tests/components/views/voip/CallView-test.tsx index 255f548abf9..ba8e9b619fb 100644 --- a/test/unit-tests/components/views/voip/CallView-test.tsx +++ b/test/unit-tests/components/views/voip/CallView-test.tsx @@ -18,7 +18,6 @@ import { } from "matrix-js-sdk/src/matrix"; import { Widget } from "matrix-widget-api"; -import type { ClientWidgetApi } from "matrix-widget-api"; import { stubClient, mkRoomMember, @@ -33,6 +32,7 @@ import { CallView as _CallView } from "../../../../../src/components/views/voip/ import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; import { CallStore } from "../../../../../src/stores/CallStore"; import DMRoomMap from "../../../../../src/utils/DMRoomMap"; +import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging"; const CallView = wrapInMatrixClientContext(_CallView); @@ -73,8 +73,11 @@ describe("CallView", () => { widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { + on: () => {}, + off: () => {}, stop: () => {}, - } as unknown as ClientWidgetApi); + embedUrl: "https://example.org", + } as unknown as WidgetMessaging); }); afterEach(() => { diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts index dbf2dca974a..d5a553a4d7d 100644 --- a/test/unit-tests/models/Call-test.ts +++ b/test/unit-tests/models/Call-test.ts @@ -48,36 +48,37 @@ import { Anonymity, PosthogAnalytics } from "../../../src/PosthogAnalytics"; import { type SettingKey } from "../../../src/settings/Settings.tsx"; import SdkConfig from "../../../src/SdkConfig.ts"; import DMRoomMap from "../../../src/utils/DMRoomMap.ts"; +import { type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging.ts"; const { enabledSettings } = enableCalls(); -const setUpWidget = (call: Call): { widget: Widget; messaging: Mocked } => { +const setUpWidget = ( + call: Call, +): { widget: Widget; messaging: Mocked; widgetApi: Mocked } => { call.widget.data = { ...call.widget, skipLobby: true }; const widget = new Widget(call.widget); - const eventEmitter = new EventEmitter(); - const messaging = { - on: eventEmitter.on.bind(eventEmitter), - off: eventEmitter.off.bind(eventEmitter), - once: eventEmitter.once.bind(eventEmitter), - emit: eventEmitter.emit.bind(eventEmitter), - stop: jest.fn(), - transport: { + const widgetApi = new (class extends EventEmitter { + transport = { send: jest.fn(), reply: jest.fn(), - }, - } as unknown as Mocked; + }; + })() as unknown as Mocked; + const messaging = new (class extends EventEmitter { + stop = jest.fn(); + widgetApi = widgetApi; + })() as unknown as Mocked; WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging); - return { widget, messaging }; + return { widget, messaging, widgetApi }; }; -async function connect(call: Call, messaging: Mocked, startWidget = true): Promise { +async function connect(call: Call, widgetApi: Mocked, startWidget = true): Promise { async function sessionConnect() { await new Promise((r) => { setTimeout(() => r(), 400); }); - messaging.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {})); + widgetApi.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {})); } async function runTimers() { jest.advanceTimersByTime(500); @@ -87,12 +88,12 @@ async function connect(call: Call, messaging: Mocked, startWidg await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]); } -async function disconnect(call: Call, messaging: Mocked): Promise { +async function disconnect(call: Call, widgetApi: Mocked): Promise { async function sessionDisconnect() { await new Promise((r) => { setTimeout(() => r(), 400); }); - messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); } async function runTimers() { jest.advanceTimersByTime(500); @@ -150,7 +151,8 @@ describe("JitsiCall", () => { describe("instance in a video room", () => { let call: JitsiCall; let widget: Widget; - let messaging: Mocked; + let messaging: Mocked; + let widgetApi: Mocked; beforeEach(async () => { jest.useFakeTimers(); @@ -161,16 +163,16 @@ describe("JitsiCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging } = setUpWidget(call)); + ({ widget, messaging, widgetApi } = setUpWidget(call)); - mocked(messaging.transport).send.mockImplementation(async (action, data): Promise => { + mocked(widgetApi.transport).send.mockImplementation(async (action, data): Promise => { if (action === ElementWidgetActions.JoinCall) { - messaging.emit( + widgetApi.emit( `action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", { detail: { data } }), ); } else if (action === ElementWidgetActions.HangupCall) { - messaging.emit( + widgetApi.emit( `action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", { detail: { data } }), ); @@ -183,7 +185,7 @@ describe("JitsiCall", () => { it("connects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); }); @@ -196,27 +198,27 @@ describe("JitsiCall", () => { const startup = call.start(); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); await startup; - await connect(call, messaging, false); + await connect(call, widgetApi, false); expect(call.connectionState).toBe(ConnectionState.Connected); }); it("fails to disconnect if the widget returns an error", async () => { - await connect(call, messaging); - mocked(messaging.transport).send.mockRejectedValue(new Error("never!")); + await connect(call, widgetApi); + mocked(widgetApi.transport).send.mockRejectedValue(new Error("never!")); await expect(call.disconnect()).rejects.toBeDefined(); }); it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); const callback = jest.fn(); call.on(CallEvent.ConnectionState, callback); - messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); await waitFor(() => { expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected); }); @@ -226,14 +228,14 @@ describe("JitsiCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); await call.disconnect(); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave); expect(call.connectionState).toBe(ConnectionState.Disconnected); @@ -241,14 +243,14 @@ describe("JitsiCall", () => { it("reconnects after disconnect in video rooms", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); await call.disconnect(); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("remains connected if we stay in the room", async () => { - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Join); expect(call.connectionState).toBe(ConnectionState.Connected); @@ -274,7 +276,7 @@ describe("JitsiCall", () => { // Now, stub out client.sendStateEvent so we can test our local echo client.sendStateEvent.mockReset(); - await connect(call, messaging); + await connect(call, widgetApi); expect(call.participants).toEqual( new Map([ [alice, new Set(["alices_device"])], @@ -287,7 +289,7 @@ describe("JitsiCall", () => { }); it("updates room state when connecting and disconnecting", async () => { - await connect(call, messaging); + await connect(call, widgetApi); const now1 = Date.now(); await waitFor( () => @@ -315,7 +317,7 @@ describe("JitsiCall", () => { }); it("repeatedly updates room state while connected", async () => { - await connect(call, messaging); + await connect(call, widgetApi); await waitFor( () => expect(client.sendStateEvent).toHaveBeenLastCalledWith( @@ -345,7 +347,7 @@ describe("JitsiCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await connect(call, messaging); + await connect(call, widgetApi); await call.disconnect(); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.Connected, ConnectionState.Disconnected], @@ -360,7 +362,7 @@ describe("JitsiCall", () => { const onParticipants = jest.fn(); call.on(CallEvent.Participants, onParticipants); - await connect(call, messaging); + await connect(call, widgetApi); await call.disconnect(); expect(onParticipants.mock.calls).toEqual([ [new Map([[alice, new Set(["alices_device"])]]), new Map()], @@ -373,11 +375,11 @@ describe("JitsiCall", () => { }); it("switches to spotlight layout when the widget becomes a PiP", async () => { - await connect(call, messaging); + await connect(call, widgetApi); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); + expect(widgetApi.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock); - expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {}); + expect(widgetApi.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {}); }); describe("clean", () => { @@ -417,7 +419,7 @@ describe("JitsiCall", () => { }); it("doesn't clean up valid devices", async () => { - await connect(call, messaging); + await connect(call, widgetApi); await client.sendStateEvent( room.roomId, JitsiCall.MEMBER_EVENT_TYPE, @@ -706,7 +708,8 @@ describe("ElementCall", () => { describe("instance in a non-video room", () => { let call: ElementCall; let widget: Widget; - let messaging: Mocked; + let messaging: Mocked; + let widgetApi: Mocked; beforeEach(async () => { jest.useFakeTimers(); @@ -717,7 +720,7 @@ describe("ElementCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging } = setUpWidget(call)); + ({ widget, messaging, widgetApi } = setUpWidget(call)); }); afterEach(() => cleanUpCallAndWidget(call, widget)); @@ -733,65 +736,65 @@ describe("ElementCall", () => { const startup = call.start({}); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); await startup; - await connect(call, messaging, false); + await connect(call, widgetApi, false); expect(call.connectionState).toBe(ConnectionState.Connected); }); it("fails to disconnect if the widget returns an error", async () => { - await connect(call, messaging); - mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:(")); + await connect(call, widgetApi); + mocked(widgetApi.transport).send.mockRejectedValue(new Error("never!!1! >:(")); await expect(call.disconnect()).rejects.toBeDefined(); }); it("handles remote disconnection", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); - messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); - messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {})); + widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + widgetApi.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {})); await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 }); }); it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); - await disconnect(call, messaging); + await disconnect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("remains connected if we stay in the room", async () => { - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Join); expect(call.connectionState).toBe(ConnectionState.Connected); }); it("disconnects if the widget dies", async () => { - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("acknowledges mute_device widget action", async () => { - await connect(call, messaging); + await connect(call, widgetApi); const preventDefault = jest.fn(); const mockEv = { preventDefault, detail: { video_enabled: false }, }; - messaging.emit(`action:${ElementWidgetActions.DeviceMute}`, mockEv); - expect(messaging.transport.reply).toHaveBeenCalledWith({ video_enabled: false }, {}); + widgetApi.emit(`action:${ElementWidgetActions.DeviceMute}`, mockEv); + expect(widgetApi.transport.reply).toHaveBeenCalledWith({ video_enabled: false }, {}); expect(preventDefault).toHaveBeenCalled(); }); @@ -800,8 +803,8 @@ describe("ElementCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await connect(call, messaging); - await disconnect(call, messaging); + await connect(call, widgetApi); + await disconnect(call, widgetApi); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.Connected, ConnectionState.Disconnected], [ConnectionState.Disconnecting, ConnectionState.Connected], @@ -823,10 +826,10 @@ describe("ElementCall", () => { }); it("ends the call immediately if the session ended", async () => { - await connect(call, messaging); + await connect(call, widgetApi); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await disconnect(call, messaging); + await disconnect(call, widgetApi); // this will be called automatically // disconnect -> widget sends state event -> session manager notices no-one left client.matrixRTC.emit( @@ -867,7 +870,7 @@ describe("ElementCall", () => { describe("instance in a video room", () => { let call: ElementCall; let widget: Widget; - let messaging: Mocked; + let widgetApi: Mocked; beforeEach(async () => { jest.useFakeTimers(); @@ -880,29 +883,29 @@ describe("ElementCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging } = setUpWidget(call)); + ({ widget, widgetApi } = setUpWidget(call)); }); afterEach(() => cleanUpCallAndWidget(call, widget)); it("doesn't end the call when the last participant leaves", async () => { - await connect(call, messaging); + await connect(call, widgetApi); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await disconnect(call, messaging); + await disconnect(call, widgetApi); expect(onDestroy).not.toHaveBeenCalled(); call.off(CallEvent.Destroy, onDestroy); }); it("handles remote disconnection and reconnect right after", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, messaging); + await connect(call, widgetApi); expect(call.connectionState).toBe(ConnectionState.Connected); - messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); // We should now be able to reconnect without manually starting the widget expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, messaging, false); + await connect(call, widgetApi, false); await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected), { interval: 5 }); }); }); diff --git a/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts b/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts index a8b8afe7e70..893a13adfc2 100644 --- a/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts +++ b/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts @@ -12,7 +12,6 @@ import { KnownMembership } from "matrix-js-sdk/src/types"; import { Widget } from "matrix-widget-api"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import type { ClientWidgetApi } from "matrix-widget-api"; import { stubClient, setupAsyncStoreWithClient, @@ -29,6 +28,7 @@ import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algori import { CallStore } from "../../../../../src/stores/CallStore"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; import { ConnectionState } from "../../../../../src/models/Call"; +import { type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging"; describe("Algorithm", () => { useMockedCalls(); @@ -89,7 +89,7 @@ describe("Algorithm", () => { const widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, { stop: () => {}, - } as unknown as ClientWidgetApi); + } as unknown as WidgetMessaging); // End of setup diff --git a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts b/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts similarity index 99% rename from test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts rename to test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts index 9b6411b134a..aa5f425e996 100644 --- a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts @@ -36,7 +36,7 @@ import { import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { StopGapWidgetDriver } from "../../../../src/stores/widgets/StopGapWidgetDriver"; +import { ElementWidgetDriver } from "../../../../src/stores/widgets/ElementWidgetDriver"; import { mkEvent, stubClient } from "../../../test-utils"; import { ModuleRunner } from "../../../../src/modules/ModuleRunner"; import dis from "../../../../src/dispatcher/dispatcher"; @@ -44,12 +44,11 @@ import Modal from "../../../../src/Modal"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { WidgetType } from "../../../../src/widgets/WidgetType.ts"; -describe("StopGapWidgetDriver", () => { +describe("ElementWidgetDriver", () => { let client: MockedObject; const mkDefaultDriver = (): WidgetDriver => - new StopGapWidgetDriver( - [], + new ElementWidgetDriver( new Widget({ id: "test", creatorUserId: "@alice:example.org", @@ -73,8 +72,7 @@ describe("StopGapWidgetDriver", () => { }); it("auto-approves capabilities of virtual Element Call widgets", async () => { - const driver = new StopGapWidgetDriver( - [], + const driver = new ElementWidgetDriver( new Widget({ id: "group_call", creatorUserId: "@alice:example.org", diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/WidgetMessaging-test.ts similarity index 88% rename from test/unit-tests/stores/widgets/StopGapWidget-test.ts rename to test/unit-tests/stores/widgets/WidgetMessaging-test.ts index 9ce5f102407..d9378bcc696 100644 --- a/test/unit-tests/stores/widgets/StopGapWidget-test.ts +++ b/test/unit-tests/stores/widgets/WidgetMessaging-test.ts @@ -24,36 +24,38 @@ import { type Optional } from "matrix-events-sdk"; import { stubClient, mkRoom, mkEvent } from "../../../test-utils"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget"; +import { ElementWidget, WidgetMessaging } from "../../../../src/stores/widgets/WidgetMessaging"; import ActiveWidgetStore from "../../../../src/stores/ActiveWidgetStore"; import SettingsStore from "../../../../src/settings/SettingsStore"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../src/dispatcher/actions"; import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { UPDATE_EVENT } from "../../../../src/stores/AsyncStore"; +import { type IApp } from "../../../../src/utils/WidgetUtils-types"; jest.mock("matrix-widget-api", () => ({ ...jest.requireActual("matrix-widget-api"), ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi, })); -describe("StopGapWidget", () => { +describe("WidgetMessaging", () => { let client: MockedObject; - let widget: StopGapWidget; + let widget: WidgetMessaging; let messaging: MockedObject; beforeEach(() => { stubClient(); client = mocked(MatrixClientPeg.safeGet()); - widget = new StopGapWidget({ - app: { - id: "test", - creatorUserId: "@alice:example.org", - type: "example", - url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url&theme=$org.matrix.msc2873.client_theme", - roomId: "!1:example.org", - }, + const app: IApp = { + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url&theme=$org.matrix.msc2873.client_theme", + roomId: "!1:example.org", + }; + widget = new WidgetMessaging(new ElementWidget(app), { + app, room: mkRoom(client, "!1:example.org"), userId: "@alice:example.org", creatorUserId: "@alice:example.org", @@ -61,13 +63,13 @@ describe("StopGapWidget", () => { userWidget: false, }); // Start messaging without an iframe, since ClientWidgetApi is mocked - widget.startMessaging(null as unknown as HTMLIFrameElement); + widget.start(null as unknown as HTMLIFrameElement); messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); messaging.feedStateUpdate.mockResolvedValue(); }); afterEach(() => { - widget.stopMessaging(); + widget.stop(); }); it("should replace parameters in widget url template", () => { @@ -293,9 +295,9 @@ describe("StopGapWidget", () => { }); }); -describe("StopGapWidget with stickyPromise", () => { +describe("WidgetMessaging with stickyPromise", () => { let client: MockedObject; - let widget: StopGapWidget; + let widget: WidgetMessaging; let messaging: MockedObject; beforeEach(() => { @@ -304,7 +306,7 @@ describe("StopGapWidget with stickyPromise", () => { }); afterEach(() => { - widget.stopMessaging(); + widget.stop(); }); it("should wait for the sticky promise to resolve before starting messaging", async () => { jest.useFakeTimers(); @@ -315,14 +317,15 @@ describe("StopGapWidget with stickyPromise", () => { }, 1000); }); }; - widget = new StopGapWidget({ - app: { - id: "test", - creatorUserId: "@alice:example.org", - type: "example", - url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url", - roomId: "!1:example.org", - }, + const app: IApp = { + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url", + roomId: "!1:example.org", + }; + widget = new WidgetMessaging(new ElementWidget(app), { + app, room: mkRoom(client, "!1:example.org"), userId: "@alice:example.org", creatorUserId: "@alice:example.org", @@ -334,7 +337,7 @@ describe("StopGapWidget with stickyPromise", () => { const setPersistenceSpy = jest.spyOn(ActiveWidgetStore.instance, "setWidgetPersistence"); // Start messaging without an iframe, since ClientWidgetApi is mocked - widget.startMessaging(null as unknown as HTMLIFrameElement); + widget.start(null as unknown as HTMLIFrameElement); const emitSticky = async () => { messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); messaging?.hasCapability.mockReturnValue(true); @@ -359,8 +362,8 @@ describe("StopGapWidget with stickyPromise", () => { }); }); -describe("StopGapWidget as an account widget", () => { - let widget: StopGapWidget; +describe("WidgetMessaging as an account widget", () => { + let widget: WidgetMessaging; let messaging: MockedObject; let getRoomId: MockedFunction<() => Optional>; @@ -372,26 +375,27 @@ describe("StopGapWidget as an account widget", () => { >; getRoomId.mockReturnValue("!1:example.org"); - widget = new StopGapWidget({ - app: { - id: "test", - creatorUserId: "@alice:example.org", - type: "example", - url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url&theme=$org.matrix.msc2873.client_theme", - roomId: "!1:example.org", - }, + const app: IApp = { + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url&theme=$org.matrix.msc2873.client_theme", + roomId: "!1:example.org", + }; + widget = new WidgetMessaging(new ElementWidget(app), { + app, userId: "@alice:example.org", creatorUserId: "@alice:example.org", waitForIframeLoad: true, userWidget: false, }); // Start messaging without an iframe, since ClientWidgetApi is mocked - widget.startMessaging(null as unknown as HTMLIFrameElement); + widget.start(null as unknown as HTMLIFrameElement); messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); }); afterEach(() => { - widget.stopMessaging(); + widget.stop(); getRoomId.mockRestore(); }); diff --git a/test/unit-tests/stores/widgets/WidgetPermissionStore-test.ts b/test/unit-tests/stores/widgets/WidgetPermissionStore-test.ts index 81c7f35e7bd..b0ebf2b23b5 100644 --- a/test/unit-tests/stores/widgets/WidgetPermissionStore-test.ts +++ b/test/unit-tests/stores/widgets/WidgetPermissionStore-test.ts @@ -16,7 +16,7 @@ import { TestSdkContext } from "../../TestSdkContext"; import { type SettingLevel } from "../../../../src/settings/SettingLevel"; import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { stubClient } from "../../../test-utils"; -import { StopGapWidgetDriver } from "../../../../src/stores/widgets/StopGapWidgetDriver"; +import { ElementWidgetDriver } from "../../../../src/stores/widgets/ElementWidgetDriver"; import { WidgetType } from "../../../../src/widgets/WidgetType.ts"; jest.mock("../../../../src/settings/SettingsStore"); @@ -93,7 +93,7 @@ describe("WidgetPermissionStore", () => { expect(store2).toStrictEqual(store); }); it("auto-approves OIDC requests for element-call", async () => { - new StopGapWidgetDriver([], elementCallWidget, WidgetKind.Room, true, roomId); + new ElementWidgetDriver(elementCallWidget, WidgetKind.Room, true, roomId); expect(widgetPermissionStore.getOIDCState(elementCallWidget, WidgetKind.Room, roomId)).toEqual( OIDCState.Allowed, ); diff --git a/test/unit-tests/toasts/IncomingCallToast-test.tsx b/test/unit-tests/toasts/IncomingCallToast-test.tsx index fd6508c2286..c5d973ea2e9 100644 --- a/test/unit-tests/toasts/IncomingCallToast-test.tsx +++ b/test/unit-tests/toasts/IncomingCallToast-test.tsx @@ -21,7 +21,7 @@ import { type IRoomTimelineData, type ISendEventResponse, } from "matrix-js-sdk/src/matrix"; -import { type ClientWidgetApi, Widget } from "matrix-widget-api"; +import { Widget } from "matrix-widget-api"; import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; import { @@ -47,6 +47,7 @@ import { } from "../../../src/toasts/IncomingCallToast"; import LegacyCallHandler, { AudioID } from "../../../src/LegacyCallHandler"; import { CallEvent } from "../../../src/models/Call"; +import { type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging"; describe("IncomingCallToast", () => { useMockedCalls(); @@ -113,7 +114,7 @@ describe("IncomingCallToast", () => { widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, - } as unknown as ClientWidgetApi); + } as unknown as WidgetMessaging); jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); jest.spyOn(ToastStore, "sharedInstance").mockReturnValue(toastStore);