Merge pull request #2056 from matrix-org/dbkr/tiny_jitsi_follows_you_between_rooms

Implement always-on-screen capability for widgets
This commit is contained in:
David Baker 2018-07-16 17:26:37 +01:00 committed by GitHub
commit 149a935594
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 216 additions and 68 deletions

View File

@ -84,6 +84,7 @@
"react-beautiful-dnd": "^4.0.1", "react-beautiful-dnd": "^4.0.1",
"react-dom": "^15.6.0", "react-dom": "^15.6.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"resize-observer-polyfill": "^1.5.0",
"slate": "0.33.4", "slate": "0.33.4",
"slate-react": "^0.12.4", "slate-react": "^0.12.4",
"slate-html-serializer": "^0.6.1", "slate-html-serializer": "^0.6.1",

View File

@ -54,6 +54,10 @@ limitations under the License.
} }
.mx_LeftPanel .mx_AppTileFullWidth {
height: 132px;
}
.mx_LeftPanel .mx_RoomList_scrollbar { .mx_LeftPanel .mx_RoomList_scrollbar {
order: 1; order: 1;

View File

@ -126,6 +126,12 @@ limitations under the License.
overflow: hidden; overflow: hidden;
} }
.mx_AppTileBody_mini {
height: 132px;
width: 100%;
overflow: hidden;
}
.mx_AppTileBody iframe { .mx_AppTileBody iframe {
width: 100%; width: 100%;
height: 280px; height: 280px;

View File

@ -164,6 +164,7 @@ export default class AppTile extends React.Component {
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
ActiveWidgetStore.delWidgetMessaging(this.props.id); ActiveWidgetStore.delWidgetMessaging(this.props.id);
ActiveWidgetStore.delWidgetCapabilities(this.props.id); ActiveWidgetStore.delWidgetCapabilities(this.props.id);
ActiveWidgetStore.delRoomId(this.props.id);
} }
} }
@ -343,6 +344,7 @@ export default class AppTile extends React.Component {
if (!ActiveWidgetStore.getWidgetMessaging(this.props.id)) { if (!ActiveWidgetStore.getWidgetMessaging(this.props.id)) {
this._setupWidgetMessaging(); this._setupWidgetMessaging();
} }
ActiveWidgetStore.setRoomId(this.props.id, this.props.room.roomId);
this.setState({loading: false}); this.setState({loading: false});
} }
@ -522,6 +524,8 @@ export default class AppTile extends React.Component {
// (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
const iframeFeatures = "microphone; camera; encrypted-media;"; const iframeFeatures = "microphone; camera; encrypted-media;";
const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' ');
if (this.props.show) { if (this.props.show) {
const loadingElement = ( const loadingElement = (
<div className="mx_AppLoading_spinner_fadeIn"> <div className="mx_AppLoading_spinner_fadeIn">
@ -530,20 +534,20 @@ export default class AppTile extends React.Component {
); );
if (this.state.initialising) { if (this.state.initialising) {
appTileBody = ( appTileBody = (
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}> <div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ loadingElement } { loadingElement }
</div> </div>
); );
} else if (this.state.hasPermissionToLoad == true) { } else if (this.state.hasPermissionToLoad == true) {
if (this.isMixedContent()) { if (this.isMixedContent()) {
appTileBody = ( appTileBody = (
<div className="mx_AppTileBody"> <div className={appTileBodyClass}>
<AppWarning errorMsg="Error - Mixed content" /> <AppWarning errorMsg="Error - Mixed content" />
</div> </div>
); );
} else { } else {
appTileBody = ( appTileBody = (
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}> <div className={appTileBodyClass + (this.state.loading ? 'mx_AppLoading' : '')}>
{ this.state.loading && loadingElement } { this.state.loading && loadingElement }
{ /* { /*
The "is" attribute in the following iframe tag is needed in order to enable rendering of the The "is" attribute in the following iframe tag is needed in order to enable rendering of the
@ -573,7 +577,7 @@ export default class AppTile extends React.Component {
} else { } else {
const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId); const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.room.roomId);
appTileBody = ( appTileBody = (
<div className="mx_AppTileBody"> <div className={appTileBodyClass}>
<AppPermission <AppPermission
isRoomEncrypted={isRoomEncrypted} isRoomEncrypted={isRoomEncrypted}
url={this.state.widgetUrl} url={this.state.widgetUrl}
@ -686,6 +690,8 @@ AppTile.propTypes = {
// Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer. // Specifying 'fullWidth' as true will render the app tile to fill the width of the app drawer continer.
// This should be set to true when there is only one widget in the app drawer, otherwise it should be false. // This should be set to true when there is only one widget in the app drawer, otherwise it should be false.
fullWidth: PropTypes.bool, fullWidth: PropTypes.bool,
// Optional. If set, renders a smaller view of the widget
miniMode: PropTypes.bool,
// UserId of the current user // UserId of the current user
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
// UserId of the entity that added / modified the widget // UserId of the entity that added / modified the widget
@ -738,4 +744,5 @@ AppTile.defaultProps = {
handleMinimisePointerEvents: false, handleMinimisePointerEvents: false,
whitelistCapabilities: [], whitelistCapabilities: [],
userWidget: false, userWidget: false,
miniMode: false,
}; };

View File

@ -14,9 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
const React = require('react'); import React from 'react';
const ReactDOM = require('react-dom'); import ReactDOM from 'react-dom';
const PropTypes = require('prop-types'); import PropTypes from 'prop-types';
import ResizeObserver from 'resize-observer-polyfill';
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // of doing reusable widgets like dialog boxes & menus where we go and
@ -62,6 +64,9 @@ export default class PersistedElement extends React.Component {
super(); super();
this.collectChildContainer = this.collectChildContainer.bind(this); this.collectChildContainer = this.collectChildContainer.bind(this);
this.collectChild = this.collectChild.bind(this); this.collectChild = this.collectChild.bind(this);
this._onContainerResize = this._onContainerResize.bind(this);
this.resizeObserver = new ResizeObserver(this._onContainerResize);
} }
/** /**
@ -83,7 +88,13 @@ export default class PersistedElement extends React.Component {
} }
collectChildContainer(ref) { collectChildContainer(ref) {
if (this.childContainer) {
this.resizeObserver.unobserve(this.childContainer);
}
this.childContainer = ref; this.childContainer = ref;
if (ref) {
this.resizeObserver.observe(ref);
}
} }
collectChild(ref) { collectChild(ref) {
@ -101,6 +112,11 @@ export default class PersistedElement extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this.updateChildVisibility(this.child, false); this.updateChildVisibility(this.child, false);
this.resizeObserver.disconnect();
}
_onContainerResize() {
this.updateChildPosition(this.child, this.childContainer);
} }
updateChild() { updateChild() {

View File

@ -0,0 +1,87 @@
/*
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.
*/
import React from 'react';
import RoomViewStore from '../../../stores/RoomViewStore';
import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
import WidgetUtils from '../../../utils/WidgetUtils';
import sdk from '../../../index';
import MatrixClientPeg from '../../../MatrixClientPeg';
module.exports = React.createClass({
displayName: 'PersistentApp',
getInitialState: function() {
return {
roomId: RoomViewStore.getRoomId(),
};
},
componentWillMount: function() {
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
},
componentWillUnmount: function() {
if (this._roomStoreToken) {
this._roomStoreToken.remove();
}
},
_onRoomViewStoreUpdate: function(payload) {
if (RoomViewStore.getRoomId() === this.state.roomId) return;
this.setState({
roomId: RoomViewStore.getRoomId(),
});
},
render: function() {
if (ActiveWidgetStore.getPersistentWidgetId()) {
const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(ActiveWidgetStore.getPersistentWidgetId());
if (this.state.roomId !== persistentWidgetInRoomId) {
const persistentWidgetInRoom = MatrixClientPeg.get().getRoom(persistentWidgetInRoomId);
// get the widget data
const appEvent = WidgetUtils.getRoomWidgets(persistentWidgetInRoom).find((ev) => {
return ev.getStateKey() === ActiveWidgetStore.getPersistentWidgetId();
});
const app = WidgetUtils.makeAppConfig(
appEvent.getStateKey(), appEvent.getContent(), appEvent.sender, persistentWidgetInRoomId,
);
const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, persistentWidgetInRoomId);
const AppTile = sdk.getComponent('elements.AppTile');
return <AppTile
key={app.id}
id={app.id}
url={app.url}
name={app.name}
type={app.type}
fullWidth={true}
room={persistentWidgetInRoom}
userId={MatrixClientPeg.get().credentials.userId}
show={true}
creatorUserId={app.creatorUserId}
widgetPageTitle={(app.data && app.data.title) ? app.data.title : ''}
waitForIframeLoad={app.waitForIframeLoad}
whitelistCapabilities={capWhitelist}
showDelete={false}
showMinimise={false}
miniMode={true}
/>;
}
}
return null;
},
});

View File

@ -29,7 +29,6 @@ import ScalarAuthClient from '../../../ScalarAuthClient';
import ScalarMessaging from '../../../ScalarMessaging'; import ScalarMessaging from '../../../ScalarMessaging';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import WidgetUtils from '../../../utils/WidgetUtils'; import WidgetUtils from '../../../utils/WidgetUtils';
import SettingsStore from "../../../settings/SettingsStore";
// The maximum number of widgets that can be added in a room // The maximum number of widgets that can be added in a room
const MAX_WIDGETS = 2; const MAX_WIDGETS = 2;
@ -107,55 +106,6 @@ module.exports = React.createClass({
} }
}, },
/**
* Encodes a URI according to a set of template variables. Variables will be
* passed through encodeURIComponent.
* @param {string} pathTemplate 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'.
*/
encodeUri: function(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
},
_initAppConfig: function(appId, app, sender) {
const user = MatrixClientPeg.get().getUser(this.props.userId);
const params = {
'$matrix_user_id': this.props.userId,
'$matrix_room_id': this.props.room.roomId,
'$matrix_display_name': user ? user.displayName : this.props.userId,
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
// TODO: Namespace themes through some standard
'$theme': SettingsStore.getValue("theme"),
};
app.id = appId;
app.name = app.name || app.type;
if (app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
}
app.url = this.encodeUri(app.url, params);
app.creatorUserId = (sender && sender.userId) ? sender.userId : null;
return app;
},
onRoomStateEvents: function(ev, state) { onRoomStateEvents: function(ev, state) {
if (ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'im.vector.modular.widgets') { if (ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'im.vector.modular.widgets') {
return; return;
@ -165,7 +115,7 @@ module.exports = React.createClass({
_getApps: function() { _getApps: function() {
return WidgetUtils.getRoomWidgets(this.props.room).map((ev) => { return WidgetUtils.getRoomWidgets(this.props.room).map((ev) => {
return this._initAppConfig(ev.getStateKey(), ev.getContent(), ev.sender); return WidgetUtils.makeAppConfig(ev.getStateKey(), ev.getContent(), ev.sender, this.props.room.roomId);
}); });
}, },
@ -213,15 +163,8 @@ module.exports = React.createClass({
}, },
render: function() { render: function() {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", this.props.room.room_id);
const apps = this.state.apps.map((app, index, arr) => { const apps = this.state.apps.map((app, index, arr) => {
const capWhitelist = enableScreenshots ? ["m.capability.screenshot"] : []; const capWhitelist = WidgetUtils.getCapWhitelistForAppTypeInRoomId(app.type, this.props.room.roomId);
// 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 return (<AppTile
key={app.id} key={app.id}

View File

@ -1,5 +1,5 @@
/* /*
Copyright 2017 New Vector Ltd Copyright 2017, 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -92,7 +92,8 @@ module.exports = React.createClass({
/> />
); );
} }
return null; const PersistentApp = sdk.getComponent('elements.PersistentApp');
return <PersistentApp />;
}, },
}); });

View File

@ -32,6 +32,9 @@ class ActiveWidgetStore {
// A WidgetMessaging instance for each widget ID // A WidgetMessaging instance for each widget ID
this._widgetMessagingByWidgetId = {}; this._widgetMessagingByWidgetId = {};
// What room ID each widget is associated with (if it's a room widget)
this._roomIdByWidgetId = {};
} }
setWidgetPersistence(widgetId, val) { setWidgetPersistence(widgetId, val) {
@ -46,6 +49,10 @@ class ActiveWidgetStore {
return this._persistentWidgetId === widgetId; return this._persistentWidgetId === widgetId;
} }
getPersistentWidgetId() {
return this._persistentWidgetId;
}
setWidgetCapabilities(widgetId, caps) { setWidgetCapabilities(widgetId, caps) {
this._capsByWidgetId[widgetId] = caps; this._capsByWidgetId[widgetId] = caps;
} }
@ -76,6 +83,18 @@ class ActiveWidgetStore {
delete this._widgetMessagingByWidgetId[widgetId]; delete this._widgetMessagingByWidgetId[widgetId];
} }
} }
getRoomId(widgetId) {
return this._roomIdByWidgetId[widgetId];
}
setRoomId(widgetId, roomId) {
this._roomIdByWidgetId[widgetId] = roomId;
}
delRoomId(widgetId) {
delete this._roomIdByWidgetId[widgetId];
}
} }
if (global.singletonActiveWidgetStore === undefined) { if (global.singletonActiveWidgetStore === undefined) {

View File

@ -19,6 +19,27 @@ import MatrixClientPeg from '../MatrixClientPeg';
import SdkConfig from "../SdkConfig"; import SdkConfig from "../SdkConfig";
import dis from '../dispatcher'; import dis from '../dispatcher';
import * as url from "url"; import * as url from "url";
import SettingsStore from "../settings/SettingsStore";
/**
* Encodes a URI according to a set of template variables. Variables will be
* passed through encodeURIComponent.
* @param {string} pathTemplate 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 encodeUri(pathTemplate, variables) {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
);
}
return pathTemplate;
}
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
@ -324,4 +345,47 @@ export default class WidgetUtils {
}); });
return client.setAccountData('m.widgets', userWidgets); return client.setAccountData('m.widgets', userWidgets);
} }
static makeAppConfig(appId, app, sender, roomId) {
const myUserId = MatrixClientPeg.get().credentials.userId;
const user = MatrixClientPeg.get().getUser(myUserId);
const params = {
'$matrix_user_id': myUserId,
'$matrix_room_id': roomId,
'$matrix_display_name': user ? user.displayName : myUserId,
'$matrix_avatar_url': user ? MatrixClientPeg.get().mxcUrlToHttp(user.avatarUrl) : '',
// TODO: Namespace themes through some standard
'$theme': SettingsStore.getValue("theme"),
};
app.id = appId;
app.name = app.name || app.type;
if (app.data) {
Object.keys(app.data).forEach((key) => {
params['$' + key] = app.data[key];
});
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
}
app.url = encodeUri(app.url, params);
app.creatorUserId = (sender && sender.userId) ? sender.userId : null;
return app;
}
static getCapWhitelistForAppTypeInRoomId(appType, roomId) {
const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId);
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 (appType == 'jitsi') capWhitelist.push("m.always_on_screen");
return capWhitelist;
}
} }