Merge remote-tracking branch 'origin/develop' into develop
This commit is contained in:
commit
bdf1fee64d
2 changed files with 389 additions and 9 deletions
326
src/WidgetMessaging.js
Normal file
326
src/WidgetMessaging.js
Normal file
|
@ -0,0 +1,326 @@
|
||||||
|
/*
|
||||||
|
Copyright 2017 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
Listens for incoming postMessage requests from embedded widgets. The following API is exposed:
|
||||||
|
{
|
||||||
|
api: "widget",
|
||||||
|
action: "content_loaded",
|
||||||
|
widgetId: $WIDGET_ID,
|
||||||
|
data: {}
|
||||||
|
// additional request fields
|
||||||
|
}
|
||||||
|
|
||||||
|
The complete request object is returned to the caller with an additional "response" key like so:
|
||||||
|
{
|
||||||
|
api: "widget",
|
||||||
|
action: "content_loaded",
|
||||||
|
widgetId: $WIDGET_ID,
|
||||||
|
data: {},
|
||||||
|
// additional request fields
|
||||||
|
response: { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
The "api" field is required to use this API, and must be set to "widget" in all requests.
|
||||||
|
|
||||||
|
The "action" determines the format of the request and response. All actions can return an error response.
|
||||||
|
|
||||||
|
Additional data can be sent as additional, abritrary fields. However, typically the data object should be used.
|
||||||
|
|
||||||
|
A success response is an object with zero or more keys.
|
||||||
|
|
||||||
|
An error response is a "response" object which consists of a sole "error" key to indicate an error.
|
||||||
|
They look like:
|
||||||
|
{
|
||||||
|
error: {
|
||||||
|
message: "Unable to invite user into room.",
|
||||||
|
_error: <Original Error Object>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
The "message" key should be a human-friendly string.
|
||||||
|
|
||||||
|
ACTIONS
|
||||||
|
=======
|
||||||
|
** All actions must include an "api" field with valie "widget".**
|
||||||
|
All actions can return an error response instead of the response outlined below.
|
||||||
|
|
||||||
|
content_loaded
|
||||||
|
--------------
|
||||||
|
Indicates that widget contet has fully loaded
|
||||||
|
|
||||||
|
Request:
|
||||||
|
- widgetId is the unique ID of the widget instance in riot / matrix state.
|
||||||
|
- No additional fields.
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
Example:
|
||||||
|
{
|
||||||
|
api: "widget",
|
||||||
|
action: "content_loaded",
|
||||||
|
widgetId: $WIDGET_ID
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
api_version
|
||||||
|
-----------
|
||||||
|
Get the current version of the widget postMessage API
|
||||||
|
|
||||||
|
Request:
|
||||||
|
- No additional fields.
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
api_version: "0.0.1"
|
||||||
|
}
|
||||||
|
Example:
|
||||||
|
{
|
||||||
|
api: "widget",
|
||||||
|
action: "api_version",
|
||||||
|
}
|
||||||
|
|
||||||
|
supported_api_versions
|
||||||
|
----------------------
|
||||||
|
Get versions of the widget postMessage API that are currently supported
|
||||||
|
|
||||||
|
Request:
|
||||||
|
- No additional fields.
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
api: "widget"
|
||||||
|
supported_versions: ["0.0.1"]
|
||||||
|
}
|
||||||
|
Example:
|
||||||
|
{
|
||||||
|
api: "widget",
|
||||||
|
action: "supported_api_versions",
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
import URL from 'url';
|
||||||
|
|
||||||
|
const WIDGET_API_VERSION = '0.0.1'; // Current API version
|
||||||
|
const SUPPORTED_WIDGET_API_VERSIONS = [
|
||||||
|
'0.0.1',
|
||||||
|
];
|
||||||
|
|
||||||
|
import dis from './dispatcher';
|
||||||
|
|
||||||
|
if (!global.mxWidgetMessagingListenerCount) {
|
||||||
|
global.mxWidgetMessagingListenerCount = 0;
|
||||||
|
}
|
||||||
|
if (!global.mxWidgetMessagingMessageEndpoints) {
|
||||||
|
global.mxWidgetMessagingMessageEndpoints = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register widget message event listeners
|
||||||
|
*/
|
||||||
|
function startListening() {
|
||||||
|
if (global.mxWidgetMessagingListenerCount === 0) {
|
||||||
|
window.addEventListener("message", onMessage, false);
|
||||||
|
}
|
||||||
|
global.mxWidgetMessagingListenerCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* De-register widget message event listeners
|
||||||
|
*/
|
||||||
|
function stopListening() {
|
||||||
|
global.mxWidgetMessagingListenerCount -= 1;
|
||||||
|
if (global.mxWidgetMessagingListenerCount === 0) {
|
||||||
|
window.removeEventListener("message", onMessage);
|
||||||
|
}
|
||||||
|
if (global.mxWidgetMessagingListenerCount < 0) {
|
||||||
|
// Make an error so we get a stack trace
|
||||||
|
const e = new Error(
|
||||||
|
"WidgetMessaging: mismatched startListening / stopListening detected." +
|
||||||
|
" Negative count",
|
||||||
|
);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a widget endpoint for trusted postMessage communication
|
||||||
|
* @param {string} widgetId Unique widget identifier
|
||||||
|
* @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
|
||||||
|
*/
|
||||||
|
function addEndpoint(widgetId, endpointUrl) {
|
||||||
|
const u = URL.parse(endpointUrl);
|
||||||
|
if (!u || !u.protocol || !u.host) {
|
||||||
|
console.warn("Invalid origin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = u.protocol + '//' + u.host;
|
||||||
|
const endpoint = new WidgetMessageEndpoint(widgetId, origin);
|
||||||
|
if (global.mxWidgetMessagingMessageEndpoints) {
|
||||||
|
if (global.mxWidgetMessagingMessageEndpoints.some(function(ep) {
|
||||||
|
return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
|
||||||
|
})) {
|
||||||
|
// Message endpoint already registered
|
||||||
|
console.warn("Endpoint already registered");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
global.mxWidgetMessagingMessageEndpoints.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
|
||||||
|
*/
|
||||||
|
function removeEndpoint(widgetId, endpointUrl) {
|
||||||
|
const u = URL.parse(endpointUrl);
|
||||||
|
if (!u || !u.protocol || !u.host) {
|
||||||
|
console.warn("Invalid origin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = u.protocol + '//' + u.host;
|
||||||
|
if (global.mxWidgetMessagingMessageEndpoints && global.mxWidgetMessagingMessageEndpoints.length > 0) {
|
||||||
|
const length = global.mxWidgetMessagingMessageEndpoints.length;
|
||||||
|
global.mxWidgetMessagingMessageEndpoints = global.mxWidgetMessagingMessageEndpoints.filter(function(endpoint) {
|
||||||
|
return (endpoint.widgetId != widgetId || endpoint.endpointUrl != origin);
|
||||||
|
});
|
||||||
|
return (length > global.mxWidgetMessagingMessageEndpoints.length);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle widget postMessage events
|
||||||
|
* @param {Event} event Event to handle
|
||||||
|
* @return {undefined}
|
||||||
|
*/
|
||||||
|
function onMessage(event) {
|
||||||
|
if (!event.origin) { // Handle chrome
|
||||||
|
event.origin = event.originalEvent.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event origin is empty string if undefined
|
||||||
|
if (
|
||||||
|
event.origin.length === 0 ||
|
||||||
|
!trustedEndpoint(event.origin) ||
|
||||||
|
event.data.api !== "widget" ||
|
||||||
|
!event.data.widgetId
|
||||||
|
) {
|
||||||
|
return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = event.data.action;
|
||||||
|
const widgetId = event.data.widgetId;
|
||||||
|
if (action === 'content_loaded') {
|
||||||
|
dis.dispatch({
|
||||||
|
action: 'widget_content_loaded',
|
||||||
|
widgetId: widgetId,
|
||||||
|
});
|
||||||
|
sendResponse(event, {success: true});
|
||||||
|
} else if (action === 'supported_api_versions') {
|
||||||
|
sendResponse(event, {
|
||||||
|
api: "widget",
|
||||||
|
supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
|
||||||
|
});
|
||||||
|
} else if (action === 'api_version') {
|
||||||
|
sendResponse(event, {
|
||||||
|
api: "widget",
|
||||||
|
version: WIDGET_API_VERSION,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("Widget postMessage event unhandled");
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
function trustedEndpoint(origin) {
|
||||||
|
if (!origin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return global.mxWidgetMessagingMessageEndpoints.some((endpoint) => {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
function sendResponse(event, res) {
|
||||||
|
const data = JSON.parse(JSON.stringify(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)
|
||||||
|
*/
|
||||||
|
function sendError(event, msg, nestedError) {
|
||||||
|
console.error("Action:" + event.data.action + " failed with message: " + msg);
|
||||||
|
const data = JSON.parse(JSON.stringify(event.data));
|
||||||
|
data.response = {
|
||||||
|
error: {
|
||||||
|
message: msg,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (nestedError) {
|
||||||
|
data.response.error._error = nestedError;
|
||||||
|
}
|
||||||
|
event.source.postMessage(data, event.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents mapping of widget instance to URLs for trusted postMessage communication.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
startListening: startListening,
|
||||||
|
stopListening: stopListening,
|
||||||
|
addEndpoint: addEndpoint,
|
||||||
|
removeEndpoint: removeEndpoint,
|
||||||
|
};
|
|
@ -22,6 +22,7 @@ import React from 'react';
|
||||||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||||
import PlatformPeg from '../../../PlatformPeg';
|
import PlatformPeg from '../../../PlatformPeg';
|
||||||
import ScalarAuthClient from '../../../ScalarAuthClient';
|
import ScalarAuthClient from '../../../ScalarAuthClient';
|
||||||
|
import WidgetMessaging from '../../../WidgetMessaging';
|
||||||
import TintableSvgButton from './TintableSvgButton';
|
import TintableSvgButton from './TintableSvgButton';
|
||||||
import SdkConfig from '../../../SdkConfig';
|
import SdkConfig from '../../../SdkConfig';
|
||||||
import Modal from '../../../Modal';
|
import Modal from '../../../Modal';
|
||||||
|
@ -71,16 +72,45 @@ export default React.createClass({
|
||||||
return {
|
return {
|
||||||
initialising: true, // True while we are mangling the widget URL
|
initialising: true, // True while we are mangling the widget URL
|
||||||
loading: true, // True while the iframe content is loading
|
loading: true, // True while the iframe content is loading
|
||||||
widgetUrl: newProps.url,
|
widgetUrl: this._addWurlParams(newProps.url),
|
||||||
widgetPermissionId: widgetPermissionId,
|
widgetPermissionId: widgetPermissionId,
|
||||||
// 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: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId,
|
hasPermissionToLoad: hasPermissionToLoad === 'true' || newProps.userId === newProps.creatorUserId,
|
||||||
error: null,
|
error: null,
|
||||||
deleting: false,
|
deleting: false,
|
||||||
|
widgetPageTitle: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
const u = url.parse(urlString);
|
||||||
|
if (!u) {
|
||||||
|
console.error("_addWurlParams", "Invalid URL", urlString);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = qs.parse(u.query);
|
||||||
|
// Append widget ID to query parameters
|
||||||
|
params.widgetId = this.props.id;
|
||||||
|
// Append current / parent URL
|
||||||
|
params.parentUrl = window.location.href;
|
||||||
|
u.search = undefined;
|
||||||
|
u.query = params;
|
||||||
|
|
||||||
|
return u.format();
|
||||||
|
},
|
||||||
|
|
||||||
getInitialState() {
|
getInitialState() {
|
||||||
return this._getNewState(this.props);
|
return this._getNewState(this.props);
|
||||||
},
|
},
|
||||||
|
@ -122,6 +152,8 @@ export default React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
|
WidgetMessaging.startListening();
|
||||||
|
WidgetMessaging.addEndpoint(this.props.id, this.props.url);
|
||||||
window.addEventListener('message', this._onMessage, false);
|
window.addEventListener('message', this._onMessage, false);
|
||||||
this.setScalarToken();
|
this.setScalarToken();
|
||||||
},
|
},
|
||||||
|
@ -137,7 +169,7 @@ export default React.createClass({
|
||||||
console.warn('Non-scalar widget, not setting scalar token!', url);
|
console.warn('Non-scalar widget, not setting scalar token!', url);
|
||||||
this.setState({
|
this.setState({
|
||||||
error: null,
|
error: null,
|
||||||
widgetUrl: this.props.url,
|
widgetUrl: this._addWurlParams(this.props.url),
|
||||||
initialising: false,
|
initialising: false,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
@ -150,7 +182,7 @@ export default React.createClass({
|
||||||
this._scalarClient.getScalarToken().done((token) => {
|
this._scalarClient.getScalarToken().done((token) => {
|
||||||
// Append scalar_token as a query param if not already present
|
// Append scalar_token as a query param if not already present
|
||||||
this._scalarClient.scalarToken = token;
|
this._scalarClient.scalarToken = token;
|
||||||
const u = url.parse(this.props.url);
|
const u = url.parse(this._addWurlParams(this.props.url));
|
||||||
const params = qs.parse(u.query);
|
const params = qs.parse(u.query);
|
||||||
if (!params.scalar_token) {
|
if (!params.scalar_token) {
|
||||||
params.scalar_token = encodeURIComponent(token);
|
params.scalar_token = encodeURIComponent(token);
|
||||||
|
@ -174,6 +206,8 @@ export default React.createClass({
|
||||||
},
|
},
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
WidgetMessaging.stopListening();
|
||||||
|
WidgetMessaging.removeEndpoint(this.props.id, this.props.url);
|
||||||
window.removeEventListener('message', this._onMessage);
|
window.removeEventListener('message', this._onMessage);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -256,10 +290,23 @@ export default React.createClass({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when widget iframe has finished loading
|
||||||
|
*/
|
||||||
_onLoaded() {
|
_onLoaded() {
|
||||||
this.setState({loading: false});
|
this.setState({loading: false});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set remote content title on AppTile
|
||||||
|
* @param {string} title Title string to set on the AppTile
|
||||||
|
*/
|
||||||
|
_updateWidgetTitle(title) {
|
||||||
|
if (title) {
|
||||||
|
this.setState({widgetPageTitle: null});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Widget labels to render, depending upon user permissions
|
// Widget labels to render, depending upon user permissions
|
||||||
// These strings are translated at the point that they are inserted in to the DOM, in the render method
|
// These strings are translated at the point that they are inserted in to the DOM, in the render method
|
||||||
_deleteWidgetLabel() {
|
_deleteWidgetLabel() {
|
||||||
|
@ -305,6 +352,15 @@ export default React.createClass({
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_getSafeUrl() {
|
||||||
|
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
|
||||||
|
let safeWidgetUrl = '';
|
||||||
|
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
|
||||||
|
safeWidgetUrl = url.format(parsedWidgetUrl);
|
||||||
|
}
|
||||||
|
return safeWidgetUrl;
|
||||||
|
},
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let appTileBody;
|
let appTileBody;
|
||||||
|
|
||||||
|
@ -320,11 +376,6 @@ export default React.createClass({
|
||||||
// a link to it.
|
// a link to it.
|
||||||
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
|
const sandboxFlags = "allow-forms allow-popups allow-popups-to-escape-sandbox "+
|
||||||
"allow-same-origin allow-scripts allow-presentation";
|
"allow-same-origin allow-scripts allow-presentation";
|
||||||
const parsedWidgetUrl = url.parse(this.state.widgetUrl);
|
|
||||||
let safeWidgetUrl = '';
|
|
||||||
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
|
|
||||||
safeWidgetUrl = url.format(parsedWidgetUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.props.show) {
|
if (this.props.show) {
|
||||||
const loadingElement = (
|
const loadingElement = (
|
||||||
|
@ -347,7 +398,7 @@ export default React.createClass({
|
||||||
{ this.state.loading && loadingElement }
|
{ this.state.loading && loadingElement }
|
||||||
<iframe
|
<iframe
|
||||||
ref="appFrame"
|
ref="appFrame"
|
||||||
src={safeWidgetUrl}
|
src={this._getSafeUrl()}
|
||||||
allowFullScreen="true"
|
allowFullScreen="true"
|
||||||
sandbox={sandboxFlags}
|
sandbox={sandboxFlags}
|
||||||
onLoad={this._onLoaded}
|
onLoad={this._onLoaded}
|
||||||
|
@ -383,6 +434,9 @@ export default React.createClass({
|
||||||
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
|
<div className={this.props.fullWidth ? "mx_AppTileFullWidth" : "mx_AppTile"} id={this.props.id}>
|
||||||
<div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}>
|
<div ref="menu_bar" className="mx_AppTileMenuBar" onClick={this.onClickMenuBar}>
|
||||||
<b>{ this.formatAppTileName() }</b>
|
<b>{ this.formatAppTileName() }</b>
|
||||||
|
{ this.state.widgetPageTitle && (
|
||||||
|
<span> - { this.state.widgetPageTitle }</span>
|
||||||
|
) }
|
||||||
<span className="mx_AppTileMenuBarWidgets">
|
<span className="mx_AppTileMenuBarWidgets">
|
||||||
{ /* Edit widget */ }
|
{ /* Edit widget */ }
|
||||||
{ showEditButton && <TintableSvgButton
|
{ showEditButton && <TintableSvgButton
|
||||||
|
|
Loading…
Reference in a new issue