Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Implement always-on-screen capability for widgets
Browse files Browse the repository at this point in the history
As per https://github.com/matrix-org/matrix-doc/issues/1354

This is whitelisted to only jitsi widgets for now as per comment,
mostly because any widget that we may make always-on-screen we need
to preemptively put in a PersistedElement container, which is
unnecessary for any other widget.

Apologies that this does a bunch of refactoring which could have
been split out separately: I only discovered what needed to be
refactored in the process of doing this.

Fixes element-hq/element-web#6984
  • Loading branch information
dbkr committed Jul 11, 2018
1 parent b482a4c commit 8f10ee0
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 74 deletions.
1 change: 0 additions & 1 deletion res/css/views/rooms/_EventTile.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ limitations under the License.
top: 14px;
left: 8px;
cursor: pointer;
z-index: 2;
}

.mx_EventTile.mx_EventTile_info .mx_EventTile_avatar {
Expand Down
3 changes: 2 additions & 1 deletion src/CallHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,8 @@ function _startCallApp(roomId, type) {
'email=$matrix_user_id',
].join('&');
const widgetUrl = (
'https://scalar.vector.im/api/widgets' +
//'https://scalar.vector.im/api/widgets' +
'http://localhost:8620' +
'/jitsi.html?' +
queryString
);
Expand Down
9 changes: 9 additions & 0 deletions src/FromWidgetPostMessageApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import URL from 'url';
import dis from './dispatcher';
import IntegrationManager from './IntegrationManager';
import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
import ActiveWidgetStore from './stores/ActiveWidgetStore';

const WIDGET_API_VERSION = '0.0.1'; // Current API version
const SUPPORTED_WIDGET_API_VERSIONS = [
Expand Down Expand Up @@ -155,6 +156,14 @@ export default class FromWidgetPostMessageApi {
const integType = (data && data.integType) ? data.integType : null;
const integId = (data && data.integId) ? data.integId : null;
IntegrationManager.open(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, 'm.always_on_screen')) {
ActiveWidgetStore.setWidgetPersistence(widgetId, val);
}
} else {
console.warn('Widget postMessage event unhandled');
this.sendError(event, {message: 'The postMessage was unhandled'});
Expand Down
87 changes: 47 additions & 40 deletions src/components/views/elements/AppTile.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
Copyright 2017 Vector Creations Ltd
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.
Expand Down Expand Up @@ -33,24 +34,28 @@ import AppWarning from './AppWarning';
import MessageSpinner from './MessageSpinner';
import WidgetUtils from '../../../utils/WidgetUtils';
import dis from '../../../dispatcher';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';

const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const ENABLE_REACT_PERF = false;

export default class AppTile extends React.Component {
constructor(props) {
super(props);

// The key used for PersistedElement
this._persistKey = 'widget_' + this.props.id;

this.state = this._getNewState(props);

this._onWidgetAction = this._onWidgetAction.bind(this);
this._onAction = this._onAction.bind(this);
this._onMessage = this._onMessage.bind(this);
this._onLoaded = this._onLoaded.bind(this);
this._onEditClick = this._onEditClick.bind(this);
this._onDeleteClick = this._onDeleteClick.bind(this);
this._onSnapshotClick = this._onSnapshotClick.bind(this);
this.onClickMenuBar = this.onClickMenuBar.bind(this);
this._onMinimiseClick = this._onMinimiseClick.bind(this);
this._onInitialLoad = this._onInitialLoad.bind(this);
this._grantWidgetPermission = this._grantWidgetPermission.bind(this);
this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this);
this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this);
Expand All @@ -66,9 +71,12 @@ export default class AppTile extends React.Component {
_getNewState(newProps) {
const widgetPermissionId = [newProps.room.roomId, encodeURIComponent(newProps.url)].join('_');
const hasPermissionToLoad = localStorage.getItem(widgetPermissionId);

const PersistedElement = sdk.getComponent("elements.PersistedElement");
return {
initialising: true, // True while we are mangling the widget URL
loading: this.props.waitForIframeLoad, // True while the iframe content is loading
// True while the iframe content is loading
loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
widgetUrl: this._addWurlParams(newProps.url),
widgetPermissionId: widgetPermissionId,
// Assume that widget has permission to load if we are the user who
Expand All @@ -77,9 +85,6 @@ export default class AppTile extends React.Component {
error: null,
deleting: false,
widgetPageTitle: newProps.widgetPageTitle,
allowedCapabilities: (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) ?
this.props.whitelistCapabilities : [],
requestedCapabilities: [],
};
}

Expand All @@ -89,7 +94,7 @@ export default class AppTile extends React.Component {
* @return {Boolean} True if capability supported
*/
_hasCapability(capability) {
return this.state.allowedCapabilities.some((c) => {return c === capability;});
return ActiveWidgetStore.widgetHasCapability(this.props.id, capability);
}

/**
Expand Down Expand Up @@ -142,30 +147,24 @@ export default class AppTile extends React.Component {
window.addEventListener('message', this._onMessage, false);

// Widget action listeners
this.dispatcherRef = dis.register(this._onWidgetAction);
}

componentDidUpdate() {
// Allow parents to access widget messaging
if (this.props.collectWidgetMessaging) {
this.props.collectWidgetMessaging(this.widgetMessaging);
}
this.dispatcherRef = dis.register(this._onAction);
}

componentWillUnmount() {
// Widget action listeners
dis.unregister(this.dispatcherRef);

// Widget postMessage listeners
try {
if (this.widgetMessaging) {
this.widgetMessaging.stop();
}
} catch (e) {
console.error('Failed to stop listening for widgetMessaging events', e.message);
}
// Jitsi listener
window.removeEventListener('message', this._onMessage);

// if it's not remaining on screen, get rid of the PersistedElement container
if (!ActiveWidgetStore.getWidgetPersistence(this.props.id)) {
// FIXME: ActiveWidgetStore should probably worry about this?
const PersistedElement = sdk.getComponent("elements.PersistedElement");
PersistedElement.destroyElement(this._persistKey);
ActiveWidgetStore.delWidgetMessaging(this.props.id);
ActiveWidgetStore.delWidgetCapabilities(this.props.id);
}
}

/**
Expand Down Expand Up @@ -286,7 +285,7 @@ export default class AppTile extends React.Component {

_onSnapshotClick(e) {
console.warn("Requesting widget snapshot");
this.widgetMessaging.getScreenshot()
ActiveWidgetStore.getWidgetMessaging(this.props.id).getScreenshot()
.catch((err) => {
console.error("Failed to get screenshot", err);
})
Expand Down Expand Up @@ -341,19 +340,19 @@ export default class AppTile extends React.Component {
* Called when widget iframe has finished loading
*/
_onLoaded() {
if (!this.widgetMessaging) {
this._onInitialLoad();
if (!ActiveWidgetStore.getWidgetMessaging(this.props.id)) {
this._setupWidgetMessaging();
}
this.setState({loading: false});
}

/**
* Called on initial load of the widget iframe
*/
_onInitialLoad() {
this.widgetMessaging = new WidgetMessaging(this.props.id, this.props.url, this.refs.appFrame.contentWindow);
this.widgetMessaging.getCapabilities().then((requestedCapabilities) => {
console.log(`Widget ${this.props.id} requested capabilities:`, requestedCapabilities);
_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.id, this.props.url, this.refs.appFrame.contentWindow);
ActiveWidgetStore.setWidgetMessaging(this.props.id, widgetMessaging);
widgetMessaging.getCapabilities().then((requestedCapabilities) => {
console.log(`Widget ${this.props.id} requested capabilities: ` + requestedCapabilities);
requestedCapabilities = requestedCapabilities || [];

// Allow whitelisted capabilities
Expand All @@ -365,16 +364,15 @@ export default class AppTile extends React.Component {
}, this.props.whitelistCapabilities);

if (requestedWhitelistCapabilies.length > 0 ) {
console.warn(`Widget ${this.props.id} allowing requested, whitelisted properties:`,
requestedWhitelistCapabilies);
console.warn(`Widget ${this.props.id} allowing requested, whitelisted properties: ` +
requestedWhitelistCapabilies,
);
}
}

// TODO -- Add UI to warn about and optionally allow requested capabilities
this.setState({
requestedCapabilities,
allowedCapabilities: this.state.allowedCapabilities.concat(requestedWhitelistCapabilies),
});

ActiveWidgetStore.setWidgetCapabilities(this.props.id, requestedWhitelistCapabilies);

if (this.props.onCapabilityRequest) {
this.props.onCapabilityRequest(requestedCapabilities);
Expand All @@ -384,7 +382,7 @@ export default class AppTile extends React.Component {
});
}

_onWidgetAction(payload) {
_onAction(payload) {
if (payload.widgetId === this.props.id) {
switch (payload.action) {
case 'm.sticker':
Expand Down Expand Up @@ -562,6 +560,15 @@ export default class AppTile extends React.Component {
></iframe>
</div>
);
// if the widget would be allowed to remian on screen, we must put it in
// a PersistedElement from the get-go, otherwise the iframe will be
// re-mounted later when we do.
if (this.props.whitelistCapabilities.includes('m.always_on_screen')) {
const PersistedElement = sdk.getComponent("elements.PersistedElement");
appTileBody = <PersistedElement persistKey={this._persistKey}>
{appTileBody}
</PersistedElement>;
}
}
} else {
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
Expand Down
24 changes: 23 additions & 1 deletion src/components/views/elements/PersistedElement.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ const PropTypes = require('prop-types');
// of doing reusable widgets like dialog boxes & menus where we go and
// pass in a custom control as the actual body.

function getContainer(containerId) {
return document.getElementById(containerId);
}

function getOrCreateContainer(containerId) {
let container = document.getElementById(containerId);
let container = getContainer(containerId);

if (!container) {
container = document.createElement("div");
Expand Down Expand Up @@ -60,6 +64,24 @@ export default class PersistedElement extends React.Component {
this.collectChild = this.collectChild.bind(this);
}

/**
* Removes the DOM elements created when a PersistedElement with the given
* persistKey was mounted. The DOM elements will be re-added if another
* PeristedElement is mounted in the future.
*
* @param {string} persistKey Key used to uniquely identify this PersistedElement
*/
static destroyElement(persistKey) {
const container = getContainer('mx_persistedElement_' + persistKey);
if (container) {
container.remove();
}
}

static isMounted(persistKey) {
return Boolean(getContainer('mx_persistedElement_' + persistKey));
}

collectChildContainer(ref) {
this.childContainer = ref;
}
Expand Down
43 changes: 25 additions & 18 deletions src/components/views/rooms/AppsDrawer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/*
Copyright 2017 Vector Creations Ltd
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.
Expand Down Expand Up @@ -214,24 +215,30 @@ module.exports = React.createClass({
render: function() {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", this.props.room.room_id);

const apps = this.state.apps.map(
(app, index, arr) => {
return (<AppTile
key={app.id}
id={app.id}
url={app.url}
name={app.name}
type={app.type}
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}
show={this.props.showApps}
creatorUserId={app.creatorUserId}
widgetPageTitle={(app.data && app.data.title) ? app.data.title : ''}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={enableScreenshots ? ["m.capability.screenshot"] : []}
/>);
});
const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = enableScreenshots ? ["m.capability.screenshot"] : [];

// Obviously anyone that can add a widget can claim it's a jitsi widget,
// so this doesn't really offer much over the set of domains we load
// widgets from at all, but it probably makes sense for sanity.
if (app.type == 'jitsi') capWhitelist.push("m.always_on_screen");

return (<AppTile
key={app.id}
id={app.id}
url={app.url}
name={app.name}
type={app.type}
fullWidth={arr.length<2 ? true : false}
room={this.props.room}
userId={this.props.userId}
show={this.props.showApps}
creatorUserId={app.creatorUserId}
widgetPageTitle={(app.data && app.data.title) ? app.data.title : ''}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
/>);
});

let addWidget;
if (this.props.showApps &&
Expand Down
17 changes: 4 additions & 13 deletions src/components/views/rooms/Stickerpicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import ScalarAuthClient from '../../../ScalarAuthClient';
import dis from '../../../dispatcher';
import AccessibleButton from '../elements/AccessibleButton';
import WidgetUtils from '../../../utils/WidgetUtils';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';

const widgetType = 'm.stickerpicker';

Expand All @@ -43,8 +44,6 @@ export default class Stickerpicker extends React.Component {
this._onResize = this._onResize.bind(this);
this._onFinished = this._onFinished.bind(this);

this._collectWidgetMessaging = this._collectWidgetMessaging.bind(this);

this.popoverWidth = 300;
this.popoverHeight = 300;

Expand Down Expand Up @@ -166,17 +165,10 @@ export default class Stickerpicker extends React.Component {
);
}

_collectWidgetMessaging(widgetMessaging) {
this._appWidgetMessaging = widgetMessaging;

// Do this now instead of in componentDidMount because we might not have had the
// reference to widgetMessaging when mounting
this._sendVisibilityToWidget(true);
}

_sendVisibilityToWidget(visible) {
if (this._appWidgetMessaging && visible !== this._prevSentVisibility) {
this._appWidgetMessaging.sendVisibility(visible);
const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(this.state.stickerpickerWidget.id);
if (widgetMessaging && visible !== this._prevSentVisibility) {
widgetMessaging.sendVisibility(visible);
this._prevSentVisibility = visible;
}
}
Expand Down Expand Up @@ -217,7 +209,6 @@ export default class Stickerpicker extends React.Component {
>
<PersistedElement containerId="mx_persisted_stickerPicker" style={{zIndex: STICKERPICKER_Z_INDEX}}>
<AppTile
collectWidgetMessaging={this._collectWidgetMessaging}
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
name={stickerpickerWidget.content.name}
Expand Down
Loading

0 comments on commit 8f10ee0

Please sign in to comment.