Merge pull request #2053 from matrix-org/dbkr/jitsi_always_on_screen

Implement always-on-screen capability for widgets
This commit is contained in:
Bruno Windels 2018-07-13 11:14:37 +01:00 committed by GitHub
commit 7bbb967ddd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 198 additions and 74 deletions

View file

@ -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 {

View file

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

View file

@ -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.
@ -33,6 +34,7 @@ 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;
@ -40,9 +42,13 @@ 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);
@ -50,7 +56,6 @@ export default class AppTile extends React.Component {
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);
@ -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
@ -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: [],
};
}
@ -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);
}
/**
@ -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);
}
}
/**
@ -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);
})
@ -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
@ -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);
@ -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':
@ -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);

View file

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

View file

@ -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.
@ -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 &&

View file

@ -702,7 +702,6 @@ module.exports = withMatrixClient(React.createClass({
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{ avatar }
{ sender }
<div className="mx_EventTile_line">
<a href={permalink} onClick={this.onPermalinkClicked}>
@ -719,6 +718,12 @@ module.exports = withMatrixClient(React.createClass({
{ keyRequestInfo }
{ editButton }
</div>
{
// The avatar goes after the event tile as it's absolutly positioned to be over the
// event tile line, so needs to be later in the DOM so it appears on top (this avoids
// the need for further z-indexing chaos)
}
{ avatar }
</div>
);
}

View file

@ -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';
@ -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;
@ -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;
}
}
@ -217,7 +209,6 @@ export default class Stickerpicker extends React.Component {
>
<PersistedElement persistKey="stickerPicker" style={{zIndex: STICKERPICKER_Z_INDEX}}>
<AppTile
collectWidgetMessaging={this._collectWidgetMessaging}
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
name={stickerpickerWidget.content.name}

View file

@ -0,0 +1,84 @@
/*
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.
*/
/**
* Stores information about the widgets active in the app right now:
* * What widget is set to remain always-on-screen, if any
* Only one widget may be 'always on screen' at any one time.
* * Negotiated capabilities for active apps
*/
class ActiveWidgetStore {
constructor() {
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 = {};
}
setWidgetPersistence(widgetId, val) {
if (this._persistentWidgetId === widgetId && !val) {
this._persistentWidgetId = null;
} else if (this._persistentWidgetId !== widgetId && val) {
this._persistentWidgetId = widgetId;
}
}
getWidgetPersistence(widgetId) {
return this._persistentWidgetId === widgetId;
}
setWidgetCapabilities(widgetId, caps) {
this._capsByWidgetId[widgetId] = caps;
}
widgetHasCapability(widgetId, cap) {
return this._capsByWidgetId[widgetId] && this._capsByWidgetId[widgetId].includes(cap);
}
delWidgetCapabilities(widgetId) {
delete this._capsByWidgetId[widgetId];
}
setWidgetMessaging(widgetId, wm) {
this._widgetMessagingByWidgetId[widgetId] = wm;
}
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];
}
}
}
if (global.singletonActiveWidgetStore === undefined) {
global.singletonActiveWidgetStore = new ActiveWidgetStore();
}
export default global.singletonActiveWidgetStore;