Merge branch 'develop' into matthew/slate

This commit is contained in:
Matthew Hodgson 2018-05-13 19:50:55 +01:00
commit 721410b710
30 changed files with 908 additions and 278 deletions

View file

@ -177,6 +177,7 @@ module.exports = React.createClass({
render: function() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
const roomName = room ? room.name : oobData.name;

View file

@ -169,6 +169,13 @@ export default class AppTile extends React.Component {
this.dispatcherRef = dis.register(this._onWidgetAction);
}
componentDidUpdate() {
// Allow parents to access widget messaging
if (this.props.collectWidgetMessaging) {
this.props.collectWidgetMessaging(this.widgetMessaging);
}
}
componentWillUnmount() {
// Widget action listeners
dis.unregister(this.dispatcherRef);
@ -274,6 +281,11 @@ export default class AppTile extends React.Component {
}
_canUserModify() {
// User widgets should always be modifiable by their creator
if (this.props.userWidget && MatrixClientPeg.get().credentials.userId === this.props.creatorUserId) {
return true;
}
// Check if the current user can modify widgets in the current room
return WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
}
@ -352,6 +364,9 @@ export default class AppTile extends React.Component {
if (!this.widgetMessaging) {
this._onInitialLoad();
}
if (this._exposeWidgetMessaging) {
this._exposeWidgetMessaging(this.widgetMessaging);
}
}
/**
@ -389,6 +404,7 @@ export default class AppTile extends React.Component {
}).catch((err) => {
console.log(`Failed to get capabilities for widget type ${this.props.type}`, this.props.id, err);
});
this.setState({loading: false});
}
@ -529,12 +545,16 @@ export default class AppTile extends React.Component {
if (this.props.show) {
const loadingElement = (
<div>
<div className="mx_AppLoading_spinner_fadeIn">
<MessageSpinner msg='Loading...' />
</div>
);
if (this.state.initialising) {
appTileBody = loadingElement;
appTileBody = (
<div className={'mx_AppTileBody ' + (this.state.loading ? 'mx_AppLoading' : '')}>
{ loadingElement }
</div>
);
} else if (this.state.hasPermissionToLoad == true) {
if (this.isMixedContent()) {
appTileBody = (
@ -698,6 +718,8 @@ AppTile.propTypes = {
// Optional function to be called on widget capability request
// Called with an array of the requested capabilities
onCapabilityRequest: PropTypes.func,
// Is this an instance of a user widget
userWidget: PropTypes.bool,
};
AppTile.defaultProps = {
@ -710,4 +732,5 @@ AppTile.defaultProps = {
showPopout: true,
handleMinimisePointerEvents: false,
whitelistCapabilities: [],
userWidget: false,
};

View file

@ -0,0 +1,114 @@
/*
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.
*/
const React = require('react');
const ReactDOM = require('react-dom');
// 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 getOrCreateContainer() {
let container = document.getElementById(ContainerId);
if (!container) {
container = document.createElement("div");
container.id = ContainerId;
document.body.appendChild(container);
}
return container;
}
// Greater than that of the ContextualMenu
const PE_Z_INDEX = 3000;
/*
* Class of component that renders its children in a separate ReactDOM virtual tree
* in a container element appended to document.body.
*
* This prevents the children from being unmounted when the parent of PersistedElement
* unmounts, allowing them to persist.
*
* When PE is unmounted, it hides the children using CSS. When mounted or updated, the
* children are made visible and are positioned into a div that is given the same
* bounding rect as the parent of PE.
*/
export default class PersistedElement extends React.Component {
constructor() {
super();
this.collectChildContainer = this.collectChildContainer.bind(this);
this.collectChild = this.collectChild.bind(this);
}
collectChildContainer(ref) {
this.childContainer = ref;
}
collectChild(ref) {
this.child = ref;
this.updateChild();
}
componentDidMount() {
this.updateChild();
}
componentDidUpdate() {
this.updateChild();
}
componentWillUnmount() {
this.updateChildVisibility(this.child, false);
}
updateChild() {
this.updateChildPosition(this.child, this.childContainer);
this.updateChildVisibility(this.child, true);
}
updateChildVisibility(child, visible) {
if (!child) return;
child.style.display = visible ? 'block' : 'none';
}
updateChildPosition(child, parent) {
if (!child || !parent) return;
const parentRect = parent.getBoundingClientRect();
Object.assign(child.style, {
position: 'absolute',
top: parentRect.top + 'px',
left: parentRect.left + 'px',
width: parentRect.width + 'px',
height: parentRect.height + 'px',
zIndex: PE_Z_INDEX,
});
}
render() {
const content = <div ref={this.collectChild}>
{this.props.children}
</div>;
ReactDOM.render(content, getOrCreateContainer());
return <div ref={this.collectChildContainer}></div>;
}
}

View file

@ -81,7 +81,7 @@ export default class ReplyThread extends React.Component {
// Part of Replies fallback support
static stripHTMLReply(html) {
return html.replace(/^<blockquote data-mx-reply>[\s\S]+?<!--end-mx-reply--><\/blockquote>/, '');
return html.replace(/^<mx-reply>[\s\S]+?<\/mx-reply>/, '');
}
// Part of Replies fallback support
@ -102,8 +102,8 @@ export default class ReplyThread extends React.Component {
switch (ev.getContent().msgtype) {
case 'm.text':
case 'm.notice': {
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>${html || body}<!--end-mx-reply--></blockquote>`;
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>${html || body}</blockquote></mx-reply>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `<${mxid}> ${lines[0]}`;
@ -112,28 +112,28 @@ export default class ReplyThread extends React.Component {
break;
}
case 'm.image':
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an image.<!--end-mx-reply--></blockquote>`;
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an image.</blockquote></mx-reply>`;
body = `> <${mxid}> sent an image.\n\n`;
break;
case 'm.video':
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a video.<!--end-mx-reply--></blockquote>`;
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a video.</blockquote></mx-reply>`;
body = `> <${mxid}> sent a video.\n\n`;
break;
case 'm.audio':
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an audio file.<!--end-mx-reply--></blockquote>`;
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent an audio file.</blockquote></mx-reply>`;
body = `> <${mxid}> sent an audio file.\n\n`;
break;
case 'm.file':
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a file.<!--end-mx-reply--></blockquote>`;
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> <a href="${userLink}">${mxid}</a>`
+ `<br>sent a file.</blockquote></mx-reply>`;
body = `> <${mxid}> sent a file.\n\n`;
break;
case 'm.emote': {
html = `<blockquote data-mx-reply><a href="${evLink}">In reply to</a> * `
+ `<a href="${userLink}">${mxid}</a><br>${html || body}<!--end-mx-reply--></blockquote>`;
html = `<mx-reply><blockquote><a href="${evLink}">In reply to</a> * `
+ `<a href="${userLink}">${mxid}</a><br>${html || body}</blockquote></mx-reply>`;
const lines = body.trim().split('\n');
if (lines.length > 0) {
lines[0] = `* <${mxid}> ${lines[0]}`;

View file

@ -202,17 +202,12 @@ module.exports = withMatrixClient(React.createClass({
return true;
}
if (!this._propsEqual(this.props, nextProps)) {
return true;
}
return false;
return !this._propsEqual(this.props, nextProps);
},
componentWillUnmount: function() {
const client = this.props.matrixClient;
client.removeListener("deviceVerificationChanged",
this.onDeviceVerificationChanged);
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
},
@ -227,7 +222,7 @@ module.exports = withMatrixClient(React.createClass({
},
onDeviceVerificationChanged: function(userId, device) {
if (userId == this.props.mxEvent.getSender()) {
if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
}
},
@ -262,7 +257,7 @@ module.exports = withMatrixClient(React.createClass({
}
// need to deep-compare readReceipts
if (key == 'readReceipts') {
if (key === 'readReceipts') {
const rA = objA[key];
const rB = objB[key];
if (rA === rB) {
@ -336,7 +331,7 @@ module.exports = withMatrixClient(React.createClass({
getReadAvatars: function() {
// return early if there are no read receipts
if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
return (<span className="mx_EventTile_readAvatars"></span>);
return (<span className="mx_EventTile_readAvatars" />);
}
const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
@ -360,7 +355,7 @@ module.exports = withMatrixClient(React.createClass({
left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset;
const userId = receipt.roomMember.userId;
var readReceiptInfo;
let readReceiptInfo;
if (this.props.readReceiptMap) {
readReceiptInfo = this.props.readReceiptMap[userId];
@ -502,17 +497,17 @@ module.exports = withMatrixClient(React.createClass({
mx_EventTile: true,
mx_EventTile_info: isInfoMessage,
mx_EventTile_12hr: this.props.isTwelveHour,
mx_EventTile_encrypting: this.props.eventSendStatus == 'encrypting',
mx_EventTile_encrypting: this.props.eventSendStatus === 'encrypting',
mx_EventTile_sending: isSending,
mx_EventTile_notSent: this.props.eventSendStatus == 'not_sent',
mx_EventTile_highlight: this.props.tileShape == 'notif' ? false : this.shouldHighlight(),
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
mx_EventTile_last: this.props.last,
mx_EventTile_contextual: this.props.contextual,
menu: this.state.menu,
mx_EventTile_verified: this.state.verified == true,
mx_EventTile_unverified: this.state.verified == false,
mx_EventTile_verified: this.state.verified === true,
mx_EventTile_unverified: this.state.verified === false,
mx_EventTile_bad: isEncryptionFailure,
mx_EventTile_emote: msgtype === 'm.emote',
mx_EventTile_redacted: isRedacted,
@ -522,7 +517,8 @@ module.exports = withMatrixClient(React.createClass({
const readAvatars = this.getReadAvatars();
let avatar, sender;
let avatar;
let sender;
let avatarSize;
let needsSenderProfile;
@ -556,11 +552,14 @@ module.exports = withMatrixClient(React.createClass({
if (needsSenderProfile) {
let text = null;
if (!this.props.tileShape) {
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') {
if (msgtype === 'm.image') text = _td('%(senderName)s sent an image');
else if (msgtype === 'm.video') text = _td('%(senderName)s sent a video');
else if (msgtype === 'm.file') text = _td('%(senderName)s uploaded a file');
sender = <SenderProfile onClick={this.onSenderProfileClick} mxEvent={this.props.mxEvent} enableFlair={!text} text={text} />;
sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent}
enableFlair={!text}
text={text} />;
} else {
sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={true} />;
}

View file

@ -338,10 +338,7 @@ export default class MessageComposer extends React.Component {
}
}
let stickerpickerButton;
if (SettingsStore.isFeatureEnabled('feature_sticker_messages')) {
stickerpickerButton = <Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />;
}
const stickerpickerButton = <Stickerpicker key='stickerpicker_controls_button' room={this.props.room} />;
controls.push(
<MessageComposerInput

View file

@ -17,7 +17,6 @@ import React from 'react';
import { _t } from '../../../languageHandler';
import Widgets from '../../../utils/widgets';
import AppTile from '../elements/AppTile';
import ContextualMenu from '../../structures/ContextualMenu';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
import sdk from '../../../index';
@ -36,21 +35,28 @@ export default class Stickerpicker extends React.Component {
this._launchManageIntegrations = this._launchManageIntegrations.bind(this);
this._removeStickerpickerWidgets = this._removeStickerpickerWidgets.bind(this);
this._onWidgetAction = this._onWidgetAction.bind(this);
this._onResize = this._onResize.bind(this);
this._onFinished = this._onFinished.bind(this);
this._collectWidgetMessaging = this._collectWidgetMessaging.bind(this);
this.popoverWidth = 300;
this.popoverHeight = 300;
this.state = {
showStickers: false,
imError: null,
stickerpickerX: null,
stickerpickerY: null,
stickerpickerWidget: null,
widgetId: null,
};
}
_removeStickerpickerWidgets() {
console.warn('Removing Stickerpicker widgets');
if (this.widgetId) {
this.scalarClient.disableWidgetAssets(widgetType, this.widgetId).then(() => {
if (this.state.widgetId) {
this.scalarClient.disableWidgetAssets(widgetType, this.state.widgetId).then(() => {
console.warn('Assets disabled');
}).catch((err) => {
console.error('Failed to disable assets');
@ -59,8 +65,7 @@ export default class Stickerpicker extends React.Component {
console.warn('No widget ID specified, not disabling assets');
}
// Wrap this in a timeout in order to avoid the DOM node from being pulled from under its feet
setTimeout(() => this.stickersMenu.close());
this.setState({showStickers: false});
Widgets.removeStickerpickerWidgets().then(() => {
this.forceUpdate();
}).catch((e) => {
@ -69,6 +74,9 @@ export default class Stickerpicker extends React.Component {
}
componentDidMount() {
// Close the sticker picker when the window resizes
window.addEventListener('resize', this._onResize);
this.scalarClient = null;
if (SdkConfig.get().integrations_ui_url && SdkConfig.get().integrations_rest_url) {
this.scalarClient = new ScalarAuthClient();
@ -82,14 +90,24 @@ export default class Stickerpicker extends React.Component {
if (!this.state.imError) {
this.dispatcherRef = dis.register(this._onWidgetAction);
}
const stickerpickerWidget = Widgets.getStickerpickerWidgets()[0];
this.setState({
stickerpickerWidget,
widgetId: stickerpickerWidget ? stickerpickerWidget.id : null,
});
}
componentWillUnmount() {
window.removeEventListener('resize', this._onResize);
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
}
componentDidUpdate(prevProps, prevState) {
this._sendVisibilityToWidget(this.state.showStickers);
}
_imError(errorMsg, e) {
console.error(errorMsg, e);
this.setState({
@ -102,9 +120,7 @@ export default class Stickerpicker extends React.Component {
if (payload.action === "user_widget_updated") {
this.forceUpdate();
} else if (payload.action === "stickerpicker_close") {
// Wrap this in a timeout in order to avoid the DOM node from being
// pulled from under its feet
setTimeout(() => this.stickersMenu.close());
this.setState({showStickers: false});
}
}
@ -127,6 +143,21 @@ 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);
this._prevSentVisibility = visible;
}
}
_getStickerpickerContent() {
// Handle Integration Manager errors
if (this.state._imError) {
@ -137,14 +168,18 @@ export default class Stickerpicker extends React.Component {
// TODO - Add support for Stickerpickers from multiple app stores.
// Render content from multiple stickerpack sources, each within their
// own iframe, within the stickerpicker UI element.
const stickerpickerWidget = Widgets.getStickerpickerWidgets()[0];
const stickerpickerWidget = this.state.stickerpickerWidget;
let stickersContent;
// Use a separate ReactDOM tree to render the AppTile separately so that it persists and does
// not unmount when we (a) close the sticker picker (b) switch rooms. It's properties are still
// updated.
const PersistedElement = sdk.getComponent("elements.PersistedElement");
// Load stickerpack content
if (stickerpickerWidget && stickerpickerWidget.content && stickerpickerWidget.content.url) {
// Set default name
stickerpickerWidget.content.name = stickerpickerWidget.name || _t("Stickerpack");
this.widgetId = stickerpickerWidget.id;
stickersContent = (
<div className='mx_Stickers_content_container'>
@ -157,15 +192,17 @@ export default class Stickerpicker extends React.Component {
width: this.popoverWidth,
}}
>
<PersistedElement>
<AppTile
collectWidgetMessaging={this._collectWidgetMessaging}
id={stickerpickerWidget.id}
url={stickerpickerWidget.content.url}
name={stickerpickerWidget.content.name}
room={this.props.room}
type={stickerpickerWidget.content.type}
fullWidth={true}
userId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
creatorUserId={MatrixClientPeg.get().credentials.userId}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
show={true}
showMenubar={true}
@ -177,8 +214,10 @@ export default class Stickerpicker extends React.Component {
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
whitelistCapabilities={['m.sticker']}
whitelistCapabilities={['m.sticker', 'visibility']}
userWidget={true}
/>
</PersistedElement>
</div>
</div>
);
@ -186,12 +225,7 @@ export default class Stickerpicker extends React.Component {
// Default content to show if stickerpicker widget not added
console.warn("No available sticker picker widgets");
stickersContent = this._defaultStickerpickerContent();
this.widgetId = null;
this.forceUpdate();
}
this.setState({
showStickers: false,
});
return stickersContent;
}
@ -201,29 +235,17 @@ export default class Stickerpicker extends React.Component {
* @param {Event} e Event that triggered the function
*/
_onShowStickersClick(e) {
const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
const buttonRect = e.target.getBoundingClientRect();
// The window X and Y offsets are to adjust position when zoomed in to page
const x = buttonRect.right + window.pageXOffset - 42;
const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19;
// const self = this;
this.stickersMenu = ContextualMenu.createMenu(GenericElementContextMenu, {
chevronOffset: 10,
chevronFace: 'bottom',
left: x,
top: y,
menuWidth: this.popoverWidth,
menuHeight: this.popoverHeight,
element: this._getStickerpickerContent(),
onFinished: this._onFinished,
menuPaddingTop: 0,
menuPaddingLeft: 0,
menuPaddingRight: 0,
this.setState({
showStickers: true,
stickerPickerX: x,
stickerPickerY: y,
});
this.setState({showStickers: true});
}
/**
@ -231,7 +253,14 @@ export default class Stickerpicker extends React.Component {
* @param {Event} ev Event that triggered the function call
*/
_onHideStickersClick(ev) {
setTimeout(() => this.stickersMenu.close());
this.setState({showStickers: false});
}
/**
* Called when the window is resized
*/
_onResize() {
this.setState({showStickers: false});
}
/**
@ -250,20 +279,37 @@ export default class Stickerpicker extends React.Component {
this.scalarClient.getScalarInterfaceUrlForRoom(
this.props.room,
'type_' + widgetType,
this.widgetId,
this.state.widgetId,
) :
null;
Modal.createTrackedDialog('Integrations Manager', '', IntegrationsManager, {
src: src,
}, "mx_IntegrationsManager");
// Wrap this in a timeout in order to avoid the DOM node from being pulled from under its feet
setTimeout(() => this.stickersMenu.close());
this.setState({showStickers: false});
}
render() {
const TintableSvg = sdk.getComponent("elements.TintableSvg");
const ContextualMenu = sdk.getComponent('structures.ContextualMenu');
const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu');
let stickersButton;
const stickerPicker = <ContextualMenu
elementClass={GenericElementContextMenu}
chevronOffset={10}
chevronFace={'bottom'}
left={this.state.stickerPickerX}
top={this.state.stickerPickerY}
menuWidth={this.popoverWidth}
menuHeight={this.popoverHeight}
element={this._getStickerpickerContent()}
onFinished={this._onFinished}
menuPaddingTop={0}
menuPaddingLeft={0}
menuPaddingRight={0}
/>;
if (this.state.showStickers) {
// Show hide-stickers button
stickersButton =
@ -288,6 +334,9 @@ export default class Stickerpicker extends React.Component {
<TintableSvg src="img/icons-show-stickers.svg" width="35" height="35" />
</div>;
}
return stickersButton;
return <div>
{stickersButton}
{this.state.showStickers && stickerPicker}
</div>;
}
}