diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index e52d781ac7..dc3549a1ea 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -544,34 +544,37 @@ test.describe("Element Call", () => { }); // For https://github.com/element-hq/element-web/issues/30838 - test.fail( - "should be able to join a call, leave via PiP, and rejoin the call", - async ({ page, user, room, app, bot }) => { - await app.viewRoomById(room.roomId); - await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); - await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50); + test("should be able to join a call, leave via PiP, and rejoin the call", async ({ + page, + user, + room, + app, + bot, + }) => { + await app.viewRoomById(room.roomId); + await expect(page.getByText("Bob and one other were invited and joined")).toBeVisible(); + await app.client.setPowerLevel(room.roomId, bot.credentials.userId, 50); - await sendRTCState(bot, room.roomId); - await openAndJoinCall(page, true); + await sendRTCState(bot, room.roomId); + await openAndJoinCall(page, true); - await app.viewRoomByName("OtherRoom"); - const pipContainer = page.locator(".mx_WidgetPip"); + await app.viewRoomByName("OtherRoom"); + const pipContainer = page.locator(".mx_WidgetPip"); - // We should have a PiP container here. - await expect(pipContainer).toBeVisible(); + // We should have a PiP container here. + await expect(pipContainer).toBeVisible(); - // Leave the call. - const overlay = page.locator(".mx_WidgetPip_overlay"); - await overlay.hover({ timeout: 2000 }); // Show the call footer. - await overlay.getByRole("button", { name: "Leave", exact: true }).click(); + // Leave the call. + const overlay = page.locator(".mx_WidgetPip_overlay"); + await overlay.hover({ timeout: 2000 }); // Show the call footer. + await overlay.getByRole("button", { name: "Leave", exact: true }).click(); - // PiP container goes. - await expect(pipContainer).not.toBeVisible(); + // PiP container goes. + await expect(pipContainer).not.toBeVisible(); - // Rejoin the call - await app.viewRoomById(room.roomId); - await openAndJoinCall(page, true); - }, - ); + // Rejoin the call + await app.viewRoomById(room.roomId); + await openAndJoinCall(page, true); + }); }); }); diff --git a/src/LegacyCallHandler.tsx b/src/LegacyCallHandler.tsx index 33770fe4bf..9e4544c37d 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 5fa315b442..cc8812fc9d 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 5b03d54e17..ff74e25d38 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 250a438c13..c0af2b632b 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.error("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 22df47865d..ed8cdb7e95 100644 --- a/src/components/views/pips/WidgetPip.tsx +++ b/src/components/views/pips/WidgetPip.tsx @@ -89,7 +89,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 6281f287ba..bbf3f49685 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 ebf3f7b50a..39e4aa4b9a 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -16,7 +16,7 @@ import { type RoomMember, } from "matrix-js-sdk/src/matrix"; import { KnownMembership, type Membership } from "matrix-js-sdk/src/types"; -import { logger } from "matrix-js-sdk/src/logger"; +import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import { secureRandomString } from "matrix-js-sdk/src/randomstring"; import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { type IWidgetApiRequest, type ClientWidgetApi, type IWidgetData } from "matrix-widget-api"; @@ -43,8 +43,10 @@ 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; +const logger = rootLogger.getChild("models/Call"); // Waits until an event is emitted satisfying the given predicate const waitForEvent = async ( @@ -122,15 +124,15 @@ export abstract class Call extends TypedEventEmitter { + public async start(_params?: WidgetGenerationParameters): Promise { const messagingStore = WidgetMessagingStore.instance; - this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null; - if (!this.messaging) { - // The widget might still be initializing, so wait for it. + const startTime = performance.now(); + let messaging: WidgetMessaging | undefined = messagingStore.getMessagingForUid(this.widgetUid); + // 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 + // (have a connected widget API), so register listeners for both cases. + while (!messaging?.widgetApi) { + if (messaging) logger.debug(`Messaging present but not yet started for ${this.widgetUid}`); + else logger.debug(`No messaging yet for ${this.widgetUid}`); + const recheck = Promise.withResolvers(); + const currentMessaging = messaging; + + // Maybe the messaging is present but not yet started. In this case, + // check again for a widget API as soon as it starts. + const onStart = (): void => recheck.resolve(); + currentMessaging?.on(WidgetMessagingEvent.Start, onStart); + + // Maybe the messaging is not present at all. It's also entirely + // possible (as shown in React strict mode) that the messaging could + // be abandoned and replaced by an entirely new messaging object + // while we were waiting for the original one to start. We need to + // react to store updates in either case. + const onStoreMessaging = (uid: string, m: WidgetMessaging): void => { + if (uid === this.widgetUid) { + messagingStore.off(WidgetMessagingStoreEvent.StoreMessaging, onStoreMessaging); + messaging = m; // Check the new messaging object on the next iteration of the loop + recheck.resolve(); + } + }; + messagingStore.on(WidgetMessagingStoreEvent.StoreMessaging, onStoreMessaging); + + // Race both of the above recheck signals against a timeout. + const timeout = setTimeout( + () => recheck.reject(new Error(`Widget for call in ${this.roomId} not started; timed out`)), + TIMEOUT_MS - (performance.now() - startTime), + ); + try { - await waitForEvent( - messagingStore, - WidgetMessagingStoreEvent.StoreMessaging, - (uid: string, widgetApi: ClientWidgetApi) => { - if (uid === this.widgetUid) { - this.messaging = widgetApi; - return true; - } - return false; - }, - ); - } catch (e) { - throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`); + await recheck.promise; + } finally { + currentMessaging?.off(WidgetMessagingEvent.Start, onStart); + messagingStore.off(WidgetMessagingStoreEvent.StoreMessaging, onStoreMessaging); + clearTimeout(timeout); } } + + logger.debug(`Widget ${this.widgetUid} now ready`); + return (this.widgetApi = messaging.widgetApi); } protected setConnected(): void { @@ -267,7 +299,7 @@ export abstract class Call extends TypedEventEmitter { if (uid === this.widgetUid && this.connected) { - logger.log("The widget died; treating this as a user hangup"); + logger.debug("The widget died; treating this as a user hangup"); this.setDisconnected(); this.close(); } @@ -448,25 +480,26 @@ export class JitsiCall extends Call { }); } - public async start(): Promise { - await super.start(); - this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); - this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + public async start(): Promise { + const widgetApi = await super.start(); + widgetApi.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + widgetApi.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock); + return widgetApi; } 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 +508,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(); @@ -508,7 +541,7 @@ export class JitsiCall extends Call { // Re-add this device every so often so our video member event doesn't become stale this.resendDevicesTimer = window.setInterval( async (): Promise => { - logger.log(`Resending video member event for ${this.roomId}`); + logger.debug(`Resending video member event for ${this.roomId}`); await this.addOurDevice(); }, (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4, @@ -527,18 +560,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 +581,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(); }; @@ -900,7 +933,7 @@ export class ElementCall extends Call { ElementCall.createOrGetCallWidget(room.roomId, room.client); } - public async start(widgetGenerationParameters: WidgetGenerationParameters): Promise { + public async start(widgetGenerationParameters: WidgetGenerationParameters): Promise { // Some parameters may only be set once the user has chosen to interact with the call, regenerate the URL // at this point in case any of the parameters have changed. this.widgetGenerationParameters = { ...this.widgetGenerationParameters, ...widgetGenerationParameters }; @@ -909,24 +942,25 @@ export class ElementCall extends Call { this.roomId, 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); + const widgetApi = await super.start(); + widgetApi.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + widgetApi.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + widgetApi.on(`action:${ElementWidgetActions.Close}`, this.onClose); + widgetApi.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute); + return widgetApi; } 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) { @@ -935,10 +969,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(); } @@ -986,12 +1020,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(); }; @@ -1001,13 +1035,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 2a2fbdd8da..76db49e5dc 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 42abdc801d..e341f7627c 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 81% rename from src/stores/widgets/StopGapWidget.ts rename to src/stores/widgets/WidgetMessaging.ts index c8de3bf0f1..58abe5b34d 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,49 @@ 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 an active communication + * channel. Instances must be tracked by WidgetMessagingStore, as only one WidgetMessaging + * instance should exist for a given widget. + * + * This class is responsible for: + * - Computing the templated widget URL + * - Starting a {@link ClientWidgetApi} communication channel with the widget + * - Eagerly pushing events from the Matrix client to the widget + * + * @see {@link ElementWidgetDriver} for the class used to *pull* data lazily from the + * Matrix client to the widget on the widget's behalf. + * @see {@link WidgetMessagingStore} for the store that holds these instances. + */ +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 +197,28 @@ 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; + } + + /** + * The widget API interface to the widget, or null if disconnected. + */ public get widgetApi(): ClientWidgetApi | null { - return this.messaging; + return this._widgetApi; } /** @@ -220,7 +248,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 +256,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 +272,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 +294,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 +303,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 +356,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 +376,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 +387,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 +444,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 +455,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 +467,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 +490,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 +502,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 +531,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 +541,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 +608,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 +637,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 2dcf8c4fdc..a9ec3d765f 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 bce01af202..2d3bcd821b 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: { @@ -149,30 +150,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 = () => { @@ -276,9 +287,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 f48ad2e353..caa5b23810 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 2c454dc86d..3a03eb25f0 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 { act, render, type RenderResult, waitForElementToBeRemoved, waitFor } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { @@ -34,7 +34,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"; @@ -116,9 +116,11 @@ describe("AppTile", () => { await RightPanelStore.instance.onReady(); }); - beforeEach(() => { + beforeEach(async () => { sdkContext = new SdkContextClass(); jest.spyOn(SettingsStore, "getValue").mockRestore(); + // @ts-ignore + await WidgetMessagingStore.instance.onReady(); }); it("destroys non-persisted right panel widget on room change", async () => { @@ -424,16 +426,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 688c9b190f..4c5b3ce692 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 a770b00bd4..f093840f1a 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 255f548abf..ba8e9b619f 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 9e2233d759..e26ae85fa4 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 { WidgetMessagingEvent, 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, @@ -796,7 +798,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(); @@ -807,81 +810,128 @@ 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)); + // TODO refactor initial device configuration to use the EW settings. // Add tests for passing EW device configuration to the widget. - it("waits for messaging when starting", async () => { + + it("waits for messaging when starting (widget API available immediately)", async () => { // Temporarily remove the messaging to simulate connecting while the // widget is still initializing - WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); expect(call.connectionState).toBe(ConnectionState.Disconnected); 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("waits for messaging when starting (widget API started asynchronously)", async () => { + // Temporarily remove the messaging to simulate connecting while the + // widget is still initializing + WidgetMessagingStore.instance.stopMessaging(widget, room.roomId); + // Also remove the widget API from said messaging until later + let storedWidgetApi: Mocked | null = null; + Object.defineProperty(messaging, "widgetApi", { + get() { + return storedWidgetApi; + }, + }); + expect(call.connectionState).toBe(ConnectionState.Disconnected); + + const startup = call.start({}); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); + // Yield the event loop to the Call.start promise, then simulate the + // widget API being started asynchronously + await Promise.resolve(); + storedWidgetApi = widgetApi; + messaging.emit(WidgetMessagingEvent.Start, storedWidgetApi); + await startup; + await connect(call, widgetApi, false); + expect(call.connectionState).toBe(ConnectionState.Connected); + }); + + it("waits for messaging when starting (even if messaging is replaced during startup)", async () => { + const firstMessaging = messaging; + // Entirely remove the widget API from this first messaging + Object.defineProperty(firstMessaging, "widgetApi", { + get() { + return null; + }, + }); + expect(call.connectionState).toBe(ConnectionState.Disconnected); + + const startup = call.start({}); + // Now imagine that the messaging gets abandoned and replaced by an + // entirely new messaging object + ({ widget, messaging, widgetApi } = setUpWidget(call)); + WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); + await startup; + await connect(call, widgetApi, false); + expect(call.connectionState).toBe(ConnectionState.Connected); + expect(firstMessaging.listenerCount(WidgetMessagingEvent.Start)).toBe(0); // No leaks + }); + 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(); }); @@ -890,8 +940,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], @@ -913,10 +963,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( @@ -957,7 +1007,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(); @@ -970,29 +1020,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 a8b8afe7e7..893a13adfc 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 9b6411b134..aa5f425e99 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/StopGapWidget-test.ts deleted file mode 100644 index 501aea7ea1..0000000000 --- a/test/unit-tests/stores/widgets/StopGapWidget-test.ts +++ /dev/null @@ -1,405 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import { mocked, type MockedFunction, type MockedObject } from "jest-mock"; -import { findLast, last } from "lodash"; -import { - MatrixEvent, - type MatrixClient, - ClientEvent, - type EventTimeline, - EventType, - MatrixEventEvent, - RoomStateEvent, - type RoomState, -} from "matrix-js-sdk/src/matrix"; -import { ClientWidgetApi, WidgetApiFromWidgetAction } from "matrix-widget-api"; -import { waitFor } from "jest-matrix-react"; - -import { stubClient, mkRoom, mkEvent } from "../../../test-utils"; -import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { StopGapWidget } from "../../../../src/stores/widgets/StopGapWidget"; -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"; - -jest.mock("matrix-widget-api", () => ({ - ...jest.requireActual("matrix-widget-api"), - ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi, -})); - -describe("StopGapWidget", () => { - let client: MockedObject; - let widget: StopGapWidget; - 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", - }, - room: mkRoom(client, "!1:example.org"), - 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); - messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); - messaging.feedStateUpdate.mockResolvedValue(); - }); - - afterEach(() => { - widget.stopMessaging(); - }); - - it("should replace parameters in widget url template", () => { - const originGetValue = SettingsStore.getValue; - const spy = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { - if (setting === "theme") return "my-theme-for-testing"; - return originGetValue(setting); - }); - expect(widget.embedUrl).toBe( - "https://example.org/?user-id=%40userId%3Amatrix.org&device-id=ABCDEFGHI&base-url=https%3A%2F%2Fmatrix-client.matrix.org&theme=my-theme-for-testing&widgetId=test&parentUrl=http%3A%2F%2Flocalhost%2F", - ); - spy.mockClear(); - }); - - it("feeds incoming to-device messages to the widget", async () => { - const receivedToDevice = { - message: { - type: "org.example.foo", - sender: "@alice:example.org", - content: { - hello: "world", - }, - }, - encryptionInfo: null, - }; - - client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice); - await Promise.resolve(); // flush promises - expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, false); - }); - - it("feeds incoming encrypted to-device messages to the widget", async () => { - const receivedToDevice = { - message: { - type: "org.example.foo", - sender: "@alice:example.org", - content: { - hello: "world", - }, - }, - encryptionInfo: { - senderVerified: false, - sender: "@alice:example.org", - senderCurve25519KeyBase64: "", - senderDevice: "ABCDEFGHI", - }, - }; - - client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice); - await Promise.resolve(); // flush promises - expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, true); - }); - - it("feeds incoming state updates to the widget", () => { - const event = mkEvent({ - event: true, - type: "org.example.foo", - skey: "", - user: "@alice:example.org", - content: { hello: "world" }, - room: "!1:example.org", - }); - - client.emit(RoomStateEvent.Events, event, {} as unknown as RoomState, null); - expect(messaging.feedStateUpdate).toHaveBeenCalledWith(event.getEffectiveEvent()); - }); - - it("informs widget of theme changes", () => { - let theme = "light"; - const settingsSpy = jest - .spyOn(SettingsStore, "getValue") - .mockImplementation((name) => (name === "theme" ? theme : null)); - try { - // Indicate that the widget is ready - findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1](); - - // Now change the theme - theme = "dark"; - defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true); - expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" }); - } finally { - settingsSpy.mockRestore(); - } - }); - - describe("feed event", () => { - let event1: MatrixEvent; - let event2: MatrixEvent; - - beforeEach(() => { - event1 = mkEvent({ - event: true, - id: "$event-id1", - type: "org.example.foo", - user: "@alice:example.org", - content: { hello: "world" }, - room: "!1:example.org", - }); - - event2 = mkEvent({ - event: true, - id: "$event-id2", - type: "org.example.foo", - user: "@alice:example.org", - content: { hello: "world" }, - room: "!1:example.org", - }); - - const room = mkRoom(client, "!1:example.org"); - client.getRoom.mockImplementation((roomId) => (roomId === "!1:example.org" ? room : null)); - room.getLiveTimeline.mockReturnValue({ - getEvents: (): MatrixEvent[] => [event1, event2], - } as unknown as EventTimeline); - - messaging.feedEvent.mockResolvedValue(); - }); - - it("feeds incoming event to the widget", async () => { - client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); - - client.emit(ClientEvent.Event, event2); - expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); - }); - - it("should not feed incoming event to the widget if seen already", async () => { - client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); - - client.emit(ClientEvent.Event, event2); - expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); - - client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); - }); - - it("feeds decrypted events asynchronously", async () => { - const event1Encrypted = new MatrixEvent({ - event_id: event1.getId(), - type: EventType.RoomMessageEncrypted, - sender: event1.sender?.userId, - room_id: event1.getRoomId(), - content: {}, - }); - const decryptingSpy1 = jest.spyOn(event1Encrypted, "isBeingDecrypted").mockReturnValue(true); - client.emit(ClientEvent.Event, event1Encrypted); - const event2Encrypted = new MatrixEvent({ - event_id: event2.getId(), - type: EventType.RoomMessageEncrypted, - sender: event2.sender?.userId, - room_id: event2.getRoomId(), - content: {}, - }); - const decryptingSpy2 = jest.spyOn(event2Encrypted, "isBeingDecrypted").mockReturnValue(true); - client.emit(ClientEvent.Event, event2Encrypted); - expect(messaging.feedEvent).not.toHaveBeenCalled(); - - // "Decrypt" the events, but in reverse order; first event 2… - event2Encrypted.event.type = event2.getType(); - event2Encrypted.event.content = event2.getContent(); - decryptingSpy2.mockReturnValue(false); - client.emit(MatrixEventEvent.Decrypted, event2Encrypted); - expect(messaging.feedEvent).toHaveBeenCalledTimes(1); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2Encrypted.getEffectiveEvent()); - // …then event 1 - event1Encrypted.event.type = event1.getType(); - event1Encrypted.event.content = event1.getContent(); - decryptingSpy1.mockReturnValue(false); - client.emit(MatrixEventEvent.Decrypted, event1Encrypted); - // The events should be fed in that same order so that event 2 - // doesn't have to be blocked on the decryption of event 1 (or - // worse, dropped) - expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event1Encrypted.getEffectiveEvent()); - }); - - it("should not feed incoming event if not in timeline", () => { - const event = mkEvent({ - event: true, - id: "$event-id", - type: "org.example.foo", - user: "@alice:example.org", - content: { - hello: "world", - }, - room: "!1:example.org", - }); - - client.emit(ClientEvent.Event, event); - expect(messaging.feedEvent).toHaveBeenCalledWith(event.getEffectiveEvent()); - }); - - it("feeds incoming event that is not in timeline but relates to unknown parent to the widget", async () => { - const event = mkEvent({ - event: true, - id: "$event-idRelation", - type: "org.example.foo", - user: "@alice:example.org", - content: { - "hello": "world", - "m.relates_to": { - event_id: "$unknown-parent", - rel_type: "m.reference", - }, - }, - room: "!1:example.org", - }); - - client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); - - client.emit(ClientEvent.Event, event); - expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent()); - - client.emit(ClientEvent.Event, event1); - expect(messaging.feedEvent).toHaveBeenCalledTimes(2); - expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent()); - }); - }); -}); - -describe("StopGapWidget with stickyPromise", () => { - let client: MockedObject; - let widget: StopGapWidget; - let messaging: MockedObject; - - beforeEach(() => { - stubClient(); - client = mocked(MatrixClientPeg.safeGet()); - }); - - afterEach(() => { - widget.stopMessaging(); - }); - it("should wait for the sticky promise to resolve before starting messaging", async () => { - jest.useFakeTimers(); - const getStickyPromise = async () => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, 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", - }, - room: mkRoom(client, "!1:example.org"), - userId: "@alice:example.org", - creatorUserId: "@alice:example.org", - waitForIframeLoad: true, - userWidget: false, - stickyPromise: getStickyPromise, - }); - - const setPersistenceSpy = jest.spyOn(ActiveWidgetStore.instance, "setWidgetPersistence"); - - // Start messaging without an iframe, since ClientWidgetApi is mocked - widget.startMessaging(null as unknown as HTMLIFrameElement); - const emitSticky = async () => { - messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); - messaging?.hasCapability.mockReturnValue(true); - // messaging.transport.reply will be called but transport is undefined in this test environment - // This just makes sure the call doesn't throw - Object.defineProperty(messaging, "transport", { value: { reply: () => {} } }); - messaging.on.mock.calls.find(([event, listener]) => { - if (event === `action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`) { - listener({ preventDefault: () => {}, detail: { data: { value: true } } }); - return true; - } - }); - }; - await emitSticky(); - expect(setPersistenceSpy).not.toHaveBeenCalled(); - // Advance the fake timer so that the sticky promise resolves - jest.runAllTimers(); - // Use a real timer and wait for the next tick so the sticky promise can resolve - jest.useRealTimers(); - - waitFor(() => expect(setPersistenceSpy).toHaveBeenCalled(), { interval: 5 }); - }); -}); - -describe("StopGapWidget as an account widget", () => { - let widget: StopGapWidget; - let messaging: MockedObject; - let getRoomId: MockedFunction<() => string | null>; - - beforeEach(() => { - stubClient(); - // I give up, getting the return type of spyOn right is hopeless - getRoomId = jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId") as unknown as MockedFunction< - () => string | null - >; - 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", - }, - 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); - messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); - }); - - afterEach(() => { - widget.stopMessaging(); - getRoomId.mockRestore(); - }); - - it("updates viewed room", () => { - expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(1); - expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!1:example.org"); - getRoomId.mockReturnValue("!2:example.org"); - SdkContextClass.instance.roomViewStore.emit(UPDATE_EVENT); - expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(2); - expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!2:example.org"); - }); -}); diff --git a/test/unit-tests/stores/widgets/WidgetMessaging-test.ts b/test/unit-tests/stores/widgets/WidgetMessaging-test.ts new file mode 100644 index 0000000000..eb66502fda --- /dev/null +++ b/test/unit-tests/stores/widgets/WidgetMessaging-test.ts @@ -0,0 +1,689 @@ +/* +Copyright 2024 New Vector Ltd. +Copyright 2022 The Matrix.org Foundation C.I.C + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { mocked, type MockedFunction, type MockedObject } from "jest-mock"; +import { findLast, last } from "lodash"; +import { + MatrixEvent, + type MatrixClient, + ClientEvent, + type EventTimeline, + EventType, + MatrixEventEvent, + RoomStateEvent, + type RoomState, + type Room, +} from "matrix-js-sdk/src/matrix"; +import { + ClientWidgetApi, + type IModalWidgetOpenRequest, + type IStickerActionRequest, + type IStickyActionRequest, + type IWidgetApiRequest, + MatrixCapabilities, + WidgetApiDirection, + WidgetApiFromWidgetAction, +} from "matrix-widget-api"; +import { waitFor } from "jest-matrix-react"; + +import { stubClient, mkRoom, mkEvent } from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +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"; +import { ModalWidgetStore } from "../../../../src/stores/ModalWidgetStore"; +import { ElementWidgetActions, type IViewRoomApiRequest } from "../../../../src/stores/widgets/ElementWidgetActions"; +import { ElementWidgetCapabilities } from "../../../../src/stores/widgets/ElementWidgetCapabilities"; +import { WidgetType } from "../../../../src/widgets/WidgetType"; +import { IntegrationManagers } from "../../../../src/integrations/IntegrationManagers"; +import { type IntegrationManagerInstance } from "../../../../src/integrations/IntegrationManagerInstance"; + +jest.mock("matrix-widget-api", () => ({ + ...jest.requireActual("matrix-widget-api"), + ClientWidgetApi: (jest.createMockFromModule("matrix-widget-api") as any).ClientWidgetApi, +})); + +const originGetValue = SettingsStore.getValue; + +describe("WidgetMessaging", () => { + let client: MockedObject; + let widget: WidgetMessaging; + let messaging: MockedObject; + + beforeEach(() => { + stubClient(); + client = mocked(MatrixClientPeg.safeGet()); + + 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", + waitForIframeLoad: true, + userWidget: false, + }); + // Start messaging without an iframe, since ClientWidgetApi is mocked + widget.start(null as unknown as HTMLIFrameElement); + messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + messaging.feedStateUpdate.mockResolvedValue(); + }); + + afterEach(() => { + widget.stop(); + }); + + it("should replace parameters in widget url template", () => { + const spy = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "theme") return "my-theme-for-testing"; + return originGetValue(setting); + }); + expect(widget.embedUrl).toBe( + "https://example.org/?user-id=%40userId%3Amatrix.org&device-id=ABCDEFGHI&base-url=https%3A%2F%2Fmatrix-client.matrix.org&theme=my-theme-for-testing&widgetId=test&parentUrl=http%3A%2F%2Flocalhost%2F", + ); + spy.mockClear(); + }); + + it("should replace parameters in widget url template for popout", () => { + const spy = jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => { + if (setting === "theme") return "my-theme-for-testing"; + return originGetValue(setting); + }); + expect(widget.popoutUrl).toBe( + "https://example.org/?user-id=%40userId%3Amatrix.org&device-id=ABCDEFGHI&base-url=https%3A%2F%2Fmatrix-client.matrix.org&theme=my-theme-for-testing", + ); + spy.mockClear(); + }); + + it("feeds incoming to-device messages to the widget", async () => { + const receivedToDevice = { + message: { + type: "org.example.foo", + sender: "@alice:example.org", + content: { + hello: "world", + }, + }, + encryptionInfo: null, + }; + + client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice); + await Promise.resolve(); // flush promises + expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, false); + }); + + it("feeds incoming encrypted to-device messages to the widget", async () => { + const receivedToDevice = { + message: { + type: "org.example.foo", + sender: "@alice:example.org", + content: { + hello: "world", + }, + }, + encryptionInfo: { + senderVerified: false, + sender: "@alice:example.org", + senderCurve25519KeyBase64: "", + senderDevice: "ABCDEFGHI", + }, + }; + + client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice); + await Promise.resolve(); // flush promises + expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, true); + }); + + it("feeds incoming state updates to the widget", () => { + const event = mkEvent({ + event: true, + type: "org.example.foo", + skey: "", + user: "@alice:example.org", + content: { hello: "world" }, + room: "!1:example.org", + }); + + client.emit(RoomStateEvent.Events, event, {} as unknown as RoomState, null); + expect(messaging.feedStateUpdate).toHaveBeenCalledWith(event.getEffectiveEvent()); + }); + + it("informs widget of theme changes", () => { + let theme = "light"; + const settingsSpy = jest + .spyOn(SettingsStore, "getValue") + .mockImplementation((name) => (name === "theme" ? theme : null)); + try { + // Indicate that the widget is ready + findLast(messaging.once.mock.calls, ([eventName]) => eventName === "ready")![1](); + + // Now change the theme + theme = "dark"; + defaultDispatcher.dispatch({ action: Action.RecheckTheme }, true); + expect(messaging.updateTheme).toHaveBeenLastCalledWith({ name: "dark" }); + } finally { + settingsSpy.mockRestore(); + } + }); + + describe("feed event", () => { + let event1: MatrixEvent; + let event2: MatrixEvent; + + beforeEach(() => { + event1 = mkEvent({ + event: true, + id: "$event-id1", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + room: "!1:example.org", + }); + + event2 = mkEvent({ + event: true, + id: "$event-id2", + type: "org.example.foo", + user: "@alice:example.org", + content: { hello: "world" }, + room: "!1:example.org", + }); + + const room = mkRoom(client, "!1:example.org"); + client.getRoom.mockImplementation((roomId) => (roomId === "!1:example.org" ? room : null)); + room.getLiveTimeline.mockReturnValue({ + getEvents: (): MatrixEvent[] => [event1, event2], + } as unknown as EventTimeline); + + messaging.feedEvent.mockResolvedValue(); + }); + + it("feeds incoming event to the widget", async () => { + client.emit(ClientEvent.Event, event1); + expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); + + client.emit(ClientEvent.Event, event2); + expect(messaging.feedEvent).toHaveBeenCalledTimes(2); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); + }); + + it("should not feed incoming event to the widget if seen already", async () => { + client.emit(ClientEvent.Event, event1); + expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); + + client.emit(ClientEvent.Event, event2); + expect(messaging.feedEvent).toHaveBeenCalledTimes(2); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); + + client.emit(ClientEvent.Event, event1); + expect(messaging.feedEvent).toHaveBeenCalledTimes(2); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2.getEffectiveEvent()); + }); + + it("feeds decrypted events asynchronously", async () => { + const event1Encrypted = new MatrixEvent({ + event_id: event1.getId(), + type: EventType.RoomMessageEncrypted, + sender: event1.sender?.userId, + room_id: event1.getRoomId(), + content: {}, + }); + const decryptingSpy1 = jest.spyOn(event1Encrypted, "isBeingDecrypted").mockReturnValue(true); + client.emit(ClientEvent.Event, event1Encrypted); + const event2Encrypted = new MatrixEvent({ + event_id: event2.getId(), + type: EventType.RoomMessageEncrypted, + sender: event2.sender?.userId, + room_id: event2.getRoomId(), + content: {}, + }); + const decryptingSpy2 = jest.spyOn(event2Encrypted, "isBeingDecrypted").mockReturnValue(true); + client.emit(ClientEvent.Event, event2Encrypted); + expect(messaging.feedEvent).not.toHaveBeenCalled(); + + // "Decrypt" the events, but in reverse order; first event 2… + event2Encrypted.event.type = event2.getType(); + event2Encrypted.event.content = event2.getContent(); + decryptingSpy2.mockReturnValue(false); + client.emit(MatrixEventEvent.Decrypted, event2Encrypted); + expect(messaging.feedEvent).toHaveBeenCalledTimes(1); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event2Encrypted.getEffectiveEvent()); + // …then event 1 + event1Encrypted.event.type = event1.getType(); + event1Encrypted.event.content = event1.getContent(); + decryptingSpy1.mockReturnValue(false); + client.emit(MatrixEventEvent.Decrypted, event1Encrypted); + // The events should be fed in that same order so that event 2 + // doesn't have to be blocked on the decryption of event 1 (or + // worse, dropped) + expect(messaging.feedEvent).toHaveBeenCalledTimes(2); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event1Encrypted.getEffectiveEvent()); + }); + + it("should not feed incoming event if not in timeline", () => { + const event = mkEvent({ + event: true, + id: "$event-id", + type: "org.example.foo", + user: "@alice:example.org", + content: { + hello: "world", + }, + room: "!1:example.org", + }); + + client.emit(ClientEvent.Event, event); + expect(messaging.feedEvent).toHaveBeenCalledWith(event.getEffectiveEvent()); + }); + + it("feeds incoming event that is not in timeline but relates to unknown parent to the widget", async () => { + const event = mkEvent({ + event: true, + id: "$event-idRelation", + type: "org.example.foo", + user: "@alice:example.org", + content: { + "hello": "world", + "m.relates_to": { + event_id: "$unknown-parent", + rel_type: "m.reference", + }, + }, + room: "!1:example.org", + }); + + client.emit(ClientEvent.Event, event1); + expect(messaging.feedEvent).toHaveBeenCalledWith(event1.getEffectiveEvent()); + + client.emit(ClientEvent.Event, event); + expect(messaging.feedEvent).toHaveBeenCalledTimes(2); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent()); + + client.emit(ClientEvent.Event, event1); + expect(messaging.feedEvent).toHaveBeenCalledTimes(2); + expect(messaging.feedEvent).toHaveBeenLastCalledWith(event.getEffectiveEvent()); + }); + }); +}); + +describe("WidgetMessaging with stickyPromise", () => { + let client: MockedObject; + let widget: WidgetMessaging; + let messaging: MockedObject; + + beforeEach(() => { + stubClient(); + client = mocked(MatrixClientPeg.safeGet()); + }); + + afterEach(() => { + widget.stop(); + }); + it("should wait for the sticky promise to resolve before starting messaging", async () => { + jest.useFakeTimers(); + const getStickyPromise = async () => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 1000); + }); + }; + 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", + waitForIframeLoad: true, + userWidget: false, + stickyPromise: getStickyPromise, + }); + + const setPersistenceSpy = jest.spyOn(ActiveWidgetStore.instance, "setWidgetPersistence"); + + // Start messaging without an iframe, since ClientWidgetApi is mocked + widget.start(null as unknown as HTMLIFrameElement); + const emitSticky = async () => { + messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + messaging?.hasCapability.mockReturnValue(true); + // messaging.transport.reply will be called but transport is undefined in this test environment + // This just makes sure the call doesn't throw + Object.defineProperty(messaging, "transport", { value: { reply: () => {} } }); + messaging.on.mock.calls.find(([event, listener]) => { + if (event === `action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`) { + listener({ preventDefault: () => {}, detail: { data: { value: true } } }); + return true; + } + }); + }; + await emitSticky(); + expect(setPersistenceSpy).not.toHaveBeenCalled(); + // Advance the fake timer so that the sticky promise resolves + jest.runAllTimers(); + // Use a real timer and wait for the next tick so the sticky promise can resolve + jest.useRealTimers(); + + waitFor(() => expect(setPersistenceSpy).toHaveBeenCalled(), { interval: 5 }); + }); +}); + +describe("WidgetMessaging as an account widget", () => { + let widget: WidgetMessaging; + let messaging: MockedObject; + let getRoomId: MockedFunction<() => string | null>; + + beforeEach(() => { + stubClient(); + // I give up, getting the return type of spyOn right is hopeless + getRoomId = jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId") as unknown as MockedFunction< + () => string | null + >; + getRoomId.mockReturnValue("!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.start(null as unknown as HTMLIFrameElement); + messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + }); + + afterEach(() => { + widget.stop(); + getRoomId.mockRestore(); + }); + + it("updates viewed room", () => { + expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(1); + expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!1:example.org"); + getRoomId.mockReturnValue("!2:example.org"); + SdkContextClass.instance.roomViewStore.emit(UPDATE_EVENT); + expect(messaging.setViewedRoomId).toHaveBeenCalledTimes(2); + expect(messaging.setViewedRoomId).toHaveBeenLastCalledWith("!2:example.org"); + }); +}); + +function createTransportEvent(data: T["data"]): CustomEvent { + // Not the complete CustomEvent but good nuff. + return { + preventDefault: () => {}, + detail: { + action: WidgetApiFromWidgetAction.OpenModalWidget, + data: data, + api: WidgetApiDirection.FromWidget, + requestId: "12345", + widgetId: "test", + }, + } as unknown as CustomEvent; +} + +describe("WidgetMessaging action handling", () => { + let widget: WidgetMessaging; + let messaging: MockedObject; + let actionFns: Record void>; + + beforeEach(() => { + const client = stubClient(); + + 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", + waitForIframeLoad: true, + userWidget: false, + }); + // Start messaging without an iframe, since ClientWidgetApi is mocked + widget.start(null as unknown as HTMLIFrameElement); + messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + Object.defineProperty(messaging, "transport", { value: { reply: jest.fn() } }); + actionFns = Object.fromEntries(messaging.on.mock.calls as any); + }); + + afterEach(() => { + widget.stop(); + jest.resetAllMocks(); + }); + + describe("open modal widget", () => { + beforeEach(() => { + // Trivial mock of ModalWidgetStore + let hasModal = false; + jest.spyOn(ModalWidgetStore.instance, "openModalWidget").mockImplementation(() => { + if (hasModal) { + throw Error("Modal already in view"); + } + hasModal = true; + }); + jest.spyOn(ModalWidgetStore.instance, "canOpenModalWidget").mockImplementation(() => !hasModal); + jest.spyOn(ModalWidgetStore.instance, "closeModalWidget").mockImplementation(() => { + if (!hasModal) { + throw Error("No modal in view"); + } + hasModal = false; + }); + }); + + it("handles an open modal request", () => { + const ev = createTransportEvent({ type: "foo", url: "bar" }); + actionFns[`action:${WidgetApiFromWidgetAction.OpenModalWidget}`](ev); + expect(messaging.transport.reply).toHaveBeenCalledWith(ev.detail, {}); + }); + + it("responds with an error if a modal is already open", () => { + const ev = createTransportEvent({ type: "foo", url: "bar" }); + actionFns[`action:${WidgetApiFromWidgetAction.OpenModalWidget}`](ev); + expect(messaging.transport.reply).toHaveBeenCalledWith(ev.detail, {}); + messaging.transport.reply.mockReset(); + actionFns[`action:${WidgetApiFromWidgetAction.OpenModalWidget}`](ev); + expect(messaging.transport.reply).toHaveBeenCalledWith(ev.detail, { + error: { message: "Unable to open modal at this time" }, + }); + }); + }); + + describe("view room", () => { + afterEach(() => { + jest.resetAllMocks(); + }); + it("errors on invalid room", () => { + const ev = createTransportEvent({ room_id: null } as any); + actionFns[`action:${ElementWidgetActions.ViewRoom}`](ev); + expect(messaging.transport.reply).toHaveBeenCalledWith(ev.detail, { + error: { message: "Room ID not supplied." }, + }); + }); + it("errors on missing permissions", () => { + const ev = createTransportEvent({ room_id: "!foo:example.org" } as any); + actionFns[`action:${ElementWidgetActions.ViewRoom}`](ev); + expect(messaging.transport.reply).toHaveBeenCalledWith(ev.detail, { + error: { message: "This widget does not have permission for this action (denied)." }, + }); + }); + it("handles room change", async () => { + const ev = createTransportEvent({ room_id: "!foo:example.org" } as any); + messaging.hasCapability.mockImplementation( + (capability) => capability === ElementWidgetCapabilities.CanChangeViewedRoom, + ); + const dispatch = (defaultDispatcher.dispatch = jest.fn()); + actionFns[`action:${ElementWidgetActions.ViewRoom}`](ev); + expect(messaging.transport.reply).toHaveBeenCalledWith(ev.detail, {}); + expect(dispatch).toHaveBeenCalledWith({ + action: "view_room", + metricsTrigger: "Widget", + room_id: "!foo:example.org", + }); + }); + }); + + describe("always on screen", () => { + let setWidgetPersistence: jest.SpyInstance>; + beforeEach(() => { + setWidgetPersistence = jest.spyOn(ActiveWidgetStore.instance, "setWidgetPersistence"); + }); + + it("does nothing if the widget does not have permission", () => { + const ev = createTransportEvent({ value: true }); + actionFns[`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`](ev); + // Currently there is no error response for this. + expect(messaging.transport.reply).not.toHaveBeenCalled(); + }); + + it("handles setting a widget as sticky", () => { + messaging.hasCapability.mockImplementation( + (capability) => capability === MatrixCapabilities.AlwaysOnScreen, + ); + const ev = createTransportEvent({ value: true }); + actionFns[`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`](ev); + expect(messaging.transport.reply).toHaveBeenCalledWith(ev.detail, {}); + expect(setWidgetPersistence).toHaveBeenCalledTimes(1); + }); + + it("handles setting a widget as unsticky", () => { + messaging.hasCapability.mockImplementation( + (capability) => capability === MatrixCapabilities.AlwaysOnScreen, + ); + const evOpen = createTransportEvent({ value: true }); + actionFns[`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`](evOpen); + expect(messaging.transport.reply).toHaveBeenCalledWith(evOpen.detail, {}); + expect(setWidgetPersistence).toHaveBeenCalledTimes(1); + messaging.transport.reply.mockReset(); + const evClose = createTransportEvent({ value: false }); + actionFns[`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`](evClose); + expect(messaging.transport.reply).toHaveBeenCalledWith(evClose.detail, {}); + expect(setWidgetPersistence).toHaveBeenCalledTimes(2); + }); + }); + + describe("send sticker", () => { + it("does nothing if the widget does not have permission", () => { + const ev = createTransportEvent({ name: "foo", content: { url: "bar" } }); + actionFns[`action:${WidgetApiFromWidgetAction.SendSticker}`](ev); + // Currently there is no error response for this. + expect(messaging.transport.reply).not.toHaveBeenCalled(); + }); + + it("handles setting a widget as sticky", () => { + messaging.hasCapability.mockImplementation( + (capability) => capability === MatrixCapabilities.StickerSending, + ); + const ev = createTransportEvent({ name: "foo", content: { url: "bar" } }); + const dispatch = (defaultDispatcher.dispatch = jest.fn()); + actionFns[`action:${WidgetApiFromWidgetAction.SendSticker}`](ev); + expect(messaging.transport.reply).toHaveBeenCalledWith(ev.detail, {}); + expect(dispatch).toHaveBeenCalledWith({ + action: "m.sticker", + data: { content: { url: "bar" }, name: "foo" }, + widgetId: "test", + }); + }); + }); +}); + +describe("WidgetMessaging action handling for stickerpicker", () => { + let widget: WidgetMessaging; + let messaging: MockedObject; + let actionFns: Record void>; + let room: Room; + + beforeEach(() => { + const client = mocked(stubClient()); + room = mkRoom(client, "!1:example.org"); + client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null)); + const getRoomId = jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId") as unknown as MockedFunction< + () => string | null + >; + getRoomId.mockReturnValue(room.roomId); + const app: IApp = { + id: "test", + creatorUserId: "@alice:example.org", + type: WidgetType.STICKERPICKER.preferred, + 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: room.roomId, + }; + widget = new WidgetMessaging(new ElementWidget(app), { + app, + room, + userId: "@alice:example.org", + creatorUserId: "@alice:example.org", + waitForIframeLoad: true, + userWidget: false, + }); + // Start messaging without an iframe, since ClientWidgetApi is mocked + widget.start(null as unknown as HTMLIFrameElement); + messaging = mocked(last(mocked(ClientWidgetApi).mock.instances)!); + Object.defineProperty(messaging, "transport", { value: { reply: jest.fn() } }); + actionFns = Object.fromEntries(messaging.on.mock.calls as any); + }); + + afterEach(() => { + widget.stop(); + jest.resetAllMocks(); + }); + + describe("open integrations manager", () => { + let openIntegrationManager: jest.SpyInstance>; + beforeEach(() => { + // Trivial mock of ModalWidgetStore + openIntegrationManager = jest.fn(); + const inst = IntegrationManagers.sharedInstance(); + jest.spyOn(inst, "getPrimaryManager").mockImplementation( + () => + ({ + open: openIntegrationManager, + }) as any, + ); + }); + + it("open the integration manager", () => { + const ev = createTransportEvent({ integType: "my_integ_type", integId: "my_integ_id" }); + const dispatch = (defaultDispatcher.dispatch = jest.fn()); + actionFns[`action:${ElementWidgetActions.OpenIntegrationManager}`](ev); + expect(dispatch).toHaveBeenCalledWith({ + action: "stickerpicker_close", + }); + expect(messaging.transport.reply).toHaveBeenCalledWith(ev.detail, {}); + expect(openIntegrationManager).toHaveBeenCalledWith(room, `type_my_integ_type`, "my_integ_id"); + }); + }); +}); diff --git a/test/unit-tests/stores/widgets/WidgetPermissionStore-test.ts b/test/unit-tests/stores/widgets/WidgetPermissionStore-test.ts index 81c7f35e7b..b0ebf2b23b 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 fd6508c228..c5d973ea2e 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);