Merge pull request #5171 from matrix-org/travis/widget-api

Switch to using the Widget API SDK for widget messaging
This commit is contained in:
Travis Ralston 2020-10-01 10:16:01 -06:00 committed by GitHub
commit cca9b91aca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 560 additions and 1272 deletions

View file

@ -79,6 +79,7 @@
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.19", "lodash": "^4.17.19",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^0.1.0-beta.2",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"pako": "^1.0.11", "pako": "^1.0.11",
"parse5": "^5.1.1", "parse5": "^5.1.1",

View file

@ -75,7 +75,8 @@ import {base32} from "rfc4648";
import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import QuestionDialog from "./components/views/dialogs/QuestionDialog";
import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import WidgetStore from "./stores/WidgetStore"; 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 // until we ts-ify the js-sdk voip code
type Call = any; type Call = any;
@ -503,10 +504,10 @@ export default class CallHandler {
const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
jitsiWidgets.forEach(w => { jitsiWidgets.forEach(w => {
const messaging = ActiveWidgetStore.getWidgetMessaging(w.id); const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id);
if (!messaging) return; // more "should never happen" words if (!messaging) return; // more "should never happen" words
messaging.hangup(); messaging.transport.send(ElementWidgetActions.HangupCall, {});
}); });
} }
} }

View file

@ -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);
}
}

View file

@ -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);
}
});
}
}

View file

@ -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);
});
},
},
);
}
}

View file

@ -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;
}
}

View file

