Merge remote-tracking branch 'origin/develop' into dbkr/widget_echo

This commit is contained in:
David Baker 2018-07-16 13:24:13 +01:00
commit 4c6419a3c5
46 changed files with 1226 additions and 328 deletions

View file

@ -56,7 +56,7 @@ export default React.createClass({
_checkGroupId: function(e) {
let error = null;
if (!this.state.groupId) {
error = _t("Community IDs cannot not be empty.");
error = _t("Community IDs cannot be empty.");
} else if (!/^[a-z0-9=_\-\.\/]*$/.test(this.state.groupId)) {
error = _t("Community IDs may only contain characters a-z, 0-9, or '=_-./'");
}

View file

@ -242,6 +242,9 @@ class SendAccountData extends GenericEditor {
}
}
const INITIAL_LOAD_TILES = 20;
const LOAD_TILES_STEP_SIZE = 50;
class FilteredList extends React.Component {
static propTypes = {
children: PropTypes.any,
@ -249,31 +252,65 @@ class FilteredList extends React.Component {
onChange: PropTypes.func,
};
static filterChildren(children, query) {
if (!query) return children;
const lcQuery = query.toLowerCase();
return children.filter((child) => child.key.toLowerCase().includes(lcQuery));
}
constructor(props, context) {
super(props, context);
this.onQuery = this.onQuery.bind(this);
this.state = {
filteredChildren: FilteredList.filterChildren(this.props.children, this.props.query),
truncateAt: INITIAL_LOAD_TILES,
};
}
onQuery(ev) {
componentWillReceiveProps(nextProps) {
if (this.props.children === nextProps.children && this.props.query === nextProps.query) return;
this.setState({
filteredChildren: FilteredList.filterChildren(nextProps.children, nextProps.query),
truncateAt: INITIAL_LOAD_TILES,
});
}
showAll = () => {
this.setState({
truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE,
});
};
createOverflowElement = (overflowCount: number, totalCount: number) => {
return <button className="mx_DevTools_RoomStateExplorer_button" onClick={this.showAll}>
{ _t("and %(count)s others...", { count: overflowCount }) }
</button>;
};
onQuery = (ev) => {
if (this.props.onChange) this.props.onChange(ev.target.value);
}
};
filterChildren() {
if (this.props.query) {
const lowerQuery = this.props.query.toLowerCase();
return this.props.children.filter((child) => child.key.toLowerCase().includes(lowerQuery));
}
return this.props.children;
}
getChildren = (start: number, end: number) => {
return this.state.filteredChildren.slice(start, end);
};
getChildCount = (): number => {
return this.state.filteredChildren.length;
};
render() {
const TruncatedList = sdk.getComponent("elements.TruncatedList");
return <div>
<input size="64"
onChange={this.onQuery}
value={this.props.query}
placeholder={_t('Filter results')}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query" />
{ this.filterChildren() }
<TruncatedList getChildren={this.getChildren}
getChildCount={this.getChildCount}
truncateAt={this.state.truncateAt}
createOverflowElement={this.createOverflowElement} />
</div>;
}
}

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);
})
@ -347,19 +346,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
@ -371,16 +370,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);
@ -390,7 +388,7 @@ export default class AppTile extends React.Component {
});
}
_onWidgetAction(payload) {
_onAction(payload) {
if (payload.widgetId === this.props.id) {
switch (payload.action) {
case 'm.sticker':
@ -568,6 +566,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

@ -16,28 +16,28 @@ limitations under the License.
const React = require('react');
const ReactDOM = require('react-dom');
const PropTypes = require('prop-types');
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
// pass in a custom control as the actual body.
const ContainerId = "mx_PersistedElement";
function getContainer(containerId) {
return document.getElementById(containerId);
}
function getOrCreateContainer() {
let container = document.getElementById(ContainerId);
function getOrCreateContainer(containerId) {
let container = getContainer(containerId);
if (!container) {
container = document.createElement("div");
container.id = ContainerId;
container.id = containerId;
document.body.appendChild(container);
}
return container;
}
// Greater than that of the ContextualMenu
const PE_Z_INDEX = 5000;
/*
* Class of component that renders its children in a separate ReactDOM virtual tree
* in a container element appended to document.body.
@ -50,12 +50,38 @@ const PE_Z_INDEX = 5000;
* bounding rect as the parent of PE.
*/
export default class PersistedElement extends React.Component {
static propTypes = {
// Unique identifier for this PersistedElement instance
// Any PersistedElements with the same persistKey will use
// the same DOM container.
persistKey: PropTypes.string.isRequired,
};
constructor() {
super();
this.collectChildContainer = this.collectChildContainer.bind(this);
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;
}
@ -97,18 +123,16 @@ export default class PersistedElement extends React.Component {
left: parentRect.left + 'px',
width: parentRect.width + 'px',
height: parentRect.height + 'px',
zIndex: PE_Z_INDEX,
});
}
render() {
const content = <div ref={this.collectChild}>
const content = <div ref={this.collectChild} style={this.props.style}>
{this.props.children}
</div>;
ReactDOM.render(content, getOrCreateContainer());
ReactDOM.render(content, getOrCreateContainer('mx_persistedElement_'+this.props.persistKey));
return <div ref={this.collectChildContainer}></div>;
}
}

View file

@ -16,6 +16,7 @@ limitations under the License.
'use strict';
import React from 'react';
import MImageBody from './MImageBody';
import sdk from '../../../index';

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.
@ -220,25 +221,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

@ -56,6 +56,7 @@ const stateEventTileTypes = {
'm.room.topic': 'messages.TextualEvent',
'm.room.power_levels': 'messages.TextualEvent',
'm.room.pinned_events': 'messages.TextualEvent',
'm.room.server_acl': 'messages.TextualEvent',
'im.vector.modular.widgets': 'messages.TextualEvent',
};
@ -484,12 +485,19 @@ module.exports = withMatrixClient(React.createClass({
// Info messages are basically information about commands processed on a room
const isInfoMessage = (eventType !== 'm.room.message' && eventType !== 'm.sticker');
const EventTileType = sdk.getComponent(getHandlerTile(this.props.mxEvent));
const tileHandler = getHandlerTile(this.props.mxEvent);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!EventTileType) {
throw new Error("Event type not supported");
if (!tileHandler) {
const {mxEvent} = this.props;
console.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`);
return <div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
<div className="mx_EventTile_line">
{ _t('This event could not be displayed') }
</div>
</div>;
}
const EventTileType = sdk.getComponent(tileHandler);
const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
@ -694,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}>
@ -711,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

@ -16,6 +16,8 @@ limitations under the License.
*/
'use strict';
import SettingsStore from "../../../settings/SettingsStore";
const React = require("react");
const ReactDOM = require("react-dom");
import PropTypes from 'prop-types';
@ -608,6 +610,10 @@ module.exports = React.createClass({
const RoomSubList = sdk.getComponent('structures.RoomSubList');
const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper");
// XXX: we can't detect device-level (localStorage) settings onChange as the SettingsStore does not notify
// so checking on every render is the sanest thing at this time.
const showEmpty = SettingsStore.getValue('RoomSubList.showEmpty');
const self = this;
return (
<GeminiScrollbarWrapper className="mx_RoomList_scrollbar"
@ -623,6 +629,7 @@ module.exports = React.createClass({
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty}
/>
<RoomSubList list={self.state.lists['im.vector.fake.invite']}
@ -635,6 +642,7 @@ module.exports = React.createClass({
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty}
/>
<RoomSubList list={self.state.lists['m.favourite']}
@ -647,7 +655,8 @@ module.exports = React.createClass({
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} />
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['im.vector.fake.direct']}
label={_t('People')}
@ -661,7 +670,8 @@ module.exports = React.createClass({
alwaysShowHeader={true}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} />
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['im.vector.fake.recent']}
label={_t('Rooms')}
@ -673,7 +683,8 @@ module.exports = React.createClass({
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} />
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
{ Object.keys(self.state.lists).map((tagName) => {
if (!tagName.match(STANDARD_TAGS_REGEX)) {
@ -688,7 +699,8 @@ module.exports = React.createClass({
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} />;
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />;
}
}) }
@ -702,7 +714,8 @@ module.exports = React.createClass({
collapsed={self.props.collapsed}
searchFilter={self.props.searchFilter}
onHeaderClick={self.onSubListHeaderClick}
onShowMoreRooms={self.onShowMoreRooms} />
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
<RoomSubList list={self.state.lists['im.vector.fake.archived']}
emptyContent={self.props.collapsed ? null :
@ -722,7 +735,8 @@ module.exports = React.createClass({
onHeaderClick={self.onArchivedHeaderClick}
incomingCall={self.state.incomingCall}
searchFilter={self.props.searchFilter}
onShowMoreRooms={self.onShowMoreRooms} />
onShowMoreRooms={self.onShowMoreRooms}
showEmpty={showEmpty} />
</div>
</GeminiScrollbarWrapper>
);

View file

@ -24,9 +24,14 @@ 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';
// We sit in a context menu, so the persisted element container needs to float
// above it, so it needs a greater z-index than the ContextMenu
const STICKERPICKER_Z_INDEX = 5000;
export default class Stickerpicker extends React.Component {
constructor(props) {
super(props);
@ -39,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;
@ -162,17 +165,11 @@ 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);
if (!this.state.stickerpickerWidget) return;
const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(this.state.stickerpickerWidget.id);
if (widgetMessaging && visible !== this._prevSentVisibility) {
widgetMessaging.sendVisibility(visible);
this._prevSentVisibility = visible;
}
}
@ -211,9 +208,8 @@ export default class Stickerpicker extends React.Component {
width: this.popoverWidth,
}}
>
<PersistedElement>
<PersistedElement persistKey="stickerPicker" style={{zIndex: STICKERPICKER_Z_INDEX}}>
<AppTile
collectWidgetMessaging={this._collectWidgetMessaging}
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
name={stickerpickerWidget.content.name}