From 999b5afa0ad9c40a810a39dd2d77a92ed8ab6b09 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Fri, 4 Sep 2020 21:41:14 -0600 Subject: [PATCH 01/21] Acknowledge the visibility request --- src/FromWidgetPostMessageApi.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js index d5d7c08d50..bbccc47d28 100644 --- a/src/FromWidgetPostMessageApi.js +++ b/src/FromWidgetPostMessageApi.js @@ -218,6 +218,9 @@ export default class FromWidgetPostMessageApi { if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) { ActiveWidgetStore.setWidgetPersistence(widgetId, val); } + + // acknowledge + this.sendResponse(event, {}); } else if (action === 'get_openid') { // Handled by caller } else { From 634ffb0140d2e13e6b9af32400e024ead6b5c577 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Fri, 25 Sep 2020 09:39:21 -0600 Subject: [PATCH 02/21] Add structure for widget messaging layer --- src/stores/widgets/SdkWidgetDriver.ts | 34 ++++++ src/stores/widgets/WidgetMessagingStore.ts | 117 +++++++++++++++++++++ src/stores/widgets/WidgetSurrogate.ts | 25 +++++ src/utils/iterables.ts | 21 ++++ src/utils/maps.ts | 17 +++ 5 files changed, 214 insertions(+) create mode 100644 src/stores/widgets/SdkWidgetDriver.ts create mode 100644 src/stores/widgets/WidgetMessagingStore.ts create mode 100644 src/stores/widgets/WidgetSurrogate.ts create mode 100644 src/utils/iterables.ts diff --git a/src/stores/widgets/SdkWidgetDriver.ts b/src/stores/widgets/SdkWidgetDriver.ts new file mode 100644 index 0000000000..1462303fa3 --- /dev/null +++ b/src/stores/widgets/SdkWidgetDriver.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Capability, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api"; +import { iterableUnion } from "../../utils/iterables"; + +export class SdkWidgetDriver extends WidgetDriver { + public constructor( + private widget: Widget, + private widgetKind: WidgetKind, + private locationEntityId: string, + private preapprovedCapabilities: Set<Capability> = new Set(), + ) { + super(); + } + + public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> { + // TODO: Prompt the user to accept capabilities + return iterableUnion(requested, this.preapprovedCapabilities); + } +} diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts new file mode 100644 index 0000000000..6d05cae8c6 --- /dev/null +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ClientWidgetApi, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ActionPayload } from "../../dispatcher/payloads"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { WidgetSurrogate } from "./WidgetSurrogate"; +import { SdkWidgetDriver } from "./SdkWidgetDriver"; +import { EnhancedMap } from "../../utils/maps"; + +/** + * Temporary holding store for widget messaging instances. This is eventually + * going to be merged with a more complete WidgetStore, but for now it's + * easiest to split this into a single place. + */ +export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> { + private static internalInstance = new WidgetMessagingStore(); + + // <room/user ID, <widget ID, Widget>> + private widgetMap = new EnhancedMap<string, EnhancedMap<string, WidgetSurrogate>>(); + + public constructor() { + super(defaultDispatcher); + } + + public static get instance(): WidgetMessagingStore { + return WidgetMessagingStore.internalInstance; + } + + protected async onAction(payload: ActionPayload): Promise<any> { + // nothing to do + } + + protected async onReady(): Promise<any> { + // just in case + this.widgetMap.clear(); + } + + /** + * Gets the messaging instance for the widget. Returns a falsey value if none + * is present. + * @param {Room} room The room for which the widget lives within. + * @param {Widget} widget The widget to get messaging for. + * @returns {ClientWidgetApi} The messaging, or a falsey value. + */ + public messagingForRoomWidget(room: Room, widget: Widget): ClientWidgetApi { + return this.widgetMap.get(room.roomId)?.get(widget.id)?.messaging; + } + + /** + * Gets the messaging instance for the widget. Returns a falsey value if none + * is present. + * @param {Widget} widget The widget to get messaging for. + * @returns {ClientWidgetApi} The messaging, or a falsey value. + */ + public messagingForAccountWidget(widget: Widget): ClientWidgetApi { + return this.widgetMap.get(this.matrixClient?.getUserId())?.get(widget.id)?.messaging; + } + + private generateMessaging(locationId: string, widget: Widget, iframe: HTMLIFrameElement, driver: WidgetDriver) { + const messaging = new ClientWidgetApi(widget, iframe, driver); + this.widgetMap.getOrCreate(locationId, new EnhancedMap()) + .getOrCreate(widget.id, new WidgetSurrogate(widget, messaging)); + return messaging; + } + + /** + * Generates a messaging instance for the widget. If an instance already exists, it + * will be returned instead. + * @param {Room} room The room in which the widget lives. + * @param {Widget} widget The widget to generate/get messaging for. + * @param {HTMLIFrameElement} iframe The widget's iframe. + * @returns {ClientWidgetApi} The generated/cached messaging. + */ + public generateMessagingForRoomWidget(room: Room, widget: Widget, iframe: HTMLIFrameElement): ClientWidgetApi { + const existing = this.messagingForRoomWidget(room, widget); + if (existing) return existing; + + const driver = new SdkWidgetDriver(widget, WidgetKind.Room, room.roomId); + return this.generateMessaging(room.roomId, widget, iframe, driver); + } + + /** + * Generates a messaging instance for the widget. If an instance already exists, it + * will be returned instead. + * @param {Widget} widget The widget to generate/get messaging for. + * @param {HTMLIFrameElement} iframe The widget's iframe. + * @returns {ClientWidgetApi} The generated/cached messaging. + */ + public generateMessagingForAccountWidget(widget: Widget, iframe: HTMLIFrameElement): ClientWidgetApi { + if (!this.matrixClient) { + throw new Error("No matrix client to create account widgets with"); + } + + const existing = this.messagingForAccountWidget(widget); + if (existing) return existing; + + const userId = this.matrixClient.getUserId(); + const driver = new SdkWidgetDriver(widget, WidgetKind.Account, userId); + return this.generateMessaging(userId, widget, iframe, driver); + } +} diff --git a/src/stores/widgets/WidgetSurrogate.ts b/src/stores/widgets/WidgetSurrogate.ts new file mode 100644 index 0000000000..4d482124a6 --- /dev/null +++ b/src/stores/widgets/WidgetSurrogate.ts @@ -0,0 +1,25 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ClientWidgetApi, Widget } from "matrix-widget-api"; + +export class WidgetSurrogate { + public constructor( + public readonly definition: Widget, + public readonly messaging: ClientWidgetApi, + ) { + } +} diff --git a/src/utils/iterables.ts b/src/utils/iterables.ts new file mode 100644 index 0000000000..3d2585906d --- /dev/null +++ b/src/utils/iterables.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { arrayUnion } from "./arrays"; + +export function iterableUnion<C extends Iterable<T>, T>(a: C, b: C): Set<T> { + return new Set(arrayUnion(Array.from(a), Array.from(b))); +} diff --git a/src/utils/maps.ts b/src/utils/maps.ts index 96832094f0..630e0af286 100644 --- a/src/utils/maps.ts +++ b/src/utils/maps.ts @@ -44,3 +44,20 @@ export function mapKeyChanges<K, V>(a: Map<K, V>, b: Map<K, V>): K[] { const diff = mapDiff(a, b); return arrayMerge(diff.removed, diff.added, diff.changed); } + +/** + * A Map<K, V> with added utility. + */ +export class EnhancedMap<K, V> extends Map<K, V> { + public constructor(entries?: Iterable<[K, V]>) { + super(entries); + } + + public getOrCreate(key: K, def: V): V { + if (this.has(key)) { + return this.get(key); + } + this.set(key, def); + return def; + } +} From 96fa34eecfc251507b9e4788a3cdcb1214694d40 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Sat, 26 Sep 2020 18:40:26 -0600 Subject: [PATCH 03/21] Add stop functions --- src/stores/widgets/WidgetMessagingStore.ts | 20 ++++++++++++++++++++ src/utils/maps.ts | 6 ++++++ 2 files changed, 26 insertions(+) diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index 6d05cae8c6..dfa8eed943 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -114,4 +114,24 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> { const driver = new SdkWidgetDriver(widget, WidgetKind.Account, userId); return this.generateMessaging(userId, widget, iframe, driver); } + + /** + * Stops the messaging instance for the widget, unregistering it. + * @param {Room} room The room where the widget resides. + * @param {Widget} widget The widget + */ + public stopMessagingForRoomWidget(room: Room, widget: Widget) { + const api = this.widgetMap.getOrCreate(room.roomId, new EnhancedMap()).remove(widget.id); + if (api) api.messaging.stop(); + } + + /** + * Stops the messaging instance for the widget, unregistering it. + * @param {Widget} widget The widget + */ + public stopMessagingForAccountWidget(widget: Widget) { + if (!this.matrixClient) return; + const api = this.widgetMap.getOrCreate(this.matrixClient.getUserId(), new EnhancedMap()).remove(widget.id); + if (api) api.messaging.stop(); + } } diff --git a/src/utils/maps.ts b/src/utils/maps.ts index 630e0af286..57d84bd33f 100644 --- a/src/utils/maps.ts +++ b/src/utils/maps.ts @@ -60,4 +60,10 @@ export class EnhancedMap<K, V> extends Map<K, V> { this.set(key, def); return def; } + + public remove(key: K): V { + const v = this.get(key); + this.delete(key); + return v; + } } From 4ea3376abf76b72c307da1fefd4569c3a9b1c03c Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Mon, 28 Sep 2020 13:34:13 -0600 Subject: [PATCH 04/21] WIP on AppTile2 transformation --- src/components/views/elements/AppTile.js | 2 - src/components/views/elements/AppTile2.tsx | 77 ++++++++++++++++++++++ src/stores/ActiveWidgetStore.js | 60 +++-------------- src/stores/widgets/WidgetMessagingStore.ts | 19 ++++++ 4 files changed, 106 insertions(+), 52 deletions(-) create mode 100644 src/components/views/elements/AppTile2.tsx diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 6aaeab060f..83dd9d7b1e 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -108,7 +108,6 @@ export default class AppTile extends React.Component { return !!currentlyAllowedWidgets[newProps.app.eventId]; }; - const PersistedElement = sdk.getComponent("elements.PersistedElement"); return { initialising: true, // True while we are mangling the widget URL // True while the iframe content is loading @@ -190,7 +189,6 @@ export default class AppTile extends React.Component { // if it's not remaining on screen, get rid of the PersistedElement container if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); - const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); } } diff --git a/src/components/views/elements/AppTile2.tsx b/src/components/views/elements/AppTile2.tsx new file mode 100644 index 0000000000..78bb6f7754 --- /dev/null +++ b/src/components/views/elements/AppTile2.tsx @@ -0,0 +1,77 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ClientWidgetApi, Widget, WidgetKind } from "matrix-widget-api"; +import * as React from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; + +interface IProps { + widget: Widget; + kind: WidgetKind; + room?: Room; + + // TODO: All the showUIElement props +} + +interface IState { + loading: boolean; +} + +export class AppTile2 extends React.PureComponent<IProps, IState> { + private messaging: ClientWidgetApi; + private iframeRef = React.createRef<HTMLIFrameElement>(); + + public constructor(props: IProps) { + super(props); + + if (props.kind === WidgetKind.Room && !props.room) { + throw new Error("Expected room when supplied with a room widget"); + } + + this.state = { + loading: true, + }; + } + + private get isMixedContent(): boolean { + const myProtocol = window.location.protocol; + const widgetProtocol = new URL(this.props.widget.templateUrl).protocol; + return myProtocol === 'https:' && widgetProtocol !== 'https:'; + } + + public componentDidMount() { + if (!this.iframeRef.current) { + throw new Error("iframe has not yet been associated - fix the render code"); + } + + // TODO: Provide capabilities to widget messaging + + if (this.props.kind === WidgetKind.Room) { + this.messaging = WidgetMessagingStore.instance + .generateMessagingForRoomWidget(this.props.room, this.props.widget, this.iframeRef.current); + } else if (this.props.kind === WidgetKind.Account) { + this.messaging = WidgetMessagingStore.instance + .generateMessagingForAccountWidget(this.props.widget, this.iframeRef.current); + } else { + throw new Error("Unexpected widget kind: " + this.props.kind); + } + + this.messaging.once("ready", () => { + this.setState({loading: false}); + }); + } +} diff --git a/src/stores/ActiveWidgetStore.js b/src/stores/ActiveWidgetStore.js index bf9ae3586c..d6aaf83196 100644 --- a/src/stores/ActiveWidgetStore.js +++ b/src/stores/ActiveWidgetStore.js @@ -17,6 +17,7 @@ limitations under the License. import EventEmitter from 'events'; import {MatrixClientPeg} from '../MatrixClientPeg'; +import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore"; /** * Stores information about the widgets active in the app right now: @@ -29,15 +30,6 @@ class ActiveWidgetStore extends EventEmitter { super(); this._persistentWidgetId = null; - // A list of negotiated capabilities for each widget, by ID - // { - // widgetId: [caps...], - // } - this._capsByWidgetId = {}; - - // A WidgetMessaging instance for each widget ID - this._widgetMessagingByWidgetId = {}; - // What room ID each widget is associated with (if it's a room widget) this._roomIdByWidgetId = {}; @@ -54,8 +46,6 @@ class ActiveWidgetStore extends EventEmitter { if (MatrixClientPeg.get()) { MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents); } - this._capsByWidgetId = {}; - this._widgetMessagingByWidgetId = {}; this._roomIdByWidgetId = {}; } @@ -76,9 +66,16 @@ class ActiveWidgetStore extends EventEmitter { if (id !== this._persistentWidgetId) return; const toDeleteId = this._persistentWidgetId; + const result = WidgetMessagingStore.instance.findWidgetById(id); + if (result) { + if (result.room) { + WidgetMessagingStore.instance.stopMessagingForRoomWidget(result.room, result.widget); + } else { + WidgetMessagingStore.instance.stopMessagingForAccountWidget(result.widget); + } + } + this.setWidgetPersistence(toDeleteId, false); - this.delWidgetMessaging(toDeleteId); - this.delWidgetCapabilities(toDeleteId); this.delRoomId(toDeleteId); } @@ -99,43 +96,6 @@ class ActiveWidgetStore extends EventEmitter { return this._persistentWidgetId; } - setWidgetCapabilities(widgetId, caps) { - this._capsByWidgetId[widgetId] = caps; - this.emit('update'); - } - - widgetHasCapability(widgetId, cap) { - return this._capsByWidgetId[widgetId] && this._capsByWidgetId[widgetId].includes(cap); - } - - delWidgetCapabilities(widgetId) { - delete this._capsByWidgetId[widgetId]; - this.emit('update'); - } - - setWidgetMessaging(widgetId, wm) { - // Stop any existing widget messaging first - this.delWidgetMessaging(widgetId); - this._widgetMessagingByWidgetId[widgetId] = wm; - this.emit('update'); - } - - getWidgetMessaging(widgetId) { - return this._widgetMessagingByWidgetId[widgetId]; - } - - delWidgetMessaging(widgetId) { - if (this._widgetMessagingByWidgetId[widgetId]) { - try { - this._widgetMessagingByWidgetId[widgetId].stop(); - } catch (e) { - console.error('Failed to stop listening for widgetMessaging events', e.message); - } - delete this._widgetMessagingByWidgetId[widgetId]; - this.emit('update'); - } - } - getRoomId(widgetId) { return this._roomIdByWidgetId[widgetId]; } diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index dfa8eed943..fedc9c6c87 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -51,6 +51,25 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> { this.widgetMap.clear(); } + /** + * Finds a widget by ID. Not guaranteed to return an accurate result. + * @param {string} id The widget ID. + * @returns {{widget, room}} The widget and possible room ID, or a falsey value + * if not found. + * @deprecated Do not use. + */ + public findWidgetById(id: string): { widget: Widget, room?: Room } { + for (const key of this.widgetMap.keys()) { + for (const [entityId, surrogate] of this.widgetMap.get(key).entries()) { + if (surrogate.definition.id === id) { + const room: Room = this.matrixClient?.getRoom(entityId); // will be null for non-rooms + return {room, widget: surrogate.definition}; + } + } + } + return null; + } + /** * Gets the messaging instance for the widget. Returns a falsey value if none * is present. From 6b2e34dc0045a23f52e298f5b5f1d67e8b468e28 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Mon, 28 Sep 2020 21:14:50 -0600 Subject: [PATCH 05/21] Fix export --- src/components/views/elements/AppTile2.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/elements/AppTile2.tsx b/src/components/views/elements/AppTile2.tsx index 78bb6f7754..516c00170a 100644 --- a/src/components/views/elements/AppTile2.tsx +++ b/src/components/views/elements/AppTile2.tsx @@ -31,7 +31,7 @@ interface IState { loading: boolean; } -export class AppTile2 extends React.PureComponent<IProps, IState> { +export default class AppTile2 extends React.PureComponent<IProps, IState> { private messaging: ClientWidgetApi; private iframeRef = React.createRef<HTMLIFrameElement>(); From 78a04a610662ea0071de3f77b0ab41d0bef6e3ae Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Mon, 28 Sep 2020 21:23:55 -0600 Subject: [PATCH 06/21] Remove unused prop --- src/components/views/elements/AppTile.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 83dd9d7b1e..0558c48434 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -455,10 +455,6 @@ export default class AppTile extends React.Component { ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies); - if (this.props.onCapabilityRequest) { - this.props.onCapabilityRequest(requestedCapabilities); - } - // We only tell Jitsi widgets that we're ready because they're realistically the only ones // using this custom extension to the widget API. if (WidgetType.JITSI.matches(this.props.app.type)) { @@ -941,9 +937,6 @@ AppTile.propTypes = { // NOTE -- Use with caution. This is intended to aid better integration / UX // basic widget capabilities, e.g. injecting sticker message events. whitelistCapabilities: PropTypes.array, - // Optional function to be called on widget capability request - // Called with an array of the requested capabilities - onCapabilityRequest: PropTypes.func, // Is this an instance of a user widget userWidget: PropTypes.bool, }; From cd93b2c22ad951ed3ee50ae56cadb40efe49a620 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Tue, 29 Sep 2020 14:14:51 -0600 Subject: [PATCH 07/21] First rough cut of cutting AppTile over to the ClientWidgetApi --- src/components/views/elements/AppTile.js | 389 ++++----------------- src/stores/OwnProfileStore.ts | 8 +- src/stores/widgets/StopGapWidget.ts | 171 +++++++++ src/stores/widgets/StopGapWidgetDriver.ts | 30 ++ src/stores/widgets/WidgetMessagingStore.ts | 107 +----- src/utils/WidgetUtils.js | 1 - src/widgets/WidgetApi.ts | 1 - 7 files changed, 273 insertions(+), 434 deletions(-) create mode 100644 src/stores/widgets/StopGapWidget.ts create mode 100644 src/stores/widgets/StopGapWidgetDriver.ts diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 0558c48434..f6f6d22991 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -18,11 +18,9 @@ limitations under the License. */ import url from 'url'; -import qs from 'qs'; import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import WidgetMessaging from '../../../WidgetMessaging'; import AccessibleButton from './AccessibleButton'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; @@ -34,37 +32,15 @@ import WidgetUtils from '../../../utils/WidgetUtils'; import dis from '../../../dispatcher/dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; -import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import PersistedElement from "./PersistedElement"; import {WidgetType} from "../../../widgets/WidgetType"; import {Capability} from "../../../widgets/WidgetApi"; -import {sleep} from "../../../utils/promise"; import {SettingLevel} from "../../../settings/SettingLevel"; import WidgetStore from "../../../stores/WidgetStore"; import {Action} from "../../../dispatcher/actions"; - -const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; -const ENABLE_REACT_PERF = false; - -/** - * Does template substitution on a URL (or any string). Variables will be - * passed through encodeURIComponent. - * @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'. - * @param {Object} variables The key/value pairs to replace the template - * variables with. E.g. { '$bar': 'baz' }. - * @return {string} The result of replacing all template variables e.g. '/foo/baz'. - */ -function uriFromTemplate(uriTemplate, variables) { - let out = uriTemplate; - for (const [key, val] of Object.entries(variables)) { - out = out.replace( - '$' + key, encodeURIComponent(val), - ); - } - return out; -} +import {StopGapWidget} from "../../../stores/widgets/StopGapWidget"; export default class AppTile extends React.Component { constructor(props) { @@ -72,6 +48,8 @@ export default class AppTile extends React.Component { // The key used for PersistedElement this._persistKey = 'widget_' + this.props.app.id; + this._sgWidget = new StopGapWidget(this.props); + this._sgWidget.on("ready", this._onWidgetReady); this.state = this._getNewState(props); @@ -123,43 +101,6 @@ export default class AppTile extends React.Component { }; } - /** - * Does the widget support a given capability - * @param {string} capability Capability to check for - * @return {Boolean} True if capability supported - */ - _hasCapability(capability) { - return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability); - } - - /** - * Add widget instance specific parameters to pass in wUrl - * Properties passed to widget instance: - * - widgetId - * - origin / parent URL - * @param {string} urlString Url string to modify - * @return {string} - * Url string with parameters appended. - * If url can not be parsed, it is returned unmodified. - */ - _addWurlParams(urlString) { - try { - const parsed = new URL(urlString); - - // TODO: Replace these with proper widget params - // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833 - parsed.searchParams.set('widgetId', this.props.app.id); - parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]); - - // Replace the encoded dollar signs back to dollar signs. They have no special meaning - // in HTTP, but URL parsers encode them anyways. - return parsed.toString().replace(/%24/g, '$'); - } catch (e) { - console.error("Failed to add widget URL params:", e); - return urlString; - } - } - isMixedContent() { const parentContentProtocol = window.location.protocol; const u = url.parse(this.props.app.url); @@ -175,7 +116,7 @@ export default class AppTile extends React.Component { componentDidMount() { // Only fetch IM token on mount if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._startWidget(); } // Widget action listeners @@ -191,80 +132,26 @@ export default class AppTile extends React.Component { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); PersistedElement.destroyElement(this._persistKey); } + + if (this._sgWidget) { + this._sgWidget.stop(); + } } - // TODO: Generify the name of this function. It's not just scalar tokens. - /** - * Adds a scalar token to the widget URL, if required - * Component initialisation is only complete when this function has resolved - */ - setScalarToken() { - if (!WidgetUtils.isScalarUrl(this.props.app.url)) { - console.warn('Widget does not match integration manager, refusing to set auth token', url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; + _resetWidget(newProps) { + if (this._sgWidget) { + this._sgWidget.stop(); } + this._sgWidget = new StopGapWidget(newProps); + this._sgWidget.on("ready", this._onWidgetReady); + this._startWidget(); + } - const managers = IntegrationManagers.sharedInstance(); - if (!managers.hasManager()) { - console.warn("No integration manager - not setting scalar token", url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; - } - - // TODO: Pick the right manager for the widget - - const defaultManager = managers.getPrimaryManager(); - if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) { - console.warn('Unknown integration manager, refusing to set auth token', url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; - } - - // Fetch the token before loading the iframe as we need it to mangle the URL - if (!this._scalarClient) { - this._scalarClient = defaultManager.getScalarClient(); - } - this._scalarClient.getScalarToken().then((token) => { - // Append scalar_token as a query param if not already present - this._scalarClient.scalarToken = token; - const u = url.parse(this._addWurlParams(this.props.app.url)); - const params = qs.parse(u.query); - if (!params.scalar_token) { - params.scalar_token = encodeURIComponent(token); - // u.search must be set to undefined, so that u.format() uses query parameters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options - u.search = undefined; - u.query = params; + _startWidget() { + this._sgWidget.prepare().then(() => { + if (this._appFrame.current) { + this._sgWidget.start(this._appFrame.current); } - - this.setState({ - error: null, - widgetUrl: u.format(), - initialising: false, - }); - - // Fetch page title from remote content if not already set - if (!this.state.widgetPageTitle && params.url) { - this._fetchWidgetTitle(params.url); - } - }, (err) => { - console.error("Failed to get scalar_token", err); - this.setState({ - error: err.message, - initialising: false, - }); }); } @@ -272,9 +159,8 @@ export default class AppTile extends React.Component { UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase if (nextProps.app.url !== this.props.app.url) { this._getNewState(nextProps); - // Fetch IM token for new URL if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._resetWidget(nextProps); } } @@ -285,9 +171,9 @@ export default class AppTile extends React.Component { loading: true, }); } - // Fetch IM token now that we're showing if we already have permission to load + // Start the widget now that we're showing if we already have permission to load if (this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._startWidget(); } } @@ -317,7 +203,14 @@ export default class AppTile extends React.Component { } _onSnapshotClick() { - WidgetUtils.snapshotWidget(this.props.app); + this._sgWidget.widgetApi.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, + }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); } /** @@ -326,34 +219,23 @@ export default class AppTile extends React.Component { * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ _endWidgetActions() { - let terminationPromise; - - if (this._hasCapability(Capability.ReceiveTerminate)) { - // Wait for widget to terminate within a timeout - const timeout = 2000; - const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id); - terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]); - } else { - terminationPromise = Promise.resolve(); + // HACK: This is a really dirty way to ensure that Jitsi cleans up + // its hold on the webcam. Without this, the widget holds a media + // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 + if (this._appFrame.current) { + // In practice we could just do `+= ''` to trick the browser + // into thinking the URL changed, however I can foresee this + // being optimized out by a browser. Instead, we'll just point + // the iframe at a page that is reasonably safe to use in the + // event the iframe doesn't wink away. + // This is relative to where the Element instance is located. + this._appFrame.current.src = 'about:blank'; } - return terminationPromise.finally(() => { - // HACK: This is a really dirty way to ensure that Jitsi cleans up - // its hold on the webcam. Without this, the widget holds a media - // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 - if (this._appFrame.current) { - // In practice we could just do `+= ''` to trick the browser - // into thinking the URL changed, however I can foresee this - // being optimized out by a browser. Instead, we'll just point - // the iframe at a page that is reasonably safe to use in the - // event the iframe doesn't wink away. - // This is relative to where the Element instance is located. - this._appFrame.current.src = 'about:blank'; - } + // Delete the widget from the persisted store for good measure. + PersistedElement.destroyElement(this._persistKey); - // Delete the widget from the persisted store for good measure. - PersistedElement.destroyElement(this._persistKey); - }); + this._sgWidget.stop(); } /* If user has permission to modify widgets, delete the widget, @@ -407,69 +289,18 @@ export default class AppTile extends React.Component { this._revokeWidgetPermission(); } - /** - * Called when widget iframe has finished loading - */ - _onLoaded() { - // Destroy the old widget messaging before starting it back up again. Some widgets - // have startup routines that run when they are loaded, so we just need to reinitialize - // the messaging for them. - ActiveWidgetStore.delWidgetMessaging(this.props.app.id); - this._setupWidgetMessaging(); - - ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId); + _onWidgetReady = () => { this.setState({loading: false}); - } - - _setupWidgetMessaging() { - // FIXME: There's probably no reason to do this here: it should probably be done entirely - // in ActiveWidgetStore. - const widgetMessaging = new WidgetMessaging( - this.props.app.id, - this.props.app.url, - this._getRenderedUrl(), - this.props.userWidget, - this._appFrame.current.contentWindow, - ); - ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging); - widgetMessaging.getCapabilities().then((requestedCapabilities) => { - console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities); - requestedCapabilities = requestedCapabilities || []; - - // Allow whitelisted capabilities - let requestedWhitelistCapabilies = []; - - if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) { - requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) { - return this.indexOf(e)>=0; - }, this.props.whitelistCapabilities); - - if (requestedWhitelistCapabilies.length > 0 ) { - console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` + - requestedWhitelistCapabilies, - ); - } - } - - // TODO -- Add UI to warn about and optionally allow requested capabilities - - ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies); - - // We only tell Jitsi widgets that we're ready because they're realistically the only ones - // using this custom extension to the widget API. - if (WidgetType.JITSI.matches(this.props.app.type)) { - widgetMessaging.flagReadyToContinue(); - } - }).catch((err) => { - console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err); - }); - } + if (WidgetType.JITSI.matches(this.props.app.type)) { + this._sgWidget.widgetApi.transport.send("im.vector.ready", {}); + } + }; _onAction(payload) { if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': - if (this._hasCapability('m.sticker')) { + if (this._sgWidget.widgetApi.hasCapability(Capability.Sticker)) { dis.dispatch({action: 'post_sticker_message', data: payload.data}); } else { console.warn('Ignoring sticker message. Invalid capability'); @@ -487,20 +318,6 @@ export default class AppTile extends React.Component { } } - /** - * Set remote content title on AppTile - * @param {string} url Url to check for title - */ - _fetchWidgetTitle(url) { - this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => { - if (widgetPageTitle) { - this.setState({widgetPageTitle: widgetPageTitle}); - } - }, (err) =>{ - console.error("Failed to get page title", err); - }); - } - _grantWidgetPermission() { const roomId = this.props.room.roomId; console.info("Granting permission for widget to load: " + this.props.app.eventId); @@ -510,7 +327,7 @@ export default class AppTile extends React.Component { this.setState({hasPermissionToLoad: true}); // Fetch a token for the integration manager, now that we're allowed to - this.setScalarToken(); + this._startWidget(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -529,6 +346,7 @@ export default class AppTile extends React.Component { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); + this._sgWidget.stop(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -566,40 +384,6 @@ export default class AppTile extends React.Component { } } - /** - * Replace the widget template variables in a url with their values - * - * @param {string} u The URL with template variables - * @param {string} widgetType The widget's type - * - * @returns {string} url with temlate variables replaced - */ - _templatedUrl(u, widgetType: string) { - const targetData = {}; - if (WidgetType.JITSI.matches(widgetType)) { - targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded - } - const myUserId = MatrixClientPeg.get().credentials.userId; - const myUser = MatrixClientPeg.get().getUser(myUserId); - const vars = Object.assign(targetData, this.props.app.data, { - 'matrix_user_id': myUserId, - 'matrix_room_id': this.props.room.roomId, - 'matrix_display_name': myUser ? myUser.displayName : myUserId, - 'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '', - - // TODO: Namespace themes through some standard - 'theme': SettingsStore.getValue("theme"), - }); - - if (vars.conferenceId === undefined) { - // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets - const parsedUrl = new URL(this.props.app.url); - vars.conferenceId = parsedUrl.searchParams.get("confId"); - } - - return uriFromTemplate(u, vars); - } - /** * Whether we're using a local version of the widget rather than loading the * actual widget URL @@ -609,67 +393,11 @@ export default class AppTile extends React.Component { return WidgetType.JITSI.matches(this.props.app.type); } - /** - * Get the URL used in the iframe - * In cases where we supply our own UI for a widget, this is an internal - * URL different to the one used if the widget is popped out to a separate - * tab / browser - * - * @returns {string} url - */ - _getRenderedUrl() { - let url; - - if (WidgetType.JITSI.matches(this.props.app.type)) { - console.log("Replacing Jitsi widget URL with local wrapper"); - url = WidgetUtils.getLocalJitsiWrapperUrl({ - forLocalRender: true, - auth: this.props.app.data ? this.props.app.data.auth : null, - }); - url = this._addWurlParams(url); - } else { - url = this._getSafeUrl(this.state.widgetUrl); - } - return this._templatedUrl(url, this.props.app.type); - } - - _getPopoutUrl() { - if (WidgetType.JITSI.matches(this.props.app.type)) { - return this._templatedUrl( - WidgetUtils.getLocalJitsiWrapperUrl({ - forLocalRender: false, - auth: this.props.app.data ? this.props.app.data.auth : null, - }), - this.props.app.type, - ); - } else { - // use app.url, not state.widgetUrl, because we want the one without - // the wURL params for the popped-out version. - return this._templatedUrl(this._getSafeUrl(this.props.app.url), this.props.app.type); - } - } - - _getSafeUrl(u) { - const parsedWidgetUrl = url.parse(u, true); - if (ENABLE_REACT_PERF) { - parsedWidgetUrl.search = null; - parsedWidgetUrl.query.react_perf = true; - } - let safeWidgetUrl = ''; - if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) { - safeWidgetUrl = url.format(parsedWidgetUrl); - } - - // Replace all the dollar signs back to dollar signs as they don't affect HTTP at all. - // We also need the dollar signs in-tact for variable substitution. - return safeWidgetUrl.replace(/%24/g, '$'); - } - _getTileTitle() { const name = this.formatAppTileName(); const titleSpacer = <span> - </span>; let title = ''; - if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) { + if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) { title = this.state.widgetPageTitle; } @@ -694,7 +422,7 @@ export default class AppTile extends React.Component { this._endWidgetActions().then(() => { if (this._appFrame.current) { // Reload iframe - this._appFrame.current.src = this._getRenderedUrl(); + this._appFrame.current.src = this._sgWidget.embedUrl; this.setState({}); } }); @@ -702,7 +430,7 @@ export default class AppTile extends React.Component { // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click(); + { target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click(); } _onReloadWidgetClick() { @@ -780,7 +508,7 @@ export default class AppTile extends React.Component { <iframe allow={iframeFeatures} ref={this._appFrame} - src={this._getRenderedUrl()} + src={this._sgWidget.embedUrl} allowFullScreen={true} sandbox={sandboxFlags} onLoad={this._onLoaded} /> @@ -827,9 +555,10 @@ export default class AppTile extends React.Component { const elementRect = this._contextMenuButton.current.getBoundingClientRect(); const canUserModify = this._canUserModify(); - const showEditButton = Boolean(this._scalarClient && canUserModify); + const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify); const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify; - const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show; + const showPictureSnapshotButton = this._sgWidget.widgetApi.hasCapability(Capability.Screenshot) + && this.props.show; const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu'); contextMenu = ( diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts index 1aa761e1c4..61387e3c26 100644 --- a/src/stores/OwnProfileStore.ts +++ b/src/stores/OwnProfileStore.ts @@ -66,12 +66,14 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> { /** * Gets the user's avatar as an HTTP URL of the given size. If the user's * avatar is not present, this returns null. - * @param size The size of the avatar + * @param size The size of the avatar. If zero, a full res copy of the avatar + * will be returned as an HTTP URL. * @returns The HTTP URL of the user's avatar */ - public getHttpAvatarUrl(size: number): string { + public getHttpAvatarUrl(size: number = 0): string { if (!this.avatarMxc) return null; - return this.matrixClient.mxcUrlToHttp(this.avatarMxc, size, size); + const adjustedSize = size > 1 ? size : undefined; // don't let negatives or zero through + return this.matrixClient.mxcUrlToHttp(this.avatarMxc, adjustedSize, adjustedSize); } protected async onNotReady() { diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts new file mode 100644 index 0000000000..2b8ab9f5a8 --- /dev/null +++ b/src/stores/widgets/StopGapWidget.ts @@ -0,0 +1,171 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Room} from "matrix-js-sdk/src/models/room"; +import { ClientWidgetApi, IWidget, IWidgetData, Widget } from "matrix-widget-api"; +import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; +import { EventEmitter } from "events"; +import { WidgetMessagingStore } from "./WidgetMessagingStore"; +import RoomViewStore from "../RoomViewStore"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { OwnProfileStore } from "../OwnProfileStore"; +import WidgetUtils from '../../utils/WidgetUtils'; +import { IntegrationManagers } from "../../integrations/IntegrationManagers"; +import SettingsStore from "../../settings/SettingsStore"; +import { WidgetType } from "../../widgets/WidgetType"; + +// TODO: Destroy all of this code + +interface IAppTileProps { + // Note: these are only the props we care about + + app: IWidget; + room: Room; + userId: string; + creatorUserId: string; + waitForIframeLoad: boolean; + whitelistCapabilities: string[]; + userWidget: boolean; +} + +// TODO: Don't use this because it's wrong +class ElementWidget extends Widget { + constructor(w) { + super(w); + } + + public get templateUrl(): string { + if (WidgetType.JITSI.matches(this.type)) { + return WidgetUtils.getLocalJitsiWrapperUrl({ + forLocalRender: true, + auth: this.rawData?.auth, + }); + } + return super.templateUrl; + } + + public get rawData(): IWidgetData { + let conferenceId = super.rawData['conferenceId']; + if (conferenceId === undefined) { + // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets + const parsedUrl = new URL(this.templateUrl); + conferenceId = parsedUrl.searchParams.get("confId"); + } + return { + ...super.rawData, + theme: SettingsStore.getValue("theme"), + conferenceId, + }; + } +} + +export class StopGapWidget extends EventEmitter { + private messaging: ClientWidgetApi; + private mockWidget: Widget; + private scalarToken: string; + + constructor(private appTileProps: IAppTileProps) { + super(); + this.mockWidget = new ElementWidget(appTileProps.app); + } + + public get widgetApi(): ClientWidgetApi { + return this.messaging; + } + + /** + * The URL to use in the iframe + */ + public get embedUrl(): string { + const templated = this.mockWidget.getCompleteUrl({ + currentRoomId: RoomViewStore.getRoomId(), + currentUserId: MatrixClientPeg.get().getUserId(), + userDisplayName: OwnProfileStore.instance.displayName, + userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(), + }); + + // Add in some legacy support sprinkles + // TODO: Replace these with proper widget params + // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833 + const parsed = new URL(templated); + parsed.searchParams.set('widgetId', this.mockWidget.id); + parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]); + + // Give the widget a scalar token if we're supposed to (more legacy) + // TODO: Stop doing this + if (this.scalarToken) { + parsed.searchParams.set('scalar_token', this.scalarToken); + } + + // Replace the encoded dollar signs back to dollar signs. They have no special meaning + // in HTTP, but URL parsers encode them anyways. + return parsed.toString().replace(/%24/g, '$'); + } + + /** + * The URL to use in the popout + */ + public get popoutUrl(): string { + if (WidgetType.JITSI.matches(this.mockWidget.type)) { + return WidgetUtils.getLocalJitsiWrapperUrl({ + forLocalRender: false, + auth: this.mockWidget.rawData?.auth, + }); + } + return this.embedUrl; + } + + public get isManagedByManager(): boolean { + return !!this.scalarToken; + } + + public get started(): boolean { + return !!this.messaging; + } + + public start(iframe: HTMLIFrameElement) { + if (this.started) return; + const driver = new StopGapWidgetDriver(this.appTileProps.whitelistCapabilities || []); + this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); + this.messaging.addEventListener("ready", () => this.emit("ready")); + WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging); + } + + public async prepare(): Promise<void> { + if (this.scalarToken) return; + try { + if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) { + const managers = IntegrationManagers.sharedInstance(); + if (managers.hasManager()) { + // TODO: Pick the right manager for the widget + const defaultManager = managers.getPrimaryManager(); + if (WidgetUtils.isScalarUrl(defaultManager.apiUrl)) { + const scalar = defaultManager.getScalarClient(); + this.scalarToken = await scalar.getScalarToken(); + } + } + } + } catch (e) { + // All errors are non-fatal + console.error("Error preparing widget communications: ", e); + } + } + + public stop() { + if (!this.started) return; + WidgetMessagingStore.instance.stopMessaging(this.mockWidget); + } +} diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts new file mode 100644 index 0000000000..84626e74fb --- /dev/null +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Capability, WidgetDriver } from "matrix-widget-api"; +import { iterableUnion } from "../../utils/iterables"; + +// TODO: Purge this from the universe + +export class StopGapWidgetDriver extends WidgetDriver { + constructor(private allowedCapabilities: Capability[]) { + super(); + } + + public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> { + return iterableUnion(requested, new Set(this.allowedCapabilities)); + } +} diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index fedc9c6c87..fa743fdeaf 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -31,8 +31,7 @@ import { EnhancedMap } from "../../utils/maps"; export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> { private static internalInstance = new WidgetMessagingStore(); - // <room/user ID, <widget ID, Widget>> - private widgetMap = new EnhancedMap<string, EnhancedMap<string, WidgetSurrogate>>(); + private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget ID, ClientWidgetAPi> public constructor() { super(defaultDispatcher); @@ -51,106 +50,16 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> { this.widgetMap.clear(); } - /** - * Finds a widget by ID. Not guaranteed to return an accurate result. - * @param {string} id The widget ID. - * @returns {{widget, room}} The widget and possible room ID, or a falsey value - * if not found. - * @deprecated Do not use. - */ - public findWidgetById(id: string): { widget: Widget, room?: Room } { - for (const key of this.widgetMap.keys()) { - for (const [entityId, surrogate] of this.widgetMap.get(key).entries()) { - if (surrogate.definition.id === id) { - const room: Room = this.matrixClient?.getRoom(entityId); // will be null for non-rooms - return {room, widget: surrogate.definition}; - } - } - } - return null; + public storeMessaging(widget: Widget, widgetApi: ClientWidgetApi) { + this.stopMessaging(widget); + this.widgetMap.set(widget.id, widgetApi); } - /** - * Gets the messaging instance for the widget. Returns a falsey value if none - * is present. - * @param {Room} room The room for which the widget lives within. - * @param {Widget} widget The widget to get messaging for. - * @returns {ClientWidgetApi} The messaging, or a falsey value. - */ - public messagingForRoomWidget(room: Room, widget: Widget): ClientWidgetApi { - return this.widgetMap.get(room.roomId)?.get(widget.id)?.messaging; + public stopMessaging(widget: Widget) { + this.widgetMap.remove(widget.id)?.stop(); } - /** - * Gets the messaging instance for the widget. Returns a falsey value if none - * is present. - * @param {Widget} widget The widget to get messaging for. - * @returns {ClientWidgetApi} The messaging, or a falsey value. - */ - public messagingForAccountWidget(widget: Widget): ClientWidgetApi { - return this.widgetMap.get(this.matrixClient?.getUserId())?.get(widget.id)?.messaging; - } - - private generateMessaging(locationId: string, widget: Widget, iframe: HTMLIFrameElement, driver: WidgetDriver) { - const messaging = new ClientWidgetApi(widget, iframe, driver); - this.widgetMap.getOrCreate(locationId, new EnhancedMap()) - .getOrCreate(widget.id, new WidgetSurrogate(widget, messaging)); - return messaging; - } - - /** - * Generates a messaging instance for the widget. If an instance already exists, it - * will be returned instead. - * @param {Room} room The room in which the widget lives. - * @param {Widget} widget The widget to generate/get messaging for. - * @param {HTMLIFrameElement} iframe The widget's iframe. - * @returns {ClientWidgetApi} The generated/cached messaging. - */ - public generateMessagingForRoomWidget(room: Room, widget: Widget, iframe: HTMLIFrameElement): ClientWidgetApi { - const existing = this.messagingForRoomWidget(room, widget); - if (existing) return existing; - - const driver = new SdkWidgetDriver(widget, WidgetKind.Room, room.roomId); - return this.generateMessaging(room.roomId, widget, iframe, driver); - } - - /** - * Generates a messaging instance for the widget. If an instance already exists, it - * will be returned instead. - * @param {Widget} widget The widget to generate/get messaging for. - * @param {HTMLIFrameElement} iframe The widget's iframe. - * @returns {ClientWidgetApi} The generated/cached messaging. - */ - public generateMessagingForAccountWidget(widget: Widget, iframe: HTMLIFrameElement): ClientWidgetApi { - if (!this.matrixClient) { - throw new Error("No matrix client to create account widgets with"); - } - - const existing = this.messagingForAccountWidget(widget); - if (existing) return existing; - - const userId = this.matrixClient.getUserId(); - const driver = new SdkWidgetDriver(widget, WidgetKind.Account, userId); - return this.generateMessaging(userId, widget, iframe, driver); - } - - /** - * Stops the messaging instance for the widget, unregistering it. - * @param {Room} room The room where the widget resides. - * @param {Widget} widget The widget - */ - public stopMessagingForRoomWidget(room: Room, widget: Widget) { - const api = this.widgetMap.getOrCreate(room.roomId, new EnhancedMap()).remove(widget.id); - if (api) api.messaging.stop(); - } - - /** - * Stops the messaging instance for the widget, unregistering it. - * @param {Widget} widget The widget - */ - public stopMessagingForAccountWidget(widget: Widget) { - if (!this.matrixClient) return; - const api = this.widgetMap.getOrCreate(this.matrixClient.getUserId(), new EnhancedMap()).remove(widget.id); - if (api) api.messaging.stop(); + public getMessaging(widget: Widget): ClientWidgetApi { + return this.widgetMap.get(widget.id); } } diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index d1daba7ca5..57459ba897 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -424,7 +424,6 @@ export default class WidgetUtils { if (WidgetType.JITSI.matches(appType)) { capWhitelist.push(Capability.AlwaysOnScreen); } - capWhitelist.push(Capability.ReceiveTerminate); return capWhitelist; } diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts index c25d607948..ab9604d155 100644 --- a/src/widgets/WidgetApi.ts +++ b/src/widgets/WidgetApi.ts @@ -25,7 +25,6 @@ export enum Capability { Screenshot = "m.capability.screenshot", Sticker = "m.sticker", AlwaysOnScreen = "m.always_on_screen", - ReceiveTerminate = "im.vector.receive_terminate", } export enum KnownWidgetActions { From fc1cbc668c6473a43c5760d5060554a2a746b054 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Tue, 29 Sep 2020 14:33:46 -0600 Subject: [PATCH 08/21] Get the widget loading again --- src/components/views/elements/AppTile.js | 32 +++++++++++++----------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index f6f6d22991..8888fe79b4 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -54,7 +54,6 @@ export default class AppTile extends React.Component { this.state = this._getNewState(props); this._onAction = this._onAction.bind(this); - this._onLoaded = this._onLoaded.bind(this); this._onEditClick = this._onEditClick.bind(this); this._onDeleteClick = this._onDeleteClick.bind(this); this._onRevokeClicked = this._onRevokeClicked.bind(this); @@ -67,7 +66,6 @@ export default class AppTile extends React.Component { this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this); this._contextMenuButton = createRef(); - this._appFrame = createRef(); this._menu_bar = createRef(); } @@ -90,7 +88,6 @@ export default class AppTile extends React.Component { initialising: true, // True while we are mangling the widget URL // True while the iframe content is loading loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), - widgetUrl: this._addWurlParams(newProps.app.url), // Assume that widget has permission to load if we are the user who // added it to the room, or if explicitly granted by the user hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), @@ -149,11 +146,18 @@ export default class AppTile extends React.Component { _startWidget() { this._sgWidget.prepare().then(() => { - if (this._appFrame.current) { - this._sgWidget.start(this._appFrame.current); - } + this.setState({initialising: false}); }); } + + _iframeRefChange = (ref) => { + this.setState({iframe: ref}); + if (ref) { + this._sgWidget.start(ref); + } else { + this._resetWidget(this.props); + } + }; // TODO: [REACT-WARNING] Replace with appropriate lifecycle event UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase @@ -222,14 +226,14 @@ export default class AppTile extends React.Component { // HACK: This is a really dirty way to ensure that Jitsi cleans up // its hold on the webcam. Without this, the widget holds a media // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 - if (this._appFrame.current) { + if (this.state.iframe) { // In practice we could just do `+= ''` to trick the browser // into thinking the URL changed, however I can foresee this // being optimized out by a browser. Instead, we'll just point // the iframe at a page that is reasonably safe to use in the // event the iframe doesn't wink away. // This is relative to where the Element instance is located. - this._appFrame.current.src = 'about:blank'; + this.state.iframe.src = 'about:blank'; } // Delete the widget from the persisted store for good measure. @@ -420,9 +424,9 @@ export default class AppTile extends React.Component { // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop). if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) { this._endWidgetActions().then(() => { - if (this._appFrame.current) { + if (this.state.iframe) { // Reload iframe - this._appFrame.current.src = this._sgWidget.embedUrl; + this.state.iframe.src = this._sgWidget.embedUrl; this.setState({}); } }); @@ -436,7 +440,7 @@ export default class AppTile extends React.Component { _onReloadWidgetClick() { // Reload iframe in this way to avoid cross-origin restrictions // eslint-disable-next-line no-self-assign - this._appFrame.current.src = this._appFrame.current.src; + this.state.iframe.src = this.state.iframe.src; } _onContextMenuClick = () => { @@ -482,7 +486,7 @@ export default class AppTile extends React.Component { <AppPermission roomId={this.props.room.roomId} creatorUserId={this.props.creatorUserId} - url={this.state.widgetUrl} + url={this._sgWidget.embedUrl} isRoomEncrypted={isEncrypted} onPermissionGranted={this._grantWidgetPermission} /> @@ -507,11 +511,11 @@ export default class AppTile extends React.Component { { this.state.loading && loadingElement } <iframe allow={iframeFeatures} - ref={this._appFrame} + ref={this._iframeRefChange} src={this._sgWidget.embedUrl} allowFullScreen={true} sandbox={sandboxFlags} - onLoad={this._onLoaded} /> + /> </div> ); // if the widget would be allowed to remain on screen, we must put it in From ca76ba5cf1d14acd604a8163b5fc2c7d687678c8 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Tue, 29 Sep 2020 15:09:52 -0600 Subject: [PATCH 09/21] Fix widget persistence --- src/stores/widgets/StopGapWidget.ts | 36 +++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 2b8ab9f5a8..4b6ce70a6e 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -15,7 +15,14 @@ */ import {Room} from "matrix-js-sdk/src/models/room"; -import { ClientWidgetApi, IWidget, IWidgetData, Widget } from "matrix-widget-api"; +import { + ClientWidgetApi, + IStickyActionRequest, + IWidget, + IWidgetApiRequestEmptyData, + IWidgetData, + Widget +} from "matrix-widget-api"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { EventEmitter } from "events"; import { WidgetMessagingStore } from "./WidgetMessagingStore"; @@ -26,6 +33,8 @@ import WidgetUtils from '../../utils/WidgetUtils'; import { IntegrationManagers } from "../../integrations/IntegrationManagers"; import SettingsStore from "../../settings/SettingsStore"; import { WidgetType } from "../../widgets/WidgetType"; +import { Capability } from "../../widgets/WidgetApi"; +import ActiveWidgetStore from "../ActiveWidgetStore"; // TODO: Destroy all of this code @@ -138,14 +147,32 @@ export class StopGapWidget extends EventEmitter { public start(iframe: HTMLIFrameElement) { if (this.started) return; - const driver = new StopGapWidgetDriver(this.appTileProps.whitelistCapabilities || []); + const driver = new StopGapWidgetDriver( this.appTileProps.whitelistCapabilities || []); this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver); this.messaging.addEventListener("ready", () => this.emit("ready")); WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging); + + if (!this.appTileProps.userWidget && this.appTileProps.room) { + ActiveWidgetStore.setRoomId(this.mockWidget.id, this.appTileProps.room.roomId); + } + + if (WidgetType.JITSI.matches(this.mockWidget.type)) { + this.messaging.addEventListener("action:set_always_on_screen", + (ev: CustomEvent<IStickyActionRequest>) => { + if (this.messaging.hasCapability(Capability.AlwaysOnScreen)) { + ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value); + ev.preventDefault(); + this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack + } + }, + ); + } } public async prepare(): Promise<void> { if (this.scalarToken) return; + const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget); + if (existingMessaging) this.messaging = existingMessaging; try { if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) { const managers = IntegrationManagers.sharedInstance(); @@ -165,7 +192,12 @@ export class StopGapWidget extends EventEmitter { } public stop() { + if (ActiveWidgetStore.getPersistentWidgetId() === this.mockWidget.id) { + console.log("Skipping destroy - persistent widget"); + return; + } if (!this.started) return; WidgetMessagingStore.instance.stopMessaging(this.mockWidget); + ActiveWidgetStore.delRoomId(this.mockWidget.id); } } From bb5184bc50a0dfd63c73bbcb718f6b837bc98eb1 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Tue, 29 Sep 2020 15:32:07 -0600 Subject: [PATCH 10/21] Remove unused function --- src/utils/WidgetUtils.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index 57459ba897..9373738bf8 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -494,16 +494,4 @@ export default class WidgetUtils { IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id); } } - - static snapshotWidget(app) { - console.log("Requesting widget snapshot"); - ActiveWidgetStore.getWidgetMessaging(app.id).getScreenshot().catch((err) => { - console.error("Failed to get screenshot", err); - }).then((screenshot) => { - dis.dispatch({ - action: 'picture_snapshot', - file: screenshot, - }, true); - }); - } } From 555bcc6010c19b019a96fec08db4dbd4346fc44b Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Tue, 29 Sep 2020 15:32:18 -0600 Subject: [PATCH 11/21] Document remaining (known) cases to fix --- src/CallHandler.tsx | 1 + src/components/views/right_panel/WidgetCard.tsx | 1 + src/components/views/rooms/Stickerpicker.js | 1 + 3 files changed, 3 insertions(+) diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 2ff018d4d6..849859eb20 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -495,6 +495,7 @@ export default class CallHandler { const roomInfo = WidgetStore.instance.getRoom(roomId); if (!roomInfo) return; // "should never happen" clauses go here + // TODO: [TravisR] Fix this const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); jitsiWidgets.forEach(w => { const messaging = ActiveWidgetStore.getWidgetMessaging(w.id); diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 1677494708..b0eefb0fed 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -77,6 +77,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => { let contextMenu; if (menuDisplayed) { let snapshotButton; + // TODO: [TravisR] Fix this if (ActiveWidgetStore.widgetHasCapability(app.id, Capability.Screenshot)) { const onSnapshotClick = () => { WidgetUtils.snapshotWidget(app); diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index dba25a94cf..548e1d02bb 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -212,6 +212,7 @@ export default class Stickerpicker extends React.Component { _sendVisibilityToWidget(visible) { if (!this.state.stickerpickerWidget) return; + // TODO: [TravisR] Fix this const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(this.state.stickerpickerWidget.id); if (widgetMessaging && visible !== this._prevSentVisibility) { widgetMessaging.sendVisibility(visible); From 9190c921d2d47bd2fa234be620fd788f79b1e2b0 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Tue, 29 Sep 2020 15:35:04 -0600 Subject: [PATCH 12/21] Clean up failed experiment --- src/stores/widgets/SdkWidgetDriver.ts | 34 ---------------------- src/stores/widgets/WidgetMessagingStore.ts | 5 +--- src/stores/widgets/WidgetSurrogate.ts | 25 ---------------- 3 files changed, 1 insertion(+), 63 deletions(-) delete mode 100644 src/stores/widgets/SdkWidgetDriver.ts delete mode 100644 src/stores/widgets/WidgetSurrogate.ts diff --git a/src/stores/widgets/SdkWidgetDriver.ts b/src/stores/widgets/SdkWidgetDriver.ts deleted file mode 100644 index 1462303fa3..0000000000 --- a/src/stores/widgets/SdkWidgetDriver.ts +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2020 The Matrix.org Foundation C.I.C. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { Capability, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api"; -import { iterableUnion } from "../../utils/iterables"; - -export class SdkWidgetDriver extends WidgetDriver { - public constructor( - private widget: Widget, - private widgetKind: WidgetKind, - private locationEntityId: string, - private preapprovedCapabilities: Set<Capability> = new Set(), - ) { - super(); - } - - public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> { - // TODO: Prompt the user to accept capabilities - return iterableUnion(requested, this.preapprovedCapabilities); - } -} diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index fa743fdeaf..34fc2c4e04 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -14,13 +14,10 @@ * limitations under the License. */ -import { ClientWidgetApi, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api"; +import { ClientWidgetApi, Widget } from "matrix-widget-api"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; -import { Room } from "matrix-js-sdk/src/models/room"; -import { WidgetSurrogate } from "./WidgetSurrogate"; -import { SdkWidgetDriver } from "./SdkWidgetDriver"; import { EnhancedMap } from "../../utils/maps"; /** diff --git a/src/stores/widgets/WidgetSurrogate.ts b/src/stores/widgets/WidgetSurrogate.ts deleted file mode 100644 index 4d482124a6..0000000000 --- a/src/stores/widgets/WidgetSurrogate.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright 2020 The Matrix.org Foundation C.I.C. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ClientWidgetApi, Widget } from "matrix-widget-api"; - -export class WidgetSurrogate { - public constructor( - public readonly definition: Widget, - public readonly messaging: ClientWidgetApi, - ) { - } -} From b710d42832579c3d1543f42e4bbf0307e610a4f4 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Wed, 30 Sep 2020 16:12:00 -0600 Subject: [PATCH 13/21] Fix stickerpicker to use new messaging --- src/components/views/rooms/Stickerpicker.js | 9 ++- src/stores/widgets/StopGapWidget.ts | 68 +++++++++++++++++++-- src/stores/widgets/WidgetMessagingStore.ts | 11 ++++ 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index 548e1d02bb..039d2571f4 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -30,6 +30,7 @@ import {ContextMenu} from "../../structures/ContextMenu"; import {WidgetType} from "../../../widgets/WidgetType"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {Action} from "../../../dispatcher/actions"; +import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore"; // This should be below the dialog level (4000), but above the rest of the UI (1000-2000). // We sit in a context menu, so this should be given to the context menu. @@ -213,9 +214,11 @@ export default class Stickerpicker extends React.Component { _sendVisibilityToWidget(visible) { if (!this.state.stickerpickerWidget) return; // TODO: [TravisR] Fix this - const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(this.state.stickerpickerWidget.id); - if (widgetMessaging && visible !== this._prevSentVisibility) { - widgetMessaging.sendVisibility(visible); + const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id); + if (messaging && visible !== this._prevSentVisibility) { + messaging.updateVisibility(visible).catch(err => { + console.error("Error updating widget visibility: ", err); + }); this._prevSentVisibility = visible; } } diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 4b6ce70a6e..073073abec 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -16,12 +16,12 @@ import {Room} from "matrix-js-sdk/src/models/room"; import { - ClientWidgetApi, + ClientWidgetApi, IStickerActionRequest, IStickyActionRequest, - IWidget, + IWidget, IWidgetApiRequest, IWidgetApiRequestEmptyData, IWidgetData, - Widget + Widget, WidgetApiFromWidgetAction } from "matrix-widget-api"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { EventEmitter } from "events"; @@ -35,6 +35,9 @@ import SettingsStore from "../../settings/SettingsStore"; import { WidgetType } from "../../widgets/WidgetType"; import { Capability } from "../../widgets/WidgetApi"; import ActiveWidgetStore from "../ActiveWidgetStore"; +import { objectShallowClone } from "../../utils/objects"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import dis from "../../dispatcher/dispatcher"; // TODO: Destroy all of this code @@ -88,7 +91,15 @@ export class StopGapWidget extends EventEmitter { constructor(private appTileProps: IAppTileProps) { super(); - this.mockWidget = new ElementWidget(appTileProps.app); + 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 = MatrixClientPeg.get().getUserId(); + } + + this.mockWidget = new ElementWidget(app); } public get widgetApi(): ClientWidgetApi { @@ -166,6 +177,55 @@ export class StopGapWidget extends EventEmitter { } }, ); + } else if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) { + this.messaging.addEventListener("action:integration_manager_open", + (ev: CustomEvent<IWidgetApiRequest>) => { + // Acknowledge first + ev.preventDefault(); + this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); + + // First close the stickerpicker + defaultDispatcher.dispatch({action: "stickerpicker_close"}); + + // Now open the integration manager + // TODO: Spec this interaction. + const data = ev.detail.data; + const integType = data?.integType + const integId = <string>data?.integId; + + // TODO: Open the right integration manager for the widget + if (SettingsStore.getValue("feature_many_integration_managers")) { + IntegrationManagers.sharedInstance().openAll( + MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), + `type_${integType}`, + integId, + ); + } else { + IntegrationManagers.sharedInstance().getPrimaryManager().open( + MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), + `type_${integType}`, + integId, + ); + } + }, + ); + + // TODO: Replace this event listener with appropriate driver functionality once the API + // establishes a sane way to send events back and forth. + this.messaging.addEventListener(`action:${WidgetApiFromWidgetAction.SendSticker}`, + (ev: CustomEvent<IStickerActionRequest>) => { + // Acknowledge first + ev.preventDefault(); + this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); + + // Send the sticker + defaultDispatcher.dispatch({ + action: 'm.sticker', + data: ev.detail.data, + widgetId: this.mockWidget.id, + }); + }, + ); } } diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index 34fc2c4e04..828465ce84 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -28,6 +28,7 @@ import { EnhancedMap } from "../../utils/maps"; export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> { private static internalInstance = new WidgetMessagingStore(); + // TODO: Fix uniqueness problem (widget IDs are not unique across the whole app) private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget ID, ClientWidgetAPi> public constructor() { @@ -59,4 +60,14 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> { public getMessaging(widget: Widget): ClientWidgetApi { return this.widgetMap.get(widget.id); } + + /** + * Gets the widget messaging class for a given widget ID. + * @param {string} widgetId The widget ID. + * @returns {ClientWidgetApi} The widget API, or a falsey value if not found. + * @deprecated Widget IDs are not globally unique. + */ + public getMessagingForId(widgetId: string): ClientWidgetApi { + return this.widgetMap.get(widgetId); + } } From 9b984a35e063f51fac8658199a7afd37f93c06fc Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Wed, 30 Sep 2020 19:58:16 -0600 Subject: [PATCH 14/21] Remove dead AppTile2 --- src/components/views/elements/AppTile2.tsx | 77 ---------------------- 1 file changed, 77 deletions(-) delete mode 100644 src/components/views/elements/AppTile2.tsx diff --git a/src/components/views/elements/AppTile2.tsx b/src/components/views/elements/AppTile2.tsx deleted file mode 100644 index 516c00170a..0000000000 --- a/src/components/views/elements/AppTile2.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright 2020 The Matrix.org Foundation C.I.C. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ClientWidgetApi, Widget, WidgetKind } from "matrix-widget-api"; -import * as React from "react"; -import { Room } from "matrix-js-sdk/src/models/room"; -import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; - -interface IProps { - widget: Widget; - kind: WidgetKind; - room?: Room; - - // TODO: All the showUIElement props -} - -interface IState { - loading: boolean; -} - -export default class AppTile2 extends React.PureComponent<IProps, IState> { - private messaging: ClientWidgetApi; - private iframeRef = React.createRef<HTMLIFrameElement>(); - - public constructor(props: IProps) { - super(props); - - if (props.kind === WidgetKind.Room && !props.room) { - throw new Error("Expected room when supplied with a room widget"); - } - - this.state = { - loading: true, - }; - } - - private get isMixedContent(): boolean { - const myProtocol = window.location.protocol; - const widgetProtocol = new URL(this.props.widget.templateUrl).protocol; - return myProtocol === 'https:' && widgetProtocol !== 'https:'; - } - - public componentDidMount() { - if (!this.iframeRef.current) { - throw new Error("iframe has not yet been associated - fix the render code"); - } - - // TODO: Provide capabilities to widget messaging - - if (this.props.kind === WidgetKind.Room) { - this.messaging = WidgetMessagingStore.instance - .generateMessagingForRoomWidget(this.props.room, this.props.widget, this.iframeRef.current); - } else if (this.props.kind === WidgetKind.Account) { - this.messaging = WidgetMessagingStore.instance - .generateMessagingForAccountWidget(this.props.widget, this.iframeRef.current); - } else { - throw new Error("Unexpected widget kind: " + this.props.kind); - } - - this.messaging.once("ready", () => { - this.setState({loading: false}); - }); - } -} From b46f58274e2b097a2067ee1bb12c2b86d362f481 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Wed, 30 Sep 2020 20:09:23 -0600 Subject: [PATCH 15/21] Fix the hangup button and other custom actions --- src/CallHandler.tsx | 7 ++++--- src/components/views/elements/AppTile.js | 3 ++- src/components/views/rooms/Stickerpicker.js | 1 - src/stores/widgets/ElementWidgetActions.ts | 21 +++++++++++++++++++++ src/stores/widgets/StopGapWidget.ts | 3 ++- 5 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 src/stores/widgets/ElementWidgetActions.ts diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 849859eb20..49f82e3209 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -76,6 +76,8 @@ import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import WidgetStore from "./stores/WidgetStore"; import ActiveWidgetStore from "./stores/ActiveWidgetStore"; +import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; +import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; // until we ts-ify the js-sdk voip code type Call = any; @@ -495,13 +497,12 @@ export default class CallHandler { const roomInfo = WidgetStore.instance.getRoom(roomId); if (!roomInfo) return; // "should never happen" clauses go here - // TODO: [TravisR] Fix this const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); jitsiWidgets.forEach(w => { - const messaging = ActiveWidgetStore.getWidgetMessaging(w.id); + const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id); if (!messaging) return; // more "should never happen" words - messaging.hangup(); + messaging.transport.send(ElementWidgetActions.HangupCall, {}); }); } } diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 8888fe79b4..e8ef4de257 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -41,6 +41,7 @@ import {SettingLevel} from "../../../settings/SettingLevel"; import WidgetStore from "../../../stores/WidgetStore"; import {Action} from "../../../dispatcher/actions"; import {StopGapWidget} from "../../../stores/widgets/StopGapWidget"; +import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions"; export default class AppTile extends React.Component { constructor(props) { @@ -296,7 +297,7 @@ export default class AppTile extends React.Component { _onWidgetReady = () => { this.setState({loading: false}); if (WidgetType.JITSI.matches(this.props.app.type)) { - this._sgWidget.widgetApi.transport.send("im.vector.ready", {}); + this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {}); } }; diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index 039d2571f4..d191e05407 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -213,7 +213,6 @@ export default class Stickerpicker extends React.Component { _sendVisibilityToWidget(visible) { if (!this.state.stickerpickerWidget) return; - // TODO: [TravisR] Fix this const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id); if (messaging && visible !== this._prevSentVisibility) { messaging.updateVisibility(visible).catch(err => { diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts new file mode 100644 index 0000000000..b101a119a4 --- /dev/null +++ b/src/stores/widgets/ElementWidgetActions.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2020 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export enum ElementWidgetActions { + ClientReady = "im.vector.ready", + HangupCall = "im.vector.hangup", + OpenIntegrationManager = "integration_manager_open", +} diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 073073abec..cd66522488 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -38,6 +38,7 @@ import ActiveWidgetStore from "../ActiveWidgetStore"; import { objectShallowClone } from "../../utils/objects"; import defaultDispatcher from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher"; +import { ElementWidgetActions } from "./ElementWidgetActions"; // TODO: Destroy all of this code @@ -178,7 +179,7 @@ export class StopGapWidget extends EventEmitter { }, ); } else if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) { - this.messaging.addEventListener("action:integration_manager_open", + this.messaging.addEventListener(`action:${ElementWidgetActions.OpenIntegrationManager}`, (ev: CustomEvent<IWidgetApiRequest>) => { // Acknowledge first ev.preventDefault(); From 9377306b813bdbbeb7bee6e6a52dad6945fea445 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Wed, 30 Sep 2020 20:11:27 -0600 Subject: [PATCH 16/21] Fix the screenshot button on the right panel card --- src/components/views/right_panel/WidgetCard.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index b0eefb0fed..6bb45df109 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -39,6 +39,8 @@ import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPa import {Capability} from "../../../widgets/WidgetApi"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import classNames from "classnames"; +import dis from "../../../dispatcher/dispatcher"; +import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; interface IProps { room: Room; @@ -77,10 +79,17 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => { let contextMenu; if (menuDisplayed) { let snapshotButton; - // TODO: [TravisR] Fix this - if (ActiveWidgetStore.widgetHasCapability(app.id, Capability.Screenshot)) { + const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); + if (widgetMessaging?.hasCapability(Capability.Screenshot)) { const onSnapshotClick = () => { - WidgetUtils.snapshotWidget(app); + widgetMessaging.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, + }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); closeMenu(); }; From f27071ee64db80ddbe551a4d6eac7ac1b00ea5a4 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Wed, 30 Sep 2020 20:20:31 -0600 Subject: [PATCH 17/21] Transition all remaining messaging over (delete the old stuff) --- src/CallHandler.tsx | 1 - src/FromWidgetPostMessageApi.js | 278 ------------------ src/ToWidgetPostMessageApi.js | 84 ------ src/WidgetMessaging.js | 223 -------------- src/WidgetMessagingEndpoint.js | 37 --- src/components/views/elements/AppTile.js | 6 +- .../views/right_panel/WidgetCard.tsx | 5 +- src/stores/ActiveWidgetStore.js | 9 +- src/stores/widgets/StopGapWidget.ts | 16 +- src/stores/widgets/WidgetMessagingStore.ts | 9 + src/utils/WidgetUtils.js | 6 +- src/widgets/WidgetApi.ts | 222 -------------- src/widgets/WidgetType.ts | 1 + 13 files changed, 29 insertions(+), 868 deletions(-) delete mode 100644 src/FromWidgetPostMessageApi.js delete mode 100644 src/ToWidgetPostMessageApi.js delete mode 100644 src/WidgetMessaging.js delete mode 100644 src/WidgetMessagingEndpoint.js delete mode 100644 src/widgets/WidgetApi.ts diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 49f82e3209..2259913c6d 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -75,7 +75,6 @@ import {base32} from "rfc4648"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import WidgetStore from "./stores/WidgetStore"; -import ActiveWidgetStore from "./stores/ActiveWidgetStore"; import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js deleted file mode 100644 index bbccc47d28..0000000000 --- a/src/FromWidgetPostMessageApi.js +++ /dev/null @@ -1,278 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 Travis Ralston -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the 'License'); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an 'AS IS' BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import URL from 'url'; -import dis from './dispatcher/dispatcher'; -import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; -import ActiveWidgetStore from './stores/ActiveWidgetStore'; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import RoomViewStore from "./stores/RoomViewStore"; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import SettingsStore from "./settings/SettingsStore"; -import {Capability} from "./widgets/WidgetApi"; -import {objectClone} from "./utils/objects"; - -const WIDGET_API_VERSION = '0.0.2'; // Current API version -const SUPPORTED_WIDGET_API_VERSIONS = [ - '0.0.1', - '0.0.2', -]; -const INBOUND_API_NAME = 'fromWidget'; - -// Listen for and handle incoming requests using the 'fromWidget' postMessage -// API and initiate responses -export default class FromWidgetPostMessageApi { - constructor() { - this.widgetMessagingEndpoints = []; - this.widgetListeners = {}; // {action: func[]} - - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - /** - * Adds a listener for a given action - * @param {string} action The action to listen for. - * @param {Function} callbackFn A callback function to be called when the action is - * encountered. Called with two parameters: the interesting request information and - * the raw event received from the postMessage API. The raw event is meant to be used - * for sendResponse and similar functions. - */ - addListener(action, callbackFn) { - if (!this.widgetListeners[action]) this.widgetListeners[action] = []; - this.widgetListeners[action].push(callbackFn); - } - - /** - * Removes a listener for a given action. - * @param {string} action The action that was subscribed to. - * @param {Function} callbackFn The original callback function that was used to subscribe - * to updates. - */ - removeListener(action, callbackFn) { - if (!this.widgetListeners[action]) return; - - const idx = this.widgetListeners[action].indexOf(callbackFn); - if (idx !== -1) this.widgetListeners[action].splice(idx, 1); - } - - /** - * Register a widget endpoint for trusted postMessage communication - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - */ - addEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl); - return; - } - - const origin = u.protocol + '//' + u.host; - const endpoint = new WidgetMessagingEndpoint(widgetId, origin); - if (this.widgetMessagingEndpoints.some(function(ep) { - return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl); - })) { - // Message endpoint already registered - console.warn('Add FromWidgetPostMessageApi - Endpoint already registered'); - return; - } else { - console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint); - this.widgetMessagingEndpoints.push(endpoint); - } - } - - /** - * De-register a widget endpoint from trusted communication sources - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - * @return {boolean} True if endpoint was successfully removed - */ - removeEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Remove widget messaging endpoint - Invalid origin'); - return; - } - - const origin = u.protocol + '//' + u.host; - if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) { - const length = this.widgetMessagingEndpoints.length; - this.widgetMessagingEndpoints = this.widgetMessagingEndpoints - .filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin); - return (length > this.widgetMessagingEndpoints.length); - } - return false; - } - - /** - * Handle widget postMessage events - * Messages are only handled where a valid, registered messaging endpoints - * @param {Event} event Event to handle - * @return {undefined} - */ - onPostMessage(event) { - if (!event.origin) { // Handle chrome - event.origin = event.originalEvent.origin; - } - - // Event origin is empty string if undefined - if ( - event.origin.length === 0 || - !this.trustedEndpoint(event.origin) || - event.data.api !== INBOUND_API_NAME || - !event.data.widgetId - ) { - return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise - } - - // Call any listeners we have registered - if (this.widgetListeners[event.data.action]) { - for (const fn of this.widgetListeners[event.data.action]) { - fn(event.data, event); - } - } - - // Although the requestId is required, we don't use it. We'll be nice and process the message - // if the property is missing, but with a warning for widget developers. - if (!event.data.requestId) { - console.warn("fromWidget action '" + event.data.action + "' does not have a requestId"); - } - - const action = event.data.action; - const widgetId = event.data.widgetId; - if (action === 'content_loaded') { - console.log('Widget reported content loaded for', widgetId); - dis.dispatch({ - action: 'widget_content_loaded', - widgetId: widgetId, - }); - this.sendResponse(event, {success: true}); - } else if (action === 'supported_api_versions') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - supported_versions: SUPPORTED_WIDGET_API_VERSIONS, - }); - } else if (action === 'api_version') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - version: WIDGET_API_VERSION, - }); - } else if (action === 'm.sticker') { - // console.warn('Got sticker message from widget', widgetId); - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId}); - } else if (action === 'integration_manager_open') { - // Close the stickerpicker - dis.dispatch({action: 'stickerpicker_close'}); - // Open the integration manager - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - const integType = (data && data.integType) ? data.integType : null; - const integId = (data && data.integId) ? data.integId : null; - - // TODO: Open the right integration manager for the widget - if (SettingsStore.getValue("feature_many_integration_managers")) { - IntegrationManagers.sharedInstance().openAll( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } else { - IntegrationManagers.sharedInstance().getPrimaryManager().open( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } - } else if (action === 'set_always_on_screen') { - // This is a new message: there is no reason to support the deprecated widgetData here - const data = event.data.data; - const val = data.value; - - if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) { - ActiveWidgetStore.setWidgetPersistence(widgetId, val); - } - - // acknowledge - this.sendResponse(event, {}); - } else if (action === 'get_openid') { - // Handled by caller - } else { - console.warn('Widget postMessage event unhandled'); - this.sendError(event, {message: 'The postMessage was unhandled'}); - } - } - - /** - * Check if message origin is registered as trusted - * @param {string} origin PostMessage origin to check - * @return {boolean} True if trusted - */ - trustedEndpoint(origin) { - if (!origin) { - return false; - } - - return this.widgetMessagingEndpoints.some((endpoint) => { - // TODO / FIXME -- Should this also check the widgetId? - return endpoint.endpointUrl === origin; - }); - } - - /** - * Send a postmessage response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {Object} res Response data - */ - sendResponse(event, res) { - const data = objectClone(event.data); - data.response = res; - event.source.postMessage(data, event.origin); - } - - /** - * Send an error response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {string} msg Error message - * @param {Error} nestedError Nested error event (optional) - */ - sendError(event, msg, nestedError) { - console.error('Action:' + event.data.action + ' failed with message: ' + msg); - const data = objectClone(event.data); - data.response = { - error: { - message: msg, - }, - }; - if (nestedError) { - data.response.error._error = nestedError; - } - event.source.postMessage(data, event.origin); - } -} diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js deleted file mode 100644 index 00309d252c..0000000000 --- a/src/ToWidgetPostMessageApi.js +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// const OUTBOUND_API_NAME = 'toWidget'; - -// Initiate requests using the "toWidget" postMessage API and handle responses -// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a -// response field -export default class ToWidgetPostMessageApi { - constructor(timeoutMs) { - this._timeoutMs = timeoutMs || 5000; // default to 5s timer - this._counter = 0; - this._requestMap = { - // $ID: {resolve, reject} - }; - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - onPostMessage(ev) { - // THIS IS ALL UNSAFE EXECUTION. - // We do not verify who the sender of `ev` is! - const payload = ev.data; - // NOTE: Workaround for running in a mobile WebView where a - // postMessage immediately triggers this callback even though it is - // not the response. - if (payload.response === undefined) { - return; - } - const promise = this._requestMap[payload.requestId]; - if (!promise) { - return; - } - delete this._requestMap[payload.requestId]; - promise.resolve(payload); - } - - // Initiate outbound requests (toWidget) - exec(action, targetWindow, targetOrigin) { - targetWindow = targetWindow || window.parent; // default to parent window - targetOrigin = targetOrigin || "*"; - this._counter += 1; - action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter; - - return new Promise((resolve, reject) => { - this._requestMap[action.requestId] = {resolve, reject}; - targetWindow.postMessage(action, targetOrigin); - - if (this._timeoutMs > 0) { - setTimeout(() => { - if (!this._requestMap[action.requestId]) { - return; - } - console.error("postMessage request timed out. Sent object: " + JSON.stringify(action), - this._requestMap); - this._requestMap[action.requestId].reject(new Error("Timed out")); - delete this._requestMap[action.requestId]; - }, this._timeoutMs); - } - }); - } -} diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js deleted file mode 100644 index 9394abf025..0000000000 --- a/src/WidgetMessaging.js +++ /dev/null @@ -1,223 +0,0 @@ -/* -Copyright 2017 New Vector Ltd -Copyright 2019 Travis Ralston - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/* -* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for -* spec. details / documentation. -*/ - -import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; -import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; -import Modal from "./Modal"; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import SettingsStore from "./settings/SettingsStore"; -import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog"; -import WidgetUtils from "./utils/WidgetUtils"; -import {KnownWidgetActions} from "./widgets/WidgetApi"; - -if (!global.mxFromWidgetMessaging) { - global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); - global.mxFromWidgetMessaging.start(); -} -if (!global.mxToWidgetMessaging) { - global.mxToWidgetMessaging = new ToWidgetPostMessageApi(); - global.mxToWidgetMessaging.start(); -} - -const OUTBOUND_API_NAME = 'toWidget'; - -export default class WidgetMessaging { - /** - * @param {string} widgetId The widget's ID - * @param {string} wurl The raw URL of the widget as in the event (the 'wURL') - * @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL - * or a different URL of the clients choosing if it is using its own impl). - * @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget - * @param {object} target Where widget messages should be sent (eg. the iframe object) - */ - constructor(widgetId, wurl, renderedUrl, isUserWidget, target) { - this.widgetId = widgetId; - this.wurl = wurl; - this.renderedUrl = renderedUrl; - this.isUserWidget = isUserWidget; - this.target = target; - this.fromWidget = global.mxFromWidgetMessaging; - this.toWidget = global.mxToWidgetMessaging; - this._onOpenIdRequest = this._onOpenIdRequest.bind(this); - this.start(); - } - - messageToWidget(action) { - action.widgetId = this.widgetId; // Required to be sent for all outbound requests - - return this.toWidget.exec(action, this.target).then((data) => { - // Check for errors and reject if found - if (data.response === undefined) { // null is valid - throw new Error("Missing 'response' field"); - } - if (data.response && data.response.error) { - const err = data.response.error; - const msg = String(err.message ? err.message : "An error was returned"); - if (err._error) { - console.error(err._error); - } - // Potential XSS attack if 'msg' is not appropriately sanitized, - // as it is untrusted input by our parent window (which we assume is Element). - // We can't aggressively sanitize [A-z0-9] since it might be a translation. - throw new Error(msg); - } - // Return the response field for the request - return data.response; - }); - } - - /** - * Tells the widget that the client is ready to handle further widget requests. - * @returns {Promise<*>} Resolves after the widget has acknowledged the ready message. - */ - flagReadyToContinue() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.ClientReady, - }); - } - - /** - * Tells the widget that it should terminate now. - * @returns {Promise<*>} Resolves when widget has acknowledged the message. - */ - terminate() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.Terminate, - }); - } - - /** - * Tells the widget to hang up on its call. - * @returns {Promise<*>} Resolves when the widget has acknowledged the message. - */ - hangup() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.Hangup, - }); - } - - /** - * Request a screenshot from a widget - * @return {Promise} To be resolved with screenshot data when it has been generated - */ - getScreenshot() { - console.log('Requesting screenshot for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "screenshot", - }) - .catch((error) => new Error("Failed to get screenshot: " + error.message)) - .then((response) => response.screenshot); - } - - /** - * Request capabilities required by the widget - * @return {Promise} To be resolved with an array of requested widget capabilities - */ - getCapabilities() { - console.log('Requesting capabilities for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "capabilities", - }).then((response) => { - console.log('Got capabilities for', this.widgetId, response.capabilities); - return response.capabilities; - }); - } - - sendVisibility(visible) { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "visibility", - visible, - }) - .catch((error) => { - console.error("Failed to send visibility: ", error); - }); - } - - start() { - this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.addListener("get_openid", this._onOpenIdRequest); - } - - stop() { - this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.removeListener("get_openid", this._onOpenIdRequest); - } - - async _onOpenIdRequest(ev, rawEv) { - if (ev.widgetId !== this.widgetId) return; // not interesting - - const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget); - - const settings = SettingsStore.getValue("widgetOpenIDPermissions"); - if (settings.deny && settings.deny.includes(widgetSecurityKey)) { - this.fromWidget.sendResponse(rawEv, {state: "blocked"}); - return; - } - if (settings.allow && settings.allow.includes(widgetSecurityKey)) { - const responseBody = {state: "allowed"}; - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - this.fromWidget.sendResponse(rawEv, responseBody); - return; - } - - // Confirm that we received the request - this.fromWidget.sendResponse(rawEv, {state: "request"}); - - // Actually ask for permission to send the user's data - Modal.createTrackedDialog("OpenID widget permissions", '', - WidgetOpenIDPermissionsDialog, { - widgetUrl: this.wurl, - widgetId: this.widgetId, - isUserWidget: this.isUserWidget, - - onFinished: async (confirm) => { - const responseBody = { - // Legacy (early draft) fields - success: confirm, - - // New style MSC1960 fields - state: confirm ? "allowed" : "blocked", - original_request_id: ev.requestId, // eslint-disable-line camelcase - }; - if (confirm) { - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - } - this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "openid_credentials", - data: responseBody, - }).catch((error) => { - console.error("Failed to send OpenID credentials: ", error); - }); - }, - }, - ); - } -} diff --git a/src/WidgetMessagingEndpoint.js b/src/WidgetMessagingEndpoint.js deleted file mode 100644 index 9114e12137..0000000000 --- a/src/WidgetMessagingEndpoint.js +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - - -/** - * Represents mapping of widget instance to URLs for trusted postMessage communication. - */ -export default class WidgetMessageEndpoint { - /** - * Mapping of widget instance to URL for trusted postMessage communication. - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin. - */ - constructor(widgetId, endpointUrl) { - if (!widgetId) { - throw new Error("No widgetId specified in widgetMessageEndpoint constructor"); - } - if (!endpointUrl) { - throw new Error("No endpoint specified in widgetMessageEndpoint constructor"); - } - this.widgetId = widgetId; - this.endpointUrl = endpointUrl; - } -} diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index e8ef4de257..df1fbe0f3c 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -36,12 +36,12 @@ import SettingsStore from "../../../settings/SettingsStore"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import PersistedElement from "./PersistedElement"; import {WidgetType} from "../../../widgets/WidgetType"; -import {Capability} from "../../../widgets/WidgetApi"; import {SettingLevel} from "../../../settings/SettingLevel"; import WidgetStore from "../../../stores/WidgetStore"; import {Action} from "../../../dispatcher/actions"; import {StopGapWidget} from "../../../stores/widgets/StopGapWidget"; import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions"; +import {MatrixCapabilities} from "matrix-widget-api"; export default class AppTile extends React.Component { constructor(props) { @@ -305,7 +305,7 @@ export default class AppTile extends React.Component { if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': - if (this._sgWidget.widgetApi.hasCapability(Capability.Sticker)) { + if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { dis.dispatch({action: 'post_sticker_message', data: payload.data}); } else { console.warn('Ignoring sticker message. Invalid capability'); @@ -562,7 +562,7 @@ export default class AppTile extends React.Component { const canUserModify = this._canUserModify(); const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify); const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify; - const showPictureSnapshotButton = this._sgWidget.widgetApi.hasCapability(Capability.Screenshot) + const showPictureSnapshotButton = this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.Screenshots) && this.props.show; const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu'); diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 6bb45df109..8efbe3dcf3 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -36,11 +36,12 @@ import IconizedContextMenu, { IconizedContextMenuOptionList, } from "../context_menus/IconizedContextMenu"; import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload"; -import {Capability} from "../../../widgets/WidgetApi"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import classNames from "classnames"; import dis from "../../../dispatcher/dispatcher"; import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; +import { Capability } from "matrix-widget-api/lib/interfaces/Capabilities"; +import { MatrixCapabilities } from "matrix-widget-api"; interface IProps { room: Room; @@ -80,7 +81,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => { if (menuDisplayed) { let snapshotButton; const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); - if (widgetMessaging?.hasCapability(Capability.Screenshot)) { + if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { const onSnapshotClick = () => { widgetMessaging.takeScreenshot().then(data => { dis.dispatch({ diff --git a/src/stores/ActiveWidgetStore.js b/src/stores/ActiveWidgetStore.js index d6aaf83196..4ae8dfeddb 100644 --- a/src/stores/ActiveWidgetStore.js +++ b/src/stores/ActiveWidgetStore.js @@ -66,14 +66,7 @@ class ActiveWidgetStore extends EventEmitter { if (id !== this._persistentWidgetId) return; const toDeleteId = this._persistentWidgetId; - const result = WidgetMessagingStore.instance.findWidgetById(id); - if (result) { - if (result.room) { - WidgetMessagingStore.instance.stopMessagingForRoomWidget(result.room, result.widget); - } else { - WidgetMessagingStore.instance.stopMessagingForAccountWidget(result.widget); - } - } + WidgetMessagingStore.instance.stopMessagingById(id); this.setWidgetPersistence(toDeleteId, false); this.delRoomId(toDeleteId); diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index cd66522488..76c027bb33 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -14,14 +14,18 @@ * limitations under the License. */ -import {Room} from "matrix-js-sdk/src/models/room"; +import { Room } from "matrix-js-sdk/src/models/room"; import { - ClientWidgetApi, IStickerActionRequest, + ClientWidgetApi, + IStickerActionRequest, IStickyActionRequest, - IWidget, IWidgetApiRequest, + IWidget, + IWidgetApiRequest, IWidgetApiRequestEmptyData, IWidgetData, - Widget, WidgetApiFromWidgetAction + MatrixCapabilities, + Widget, + WidgetApiFromWidgetAction } from "matrix-widget-api"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { EventEmitter } from "events"; @@ -33,11 +37,9 @@ import WidgetUtils from '../../utils/WidgetUtils'; import { IntegrationManagers } from "../../integrations/IntegrationManagers"; import SettingsStore from "../../settings/SettingsStore"; import { WidgetType } from "../../widgets/WidgetType"; -import { Capability } from "../../widgets/WidgetApi"; import ActiveWidgetStore from "../ActiveWidgetStore"; import { objectShallowClone } from "../../utils/objects"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import dis from "../../dispatcher/dispatcher"; import { ElementWidgetActions } from "./ElementWidgetActions"; // TODO: Destroy all of this code @@ -171,7 +173,7 @@ export class StopGapWidget extends EventEmitter { if (WidgetType.JITSI.matches(this.mockWidget.type)) { this.messaging.addEventListener("action:set_always_on_screen", (ev: CustomEvent<IStickyActionRequest>) => { - if (this.messaging.hasCapability(Capability.AlwaysOnScreen)) { + if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) { ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value); ev.preventDefault(); this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts index 828465ce84..83d3ac7df8 100644 --- a/src/stores/widgets/WidgetMessagingStore.ts +++ b/src/stores/widgets/WidgetMessagingStore.ts @@ -61,6 +61,15 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> { return this.widgetMap.get(widget.id); } + /** + * Stops the widget messaging instance for a given widget ID. + * @param {string} widgetId The widget ID. + * @deprecated Widget IDs are not globally unique. + */ + public stopMessagingById(widgetId: string) { + this.widgetMap.remove(widgetId)?.stop(); + } + /** * Gets the widget messaging class for a given widget ID. * @param {string} widgetId The widget ID. diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js index 9373738bf8..6cc95efb25 100644 --- a/src/utils/WidgetUtils.js +++ b/src/utils/WidgetUtils.js @@ -28,11 +28,11 @@ const WIDGET_WAIT_TIME = 20000; import SettingsStore from "../settings/SettingsStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import {IntegrationManagers} from "../integrations/IntegrationManagers"; -import {Capability} from "../widgets/WidgetApi"; import {Room} from "matrix-js-sdk/src/models/room"; import {WidgetType} from "../widgets/WidgetType"; import {objectClone} from "./objects"; import {_t} from "../languageHandler"; +import {MatrixCapabilities} from "matrix-widget-api"; export default class WidgetUtils { /* Returns true if user is able to send state events to modify widgets in this room @@ -416,13 +416,13 @@ export default class WidgetUtils { static getCapWhitelistForAppTypeInRoomId(appType, roomId) { const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId); - const capWhitelist = enableScreenshots ? [Capability.Screenshot] : []; + const capWhitelist = enableScreenshots ? [MatrixCapabilities.Screenshots] : []; // Obviously anyone that can add a widget can claim it's a jitsi widget, // so this doesn't really offer much over the set of domains we load // widgets from at all, but it probably makes sense for sanity. if (WidgetType.JITSI.matches(appType)) { - capWhitelist.push(Capability.AlwaysOnScreen); + capWhitelist.push(MatrixCapabilities.AlwaysOnScreen); } return capWhitelist; diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts deleted file mode 100644 index ab9604d155..0000000000 --- a/src/widgets/WidgetApi.ts +++ /dev/null @@ -1,222 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Dev note: This is largely inspired by Dimension. Used with permission. -// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts - -import { randomString } from "matrix-js-sdk/src/randomstring"; -import { EventEmitter } from "events"; -import { objectClone } from "../utils/objects"; - -export enum Capability { - Screenshot = "m.capability.screenshot", - Sticker = "m.sticker", - AlwaysOnScreen = "m.always_on_screen", -} - -export enum KnownWidgetActions { - GetSupportedApiVersions = "supported_api_versions", - TakeScreenshot = "screenshot", - GetCapabilities = "capabilities", - SendEvent = "send_event", - UpdateVisibility = "visibility", - GetOpenIDCredentials = "get_openid", - ReceiveOpenIDCredentials = "openid_credentials", - SetAlwaysOnScreen = "set_always_on_screen", - ClientReady = "im.vector.ready", - Terminate = "im.vector.terminate", - Hangup = "im.vector.hangup", -} - -export type WidgetAction = KnownWidgetActions | string; - -export enum WidgetApiType { - ToWidget = "toWidget", - FromWidget = "fromWidget", -} - -export interface WidgetRequest { - api: WidgetApiType; - widgetId: string; - requestId: string; - data: any; - action: WidgetAction; -} - -export interface ToWidgetRequest extends WidgetRequest { - api: WidgetApiType.ToWidget; -} - -export interface FromWidgetRequest extends WidgetRequest { - api: WidgetApiType.FromWidget; - response: any; -} - -export interface OpenIDCredentials { - accessToken: string; - tokenType: string; - matrixServerName: string; - expiresIn: number; -} - -/** - * Handles Element <--> Widget interactions for embedded/standalone widgets. - * - * Emitted events: - * - terminate(wait): client requested the widget to terminate. - * Call the argument 'wait(promise)' to postpone the finalization until - * the given promise resolves. - */ -export class WidgetApi extends EventEmitter { - private readonly origin: string; - private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {}; - private readonly readyPromise: Promise<any>; - private readyPromiseResolve: () => void; - private openIDCredentialsCallback: () => void; - public openIDCredentials: OpenIDCredentials; - - /** - * Set this to true if your widget is expecting a ready message from the client. False otherwise (default). - */ - public expectingExplicitReady = false; - - constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) { - super(); - - this.origin = new URL(currentUrl).origin; - - this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve); - - window.addEventListener("message", event => { - if (event.origin !== this.origin) return; // ignore: invalid origin - if (!event.data) return; // invalid schema - if (event.data.widgetId !== this.widgetId) return; // not for us - - const payload = <WidgetRequest>event.data; - if (payload.api === WidgetApiType.ToWidget && payload.action) { - console.log(`[WidgetAPI] Got request: ${JSON.stringify(payload)}`); - - if (payload.action === KnownWidgetActions.GetCapabilities) { - this.onCapabilitiesRequest(<ToWidgetRequest>payload); - if (!this.expectingExplicitReady) { - this.readyPromiseResolve(); - } - } else if (payload.action === KnownWidgetActions.ClientReady) { - this.readyPromiseResolve(); - - // Automatically acknowledge so we can move on - this.replyToRequest(<ToWidgetRequest>payload, {}); - } else if (payload.action === KnownWidgetActions.Terminate - || payload.action === KnownWidgetActions.Hangup) { - // Finalization needs to be async, so postpone with a promise - let finalizePromise = Promise.resolve(); - const wait = (promise) => { - finalizePromise = finalizePromise.then(() => promise); - }; - const emitName = payload.action === KnownWidgetActions.Terminate ? 'terminate' : 'hangup'; - this.emit(emitName, wait); - Promise.resolve(finalizePromise).then(() => { - // Acknowledge that we're shut down now - this.replyToRequest(<ToWidgetRequest>payload, {}); - }); - } else if (payload.action === KnownWidgetActions.ReceiveOpenIDCredentials) { - // Save OpenID credentials - this.setOpenIDCredentials(<ToWidgetRequest>payload); - this.replyToRequest(<ToWidgetRequest>payload, {}); - } else { - console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`); - } - } else if (payload.api === WidgetApiType.FromWidget && this.inFlightRequests[payload.requestId]) { - console.log(`[WidgetAPI] Got reply: ${JSON.stringify(payload)}`); - const handler = this.inFlightRequests[payload.requestId]; - delete this.inFlightRequests[payload.requestId]; - handler(<FromWidgetRequest>payload); - } else { - console.warn(`[WidgetAPI] Unhandled payload: ${JSON.stringify(payload)}`); - } - }); - } - - public setOpenIDCredentials(value: WidgetRequest) { - const data = value.data; - if (data.state === 'allowed') { - this.openIDCredentials = { - accessToken: data.access_token, - tokenType: data.token_type, - matrixServerName: data.matrix_server_name, - expiresIn: data.expires_in, - } - } else if (data.state === 'blocked') { - this.openIDCredentials = null; - } - if (['allowed', 'blocked'].includes(data.state) && this.openIDCredentialsCallback) { - this.openIDCredentialsCallback() - } - } - - public requestOpenIDCredentials(credentialsResponseCallback: () => void) { - this.openIDCredentialsCallback = credentialsResponseCallback; - this.callAction( - KnownWidgetActions.GetOpenIDCredentials, - {}, - this.setOpenIDCredentials, - ); - } - - public waitReady(): Promise<any> { - return this.readyPromise; - } - - private replyToRequest(payload: ToWidgetRequest, reply: any) { - if (!window.parent) return; - - const request: ToWidgetRequest & {response?: any} = objectClone(payload); - request.response = reply; - - window.parent.postMessage(request, this.origin); - } - - private onCapabilitiesRequest(payload: ToWidgetRequest) { - return this.replyToRequest(payload, {capabilities: this.requestedCapabilities}); - } - - public callAction(action: WidgetAction, payload: any, callback: (reply: FromWidgetRequest) => void) { - if (!window.parent) return; - - const request: FromWidgetRequest = { - api: WidgetApiType.FromWidget, - widgetId: this.widgetId, - action: action, - requestId: randomString(160), - data: payload, - response: {}, // Not used at this layer - it's used when the client responds - }; - - if (callback) { - this.inFlightRequests[request.requestId] = callback; - } - - console.log(`[WidgetAPI] Sending request: `, request); - window.parent.postMessage(request, "*"); - } - - public setAlwaysOnScreen(onScreen: boolean): Promise<any> { - return new Promise<any>(resolve => { - this.callAction(KnownWidgetActions.SetAlwaysOnScreen, {value: onScreen}, null); - resolve(); // SetAlwaysOnScreen is currently fire-and-forget, but that could change. - }); - } -} diff --git a/src/widgets/WidgetType.ts b/src/widgets/WidgetType.ts index e4b37e639c..e42f3ffa9b 100644 --- a/src/widgets/WidgetType.ts +++ b/src/widgets/WidgetType.ts @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +// TODO: Move to matrix-widget-api export class WidgetType { public static readonly JITSI = new WidgetType("m.jitsi", "jitsi"); public static readonly STICKERPICKER = new WidgetType("m.stickerpicker", "m.stickerpicker"); From 08c5e9e039777dcb4c869eb829d7ec57ab85180e Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Wed, 30 Sep 2020 20:42:37 -0600 Subject: [PATCH 18/21] Use the beta release of the widget-api --- package.json | 1 + yarn.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/package.json b/package.json index 3ab523ee9a..e66d0aabcf 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "linkifyjs": "^2.1.9", "lodash": "^4.17.19", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-widget-api": "^0.1.0-beta.2", "minimist": "^1.2.5", "pako": "^1.0.11", "parse5": "^5.1.1", diff --git a/yarn.lock b/yarn.lock index 9ecf43d7a4..51ff681783 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5953,6 +5953,11 @@ matrix-react-test-utils@^0.2.2: resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853" integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== +matrix-widget-api@^0.1.0-beta.2: + version "0.1.0-beta.2" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.2.tgz#367da1ccd26b711f73fc5b6e02edf55ac2ea2692" + integrity sha512-q5g5RZN+RRjM4HmcJ+LYoQAYrB1wzyERmoQ+LvKbTV/+9Ov36Kp0QEP8CleSXEd5WLp6bkRlt60axDaY6pWGmg== + mdast-util-compact@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.4.tgz#d531bb7667b5123abf20859be086c4d06c894593" From 2ec94e8a699c6288c5fa30f355c0e685ef515014 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Wed, 30 Sep 2020 20:49:31 -0600 Subject: [PATCH 19/21] Appease the linter --- src/components/views/elements/AppTile.js | 4 ++-- src/components/views/right_panel/WidgetCard.tsx | 2 -- src/components/views/rooms/Stickerpicker.js | 1 - src/stores/OwnProfileStore.ts | 2 +- src/stores/widgets/StopGapWidget.ts | 2 +- 5 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index df1fbe0f3c..5fe8b50b64 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -150,7 +150,7 @@ export default class AppTile extends React.Component { this.setState({initialising: false}); }); } - + _iframeRefChange = (ref) => { this.setState({iframe: ref}); if (ref) { @@ -223,7 +223,7 @@ export default class AppTile extends React.Component { * @private * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ - _endWidgetActions() { + async _endWidgetActions() { // widget migration dev note: async to maintain signature // HACK: This is a really dirty way to ensure that Jitsi cleans up // its hold on the webcam. Without this, the widget holds a media // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 8efbe3dcf3..30900b9a4d 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -29,7 +29,6 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import {Action} from "../../../dispatcher/actions"; import WidgetStore from "../../../stores/WidgetStore"; -import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, @@ -40,7 +39,6 @@ import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import classNames from "classnames"; import dis from "../../../dispatcher/dispatcher"; import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; -import { Capability } from "matrix-widget-api/lib/interfaces/Capabilities"; import { MatrixCapabilities } from "matrix-widget-api"; interface IProps { diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index d191e05407..2faa0fea27 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -22,7 +22,6 @@ import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import AccessibleButton from '../elements/AccessibleButton'; import WidgetUtils from '../../../utils/WidgetUtils'; -import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import PersistedElement from "../elements/PersistedElement"; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts index 61387e3c26..8983380fec 100644 --- a/src/stores/OwnProfileStore.ts +++ b/src/stores/OwnProfileStore.ts @@ -70,7 +70,7 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> { * will be returned as an HTTP URL. * @returns The HTTP URL of the user's avatar */ - public getHttpAvatarUrl(size: number = 0): string { + public getHttpAvatarUrl(size = 0): string { if (!this.avatarMxc) return null; const adjustedSize = size > 1 ? size : undefined; // don't let negatives or zero through return this.matrixClient.mxcUrlToHttp(this.avatarMxc, adjustedSize, adjustedSize); diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 76c027bb33..1c24f70d0d 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -25,7 +25,7 @@ import { IWidgetData, MatrixCapabilities, Widget, - WidgetApiFromWidgetAction + WidgetApiFromWidgetAction, } from "matrix-widget-api"; import { StopGapWidgetDriver } from "./StopGapWidgetDriver"; import { EventEmitter } from "events"; From 0570deffa2d026e56a6e62e9b49407aacbead399 Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Thu, 1 Oct 2020 10:01:13 -0600 Subject: [PATCH 20/21] Fix iterableUnion types --- src/stores/widgets/StopGapWidgetDriver.ts | 2 +- src/utils/iterables.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 84626e74fb..b54e4a5f7d 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -25,6 +25,6 @@ export class StopGapWidgetDriver extends WidgetDriver { } public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> { - return iterableUnion(requested, new Set(this.allowedCapabilities)); + return new Set(iterableUnion(requested, this.allowedCapabilities)); } } diff --git a/src/utils/iterables.ts b/src/utils/iterables.ts index 3d2585906d..56e0bca1b7 100644 --- a/src/utils/iterables.ts +++ b/src/utils/iterables.ts @@ -16,6 +16,6 @@ import { arrayUnion } from "./arrays"; -export function iterableUnion<C extends Iterable<T>, T>(a: C, b: C): Set<T> { - return new Set(arrayUnion(Array.from(a), Array.from(b))); +export function iterableUnion<T>(a: Iterable<T>, b: Iterable<T>): Iterable<T> { + return arrayUnion(Array.from(a), Array.from(b)); } From d64049059507bda2420e323e4d5366ff6af76e3b Mon Sep 17 00:00:00 2001 From: Travis Ralston <travpc@gmail.com> Date: Thu, 1 Oct 2020 10:03:52 -0600 Subject: [PATCH 21/21] Fix iframe reference --- src/components/views/elements/AppTile.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 5fe8b50b64..3945eaa763 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -51,6 +51,7 @@ export default class AppTile extends React.Component { this._persistKey = 'widget_' + this.props.app.id; this._sgWidget = new StopGapWidget(this.props); this._sgWidget.on("ready", this._onWidgetReady); + this.iframe = null; // ref to the iframe (callback style) this.state = this._getNewState(props); @@ -152,7 +153,7 @@ export default class AppTile extends React.Component { } _iframeRefChange = (ref) => { - this.setState({iframe: ref}); + this.iframe = ref; if (ref) { this._sgWidget.start(ref); } else { @@ -227,14 +228,14 @@ export default class AppTile extends React.Component { // HACK: This is a really dirty way to ensure that Jitsi cleans up // its hold on the webcam. Without this, the widget holds a media // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 - if (this.state.iframe) { + if (this.iframe) { // In practice we could just do `+= ''` to trick the browser // into thinking the URL changed, however I can foresee this // being optimized out by a browser. Instead, we'll just point // the iframe at a page that is reasonably safe to use in the // event the iframe doesn't wink away. // This is relative to where the Element instance is located. - this.state.iframe.src = 'about:blank'; + this.iframe.src = 'about:blank'; } // Delete the widget from the persisted store for good measure. @@ -425,9 +426,9 @@ export default class AppTile extends React.Component { // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop). if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) { this._endWidgetActions().then(() => { - if (this.state.iframe) { + if (this.iframe) { // Reload iframe - this.state.iframe.src = this._sgWidget.embedUrl; + this.iframe.src = this._sgWidget.embedUrl; this.setState({}); } }); @@ -441,7 +442,7 @@ export default class AppTile extends React.Component { _onReloadWidgetClick() { // Reload iframe in this way to avoid cross-origin restrictions // eslint-disable-next-line no-self-assign - this.state.iframe.src = this.state.iframe.src; + this.iframe.src = this.iframe.src; } _onContextMenuClick = () => {