From 999b5afa0ad9c40a810a39dd2d77a92ed8ab6b09 Mon Sep 17 00:00:00 2001 From: Travis Ralston 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 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 = new Set(), + ) { + super(); + } + + public async validateCapabilities(requested: Set): Promise> { + // 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 { + private static internalInstance = new WidgetMessagingStore(); + + // > + private widgetMap = new EnhancedMap>(); + + public constructor() { + super(defaultDispatcher); + } + + public static get instance(): WidgetMessagingStore { + return WidgetMessagingStore.internalInstance; + } + + protected async onAction(payload: ActionPayload): Promise { + // nothing to do + } + + protected async onReady(): Promise { + // 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, T>(a: C, b: C): Set { + 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(a: Map, b: Map): K[] { const diff = mapDiff(a, b); return arrayMerge(diff.removed, diff.added, diff.changed); } + +/** + * A Map with added utility. + */ +export class EnhancedMap extends Map { + 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 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 { 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 extends Map { 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 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 { + private messaging: ClientWidgetApi; + private iframeRef = React.createRef(); + + 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 { 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 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 { +export default class AppTile2 extends React.PureComponent { private messaging: ClientWidgetApi; private iframeRef = React.createRef(); From 78a04a610662ea0071de3f77b0ab41d0bef6e3ae Mon Sep 17 00:00:00 2001 From: Travis Ralston 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 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 =  - ; 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 {