@ -18,11 +18,9 @@ limitations under the License.
*/ */
import url from 'url'; import url from 'url';
import qs from 'qs';
import React, {createRef} from 'react'; import React, {createRef} from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import WidgetMessaging from '../../../WidgetMessaging';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -34,37 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import classNames from 'classnames'; import classNames from 'classnames';
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement from "./PersistedElement"; import PersistedElement from "./PersistedElement";
import {WidgetType} from "../../../widgets/WidgetType"; import {WidgetType} from "../../../widgets/WidgetType";
import {Capability} from "../../../widgets/WidgetApi";
import {sleep} from "../../../utils/promise";
import {SettingLevel} from "../../../settings/SettingLevel"; import {SettingLevel} from "../../../settings/SettingLevel";
import WidgetStore from "../../../stores/WidgetStore"; import WidgetStore from "../../../stores/WidgetStore";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
const ENABLE_REACT_PERF = false; import {MatrixCapabilities} from "matrix-widget-api";
/**
* 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;
}
export default class AppTile extends React.Component { export default class AppTile extends React.Component {
constructor(props) { constructor(props) {
@ -72,11 +49,13 @@ export default class AppTile extends React.Component {
// The key used for PersistedElement // The key used for PersistedElement
this._persistKey = 'widget_' + this.props.app.id; 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.state = this._getNewState(props);
this._onAction = this._onAction.bind(this); this._onAction = this._onAction.bind(this);
this._onLoaded = this._onLoaded.bind(this);
this._onEditClick = this._onEditClick.bind(this); this._onEditClick = this._onEditClick.bind(this);
this._onDeleteClick = this._onDeleteClick.bind(this); this._onDeleteClick = this._onDeleteClick.bind(this);
this._onRevokeClicked = this._onRevokeClicked.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._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
this._contextMenuButton = createRef(); this._contextMenuButton = createRef();
this._appFrame = createRef();
this._menu_bar = createRef(); this._menu_bar = createRef();
} }
@ -108,12 +86,10 @@ export default class AppTile extends React.Component {
return !!currentlyAllowedWidgets[newProps.app.eventId]; return !!currentlyAllowedWidgets[newProps.app.eventId];
}; };
const PersistedElement = sdk.getComponent("elements.PersistedElement");
return { return {
initialising: true, // True while we are mangling the widget URL initialising: true, // True while we are mangling the widget URL
// True while the iframe content is loading // True while the iframe content is loading
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), 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 // 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 // added it to the room, or if explicitly granted by the user
hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), 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() { isMixedContent() {
const parentContentProtocol = window.location.protocol; const parentContentProtocol = window.location.protocol;
const u = url.parse(this.props.app.url); const u = url.parse(this.props.app.url);
@ -176,7 +115,7 @@ export default class AppTile extends React.Component {
componentDidMount() { componentDidMount() {
// Only fetch IM token on mount if we're showing and have permission to load // Only fetch IM token on mount if we're showing and have permission to load
if (this.props.show && this.state.hasPermissionToLoad) { if (this.props.show && this.state.hasPermissionToLoad) {
this.setScalarToken(); this._startWidget();
} }
// Widget action listeners // 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 it's not remaining on screen, get rid of the PersistedElement container
if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) { if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
} }
if (this._sgWidget) {
this._sgWidget.stop();
}
} }
// TODO: Generify the name of this function. It's not just scalar tokens. _resetWidget(newProps) {
/** if (this._sgWidget) {
* Adds a scalar token to the widget URL, if required this._sgWidget.stop();
* Component initialisation is only complete when this function has resolved }
*/ this._sgWidget = new StopGapWidget(newProps);
setScalarToken() { this._sgWidget.on("ready", this._onWidgetReady);
if (!WidgetUtils.isScalarUrl(this.props.app.url)) { this._startWidget();
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;
} }
const managers = IntegrationManagers.sharedInstance(); _startWidget() {
if (!managers.hasManager()) { this._sgWidget.prepare().then(() => {
console.warn("No integration manager - not setting scalar token", url); this.setState({initialising: false});
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,
});
}); });
} }
_iframeRefChange = (ref) => {
this.iframe = ref;
if (ref) {
this._sgWidget.start(ref);
} else {
this._resetWidget(this.props);
}
};
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
if (nextProps.app.url !== this.props.app.url) { if (nextProps.app.url !== this.props.app.url) {
this._getNewState(nextProps); 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) { 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, 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) { if (this.state.hasPermissionToLoad) {
this.setScalarToken(); this._startWidget();
} }
} }
@ -319,7 +209,14 @@ export default class AppTile extends React.Component {
} }
_onSnapshotClick() { _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 * @private
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
*/ */
_endWidgetActions() { async _endWidgetActions() { // widget migration dev note: async to maintain signature
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();
}
return terminationPromise.finally(() => {
// HACK: This is a really dirty way to ensure that Jitsi cleans up // 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 // 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 // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
if (this._appFrame.current) { if (this.iframe) {
// In practice we could just do `+= ''` to trick the browser // In practice we could just do `+= ''` to trick the browser
// into thinking the URL changed, however I can foresee this // into thinking the URL changed, however I can foresee this
// being optimized out by a browser. Instead, we'll just point // being optimized out by a browser. Instead, we'll just point
// the iframe at a page that is reasonably safe to use in the // the iframe at a page that is reasonably safe to use in the
// event the iframe doesn't wink away. // event the iframe doesn't wink away.
// This is relative to where the Element instance is located. // This is relative to where the Element instance is located.
this._appFrame.current.src = 'about:blank'; this.iframe.src = 'about:blank';
} }
// Delete the widget from the persisted store for good measure. // Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
});
this._sgWidget.stop();
} }
/* If user has permission to modify widgets, delete the widget, /* If user has permission to modify widgets, delete the widget,
@ -409,73 +295,18 @@ export default class AppTile extends React.Component {
this._revokeWidgetPermission(); this._revokeWidgetPermission();
} }
/** _onWidgetReady = () => {
* 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);
this.setState({loading: false}); 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)) { if (WidgetType.JITSI.matches(this.props.app.type)) {
widgetMessaging.flagReadyToContinue(); this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
}
}).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err);
});
} }
};
_onAction(payload) { _onAction(payload) {
if (payload.widgetId === this.props.app.id) { if (payload.widgetId === this.props.app.id) {
switch (payload.action) { switch (payload.action) {
case 'm.sticker': case 'm.sticker':
if (this._hasCapability('m.sticker')) { if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
dis.dispatch({action: 'post_sticker_message', data: payload.data}); dis.dispatch({action: 'post_sticker_message', data: payload.data});
} else { } else {
console.warn('Ignoring sticker message. Invalid capability'); 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() { _grantWidgetPermission() {
const roomId = this.props.room.roomId; const roomId = this.props.room.roomId;
console.info("Granting permission for widget to load: " + this.props.app.eventId); 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}); this.setState({hasPermissionToLoad: true});
// Fetch a token for the integration manager, now that we're allowed to // Fetch a token for the integration manager, now that we're allowed to
this.setScalarToken(); this._startWidget();
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
// We don't really need to do anything about this - the user will just hit the button again. // 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); ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
const PersistedElement = sdk.getComponent("elements.PersistedElement"); const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
this._sgWidget.stop();
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
// We don't really need to do anything about this - the user will just hit the button again. // 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 * Whether we're using a local version of the widget rather than loading the
* actual widget URL * actual widget URL
@ -615,67 +399,11 @@ export default class AppTile extends React.Component {
return WidgetType.JITSI.matches(this.props.app.type); 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() { _getTileTitle() {
const name = this.formatAppTileName(); const name = this.formatAppTileName();
const titleSpacer = <span>&nbsp;-&nbsp;</span>; const titleSpacer = <span>&nbsp;-&nbsp;</span>;
let title = ''; let title = '';
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) { if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) {
title = this.state.widgetPageTitle; 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). // 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) { if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
this._endWidgetActions().then(() => { this._endWidgetActions().then(() => {
if (this._appFrame.current) { if (this.iframe) {
// Reload iframe // Reload iframe
this._appFrame.current.src = this._getRenderedUrl(); this.iframe.src = this._sgWidget.embedUrl;
this.setState({}); 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. // Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'), 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() { _onReloadWidgetClick() {
// Reload iframe in this way to avoid cross-origin restrictions // Reload iframe in this way to avoid cross-origin restrictions
// eslint-disable-next-line no-self-assign // eslint-disable-next-line no-self-assign
this._appFrame.current.src = this._appFrame.current.src; this.iframe.src = this.iframe.src;
} }
_onContextMenuClick = () => { _onContextMenuClick = () => {
@ -760,7 +488,7 @@ export default class AppTile extends React.Component {
<AppPermission <AppPermission
roomId={this.props.room.roomId} roomId={this.props.room.roomId}
creatorUserId={this.props.creatorUserId} creatorUserId={this.props.creatorUserId}
url={this.state.widgetUrl} url={this._sgWidget.embedUrl}
isRoomEncrypted={isEncrypted} isRoomEncrypted={isEncrypted}
onPermissionGranted={this._grantWidgetPermission} onPermissionGranted={this._grantWidgetPermission}
/> />
@ -785,11 +513,11 @@ export default class AppTile extends React.Component {
{ this.state.loading && loadingElement } { this.state.loading && loadingElement }
<iframe <iframe
allow={iframeFeatures} allow={iframeFeatures}
ref={this._appFrame} ref={this._iframeRefChange}
src={this._getRenderedUrl()} src={this._sgWidget.embedUrl}
allowFullScreen={true} allowFullScreen={true}
sandbox={sandboxFlags} sandbox={sandboxFlags}
onLoad={this._onLoaded} /> />
</div> </div>
); );
// if the widget would be allowed to remain on screen, we must put it in // 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 elementRect = this._contextMenuButton.current.getBoundingClientRect();
const canUserModify = this._canUserModify(); 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 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'); const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
contextMenu = ( contextMenu = (
@ -943,9 +672,6 @@ AppTile.propTypes = {
// NOTE -- Use with caution. This is intended to aid better integration / UX // NOTE -- Use with caution. This is intended to aid better integration / UX
// basic widget capabilities, e.g. injecting sticker message events. // basic widget capabilities, e.g. injecting sticker message events.
whitelistCapabilities: PropTypes.array, 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 // Is this an instance of a user widget
userWidget: PropTypes.bool, userWidget: PropTypes.bool,
}; };

View file

@ -29,16 +29,17 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import WidgetStore from "../../../stores/WidgetStore"; import WidgetStore from "../../../stores/WidgetStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu"; import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
import IconizedContextMenu, { import IconizedContextMenu, {
IconizedContextMenuOption, IconizedContextMenuOption,
IconizedContextMenuOptionList, IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu"; } from "../context_menus/IconizedContextMenu";
import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload"; import {AppTileActionPayload} from "../../../dispatcher/payloads/AppTileActionPayload";
import {Capability} from "../../../widgets/WidgetApi";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import classNames from "classnames"; import classNames from "classnames";
import dis from "../../../dispatcher/dispatcher";
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
import { MatrixCapabilities } from "matrix-widget-api";
interface IProps { interface IProps {
room: Room; room: Room;
@ -77,9 +78,17 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
let contextMenu; let contextMenu;
if (menuDisplayed) { if (menuDisplayed) {
let snapshotButton; let snapshotButton;
if (ActiveWidgetStore.widgetHasCapability(app.id, Capability.Screenshot)) { const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id);
if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) {
const onSnapshotClick = () => { 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(); closeMenu();
}; };

View file

@ -22,7 +22,6 @@ import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import PersistedElement from "../elements/PersistedElement"; import PersistedElement from "../elements/PersistedElement";
import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
@ -30,6 +29,7 @@ import {ContextMenu} from "../../structures/ContextMenu";
import {WidgetType} from "../../../widgets/WidgetType"; import {WidgetType} from "../../../widgets/WidgetType";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {Action} from "../../../dispatcher/actions"; 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). // 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. // 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) { _sendVisibilityToWidget(visible) {
if (!this.state.stickerpickerWidget) return; if (!this.state.stickerpickerWidget) return;
const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(this.state.stickerpickerWidget.id); const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id);
if (widgetMessaging && visible !== this._prevSentVisibility) { if (messaging && visible !== this._prevSentVisibility) {
widgetMessaging.sendVisibility(visible); messaging.updateVisibility(visible).catch(err => {
console.error("Error updating widget visibility: ", err);
});
this._prevSentVisibility = visible; this._prevSentVisibility = visible;
} }
} }

View file

@ -17,6 +17,7 @@ limitations under the License.
import EventEmitter from 'events'; import EventEmitter from 'events';
import {MatrixClientPeg} from '../MatrixClientPeg'; import {MatrixClientPeg} from '../MatrixClientPeg';
import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore";
/** /**
* Stores information about the widgets active in the app right now: * Stores information about the widgets active in the app right now:
@ -29,15 +30,6 @@ class ActiveWidgetStore extends EventEmitter {
super(); super();
this._persistentWidgetId = null; 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) // What room ID each widget is associated with (if it's a room widget)
this._roomIdByWidgetId = {}; this._roomIdByWidgetId = {};
@ -54,8 +46,6 @@ class ActiveWidgetStore extends EventEmitter {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents); MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents);
} }
this._capsByWidgetId = {};
this._widgetMessagingByWidgetId = {};
this._roomIdByWidgetId = {}; this._roomIdByWidgetId = {};
} }
@ -76,9 +66,9 @@ class ActiveWidgetStore extends EventEmitter {
if (id !== this._persistentWidgetId) return; if (id !== this._persistentWidgetId) return;
const toDeleteId = this._persistentWidgetId; const toDeleteId = this._persistentWidgetId;
WidgetMessagingStore.instance.stopMessagingById(id);
this.setWidgetPersistence(toDeleteId, false); this.setWidgetPersistence(toDeleteId, false);
this.delWidgetMessaging(toDeleteId);
this.delWidgetCapabilities(toDeleteId);
this.delRoomId(toDeleteId); this.delRoomId(toDeleteId);
} }
@ -99,43 +89,6 @@ class ActiveWidgetStore extends EventEmitter {
return this._persistentWidgetId; 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) { getRoomId(widgetId) {
return this._roomIdByWidgetId[widgetId]; return this._roomIdByWidgetId[widgetId];
} }

View file

@ -66,12 +66,14 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
/** /**
* Gets the user's avatar as an HTTP URL of the given size. If the user's * Gets the user's avatar as an HTTP URL of the given size. If the user's
* avatar is not present, this returns null. * 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 * @returns The HTTP URL of the user's avatar
*/ */
public getHttpAvatarUrl(size: number): string { public getHttpAvatarUrl(size = 0): string {
if (!this.avatarMxc) return null; 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() { protected async onNotReady() {

View file

@ -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",
}

View file

@ -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<IStickyActionRequest>) => {
if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value);
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
}
},
);
} else if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) {
this.messaging.addEventListener(`action:${ElementWidgetActions.OpenIntegrationManager}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
// Acknowledge first
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
// First close the stickerpicker
defaultDispatcher.dispatch({action: "stickerpicker_close"});
// Now open the integration manager
// TODO: Spec this interaction.
const data = ev.detail.data;
const integType = data?.integType
const integId = <string>data?.integId;
// TODO: Open the right integration manager for the widget
if (SettingsStore.getValue("feature_many_integration_managers")) {
IntegrationManagers.sharedInstance().openAll(
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
`type_${integType}`,
integId,
);
} else {
IntegrationManagers.sharedInstance().getPrimaryManager().open(
MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
`type_${integType}`,
integId,
);
}
},
);
// TODO: Replace this event listener with appropriate driver functionality once the API
// establishes a sane way to send events back and forth.
this.messaging.addEventListener(`action:${WidgetApiFromWidgetAction.SendSticker}`,
(ev: CustomEvent<IStickerActionRequest>) => {
// Acknowledge first
ev.preventDefault();
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
// Send the sticker
defaultDispatcher.dispatch({
action: 'm.sticker',
data: ev.detail.data,
widgetId: this.mockWidget.id,
});
},
);
}
}
public async prepare(): Promise<void> {
if (this.scalarToken) return;
const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget);
if (existingMessaging) this.messaging = existingMessaging;
try {
if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) {
const managers = IntegrationManagers.sharedInstance();
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);
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Capability, WidgetDriver } from "matrix-widget-api";
import { iterableUnion } from "../../utils/iterables";
// TODO: Purge this from the universe
export class StopGapWidgetDriver extends WidgetDriver {
constructor(private allowedCapabilities: Capability[]) {
super();
}
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
return new Set(iterableUnion(requested, this.allowedCapabilities));
}
}

View file

@ -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<unknown> {
private static internalInstance = new WidgetMessagingStore();
// TODO: Fix uniqueness problem (widget IDs are not unique across the whole app)
private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget ID, ClientWidgetAPi>
public constructor() {
super(defaultDispatcher);
}
public static get instance(): WidgetMessagingStore {
return WidgetMessagingStore.internalInstance;
}
protected async onAction(payload: ActionPayload): Promise<any> {
// nothing to do
}
protected async onReady(): Promise<any> {
// just in case
this.widgetMap.clear();
}
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);
}
}

View file

@ -28,11 +28,11 @@ const WIDGET_WAIT_TIME = 20000;
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import {IntegrationManagers} from "../integrations/IntegrationManagers"; import {IntegrationManagers} from "../integrations/IntegrationManagers";
import {Capability} from "../widgets/WidgetApi";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {WidgetType} from "../widgets/WidgetType"; import {WidgetType} from "../widgets/WidgetType";
import {objectClone} from "./objects"; import {objectClone} from "./objects";
import {_t} from "../languageHandler"; import {_t} from "../languageHandler";
import {MatrixCapabilities} from "matrix-widget-api";
export default class WidgetUtils { export default class WidgetUtils {
/* Returns true if user is able to send state events to modify widgets in this room /* 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) { static getCapWhitelistForAppTypeInRoomId(appType, roomId) {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", 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, // 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 // 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. // widgets from at all, but it probably makes sense for sanity.
if (WidgetType.JITSI.matches(appType)) { if (WidgetType.JITSI.matches(appType)) {
capWhitelist.push(Capability.AlwaysOnScreen); capWhitelist.push(MatrixCapabilities.AlwaysOnScreen);
} }
capWhitelist.push(Capability.ReceiveTerminate);
return capWhitelist; return capWhitelist;
} }
@ -495,16 +494,4 @@ export default class WidgetUtils {
IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id); 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);
});
}
} }

21
src/utils/iterables.ts Normal file
View file

@ -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: Iterable<T>, b: Iterable<T>): Iterable<T> {
return arrayUnion(Array.from(a), Array.from(b));
}

View file

@ -44,3 +44,26 @@ export function mapKeyChanges<K, V>(a: Map<K, V>, b: Map<K, V>): K[] {
const diff = mapDiff(a, b); const diff = mapDiff(a, b);
return arrayMerge(diff.removed, diff.added, diff.changed); return arrayMerge(diff.removed, diff.added, diff.changed);
} }
/**
* A Map<K, V> with added utility.
*/
export class EnhancedMap<K, V> extends Map<K, V> {
public constructor(entries?: Iterable<[K, V]>) {
super(entries);
}
public getOrCreate(key: K, def: V): V {
if (this.has(key)) {
return this.get(key);
}
this.set(key, def);
return def;
}
public remove(key: K): V {
const v = this.get(key);
this.delete(key);
return v;
}
}

View file

@ -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<any>;
private readyPromiseResolve: () => void;
private openIDCredentialsCallback: () => void;
public openIDCredentials: OpenIDCredentials;
/**
* Set this to true if your widget is expecting a ready message from the client. False otherwise (default).
*/
public expectingExplicitReady = false;
constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) {
super();
this.origin = new URL(currentUrl).origin;
this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve);
window.addEventListener("message", event => {
if (event.origin !== this.origin) return; // ignore: invalid origin
if (!event.data) return; // invalid schema
if (event.data.widgetId !== this.widgetId) return; // not for us
const payload = <WidgetRequest>event.data;
if (payload.api === WidgetApiType.ToWidget && payload.action) {
console.log(`[WidgetAPI] Got request: ${JSON.stringify(payload)}`);
if (payload.action === KnownWidgetActions.GetCapabilities) {
this.onCapabilitiesRequest(<ToWidgetRequest>payload);
if (!this.expectingExplicitReady) {
this.readyPromiseResolve();
}
} else if (payload.action === KnownWidgetActions.ClientReady) {
this.readyPromiseResolve();
// Automatically acknowledge so we can move on
this.replyToRequest(<ToWidgetRequest>payload, {});
} else if (payload.action === KnownWidgetActions.Terminate
|| payload.action === KnownWidgetActions.Hangup) {
// Finalization needs to be async, so postpone with a promise
let finalizePromise = Promise.resolve();
const wait = (promise) => {
finalizePromise = finalizePromise.then(() => promise);
};
const emitName = payload.action === KnownWidgetActions.Terminate ? 'terminate' : 'hangup';
this.emit(emitName, wait);
Promise.resolve(finalizePromise).then(() => {
// Acknowledge that we're shut down now
this.replyToRequest(<ToWidgetRequest>payload, {});
});
} else if (payload.action === KnownWidgetActions.ReceiveOpenIDCredentials) {
// Save OpenID credentials
this.setOpenIDCredentials(<ToWidgetRequest>payload);
this.replyToRequest(<ToWidgetRequest>payload, {});
} else {
console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`);
}
} else if (payload.api === WidgetApiType.FromWidget && this.inFlightRequests[payload.requestId]) {
console.log(`[WidgetAPI] Got reply: ${JSON.stringify(payload)}`);
const handler = this.inFlightRequests[payload.requestId];
delete this.inFlightRequests[payload.requestId];
handler(<FromWidgetRequest>payload);
} else {
console.warn(`[WidgetAPI] Unhandled payload: ${JSON.stringify(payload)}`);
}
});
}
public setOpenIDCredentials(value: WidgetRequest) {
const data = value.data;
if (data.state === 'allowed') {
this.openIDCredentials = {
accessToken: data.access_token,
tokenType: data.token_type,
matrixServerName: data.matrix_server_name,
expiresIn: data.expires_in,
}
} else if (data.state === 'blocked') {
this.openIDCredentials = null;
}
if (['allowed', 'blocked'].includes(data.state) && this.openIDCredentialsCallback) {
this.openIDCredentialsCallback()
}
}
public requestOpenIDCredentials(credentialsResponseCallback: () => void) {
this.openIDCredentialsCallback = credentialsResponseCallback;
this.callAction(
KnownWidgetActions.GetOpenIDCredentials,
{},
this.setOpenIDCredentials,
);
}
public waitReady(): Promise<any> {
return this.readyPromise;
}
private replyToRequest(payload: ToWidgetRequest, reply: any) {
if (!window.parent) return;
const request: ToWidgetRequest & {response?: any} = objectClone(payload);
request.response = reply;
window.parent.postMessage(request, this.origin);
}
private onCapabilitiesRequest(payload: ToWidgetRequest) {
return this.replyToRequest(payload, {capabilities: this.requestedCapabilities});
}
public callAction(action: WidgetAction, payload: any, callback: (reply: FromWidgetRequest) => void) {
if (!window.parent) return;
const request: FromWidgetRequest = {
api: WidgetApiType.FromWidget,
widgetId: this.widgetId,
action: action,
requestId: randomString(160),
data: payload,
response: {}, // Not used at this layer - it's used when the client responds
};
if (callback) {
this.inFlightRequests[request.requestId] = callback;
}
console.log(`[WidgetAPI] Sending request: `, request);
window.parent.postMessage(request, "*");
}
public setAlwaysOnScreen(onScreen: boolean): Promise<any> {
return new Promise<any>(resolve => {
this.callAction(KnownWidgetActions.SetAlwaysOnScreen, {value: onScreen}, null);
resolve(); // SetAlwaysOnScreen is currently fire-and-forget, but that could change.
});
}
}

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// TODO: Move to matrix-widget-api
export class WidgetType { export class WidgetType {
public static readonly JITSI = new WidgetType("m.jitsi", "jitsi"); public static readonly JITSI = new WidgetType("m.jitsi", "jitsi");
public static readonly STICKERPICKER = new WidgetType("m.stickerpicker", "m.stickerpicker"); public static readonly STICKERPICKER = new WidgetType("m.stickerpicker", "m.stickerpicker");

View file

@ -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" resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853"
integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== 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: mdast-util-compact@^1.0.0:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.4.tgz#d531bb7667b5123abf20859be086c4d06c894593" resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.4.tgz#d531bb7667b5123abf20859be086c4d06c894593"