mirror of
https://github.com/element-hq/element-web
synced 2024-11-28 12:28:50 +03:00
First rough cut of cutting AppTile over to the ClientWidgetApi
This commit is contained in:
parent
14766e24b8
commit
cd93b2c22a
7 changed files with 273 additions and 434 deletions
|
@ -18,11 +18,9 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import url from 'url';
|
||||
import qs from 'qs';
|
||||
import React, {createRef} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {MatrixClientPeg} from '../../../MatrixClientPeg';
|
||||
import WidgetMessaging from '../../../WidgetMessaging';
|
||||
import AccessibleButton from './AccessibleButton';
|
||||
import Modal from '../../../Modal';
|
||||
import { _t } from '../../../languageHandler';
|
||||
|
@ -34,37 +32,15 @@ import WidgetUtils from '../../../utils/WidgetUtils';
|
|||
import dis from '../../../dispatcher/dispatcher';
|
||||
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
|
||||
import classNames from 'classnames';
|
||||
import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
||||
import PersistedElement from "./PersistedElement";
|
||||
import {WidgetType} from "../../../widgets/WidgetType";
|
||||
import {Capability} from "../../../widgets/WidgetApi";
|
||||
import {sleep} from "../../../utils/promise";
|
||||
import {SettingLevel} from "../../../settings/SettingLevel";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
|
||||
const ENABLE_REACT_PERF = false;
|
||||
|
||||
/**
|
||||
* Does template substitution on a URL (or any string). Variables will be
|
||||
* passed through encodeURIComponent.
|
||||
* @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'.
|
||||
* @param {Object} variables The key/value pairs to replace the template
|
||||
* variables with. E.g. { '$bar': 'baz' }.
|
||||
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
|
||||
*/
|
||||
function uriFromTemplate(uriTemplate, variables) {
|
||||
let out = uriTemplate;
|
||||
for (const [key, val] of Object.entries(variables)) {
|
||||
out = out.replace(
|
||||
'$' + key, encodeURIComponent(val),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
|
||||
|
||||
export default class AppTile extends React.Component {
|
||||
constructor(props) {
|
||||
|
@ -72,6 +48,8 @@ export default class AppTile extends React.Component {
|
|||
|
||||
// The key used for PersistedElement
|
||||
this._persistKey = 'widget_' + this.props.app.id;
|
||||
this._sgWidget = new StopGapWidget(this.props);
|
||||
this._sgWidget.on("ready", this._onWidgetReady);
|
||||
|
||||
this.state = this._getNewState(props);
|
||||
|
||||
|
@ -123,43 +101,6 @@ export default class AppTile extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the widget support a given capability
|
||||
* @param {string} capability Capability to check for
|
||||
* @return {Boolean} True if capability supported
|
||||
*/
|
||||
_hasCapability(capability) {
|
||||
return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add widget instance specific parameters to pass in wUrl
|
||||
* Properties passed to widget instance:
|
||||
* - widgetId
|
||||
* - origin / parent URL
|
||||
* @param {string} urlString Url string to modify
|
||||
* @return {string}
|
||||
* Url string with parameters appended.
|
||||
* If url can not be parsed, it is returned unmodified.
|
||||
*/
|
||||
_addWurlParams(urlString) {
|
||||
try {
|
||||
const parsed = new URL(urlString);
|
||||
|
||||
// TODO: Replace these with proper widget params
|
||||
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
|
||||
parsed.searchParams.set('widgetId', this.props.app.id);
|
||||
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
|
||||
|
||||
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
|
||||
// in HTTP, but URL parsers encode them anyways.
|
||||
return parsed.toString().replace(/%24/g, '$');
|
||||
} catch (e) {
|
||||
console.error("Failed to add widget URL params:", e);
|
||||
return urlString;
|
||||
}
|
||||
}
|
||||
|
||||
isMixedContent() {
|
||||
const parentContentProtocol = window.location.protocol;
|
||||
const u = url.parse(this.props.app.url);
|
||||
|
@ -175,7 +116,7 @@ export default class AppTile extends React.Component {
|
|||
componentDidMount() {
|
||||
// Only fetch IM token on mount if we're showing and have permission to load
|
||||
if (this.props.show && this.state.hasPermissionToLoad) {
|
||||
this.setScalarToken();
|
||||
this._startWidget();
|
||||
}
|
||||
|
||||
// Widget action listeners
|
||||
|
@ -191,80 +132,26 @@ export default class AppTile extends React.Component {
|
|||
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
}
|
||||
|
||||
if (this._sgWidget) {
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Generify the name of this function. It's not just scalar tokens.
|
||||
/**
|
||||
* Adds a scalar token to the widget URL, if required
|
||||
* Component initialisation is only complete when this function has resolved
|
||||
*/
|
||||
setScalarToken() {
|
||||
if (!WidgetUtils.isScalarUrl(this.props.app.url)) {
|
||||
console.warn('Widget does not match integration manager, refusing to set auth token', url);
|
||||
this.setState({
|
||||
error: null,
|
||||
widgetUrl: this._addWurlParams(this.props.app.url),
|
||||
initialising: false,
|
||||
});
|
||||
return;
|
||||
_resetWidget(newProps) {
|
||||
if (this._sgWidget) {
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
this._sgWidget = new StopGapWidget(newProps);
|
||||
this._sgWidget.on("ready", this._onWidgetReady);
|
||||
this._startWidget();
|
||||
}
|
||||
|
||||
const managers = IntegrationManagers.sharedInstance();
|
||||
if (!managers.hasManager()) {
|
||||
console.warn("No integration manager - not setting scalar token", url);
|
||||
this.setState({
|
||||
error: null,
|
||||
widgetUrl: this._addWurlParams(this.props.app.url),
|
||||
initialising: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Pick the right manager for the widget
|
||||
|
||||
const defaultManager = managers.getPrimaryManager();
|
||||
if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
|
||||
console.warn('Unknown integration manager, refusing to set auth token', url);
|
||||
this.setState({
|
||||
error: null,
|
||||
widgetUrl: this._addWurlParams(this.props.app.url),
|
||||
initialising: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch the token before loading the iframe as we need it to mangle the URL
|
||||
if (!this._scalarClient) {
|
||||
this._scalarClient = defaultManager.getScalarClient();
|
||||
}
|
||||
this._scalarClient.getScalarToken().then((token) => {
|
||||
// Append scalar_token as a query param if not already present
|
||||
this._scalarClient.scalarToken = token;
|
||||
const u = url.parse(this._addWurlParams(this.props.app.url));
|
||||
const params = qs.parse(u.query);
|
||||
if (!params.scalar_token) {
|
||||
params.scalar_token = encodeURIComponent(token);
|
||||
// u.search must be set to undefined, so that u.format() uses query parameters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options
|
||||
u.search = undefined;
|
||||
u.query = params;
|
||||
_startWidget() {
|
||||
this._sgWidget.prepare().then(() => {
|
||||
if (this._appFrame.current) {
|
||||
this._sgWidget.start(this._appFrame.current);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
error: null,
|
||||
widgetUrl: u.format(),
|
||||
initialising: false,
|
||||
});
|
||||
|
||||
// Fetch page title from remote content if not already set
|
||||
if (!this.state.widgetPageTitle && params.url) {
|
||||
this._fetchWidgetTitle(params.url);
|
||||
}
|
||||
}, (err) => {
|
||||
console.error("Failed to get scalar_token", err);
|
||||
this.setState({
|
||||
error: err.message,
|
||||
initialising: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -272,9 +159,8 @@ export default class AppTile extends React.Component {
|
|||
UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
|
||||
if (nextProps.app.url !== this.props.app.url) {
|
||||
this._getNewState(nextProps);
|
||||
// Fetch IM token for new URL if we're showing and have permission to load
|
||||
if (this.props.show && this.state.hasPermissionToLoad) {
|
||||
this.setScalarToken();
|
||||
this._resetWidget(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -285,9 +171,9 @@ export default class AppTile extends React.Component {
|
|||
loading: true,
|
||||
});
|
||||
}
|
||||
// Fetch IM token now that we're showing if we already have permission to load
|
||||
// Start the widget now that we're showing if we already have permission to load
|
||||
if (this.state.hasPermissionToLoad) {
|
||||
this.setScalarToken();
|
||||
this._startWidget();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -317,7 +203,14 @@ export default class AppTile extends React.Component {
|
|||
}
|
||||
|
||||
_onSnapshotClick() {
|
||||
WidgetUtils.snapshotWidget(this.props.app);
|
||||
this._sgWidget.widgetApi.takeScreenshot().then(data => {
|
||||
dis.dispatch({
|
||||
action: 'picture_snapshot',
|
||||
file: data.screenshot,
|
||||
});
|
||||
}).catch(err => {
|
||||
console.error("Failed to take screenshot: ", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -326,34 +219,23 @@ export default class AppTile extends React.Component {
|
|||
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
|
||||
*/
|
||||
_endWidgetActions() {
|
||||
let terminationPromise;
|
||||
|
||||
if (this._hasCapability(Capability.ReceiveTerminate)) {
|
||||
// Wait for widget to terminate within a timeout
|
||||
const timeout = 2000;
|
||||
const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id);
|
||||
terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]);
|
||||
} else {
|
||||
terminationPromise = Promise.resolve();
|
||||
// HACK: This is a really dirty way to ensure that Jitsi cleans up
|
||||
// its hold on the webcam. Without this, the widget holds a media
|
||||
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
|
||||
if (this._appFrame.current) {
|
||||
// In practice we could just do `+= ''` to trick the browser
|
||||
// into thinking the URL changed, however I can foresee this
|
||||
// being optimized out by a browser. Instead, we'll just point
|
||||
// the iframe at a page that is reasonably safe to use in the
|
||||
// event the iframe doesn't wink away.
|
||||
// This is relative to where the Element instance is located.
|
||||
this._appFrame.current.src = 'about:blank';
|
||||
}
|
||||
|
||||
return terminationPromise.finally(() => {
|
||||
// HACK: This is a really dirty way to ensure that Jitsi cleans up
|
||||
// its hold on the webcam. Without this, the widget holds a media
|
||||
// stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
|
||||
if (this._appFrame.current) {
|
||||
// In practice we could just do `+= ''` to trick the browser
|
||||
// into thinking the URL changed, however I can foresee this
|
||||
// being optimized out by a browser. Instead, we'll just point
|
||||
// the iframe at a page that is reasonably safe to use in the
|
||||
// event the iframe doesn't wink away.
|
||||
// This is relative to where the Element instance is located.
|
||||
this._appFrame.current.src = 'about:blank';
|
||||
}
|
||||
// Delete the widget from the persisted store for good measure.
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
|
||||
// Delete the widget from the persisted store for good measure.
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
});
|
||||
this._sgWidget.stop();
|
||||
}
|
||||
|
||||
/* If user has permission to modify widgets, delete the widget,
|
||||
|
@ -407,69 +289,18 @@ export default class AppTile extends React.Component {
|
|||
this._revokeWidgetPermission();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when widget iframe has finished loading
|
||||
*/
|
||||
_onLoaded() {
|
||||
// Destroy the old widget messaging before starting it back up again. Some widgets
|
||||
// have startup routines that run when they are loaded, so we just need to reinitialize
|
||||
// the messaging for them.
|
||||
ActiveWidgetStore.delWidgetMessaging(this.props.app.id);
|
||||
this._setupWidgetMessaging();
|
||||
|
||||
ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId);
|
||||
_onWidgetReady = () => {
|
||||
this.setState({loading: false});
|
||||
}
|
||||
|
||||
_setupWidgetMessaging() {
|
||||
// FIXME: There's probably no reason to do this here: it should probably be done entirely
|
||||
// in ActiveWidgetStore.
|
||||
const widgetMessaging = new WidgetMessaging(
|
||||
this.props.app.id,
|
||||
this.props.app.url,
|
||||
this._getRenderedUrl(),
|
||||
this.props.userWidget,
|
||||
this._appFrame.current.contentWindow,
|
||||
);
|
||||
ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging);
|
||||
widgetMessaging.getCapabilities().then((requestedCapabilities) => {
|
||||
console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities);
|
||||
requestedCapabilities = requestedCapabilities || [];
|
||||
|
||||
// Allow whitelisted capabilities
|
||||
let requestedWhitelistCapabilies = [];
|
||||
|
||||
if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) {
|
||||
requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) {
|
||||
return this.indexOf(e)>=0;
|
||||
}, this.props.whitelistCapabilities);
|
||||
|
||||
if (requestedWhitelistCapabilies.length > 0 ) {
|
||||
console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` +
|
||||
requestedWhitelistCapabilies,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO -- Add UI to warn about and optionally allow requested capabilities
|
||||
|
||||
ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies);
|
||||
|
||||
// We only tell Jitsi widgets that we're ready because they're realistically the only ones
|
||||
// using this custom extension to the widget API.
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
widgetMessaging.flagReadyToContinue();
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err);
|
||||
});
|
||||
}
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
this._sgWidget.widgetApi.transport.send("im.vector.ready", {});
|
||||
}
|
||||
};
|
||||
|
||||
_onAction(payload) {
|
||||
if (payload.widgetId === this.props.app.id) {
|
||||
switch (payload.action) {
|
||||
case 'm.sticker':
|
||||
if (this._hasCapability('m.sticker')) {
|
||||
if (this._sgWidget.widgetApi.hasCapability(Capability.Sticker)) {
|
||||
dis.dispatch({action: 'post_sticker_message', data: payload.data});
|
||||
} else {
|
||||
console.warn('Ignoring sticker message. Invalid capability');
|
||||
|
@ -487,20 +318,6 @@ export default class AppTile extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set remote content title on AppTile
|
||||
* @param {string} url Url to check for title
|
||||
*/
|
||||
_fetchWidgetTitle(url) {
|
||||
this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => {
|
||||
if (widgetPageTitle) {
|
||||
this.setState({widgetPageTitle: widgetPageTitle});
|
||||
}
|
||||
}, (err) =>{
|
||||
console.error("Failed to get page title", err);
|
||||
});
|
||||
}
|
||||
|
||||
_grantWidgetPermission() {
|
||||
const roomId = this.props.room.roomId;
|
||||
console.info("Granting permission for widget to load: " + this.props.app.eventId);
|
||||
|
@ -510,7 +327,7 @@ export default class AppTile extends React.Component {
|
|||
this.setState({hasPermissionToLoad: true});
|
||||
|
||||
// Fetch a token for the integration manager, now that we're allowed to
|
||||
this.setScalarToken();
|
||||
this._startWidget();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
|
@ -529,6 +346,7 @@ export default class AppTile extends React.Component {
|
|||
ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
|
||||
const PersistedElement = sdk.getComponent("elements.PersistedElement");
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
this._sgWidget.stop();
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
// We don't really need to do anything about this - the user will just hit the button again.
|
||||
|
@ -566,40 +384,6 @@ export default class AppTile extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the widget template variables in a url with their values
|
||||
*
|
||||
* @param {string} u The URL with template variables
|
||||
* @param {string} widgetType The widget's type
|
||||
*
|
||||
* @returns {string} url with temlate variables replaced
|
||||
*/
|
||||
_templatedUrl(u, widgetType: string) {
|
||||
const targetData = {};
|
||||
if (WidgetType.JITSI.matches(widgetType)) {
|
||||
targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded
|
||||
}
|
||||
const myUserId = MatrixClientPeg.get().credentials.userId;
|
||||
const myUser = MatrixClientPeg.get().getUser(myUserId);
|
||||
const vars = Object.assign(targetData, this.props.app.data, {
|
||||
'matrix_user_id': myUserId,
|
||||
'matrix_room_id': this.props.room.roomId,
|
||||
'matrix_display_name': myUser ? myUser.displayName : myUserId,
|
||||
'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '',
|
||||
|
||||
// TODO: Namespace themes through some standard
|
||||
'theme': SettingsStore.getValue("theme"),
|
||||
});
|
||||
|
||||
if (vars.conferenceId === undefined) {
|
||||
// we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
|
||||
const parsedUrl = new URL(this.props.app.url);
|
||||
vars.conferenceId = parsedUrl.searchParams.get("confId");
|
||||
}
|
||||
|
||||
return uriFromTemplate(u, vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether we're using a local version of the widget rather than loading the
|
||||
* actual widget URL
|
||||
|
@ -609,67 +393,11 @@ export default class AppTile extends React.Component {
|
|||
return WidgetType.JITSI.matches(this.props.app.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL used in the iframe
|
||||
* In cases where we supply our own UI for a widget, this is an internal
|
||||
* URL different to the one used if the widget is popped out to a separate
|
||||
* tab / browser
|
||||
*
|
||||
* @returns {string} url
|
||||
*/
|
||||
_getRenderedUrl() {
|
||||
let url;
|
||||
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
console.log("Replacing Jitsi widget URL with local wrapper");
|
||||
url = WidgetUtils.getLocalJitsiWrapperUrl({
|
||||
forLocalRender: true,
|
||||
auth: this.props.app.data ? this.props.app.data.auth : null,
|
||||
});
|
||||
url = this._addWurlParams(url);
|
||||
} else {
|
||||
url = this._getSafeUrl(this.state.widgetUrl);
|
||||
}
|
||||
return this._templatedUrl(url, this.props.app.type);
|
||||
}
|
||||
|
||||
_getPopoutUrl() {
|
||||
if (WidgetType.JITSI.matches(this.props.app.type)) {
|
||||
return this._templatedUrl(
|
||||
WidgetUtils.getLocalJitsiWrapperUrl({
|
||||
forLocalRender: false,
|
||||
auth: this.props.app.data ? this.props.app.data.auth : null,
|
||||
}),
|
||||
this.props.app.type,
|
||||
);
|
||||
} else {
|
||||
// use app.url, not state.widgetUrl, because we want the one without
|
||||
// the wURL params for the popped-out version.
|
||||
return this._templatedUrl(this._getSafeUrl(this.props.app.url), this.props.app.type);
|
||||
}
|
||||
}
|
||||
|
||||
_getSafeUrl(u) {
|
||||
const parsedWidgetUrl = url.parse(u, true);
|
||||
if (ENABLE_REACT_PERF) {
|
||||
parsedWidgetUrl.search = null;
|
||||
parsedWidgetUrl.query.react_perf = true;
|
||||
}
|
||||
let safeWidgetUrl = '';
|
||||
if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) {
|
||||
safeWidgetUrl = url.format(parsedWidgetUrl);
|
||||
}
|
||||
|
||||
// Replace all the dollar signs back to dollar signs as they don't affect HTTP at all.
|
||||
// We also need the dollar signs in-tact for variable substitution.
|
||||
return safeWidgetUrl.replace(/%24/g, '$');
|
||||
}
|
||||
|
||||
_getTileTitle() {
|
||||
const name = this.formatAppTileName();
|
||||
const titleSpacer = <span> - </span>;
|
||||
let title = '';
|
||||
if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
|
||||
if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) {
|
||||
title = this.state.widgetPageTitle;
|
||||
}
|
||||
|
||||
|
@ -694,7 +422,7 @@ export default class AppTile extends React.Component {
|
|||
this._endWidgetActions().then(() => {
|
||||
if (this._appFrame.current) {
|
||||
// Reload iframe
|
||||
this._appFrame.current.src = this._getRenderedUrl();
|
||||
this._appFrame.current.src = this._sgWidget.embedUrl;
|
||||
this.setState({});
|
||||
}
|
||||
});
|
||||
|
@ -702,7 +430,7 @@ export default class AppTile extends React.Component {
|
|||
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
|
||||
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
||||
Object.assign(document.createElement('a'),
|
||||
{ target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click();
|
||||
{ target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click();
|
||||
}
|
||||
|
||||
_onReloadWidgetClick() {
|
||||
|
@ -780,7 +508,7 @@ export default class AppTile extends React.Component {
|
|||
<iframe
|
||||
allow={iframeFeatures}
|
||||
ref={this._appFrame}
|
||||
src={this._getRenderedUrl()}
|
||||
src={this._sgWidget.embedUrl}
|
||||
allowFullScreen={true}
|
||||
sandbox={sandboxFlags}
|
||||
onLoad={this._onLoaded} />
|
||||
|
@ -827,9 +555,10 @@ export default class AppTile extends React.Component {
|
|||
const elementRect = this._contextMenuButton.current.getBoundingClientRect();
|
||||
|
||||
const canUserModify = this._canUserModify();
|
||||
const showEditButton = Boolean(this._scalarClient && canUserModify);
|
||||
const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify);
|
||||
const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
|
||||
const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
|
||||
const showPictureSnapshotButton = this._sgWidget.widgetApi.hasCapability(Capability.Screenshot)
|
||||
&& this.props.show;
|
||||
|
||||
const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
|
||||
contextMenu = (
|
||||
|
|
|
@ -66,12 +66,14 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
|
|||
/**
|
||||
* Gets the user's avatar as an HTTP URL of the given size. If the user's
|
||||
* avatar is not present, this returns null.
|
||||
* @param size The size of the avatar
|
||||
* @param size The size of the avatar. If zero, a full res copy of the avatar
|
||||
* will be returned as an HTTP URL.
|
||||
* @returns The HTTP URL of the user's avatar
|
||||
*/
|
||||
public getHttpAvatarUrl(size: number): string {
|
||||
public getHttpAvatarUrl(size: number = 0): string {
|
||||
if (!this.avatarMxc) return null;
|
||||
return this.matrixClient.mxcUrlToHttp(this.avatarMxc, size, size);
|
||||
const adjustedSize = size > 1 ? size : undefined; // don't let negatives or zero through
|
||||
return this.matrixClient.mxcUrlToHttp(this.avatarMxc, adjustedSize, adjustedSize);
|
||||
}
|
||||
|
||||
protected async onNotReady() {
|
||||
|
|
171
src/stores/widgets/StopGapWidget.ts
Normal file
171
src/stores/widgets/StopGapWidget.ts
Normal file
|
@ -0,0 +1,171 @@
|
|||
/*
|
||||
* Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Room} from "matrix-js-sdk/src/models/room";
|
||||
import { ClientWidgetApi, IWidget, IWidgetData, Widget } from "matrix-widget-api";
|
||||
import { StopGapWidgetDriver } from "./StopGapWidgetDriver";
|
||||
import { EventEmitter } from "events";
|
||||
import { WidgetMessagingStore } from "./WidgetMessagingStore";
|
||||
import RoomViewStore from "../RoomViewStore";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { OwnProfileStore } from "../OwnProfileStore";
|
||||
import WidgetUtils from '../../utils/WidgetUtils';
|
||||
import { IntegrationManagers } from "../../integrations/IntegrationManagers";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { WidgetType } from "../../widgets/WidgetType";
|
||||
|
||||
// TODO: Destroy all of this code
|
||||
|
||||
interface IAppTileProps {
|
||||
// Note: these are only the props we care about
|
||||
|
||||
app: IWidget;
|
||||
room: Room;
|
||||
userId: string;
|
||||
creatorUserId: string;
|
||||
waitForIframeLoad: boolean;
|
||||
whitelistCapabilities: string[];
|
||||
userWidget: boolean;
|
||||
}
|
||||
|
||||
// TODO: Don't use this because it's wrong
|
||||
class ElementWidget extends Widget {
|
||||
constructor(w) {
|
||||
super(w);
|
||||
}
|
||||
|
||||
public get templateUrl(): string {
|
||||
if (WidgetType.JITSI.matches(this.type)) {
|
||||
return WidgetUtils.getLocalJitsiWrapperUrl({
|
||||
forLocalRender: true,
|
||||
auth: this.rawData?.auth,
|
||||
});
|
||||
}
|
||||
return super.templateUrl;
|
||||
}
|
||||
|
||||
public get rawData(): IWidgetData {
|
||||
let conferenceId = super.rawData['conferenceId'];
|
||||
if (conferenceId === undefined) {
|
||||
// we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
|
||||
const parsedUrl = new URL(this.templateUrl);
|
||||
conferenceId = parsedUrl.searchParams.get("confId");
|
||||
}
|
||||
return {
|
||||
...super.rawData,
|
||||
theme: SettingsStore.getValue("theme"),
|
||||
conferenceId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class StopGapWidget extends EventEmitter {
|
||||
private messaging: ClientWidgetApi;
|
||||
private mockWidget: Widget;
|
||||
private scalarToken: string;
|
||||
|
||||
constructor(private appTileProps: IAppTileProps) {
|
||||
super();
|
||||
this.mockWidget = new ElementWidget(appTileProps.app);
|
||||
}
|
||||
|
||||
public get widgetApi(): ClientWidgetApi {
|
||||
return this.messaging;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to use in the iframe
|
||||
*/
|
||||
public get embedUrl(): string {
|
||||
const templated = this.mockWidget.getCompleteUrl({
|
||||
currentRoomId: RoomViewStore.getRoomId(),
|
||||
currentUserId: MatrixClientPeg.get().getUserId(),
|
||||
userDisplayName: OwnProfileStore.instance.displayName,
|
||||
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
|
||||
});
|
||||
|
||||
// Add in some legacy support sprinkles
|
||||
// TODO: Replace these with proper widget params
|
||||
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
|
||||
const parsed = new URL(templated);
|
||||
parsed.searchParams.set('widgetId', this.mockWidget.id);
|
||||
parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
|
||||
|
||||
// Give the widget a scalar token if we're supposed to (more legacy)
|
||||
// TODO: Stop doing this
|
||||
if (this.scalarToken) {
|
||||
parsed.searchParams.set('scalar_token', this.scalarToken);
|
||||
}
|
||||
|
||||
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
|
||||
// in HTTP, but URL parsers encode them anyways.
|
||||
return parsed.toString().replace(/%24/g, '$');
|
||||
}
|
||||
|
||||
/**
|
||||
* The URL to use in the popout
|
||||
*/
|
||||
public get popoutUrl(): string {
|
||||
if (WidgetType.JITSI.matches(this.mockWidget.type)) {
|
||||
return WidgetUtils.getLocalJitsiWrapperUrl({
|
||||
forLocalRender: false,
|
||||
auth: this.mockWidget.rawData?.auth,
|
||||
});
|
||||
}
|
||||
return this.embedUrl;
|
||||
}
|
||||
|
||||
public get isManagedByManager(): boolean {
|
||||
return !!this.scalarToken;
|
||||
}
|
||||
|
||||
public get started(): boolean {
|
||||
return !!this.messaging;
|
||||
}
|
||||
|
||||
public start(iframe: HTMLIFrameElement) {
|
||||
if (this.started) return;
|
||||
const driver = new StopGapWidgetDriver(this.appTileProps.whitelistCapabilities || []);
|
||||
this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
|
||||
this.messaging.addEventListener("ready", () => this.emit("ready"));
|
||||
WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging);
|
||||
}
|
||||
|
||||
public async prepare(): Promise<void> {
|
||||
if (this.scalarToken) return;
|
||||
try {
|
||||
if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) {
|
||||
const managers = IntegrationManagers.sharedInstance();
|
||||
if (managers.hasManager()) {
|
||||
// TODO: Pick the right manager for the widget
|
||||
const defaultManager = managers.getPrimaryManager();
|
||||
if (WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
|
||||
const scalar = defaultManager.getScalarClient();
|
||||
this.scalarToken = await scalar.getScalarToken();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// All errors are non-fatal
|
||||
console.error("Error preparing widget communications: ", e);
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
if (!this.started) return;
|
||||
WidgetMessagingStore.instance.stopMessaging(this.mockWidget);
|
||||
}
|
||||
}
|
30
src/stores/widgets/StopGapWidgetDriver.ts
Normal file
30
src/stores/widgets/StopGapWidgetDriver.ts
Normal 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 iterableUnion(requested, new Set(this.allowedCapabilities));
|
||||
}
|
||||
}
|
|
@ -31,8 +31,7 @@ import { EnhancedMap } from "../../utils/maps";
|
|||
export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
|
||||
private static internalInstance = new WidgetMessagingStore();
|
||||
|
||||
// <room/user ID, <widget ID, Widget>>
|
||||
private widgetMap = new EnhancedMap<string, EnhancedMap<string, WidgetSurrogate>>();
|
||||
private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget ID, ClientWidgetAPi>
|
||||
|
||||
public constructor() {
|
||||
super(defaultDispatcher);
|
||||
|
@ -51,106 +50,16 @@ export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
|
|||
this.widgetMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a widget by ID. Not guaranteed to return an accurate result.
|
||||
* @param {string} id The widget ID.
|
||||
* @returns {{widget, room}} The widget and possible room ID, or a falsey value
|
||||
* if not found.
|
||||
* @deprecated Do not use.
|
||||
*/
|
||||
public findWidgetById(id: string): { widget: Widget, room?: Room } {
|
||||
for (const key of this.widgetMap.keys()) {
|
||||
for (const [entityId, surrogate] of this.widgetMap.get(key).entries()) {
|
||||
if (surrogate.definition.id === id) {
|
||||
const room: Room = this.matrixClient?.getRoom(entityId); // will be null for non-rooms
|
||||
return {room, widget: surrogate.definition};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
public storeMessaging(widget: Widget, widgetApi: ClientWidgetApi) {
|
||||
this.stopMessaging(widget);
|
||||
this.widgetMap.set(widget.id, widgetApi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the messaging instance for the widget. Returns a falsey value if none
|
||||
* is present.
|
||||
* @param {Room} room The room for which the widget lives within.
|
||||
* @param {Widget} widget The widget to get messaging for.
|
||||
* @returns {ClientWidgetApi} The messaging, or a falsey value.
|
||||
*/
|
||||
public messagingForRoomWidget(room: Room, widget: Widget): ClientWidgetApi {
|
||||
return this.widgetMap.get(room.roomId)?.get(widget.id)?.messaging;
|
||||
public stopMessaging(widget: Widget) {
|
||||
this.widgetMap.remove(widget.id)?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the messaging instance for the widget. Returns a falsey value if none
|
||||
* is present.
|
||||
* @param {Widget} widget The widget to get messaging for.
|
||||
* @returns {ClientWidgetApi} The messaging, or a falsey value.
|
||||
*/
|
||||
public messagingForAccountWidget(widget: Widget): ClientWidgetApi {
|
||||
return this.widgetMap.get(this.matrixClient?.getUserId())?.get(widget.id)?.messaging;
|
||||
}
|
||||
|
||||
private generateMessaging(locationId: string, widget: Widget, iframe: HTMLIFrameElement, driver: WidgetDriver) {
|
||||
const messaging = new ClientWidgetApi(widget, iframe, driver);
|
||||
this.widgetMap.getOrCreate(locationId, new EnhancedMap())
|
||||
.getOrCreate(widget.id, new WidgetSurrogate(widget, messaging));
|
||||
return messaging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a messaging instance for the widget. If an instance already exists, it
|
||||
* will be returned instead.
|
||||
* @param {Room} room The room in which the widget lives.
|
||||
* @param {Widget} widget The widget to generate/get messaging for.
|
||||
* @param {HTMLIFrameElement} iframe The widget's iframe.
|
||||
* @returns {ClientWidgetApi} The generated/cached messaging.
|
||||
*/
|
||||
public generateMessagingForRoomWidget(room: Room, widget: Widget, iframe: HTMLIFrameElement): ClientWidgetApi {
|
||||
const existing = this.messagingForRoomWidget(room, widget);
|
||||
if (existing) return existing;
|
||||
|
||||
const driver = new SdkWidgetDriver(widget, WidgetKind.Room, room.roomId);
|
||||
return this.generateMessaging(room.roomId, widget, iframe, driver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a messaging instance for the widget. If an instance already exists, it
|
||||
* will be returned instead.
|
||||
* @param {Widget} widget The widget to generate/get messaging for.
|
||||
* @param {HTMLIFrameElement} iframe The widget's iframe.
|
||||
* @returns {ClientWidgetApi} The generated/cached messaging.
|
||||
*/
|
||||
public generateMessagingForAccountWidget(widget: Widget, iframe: HTMLIFrameElement): ClientWidgetApi {
|
||||
if (!this.matrixClient) {
|
||||
throw new Error("No matrix client to create account widgets with");
|
||||
}
|
||||
|
||||
const existing = this.messagingForAccountWidget(widget);
|
||||
if (existing) return existing;
|
||||
|
||||
const userId = this.matrixClient.getUserId();
|
||||
const driver = new SdkWidgetDriver(widget, WidgetKind.Account, userId);
|
||||
return this.generateMessaging(userId, widget, iframe, driver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the messaging instance for the widget, unregistering it.
|
||||
* @param {Room} room The room where the widget resides.
|
||||
* @param {Widget} widget The widget
|
||||
*/
|
||||
public stopMessagingForRoomWidget(room: Room, widget: Widget) {
|
||||
const api = this.widgetMap.getOrCreate(room.roomId, new EnhancedMap()).remove(widget.id);
|
||||
if (api) api.messaging.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the messaging instance for the widget, unregistering it.
|
||||
* @param {Widget} widget The widget
|
||||
*/
|
||||
public stopMessagingForAccountWidget(widget: Widget) {
|
||||
if (!this.matrixClient) return;
|
||||
const api = this.widgetMap.getOrCreate(this.matrixClient.getUserId(), new EnhancedMap()).remove(widget.id);
|
||||
if (api) api.messaging.stop();
|
||||
public getMessaging(widget: Widget): ClientWidgetApi {
|
||||
return this.widgetMap.get(widget.id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -424,7 +424,6 @@ export default class WidgetUtils {
|
|||
if (WidgetType.JITSI.matches(appType)) {
|
||||
capWhitelist.push(Capability.AlwaysOnScreen);
|
||||
}
|
||||
capWhitelist.push(Capability.ReceiveTerminate);
|
||||
|
||||
return capWhitelist;
|
||||
}
|
||||
|
|
|
@ -25,7 +25,6 @@ export enum Capability {
|
|||
Screenshot = "m.capability.screenshot",
|
||||
Sticker = "m.sticker",
|
||||
AlwaysOnScreen = "m.always_on_screen",
|
||||
ReceiveTerminate = "im.vector.receive_terminate",
|
||||
}
|
||||
|
||||
export enum KnownWidgetActions {
|
||||
|
|
Loading…
Reference in a new issue