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/src/CallHandler.tsx b/src/CallHandler.tsx
index bca2e4eac5..5b368016b6 100644
--- a/src/CallHandler.tsx
+++ b/src/CallHandler.tsx
@@ -75,7 +75,8 @@ 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";
// until we ts-ify the js-sdk voip code
type Call = any;
@@ -503,10 +504,10 @@ export default class CallHandler {
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/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js
deleted file mode 100644
index d5d7c08d50..0000000000
--- a/src/FromWidgetPostMessageApi.js
+++ /dev/null
@@ -1,275 +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);
- }
- } 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 6aaeab060f..3945eaa763 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,16 @@ 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";
+import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
+import {MatrixCapabilities} from "matrix-widget-api";
export default class AppTile extends React.Component {
constructor(props) {
@@ -72,11 +49,13 @@ 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.iframe = null; // ref to the iframe (callback style)
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);
@@ -89,7 +68,6 @@ export default class AppTile extends React.Component {
this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
this._contextMenuButton = createRef();
- this._appFrame = createRef();
this._menu_bar = createRef();
}
@@ -108,12 +86,10 @@ 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
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(),
@@ -124,43 +100,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);
@@ -176,7 +115,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
@@ -190,93 +129,44 @@ 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);
}
+
+ 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;
- }
-
- 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,
- });
+ _startWidget() {
+ this._sgWidget.prepare().then(() => {
+ this.setState({initialising: false});
});
}
+ _iframeRefChange = (ref) => {
+ this.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
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);
}
}
@@ -287,9 +177,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();
}
}
@@ -319,7 +209,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);
+ });
}
/**
@@ -327,35 +224,24 @@ export default class AppTile extends React.Component {
* @private
* @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();
+ 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
+ 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.iframe.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,
@@ -409,73 +295,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);
-
- 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)) {
- 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(ElementWidgetActions.ClientReady, {});
+ }
+ };
_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(MatrixCapabilities.StickerSending)) {
dis.dispatch({action: 'post_sticker_message', data: payload.data});
} else {
console.warn('Ignoring sticker message. Invalid capability');
@@ -493,20 +324,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);
@@ -516,7 +333,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.
@@ -535,6 +352,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.
@@ -572,40 +390,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
@@ -615,67 +399,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;
}
@@ -698,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._appFrame.current) {
+ if (this.iframe) {
// Reload iframe
- this._appFrame.current.src = this._getRenderedUrl();
+ this.iframe.src = this._sgWidget.embedUrl;
this.setState({});
}
});
@@ -708,13 +436,13 @@ 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() {
// 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.iframe.src = this.iframe.src;
}
_onContextMenuClick = () => {
@@ -760,7 +488,7 @@ export default class AppTile extends React.Component {
@@ -785,11 +513,11 @@ export default class AppTile extends React.Component {
{ this.state.loading && loadingElement }
+ />
);
// if the widget would be allowed to remain on screen, we must put it in
@@ -833,9 +561,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(MatrixCapabilities.Screenshots)
+ && this.props.show;
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
contextMenu = (
@@ -943,9 +672,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,
};
diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx
index 1677494708..30900b9a4d 100644
--- a/src/components/views/right_panel/WidgetCard.tsx
+++ b/src/components/views/right_panel/WidgetCard.tsx
@@ -29,16 +29,17 @@ 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,
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 { MatrixCapabilities } from "matrix-widget-api";
interface IProps {
room: Room;
@@ -77,9 +78,17 @@ const WidgetCard: React.FC = ({ room, widgetId, onClose }) => {
let contextMenu;
if (menuDisplayed) {
let snapshotButton;
- if (ActiveWidgetStore.widgetHasCapability(app.id, Capability.Screenshot)) {
+ const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
+ if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
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();
};
diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js
index dba25a94cf..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";
@@ -30,6 +29,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.
@@ -212,9 +212,11 @@ export default class Stickerpicker extends React.Component {
_sendVisibilityToWidget(visible) {
if (!this.state.stickerpickerWidget) return;
- 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/ActiveWidgetStore.js b/src/stores/ActiveWidgetStore.js
index bf9ae3586c..4ae8dfeddb 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,9 @@ class ActiveWidgetStore extends EventEmitter {
if (id !== this._persistentWidgetId) return;
const toDeleteId = this._persistentWidgetId;
+ WidgetMessagingStore.instance.stopMessagingById(id);
+
this.setWidgetPersistence(toDeleteId, false);
- this.delWidgetMessaging(toDeleteId);
- this.delWidgetCapabilities(toDeleteId);
this.delRoomId(toDeleteId);
}
@@ -99,43 +89,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/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts
index 1aa761e1c4..8983380fec 100644
--- a/src/stores/OwnProfileStore.ts
+++ b/src/stores/OwnProfileStore.ts
@@ -66,12 +66,14 @@ export class OwnProfileStore extends AsyncStoreWithClient {
/**
* 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 = 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/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
new file mode 100644
index 0000000000..1c24f70d0d
--- /dev/null
+++ b/src/stores/widgets/StopGapWidget.ts
@@ -0,0 +1,266 @@
+/*
+ * 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,
+ IStickerActionRequest,
+ IStickyActionRequest,
+ IWidget,
+ IWidgetApiRequest,
+ IWidgetApiRequestEmptyData,
+ IWidgetData,
+ MatrixCapabilities,
+ Widget,
+ WidgetApiFromWidgetAction,
+} 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";
+import ActiveWidgetStore from "../ActiveWidgetStore";
+import { objectShallowClone } from "../../utils/objects";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { ElementWidgetActions } from "./ElementWidgetActions";
+
+// 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();
+ 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 {
+ 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);
+
+ 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) => {
+ if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
+ ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value);
+ ev.preventDefault();
+ this.messaging.transport.reply(ev.detail, {}); // ack
+ }
+ },
+ );
+ } else if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) {
+ this.messaging.addEventListener(`action:${ElementWidgetActions.OpenIntegrationManager}`,
+ (ev: CustomEvent) => {
+ // Acknowledge first
+ ev.preventDefault();
+ this.messaging.transport.reply(ev.detail, {});
+
+ // 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 = 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) => {
+ // Acknowledge first
+ ev.preventDefault();
+ this.messaging.transport.reply(ev.detail, {});
+
+ // Send the sticker
+ defaultDispatcher.dispatch({
+ action: 'm.sticker',
+ data: ev.detail.data,
+ widgetId: this.mockWidget.id,
+ });
+ },
+ );
+ }
+ }
+
+ public async prepare(): Promise {
+ 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();
+ 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 (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);
+ }
+}
diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts
new file mode 100644
index 0000000000..b54e4a5f7d
--- /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): Promise> {
+ return new Set(iterableUnion(requested, this.allowedCapabilities));
+ }
+}
diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts
new file mode 100644
index 0000000000..83d3ac7df8
--- /dev/null
+++ b/src/stores/widgets/WidgetMessagingStore.ts
@@ -0,0 +1,82 @@
+/*
+ * 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";
+import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { ActionPayload } from "../../dispatcher/payloads";
+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();
+
+ // TODO: Fix uniqueness problem (widget IDs are not unique across the whole app)
+ 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();
+ }
+
+ public storeMessaging(widget: Widget, widgetApi: ClientWidgetApi) {
+ this.stopMessaging(widget);
+ this.widgetMap.set(widget.id, widgetApi);
+ }
+
+ public stopMessaging(widget: Widget) {
+ this.widgetMap.remove(widget.id)?.stop();
+ }
+
+ public getMessaging(widget: Widget): ClientWidgetApi {
+ 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.
+ * @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);
+ }
+}
diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js
index d1daba7ca5..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,15 +416,14 @@ 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);
}
- capWhitelist.push(Capability.ReceiveTerminate);
return capWhitelist;
}
@@ -495,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);
- });
- }
}
diff --git a/src/utils/iterables.ts b/src/utils/iterables.ts
new file mode 100644
index 0000000000..56e0bca1b7
--- /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(a: Iterable, b: Iterable): Iterable {
+ return arrayUnion(Array.from(a), Array.from(b));
+}
diff --git a/src/utils/maps.ts b/src/utils/maps.ts
index 96832094f0..57d84bd33f 100644
--- a/src/utils/maps.ts
+++ b/src/utils/maps.ts
@@ -44,3 +44,26 @@ 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;
+ }
+
+ public remove(key: K): V {
+ const v = this.get(key);
+ this.delete(key);
+ return v;
+ }
+}
diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts
deleted file mode 100644
index c25d607948..0000000000
--- a/src/widgets/WidgetApi.ts
+++ /dev/null
@@ -1,223 +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",
- ReceiveTerminate = "im.vector.receive_terminate",
-}
-
-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;
- 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(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 = event.data;
- if (payload.api === WidgetApiType.ToWidget && payload.action) {
- console.log(`[WidgetAPI] Got request: ${JSON.stringify(payload)}`);
-
- if (payload.action === KnownWidgetActions.GetCapabilities) {
- this.onCapabilitiesRequest(payload);
- if (!this.expectingExplicitReady) {
- this.readyPromiseResolve();
- }
- } else if (payload.action === KnownWidgetActions.ClientReady) {
- this.readyPromiseResolve();
-
- // Automatically acknowledge so we can move on
- this.replyToRequest(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(payload, {});
- });
- } else if (payload.action === KnownWidgetActions.ReceiveOpenIDCredentials) {
- // Save OpenID credentials
- this.setOpenIDCredentials(payload);
- this.replyToRequest(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(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 {
- 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 {
- return new Promise(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");
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"