diff --git a/playwright/e2e/voip/element-call.spec.ts b/playwright/e2e/voip/element-call.spec.ts index dc3549a1ea7..e52d781ac75 100644 --- a/playwright/e2e/voip/element-call.spec.ts +++ b/playwright/e2e/voip/element-call.spec.ts @@ -544,37 +544,34 @@ test.describe("Element Call", () => { }); // For https://github.com/element-hq/element-web/issues/30838 - 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); + 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); - 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 9e4544c37d4..33770fe4bf3 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?.widgetApi) return; // more "should never happen" words + if (!messaging) return; // more "should never happen" words - messaging.widgetApi.transport.send(ElementWidgetActions.HangupCall, {}); + messaging.transport.send(ElementWidgetActions.HangupCall, {}); }); } diff --git a/src/Livestream.ts b/src/Livestream.ts index cc8812fc9d1..5fa315b4427 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, - widgetApi: ClientWidgetApi, + widgetMessaging: ClientWidgetApi, roomId: string, ): Promise { const streamId = await createLiveStream(matrixClient, roomId); - await widgetApi.transport.send(ElementWidgetActions.StartLiveStream, { + await widgetMessaging.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 ff74e25d38f..5b03d54e175 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 IWidget, MatrixCapabilities } from "matrix-widget-api"; +import { type ClientWidgetApi, 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, type WidgetMessaging } from "../../../stores/widgets/WidgetMessaging"; +import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; 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: WidgetMessaging | undefined): boolean => { +const showSnapshotButton = (widgetMessaging: ClientWidgetApi | undefined): boolean => { return ( SettingsStore.getValue("enableWidgetScreenshots") && - !!widgetMessaging?.widgetApi?.hasCapability(MatrixCapabilities.Screenshots) + !!widgetMessaging?.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!.widgetApi!, roomId); + await startJitsiAudioLivestream(cli, widgetMessaging!, 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?.widgetApi + widgetMessaging ?.takeScreenshot() .then((data) => { dis.dispatch({ diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx index c0af2b632b2..250a438c135 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 { ElementWidgetDriver } from "../../../stores/widgets/ElementWidgetDriver"; +import { StopGapWidgetDriver } from "../../../stores/widgets/StopGapWidgetDriver"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { OwnProfileStore } from "../../../stores/OwnProfileStore"; import { arrayFastClone } from "../../../utils/arrays"; -import { ElementWidget } from "../../../stores/widgets/WidgetMessaging"; +import { ElementWidget } from "../../../stores/widgets/StopGapWidget"; 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 messaging?: WidgetMessaging; + private sgWidget?: StopGapWidget; private dispatcherRef?: string; private unmounted = false; @@ -165,16 +164,11 @@ export default class AppTile extends React.Component { // The key used for PersistedElement this.persistKey = getPersistKey(WidgetUtils.getWidgetUid(this.props.app)); - - 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); - } + try { + this.sgWidget = new StopGapWidget(this.props); + } catch (e) { + logger.log("Failed to construct widget", e); + this.sgWidget = undefined; } this.state = this.getNewState(props); @@ -241,11 +235,11 @@ export default class AppTile extends React.Component { private determineInitialRequiresClientState(): boolean { try { - const widget = new ElementWidget(this.props.app); - const messaging = WidgetMessagingStore.instance.getMessaging(widget, this.props.room?.roomId); - if (messaging?.widgetApi) { + const mockWidget = new ElementWidget(this.props.app); + const widgetApi = WidgetMessagingStore.instance.getMessaging(mockWidget, this.props.room?.roomId); + if (widgetApi) { // Load value from existing API to prevent resetting the requiresClient value on layout changes. - return messaging.widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient); + return widgetApi.hasCapability(ElementWidgetCapabilities.RequiresClient); } } catch { // fallback to true @@ -297,7 +291,7 @@ export default class AppTile extends React.Component { isAppWidget(this.props.app) ? this.props.app.roomId : null, ); PersistedElement.destroyElement(this.persistKey); - this.messaging?.stop(); + this.sgWidget?.stopMessaging(); } this.setState({ hasPermissionToLoad }); @@ -331,12 +325,12 @@ export default class AppTile extends React.Component { ); } - if (this.messaging) { - this.setupMessagingListeners(); + if (this.sgWidget) { + this.setupSgListeners(); } // Only fetch IM token on mount if we're showing and have permission to load - if (this.messaging && this.state.hasPermissionToLoad) { + if (this.sgWidget && this.state.hasPermissionToLoad) { this.startWidget(); } this.watchUserReady(); @@ -382,56 +376,73 @@ export default class AppTile extends React.Component { OwnProfileStore.instance.removeListener(UPDATE_EVENT, this.onUserReady); } - private setupMessagingListeners(): void { - this.messaging?.on(WidgetMessagingEvent.Start, this.onMessagingStart); - this.messaging?.on(WidgetMessagingEvent.Stop, this.onMessagingStop); + 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 stopMessagingListeners(): void { - this.messaging?.off(WidgetMessagingEvent.Start, this.onMessagingStart); - this.messaging?.off(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 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.messaging?.stop(); - this.stopMessagingListeners(); + this.sgWidget?.stopMessaging(); + this.stopSgListeners(); try { - 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.sgWidget = new StopGapWidget(newProps); + this.setupSgListeners(); this.startWidget(); } catch (e) { logger.error("Failed to construct widget", e); - this.messaging = undefined; + this.sgWidget = undefined; } } private startWidget(): void { - this.messaging?.prepare().then(() => { + this.sgWidget?.prepare().then(() => { if (this.unmounted) return; this.setState({ initialising: false }); }); } /** - * 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. + * 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. */ private iframeParentRef = (element: HTMLElement | null): void => { if (this.unmounted) return; @@ -440,43 +451,10 @@ export default class AppTile extends React.Component { this.iframeParent?.querySelector("iframe")?.remove(); this.iframeParent = element; - 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. + if (element && this.sgWidget) { + this.startMessaging(); + } else { 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); - } - }); } }; @@ -506,7 +484,7 @@ export default class AppTile extends React.Component { isAppWidget(this.props.app) ? this.props.app.roomId : null, ); - this.messaging?.stop({ forceDestroy: true }); + this.sgWidget?.stopMessaging({ forceDestroy: true }); } private onWidgetReady = (): void => { @@ -515,7 +493,7 @@ export default class AppTile extends React.Component { private updateRequiresClient = (): void => { this.setState({ - requiresClient: !!this.messaging?.widgetApi?.hasCapability(ElementWidgetCapabilities.RequiresClient), + requiresClient: !!this.sgWidget?.widgetApi?.hasCapability(ElementWidgetCapabilities.RequiresClient), }); }; @@ -524,7 +502,7 @@ export default class AppTile extends React.Component { case "m.sticker": if ( payload.widgetId === this.props.app.id && - this.messaging?.widgetApi?.hasCapability(MatrixCapabilities.StickerSending) + this.sgWidget?.widgetApi?.hasCapability(MatrixCapabilities.StickerSending) ) { dis.dispatch({ action: "post_sticker_message", @@ -624,7 +602,7 @@ export default class AppTile extends React.Component { // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement("a"), { target: "_blank", - href: this.messaging?.popoutUrl, + href: this.sgWidget?.popoutUrl, rel: "noreferrer noopener", }).click(); }; @@ -687,13 +665,13 @@ export default class AppTile extends React.Component { ); - if (this.messaging === null) { + if (this.sgWidget === null) { appTileBody = (
); - } else if (!this.state.hasPermissionToLoad && this.props.room && this.messaging) { + } else if (!this.state.hasPermissionToLoad && this.props.room && this.sgWidget) { // only possible for room widgets, can assert this.props.room here const isEncrypted = this.context.isRoomEncrypted(this.props.room.roomId); appTileBody = ( @@ -701,7 +679,7 @@ export default class AppTile extends React.Component { @@ -720,7 +698,7 @@ export default class AppTile extends React.Component { ); - } else if (this.messaging) { + } else if (this.sgWidget) { appTileBody = ( <>
diff --git a/src/components/views/pips/WidgetPip.tsx b/src/components/views/pips/WidgetPip.tsx index ed8cdb7e955..22df47865d3 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)) - ?.widgetApi?.transport.send(ElementWidgetActions.HangupCall, {}) + ?.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 bbf3f49685a..6281f287baa 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?.widgetApi && visible !== this.prevSentVisibility) { - messaging.widgetApi.updateVisibility(visible).catch((err) => { + if (messaging && visible !== this.prevSentVisibility) { + messaging.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 39e4aa4b9a6..ebf3f7b50a0 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 as rootLogger } from "matrix-js-sdk/src/logger"; +import { logger } 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,10 +43,8 @@ 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 ( @@ -124,15 +122,15 @@ export abstract class Call extends TypedEventEmitter { + public async start(_params?: WidgetGenerationParameters): Promise { const messagingStore = WidgetMessagingStore.instance; - 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), - ); - + this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null; + if (!this.messaging) { + // The widget might still be initializing, so wait for it. try { - await recheck.promise; - } finally { - currentMessaging?.off(WidgetMessagingEvent.Start, onStart); - messagingStore.off(WidgetMessagingStoreEvent.StoreMessaging, onStoreMessaging); - clearTimeout(timeout); + 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}`); } } - - logger.debug(`Widget ${this.widgetUid} now ready`); - return (this.widgetApi = messaging.widgetApi); } protected setConnected(): void { @@ -299,7 +267,7 @@ export abstract class Call extends TypedEventEmitter { if (uid === this.widgetUid && this.connected) { - logger.debug("The widget died; treating this as a user hangup"); + logger.log("The widget died; treating this as a user hangup"); this.setDisconnected(); this.close(); } @@ -480,26 +448,25 @@ export class JitsiCall extends Call { }); } - public async start(): Promise { - const widgetApi = await super.start(); - widgetApi.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); - widgetApi.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + public async start(): Promise { + await super.start(); + this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + this.messaging!.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.widgetApi!, + this.messaging!, `action:${ElementWidgetActions.HangupCall}`, (ev: CustomEvent) => { ev.preventDefault(); - this.widgetApi!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack return true; }, ); - const request = this.widgetApi!.transport.send(ElementWidgetActions.HangupCall, {}); + const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); try { await Promise.all([request, response]); } catch (e) { @@ -508,8 +475,8 @@ export class JitsiCall extends Call { } public close(): void { - this.widgetApi!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); - this.widgetApi!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin); + this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock); ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock); super.close(); @@ -541,7 +508,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.debug(`Resending video member event for ${this.roomId}`); + logger.log(`Resending video member event for ${this.roomId}`); await this.addOurDevice(); }, (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4, @@ -560,18 +527,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.widgetApi!.transport.send(ElementWidgetActions.TileLayout, {}); + await this.messaging!.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.widgetApi!.transport.send(ElementWidgetActions.SpotlightLayout, {}); + await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {}); }; private readonly onJoin = (ev: CustomEvent): void => { ev.preventDefault(); - this.widgetApi!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack this.setConnected(); }; @@ -581,7 +548,7 @@ export class JitsiCall extends Call { if (this.connectionState === ConnectionState.Disconnecting) return; ev.preventDefault(); - this.widgetApi!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); if (!isVideoRoom(this.room)) this.close(); }; @@ -933,7 +900,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 }; @@ -942,25 +909,24 @@ export class ElementCall extends Call { this.roomId, this.widgetGenerationParameters, ).toString(); - 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; + 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); } protected async performDisconnection(): Promise { const response = waitForEvent( - this.widgetApi!, + this.messaging!, `action:${ElementWidgetActions.HangupCall}`, (ev: CustomEvent) => { ev.preventDefault(); - this.widgetApi!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack return true; }, ); - const request = this.widgetApi!.transport.send(ElementWidgetActions.HangupCall, {}); + const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {}); try { await Promise.all([request, response]); } catch (e) { @@ -969,10 +935,10 @@ export class ElementCall extends Call { } public close(): void { - 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); + 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); super.close(); } @@ -1020,12 +986,12 @@ export class ElementCall extends Call { private readonly onDeviceMute = (ev: CustomEvent): void => { ev.preventDefault(); - this.widgetApi!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack }; private readonly onJoin = (ev: CustomEvent): void => { ev.preventDefault(); - this.widgetApi!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack this.setConnected(); }; @@ -1035,13 +1001,13 @@ export class ElementCall extends Call { if (this.connectionState === ConnectionState.Disconnecting) return; ev.preventDefault(); - this.widgetApi!.transport.reply(ev.detail, {}); // ack + this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); }; private readonly onClose = async (ev: CustomEvent): Promise => { ev.preventDefault(); - this.widgetApi!.transport.reply(ev.detail, {}); // ack + this.messaging!.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 76db49e5dcf..2a2fbdd8da5 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?.widgetApi) { - logger.error("No source widget API for modal widget"); + if (!sourceMessaging) { + logger.error("No source widget messaging for modal widget"); return; } - sourceMessaging.widgetApi.notifyModalWidgetClose(data); + sourceMessaging.notifyModalWidgetClose(data); } }; } diff --git a/src/stores/widgets/WidgetMessaging.ts b/src/stores/widgets/StopGapWidget.ts similarity index 81% rename from src/stores/widgets/WidgetMessaging.ts rename to src/stores/widgets/StopGapWidget.ts index 58abe5b34dd..c8de3bf0f13 100644 --- a/src/stores/widgets/WidgetMessaging.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -14,7 +14,6 @@ import { ClientEvent, RoomStateEvent, type ReceivedToDeviceMessage, - TypedEventEmitter, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { @@ -35,10 +34,11 @@ 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 { ElementWidgetDriver } from "./ElementWidgetDriver"; +import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { WidgetMessagingStore } from "./WidgetMessagingStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import { OwnProfileStore } from "../OwnProfileStore"; @@ -46,11 +46,12 @@ 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 { isAppWidget } from "../WidgetStore"; +import { type IApp, isAppWidget } from "../WidgetStore"; import ThemeWatcher, { ThemeWatcherEvent } from "../../settings/watchers/ThemeWatcher"; import { getCustomTheme } from "../../theme"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; @@ -63,10 +64,21 @@ import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; import { SdkContextClass } from "../../contexts/SDKContext"; import { UPDATE_EVENT } from "../AsyncStore"; -// TODO: Purge this code of its overgrown hacks and compatibility shims. +// TODO: Destroy all of this code -// TODO: Don't use this. We should avoid overriding/mocking matrix-widget-api -// behavior and instead strive to use widgets in more transparent ways. +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 export class ElementWidget extends Widget { public constructor(private rawDefinition: IWidget) { super(rawDefinition); @@ -140,49 +152,11 @@ export class ElementWidget extends Widget { } } -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 { +export class StopGapWidget extends EventEmitter { 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 @@ -197,28 +171,26 @@ export class WidgetMessaging extends TypedEventEmitter(); - public constructor( - private readonly widget: ElementWidget, - options: WidgetMessagingOptions, - ) { + public constructor(private appTileProps: IAppTileProps) { super(); this.client = MatrixClientPeg.safeGet(); - 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; + 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; } - /** - * The widget API interface to the widget, or null if disconnected. - */ public get widgetApi(): ClientWidgetApi | null { - return this._widgetApi; + return this.messaging; } /** @@ -248,7 +220,7 @@ export class WidgetMessaging extends TypedEventEmitter { - this.widgetApi?.updateTheme({ name: theme }); + this.messaging?.updateTheme({ name: theme }); }; private onOpenModal = async (ev: CustomEvent): Promise => { ev.preventDefault(); if (ModalWidgetStore.instance.canOpenModalWidget()) { - ModalWidgetStore.instance.openModalWidget(ev.detail.data, this.widget, this.roomId); - this.widgetApi?.transport.reply(ev.detail, {}); // ack + ModalWidgetStore.instance.openModalWidget(ev.detail.data, this.mockWidget, this.roomId); + this.messaging?.transport.reply(ev.detail, {}); // ack } else { - this.widgetApi?.transport.reply(ev.detail, { + this.messaging?.transport.reply(ev.detail, { error: { message: "Unable to open modal at this time", }, @@ -294,7 +266,7 @@ export class WidgetMessaging extends TypedEventEmitter { const roomId = SdkContextClass.instance.roomViewStore.getRoomId() ?? null; if (roomId !== this.viewedRoomId) { - this.widgetApi!.setViewedRoomId(roomId); + this.messaging!.setViewedRoomId(roomId); this.viewedRoomId = roomId; } }; @@ -303,47 +275,60 @@ export class WidgetMessaging extends TypedEventEmitter 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"); - 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.widgetApi.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal); + this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified")); + this.messaging.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.widgetApi.setViewedRoomId(SdkContextClass.instance.roomViewStore.getRoomId() ?? null); + this.messaging.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.widgetApi.setViewedRoomId(this.roomId); + this.messaging.setViewedRoomId(this.roomId); } // Always attach a handler for ViewRoom, but permission check it internally - this.widgetApi.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent) => { + this.messaging.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.widgetApi?.transport.reply(ev.detail, { + return this.messaging?.transport.reply(ev.detail, { error: { message: "Room ID not supplied." }, }); } // Check the widget's permission - if (!this.widgetApi?.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) { - return this.widgetApi?.transport.reply(ev.detail, { + if (!this.messaging?.hasCapability(ElementWidgetCapabilities.CanChangeViewedRoom)) { + return this.messaging?.transport.reply(ev.detail, { error: { message: "This widget does not have permission for this action (denied)." }, }); } @@ -356,7 +341,7 @@ export class WidgetMessaging extends TypedEventEmitter{}); + this.messaging.transport.reply(ev.detail, {}); }); // Populate the map of "read up to" events for this widget with the current event in every room. @@ -376,10 +361,10 @@ export class WidgetMessaging extends TypedEventEmitter) => { - if (this.widgetApi?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { + if (this.messaging?.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { ev.preventDefault(); if (ev.detail.data.value) { // If the widget wants to become sticky we wait for the stickyPromise to resolve @@ -387,43 +372,43 @@ export class WidgetMessaging extends TypedEventEmitter{}); + this.messaging.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.widgetApi.on( + this.messaging.on( `action:${WidgetApiFromWidgetAction.SendSticker}`, (ev: CustomEvent) => { - if (this.widgetApi?.hasCapability(MatrixCapabilities.StickerSending)) { + if (this.messaging?.hasCapability(MatrixCapabilities.StickerSending)) { // Acknowledge first ev.preventDefault(); - this.widgetApi.transport.reply(ev.detail, {}); + this.messaging.transport.reply(ev.detail, {}); // Send the sticker defaultDispatcher.dispatch({ action: "m.sticker", data: ev.detail.data, - widgetId: this.widget.id, + widgetId: this.mockWidget.id, }); } }, ); - if (WidgetType.STICKERPICKER.matches(this.widget.type)) { - this.widgetApi.on( + if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) { + this.messaging.on( `action:${ElementWidgetActions.OpenIntegrationManager}`, (ev: CustomEvent) => { // Acknowledge first ev.preventDefault(); - this.widgetApi?.transport.reply(ev.detail, {}); + this.messaging?.transport.reply(ev.detail, {}); // First close the stickerpicker defaultDispatcher.dispatch({ action: "stickerpicker_close" }); @@ -444,8 +429,8 @@ export class WidgetMessaging extends TypedEventEmitter) => { + if (WidgetType.JITSI.matches(this.mockWidget.type)) { + this.messaging.on(`action:${ElementWidgetActions.HangupCall}`, (ev: CustomEvent) => { ev.preventDefault(); if (ev.detail.data?.errorMessage) { Modal.createDialog(ErrorDialog, { @@ -455,11 +440,9 @@ export class WidgetMessaging extends TypedEventEmitter{}); + this.messaging?.transport.reply(ev.detail, {}); }); } - - this.emit(WidgetMessagingEvent.Start, this.widgetApi); } public async prepare(): Promise { @@ -467,8 +450,10 @@ export class WidgetMessaging extends TypedEventEmitter { - if (this.widgetApi === null) return; + if (this.messaging === null) return; const raw = ev.getEffectiveEvent(); - this.widgetApi.feedStateUpdate(raw as IRoomEvent).catch((e) => { + this.messaging.feedStateUpdate(raw as IRoomEvent).catch((e) => { logger.error("Error sending state update to widget: ", e); }); }; @@ -541,7 +525,7 @@ export class WidgetMessaging extends TypedEventEmitter => { const { message, encryptionInfo } = payload; // TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent - await this.widgetApi?.feedToDevice(message as IRoomEvent, encryptionInfo != null); + await this.messaging?.feedToDevice(message as IRoomEvent, encryptionInfo != null); }; /** @@ -608,7 +592,7 @@ export class WidgetMessaging extends TypedEventEmitter { + this.messaging.feedEvent(raw as IRoomEvent).catch((e) => { logger.error("Error sending event to widget: ", e); }); } diff --git a/src/stores/widgets/ElementWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts similarity index 98% rename from src/stores/widgets/ElementWidgetDriver.ts rename to src/stores/widgets/StopGapWidgetDriver.ts index e341f7627c0..42abdc801db 100644 --- a/src/stores/widgets/ElementWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -65,6 +65,8 @@ 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`) || "[]"); } @@ -79,19 +81,12 @@ const normalizeTurnServer = ({ urls, username, credential }: IClientTurnServer): password: credential, }); -/** - * 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 { +export class StopGapWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; // TODO: Refactor widgetKind into the Widget class public constructor( + allowedCapabilities: Capability[], private forWidget: Widget, private forWidgetKind: WidgetKind, virtual: boolean, @@ -102,7 +97,11 @@ export class ElementWidgetDriver 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([MatrixCapabilities.Screenshots, ElementWidgetCapabilities.RequiresClient]); + this.allowedCapabilities = new Set([ + ...allowedCapabilities, + 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/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index a9ec3d765f7..2dcf8c4fdc6 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 Widget } from "matrix-widget-api"; +import { type ClientWidgetApi, type Widget } from "matrix-widget-api"; import { type EmptyObject } from "matrix-js-sdk/src/matrix"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -14,7 +14,6 @@ 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", @@ -33,7 +32,7 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { return instance; })(); - private widgetMap = new EnhancedMap(); // + private widgetMap = new EnhancedMap(); // public constructor() { super(defaultDispatcher); @@ -52,19 +51,19 @@ export class WidgetMessagingStore extends AsyncStoreWithClient { this.widgetMap.clear(); } - public storeMessaging(widget: Widget, roomId: string | undefined, messaging: WidgetMessaging): void { + public storeMessaging(widget: Widget, roomId: string | undefined, widgetApi: ClientWidgetApi): void { this.stopMessaging(widget, roomId); const uid = WidgetUtils.calcWidgetUid(widget.id, roomId); - this.widgetMap.set(uid, messaging); + this.widgetMap.set(uid, widgetApi); - this.emit(WidgetMessagingStoreEvent.StoreMessaging, uid, messaging); + this.emit(WidgetMessagingStoreEvent.StoreMessaging, uid, widgetApi); } public stopMessaging(widget: Widget, roomId: string | undefined): void { this.stopMessagingByUid(WidgetUtils.calcWidgetUid(widget.id, roomId)); } - public getMessaging(widget: Widget, roomId: string | undefined): WidgetMessaging | undefined { + public getMessaging(widget: Widget, roomId: string | undefined): ClientWidgetApi | undefined { return this.widgetMap.get(WidgetUtils.calcWidgetUid(widget.id, roomId)); } @@ -85,7 +84,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): WidgetMessaging | undefined { + public getMessagingForUid(widgetUid: string): ClientWidgetApi | 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 2d3bcd821b6..bce01af2025 100644 --- a/test/unit-tests/components/structures/PipContainer-test.tsx +++ b/test/unit-tests/components/structures/PipContainer-test.tsx @@ -49,7 +49,6 @@ 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: { @@ -150,40 +149,30 @@ describe("PipContainer", () => { await act(async () => { WidgetStore.instance.addVirtualWidget(call.widget, room.roomId); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { - on: () => {}, - off: () => {}, - prepare: async () => {}, stop: () => {}, - widgetApi: { - hasCapability: jest.fn(), - feedStateUpdate: jest.fn().mockResolvedValue(undefined), - }, - } as unknown as WidgetMessaging); + hasCapability: jest.fn(), + feedStateUpdate: jest.fn().mockResolvedValue(undefined), + } as unknown as ClientWidgetApi); await call.start(); ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true); }); - try { - await fn(call); - } finally { - cleanup(); - act(() => { - call.destroy(); - ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); - WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId); - }); - } + await fn(call); + + 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)); - try { - await fn(); - } finally { - cleanup(); - ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId); - } + await fn(); + cleanup(); + ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId); }; const setUpRoomViewStore = () => { @@ -287,13 +276,9 @@ describe("PipContainer", () => { >() .mockResolvedValue({}); const mockMessaging = { - on: () => {}, - off: () => {}, + transport: { send: sendSpy }, stop: () => {}, - widgetApi: { - transport: { send: sendSpy }, - }, - } as unknown as WidgetMessaging; + } as unknown as ClientWidgetApi; 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 caa5b23810d..f48ad2e3537 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 { Widget } from "matrix-widget-api"; +import { type ClientWidgetApi, Widget } from "matrix-widget-api"; import { act, cleanup, render, screen } from "jest-matrix-react"; import { mocked, type Mocked } from "jest-mock"; @@ -32,7 +32,6 @@ 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; @@ -116,7 +115,7 @@ describe("", () => { widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, - } as unknown as WidgetMessaging); + } as unknown as ClientWidgetApi); }); 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 3a03eb25f03..2c454dc86db 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 IWidget, MatrixWidgetType } from "matrix-widget-api"; +import { type ClientWidgetApi, 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, type WidgetMessaging } from "../../../../../src/stores/widgets/WidgetMessaging"; +import { ElementWidget } from "../../../../../src/stores/widgets/StopGapWidget"; import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore"; import { ModuleRunner } from "../../../../../src/modules/ModuleRunner"; import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; @@ -116,11 +116,9 @@ describe("AppTile", () => { await RightPanelStore.instance.onReady(); }); - beforeEach(async () => { + beforeEach(() => { 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 () => { @@ -426,20 +424,16 @@ describe("AppTile", () => { describe("with an existing widgetApi with requiresClient = false", () => { beforeEach(() => { - const messaging = { - on: () => {}, - off: () => {}, - prepare: async () => {}, - stop: () => {}, - widgetApi: { - hasCapability: (capability: ElementWidgetCapabilities): boolean => { - return !(capability === ElementWidgetCapabilities.RequiresClient); - }, + const api = { + hasCapability: (capability: ElementWidgetCapabilities): boolean => { + return !(capability === ElementWidgetCapabilities.RequiresClient); }, - } as unknown as WidgetMessaging; + once: () => {}, + stop: () => {}, + } as unknown as ClientWidgetApi; const mockWidget = new ElementWidget(app1); - WidgetMessagingStore.instance.storeMessaging(mockWidget, r1.roomId, messaging); + WidgetMessagingStore.instance.storeMessaging(mockWidget, r1.roomId, api); 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 4c5b3ce6920..688c9b190f1 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 { Widget } from "matrix-widget-api"; +import { type ClientWidgetApi, Widget } from "matrix-widget-api"; import { useMockedCalls, @@ -35,7 +35,6 @@ 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); @@ -87,7 +86,7 @@ describe("CallEvent", () => { widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, - } as unknown as WidgetMessaging); + } as unknown as ClientWidgetApi); }); 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 f093840f1a4..a770b00bd41 100644 --- a/test/unit-tests/components/views/rooms/RoomTile-test.tsx +++ b/test/unit-tests/components/views/rooms/RoomTile-test.tsx @@ -20,6 +20,7 @@ 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, @@ -46,7 +47,6 @@ 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 WidgetMessaging); + } as unknown as ClientWidgetApi); }); afterEach(() => { diff --git a/test/unit-tests/components/views/voip/CallView-test.tsx b/test/unit-tests/components/views/voip/CallView-test.tsx index ba8e9b619fb..255f548abf9 100644 --- a/test/unit-tests/components/views/voip/CallView-test.tsx +++ b/test/unit-tests/components/views/voip/CallView-test.tsx @@ -18,6 +18,7 @@ import { } from "matrix-js-sdk/src/matrix"; import { Widget } from "matrix-widget-api"; +import type { ClientWidgetApi } from "matrix-widget-api"; import { stubClient, mkRoomMember, @@ -32,7 +33,6 @@ 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,11 +73,8 @@ describe("CallView", () => { widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { - on: () => {}, - off: () => {}, stop: () => {}, - embedUrl: "https://example.org", - } as unknown as WidgetMessaging); + } as unknown as ClientWidgetApi); }); afterEach(() => { diff --git a/test/unit-tests/models/Call-test.ts b/test/unit-tests/models/Call-test.ts index e26ae85fa49..9e2233d7595 100644 --- a/test/unit-tests/models/Call-test.ts +++ b/test/unit-tests/models/Call-test.ts @@ -48,37 +48,36 @@ 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; widgetApi: Mocked } => { +const setUpWidget = (call: Call): { widget: Widget; messaging: Mocked } => { call.widget.data = { ...call.widget, skipLobby: true }; const widget = new Widget(call.widget); - const widgetApi = new (class extends EventEmitter { - transport = { + 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: { send: jest.fn(), reply: jest.fn(), - }; - })() as unknown as Mocked; - const messaging = new (class extends EventEmitter { - stop = jest.fn(); - widgetApi = widgetApi; - })() as unknown as Mocked; + }, + } as unknown as Mocked; WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging); - return { widget, messaging, widgetApi }; + return { widget, messaging }; }; -async function connect(call: Call, widgetApi: Mocked, startWidget = true): Promise { +async function connect(call: Call, messaging: Mocked, startWidget = true): Promise { async function sessionConnect() { await new Promise((r) => { setTimeout(() => r(), 400); }); - widgetApi.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {})); + messaging.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {})); } async function runTimers() { jest.advanceTimersByTime(500); @@ -88,12 +87,12 @@ async function connect(call: Call, widgetApi: Mocked, startWidg await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]); } -async function disconnect(call: Call, widgetApi: Mocked): Promise { +async function disconnect(call: Call, messaging: Mocked): Promise { async function sessionDisconnect() { await new Promise((r) => { setTimeout(() => r(), 400); }); - widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); } async function runTimers() { jest.advanceTimersByTime(500); @@ -151,8 +150,7 @@ describe("JitsiCall", () => { describe("instance in a video room", () => { let call: JitsiCall; let widget: Widget; - let messaging: Mocked; - let widgetApi: Mocked; + let messaging: Mocked; beforeEach(async () => { jest.useFakeTimers(); @@ -163,16 +161,16 @@ describe("JitsiCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging, widgetApi } = setUpWidget(call)); + ({ widget, messaging } = setUpWidget(call)); - mocked(widgetApi.transport).send.mockImplementation(async (action, data): Promise => { + mocked(messaging.transport).send.mockImplementation(async (action, data): Promise => { if (action === ElementWidgetActions.JoinCall) { - widgetApi.emit( + messaging.emit( `action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", { detail: { data } }), ); } else if (action === ElementWidgetActions.HangupCall) { - widgetApi.emit( + messaging.emit( `action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", { detail: { data } }), ); @@ -185,7 +183,7 @@ describe("JitsiCall", () => { it("connects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, widgetApi); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); }); @@ -198,27 +196,27 @@ describe("JitsiCall", () => { const startup = call.start(); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging); await startup; - await connect(call, widgetApi, false); + await connect(call, messaging, false); expect(call.connectionState).toBe(ConnectionState.Connected); }); it("fails to disconnect if the widget returns an error", async () => { - await connect(call, widgetApi); - mocked(widgetApi.transport).send.mockRejectedValue(new Error("never!")); + await connect(call, messaging); + mocked(messaging.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, widgetApi); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); const callback = jest.fn(); call.on(CallEvent.ConnectionState, callback); - widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); await waitFor(() => { expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected); }); @@ -228,14 +226,14 @@ describe("JitsiCall", () => { it("disconnects", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, widgetApi); + await connect(call, messaging); 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, widgetApi); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave); expect(call.connectionState).toBe(ConnectionState.Disconnected); @@ -243,14 +241,14 @@ describe("JitsiCall", () => { it("reconnects after disconnect in video rooms", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); - await connect(call, widgetApi); + await connect(call, messaging); 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, widgetApi); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); room.emit(RoomEvent.MyMembership, room, KnownMembership.Join); expect(call.connectionState).toBe(ConnectionState.Connected); @@ -276,7 +274,7 @@ describe("JitsiCall", () => { // Now, stub out client.sendStateEvent so we can test our local echo client.sendStateEvent.mockReset(); - await connect(call, widgetApi); + await connect(call, messaging); expect(call.participants).toEqual( new Map([ [alice, new Set(["alices_device"])], @@ -289,7 +287,7 @@ describe("JitsiCall", () => { }); it("updates room state when connecting and disconnecting", async () => { - await connect(call, widgetApi); + await connect(call, messaging); const now1 = Date.now(); await waitFor( () => @@ -317,7 +315,7 @@ describe("JitsiCall", () => { }); it("repeatedly updates room state while connected", async () => { - await connect(call, widgetApi); + await connect(call, messaging); await waitFor( () => expect(client.sendStateEvent).toHaveBeenLastCalledWith( @@ -347,7 +345,7 @@ describe("JitsiCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await connect(call, widgetApi); + await connect(call, messaging); await call.disconnect(); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.Connected, ConnectionState.Disconnected], @@ -362,7 +360,7 @@ describe("JitsiCall", () => { const onParticipants = jest.fn(); call.on(CallEvent.Participants, onParticipants); - await connect(call, widgetApi); + await connect(call, messaging); await call.disconnect(); expect(onParticipants.mock.calls).toEqual([ [new Map([[alice, new Set(["alices_device"])]]), new Map()], @@ -375,11 +373,11 @@ describe("JitsiCall", () => { }); it("switches to spotlight layout when the widget becomes a PiP", async () => { - await connect(call, widgetApi); + await connect(call, messaging); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock); - expect(widgetApi.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); + expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {}); ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock); - expect(widgetApi.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {}); + expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {}); }); describe("clean", () => { @@ -419,7 +417,7 @@ describe("JitsiCall", () => { }); it("doesn't clean up valid devices", async () => { - await connect(call, widgetApi); + await connect(call, messaging); await client.sendStateEvent( room.roomId, JitsiCall.MEMBER_EVENT_TYPE, @@ -798,8 +796,7 @@ describe("ElementCall", () => { describe("instance in a non-video room", () => { let call: ElementCall; let widget: Widget; - let messaging: Mocked; - let widgetApi: Mocked; + let messaging: Mocked; beforeEach(async () => { jest.useFakeTimers(); @@ -810,128 +807,81 @@ describe("ElementCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, messaging, widgetApi } = setUpWidget(call)); + ({ widget, messaging } = 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 (widget API available immediately)", async () => { + it("waits for messaging when starting", 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, 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); + await connect(call, messaging, 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, widgetApi); - mocked(widgetApi.transport).send.mockRejectedValue(new Error("never!!1! >:(")); + await connect(call, messaging); + mocked(messaging.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, widgetApi); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); - widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); - widgetApi.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {})); + messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + messaging.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, widgetApi); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); - await disconnect(call, widgetApi); + await disconnect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Disconnected); }); it("disconnects when we leave the room", async () => { - await connect(call, widgetApi); + await connect(call, messaging); 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, widgetApi); + await connect(call, messaging); 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, widgetApi); + await connect(call, messaging); 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, widgetApi); + await connect(call, messaging); const preventDefault = jest.fn(); const mockEv = { preventDefault, detail: { video_enabled: false }, }; - widgetApi.emit(`action:${ElementWidgetActions.DeviceMute}`, mockEv); - expect(widgetApi.transport.reply).toHaveBeenCalledWith({ video_enabled: false }, {}); + messaging.emit(`action:${ElementWidgetActions.DeviceMute}`, mockEv); + expect(messaging.transport.reply).toHaveBeenCalledWith({ video_enabled: false }, {}); expect(preventDefault).toHaveBeenCalled(); }); @@ -940,8 +890,8 @@ describe("ElementCall", () => { const onConnectionState = jest.fn(); call.on(CallEvent.ConnectionState, onConnectionState); - await connect(call, widgetApi); - await disconnect(call, widgetApi); + await connect(call, messaging); + await disconnect(call, messaging); expect(onConnectionState.mock.calls).toEqual([ [ConnectionState.Connected, ConnectionState.Disconnected], [ConnectionState.Disconnecting, ConnectionState.Connected], @@ -963,10 +913,10 @@ describe("ElementCall", () => { }); it("ends the call immediately if the session ended", async () => { - await connect(call, widgetApi); + await connect(call, messaging); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await disconnect(call, widgetApi); + await disconnect(call, messaging); // this will be called automatically // disconnect -> widget sends state event -> session manager notices no-one left client.matrixRTC.emit( @@ -1007,7 +957,7 @@ describe("ElementCall", () => { describe("instance in a video room", () => { let call: ElementCall; let widget: Widget; - let widgetApi: Mocked; + let messaging: Mocked; beforeEach(async () => { jest.useFakeTimers(); @@ -1020,29 +970,29 @@ describe("ElementCall", () => { if (maybeCall === null) throw new Error("Failed to create call"); call = maybeCall; - ({ widget, widgetApi } = setUpWidget(call)); + ({ widget, messaging } = setUpWidget(call)); }); afterEach(() => cleanUpCallAndWidget(call, widget)); it("doesn't end the call when the last participant leaves", async () => { - await connect(call, widgetApi); + await connect(call, messaging); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); - await disconnect(call, widgetApi); + await disconnect(call, messaging); 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, widgetApi); + await connect(call, messaging); expect(call.connectionState).toBe(ConnectionState.Connected); - widgetApi.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {})); + messaging.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, widgetApi, false); + await connect(call, messaging, 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 893a13adfc2..a8b8afe7e70 100644 --- a/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts +++ b/test/unit-tests/stores/room-list/algorithms/Algorithm-test.ts @@ -12,6 +12,7 @@ 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, @@ -28,7 +29,6 @@ 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 WidgetMessaging); + } as unknown as ClientWidgetApi); // End of setup diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/StopGapWidget-test.ts new file mode 100644 index 00000000000..501aea7ea10 --- /dev/null +++ b/test/unit-tests/stores/widgets/StopGapWidget-test.ts @@ -0,0 +1,405 @@ +/* +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/ElementWidgetDriver-test.ts b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts similarity index 99% rename from test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts rename to test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts index aa5f425e996..9b6411b134a 100644 --- a/test/unit-tests/stores/widgets/ElementWidgetDriver-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts @@ -36,7 +36,7 @@ import { import { SdkContextClass } from "../../../../src/contexts/SDKContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { ElementWidgetDriver } from "../../../../src/stores/widgets/ElementWidgetDriver"; +import { StopGapWidgetDriver } from "../../../../src/stores/widgets/StopGapWidgetDriver"; import { mkEvent, stubClient } from "../../../test-utils"; import { ModuleRunner } from "../../../../src/modules/ModuleRunner"; import dis from "../../../../src/dispatcher/dispatcher"; @@ -44,11 +44,12 @@ import Modal from "../../../../src/Modal"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { WidgetType } from "../../../../src/widgets/WidgetType.ts"; -describe("ElementWidgetDriver", () => { +describe("StopGapWidgetDriver", () => { let client: MockedObject; const mkDefaultDriver = (): WidgetDriver => - new ElementWidgetDriver( + new StopGapWidgetDriver( + [], new Widget({ id: "test", creatorUserId: "@alice:example.org", @@ -72,7 +73,8 @@ describe("ElementWidgetDriver", () => { }); it("auto-approves capabilities of virtual Element Call widgets", async () => { - const driver = new ElementWidgetDriver( + const driver = new StopGapWidgetDriver( + [], new Widget({ id: "group_call", creatorUserId: "@alice:example.org", diff --git a/test/unit-tests/stores/widgets/WidgetMessaging-test.ts b/test/unit-tests/stores/widgets/WidgetMessaging-test.ts deleted file mode 100644 index eb66502fda9..00000000000 --- a/test/unit-tests/stores/widgets/WidgetMessaging-test.ts +++ /dev/null @@ -1,689 +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, - 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 b0ebf2b23b5..81c7f35e7bd 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 { ElementWidgetDriver } from "../../../../src/stores/widgets/ElementWidgetDriver"; +import { StopGapWidgetDriver } from "../../../../src/stores/widgets/StopGapWidgetDriver"; 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 ElementWidgetDriver(elementCallWidget, WidgetKind.Room, true, roomId); + new StopGapWidgetDriver([], 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 c5d973ea2e9..fd6508c2286 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 { Widget } from "matrix-widget-api"; +import { type ClientWidgetApi, Widget } from "matrix-widget-api"; import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; import { @@ -47,7 +47,6 @@ 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(); @@ -114,7 +113,7 @@ describe("IncomingCallToast", () => { widget = new Widget(call.widget); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, - } as unknown as WidgetMessaging); + } as unknown as ClientWidgetApi); jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); jest.spyOn(ToastStore, "sharedInstance").mockReturnValue(toastStore);