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

pull/21833/head
David Baker 2016-04-22 16:25:09 +01:00
commit c3365f993b
14 changed files with 405 additions and 86 deletions

View File

@ -1,3 +1,29 @@
Changes in [0.5.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.5.2) (2016-04-22)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.5.1...v0.5.2)
Performance improvements:
* Reduce number of events shown in a room to 250
[afb301f](https://github.com/matrix-org/matrix-react-sdk/commit/afb301ffb78c019a50e40caa5d9042ad39c117fe)
* add heuristics to hide URL previews...
[\#284](https://github.com/matrix-org/matrix-react-sdk/pull/284)
* Fix bug which stopped us scrolling down after we scrolled up
[\#283](https://github.com/matrix-org/matrix-react-sdk/pull/283)
* Don't relayout scrollpanels every time something changes
[\#280](https://github.com/matrix-org/matrix-react-sdk/pull/280)
* Reduce number of renders on received events
[\#279](https://github.com/matrix-org/matrix-react-sdk/pull/279)
* Avoid rerendering EventTiles when not necessary
[\#278](https://github.com/matrix-org/matrix-react-sdk/pull/278)
* Speed up processing of TimelinePanel updates on new events
[\#277](https://github.com/matrix-org/matrix-react-sdk/pull/277)
Other bug fixes:
* Fix read-receipt animation
[\#282](https://github.com/matrix-org/matrix-react-sdk/pull/282),
[\#281](https://github.com/matrix-org/matrix-react-sdk/pull/281)
Changes in [0.5.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v0.5.1) (2016-04-19)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v0.4.0...v0.5.1)

View File

@ -51,8 +51,17 @@ module.exports = function (config) {
files: [
testFile,
gsCss,
// some images to reduce noise from the tests
{pattern: 'test/img/*', watched: false, included: false,
served: true, nocache: false},
],
// redirect img links to the karma server
proxies: {
"/img/": "/base/test/img/",
},
// list of files to exclude
//
// This doesn't work. It turns out that it's webpack which does the

View File

@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "0.5.1",
"version": "0.5.2",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@ -49,6 +49,7 @@
"babel": "^5.8.23",
"babel-core": "^5.8.38",
"babel-loader": "^5.4.0",
"babel-polyfill": "^6.5.0",
"expect": "^1.16.0",
"json-loader": "^0.5.3",
"karma": "^0.13.22",

View File

@ -13,25 +13,30 @@ module.exports = React.createClass({
displayName: 'Velociraptor',
propTypes: {
children: React.PropTypes.array,
// either a list of child nodes, or a single child.
children: React.PropTypes.any,
// optional transition information for changing existing children
transition: React.PropTypes.object,
container: React.PropTypes.string
},
componentWillMount: function() {
this.children = {};
this.nodes = {};
var self = this;
React.Children.map(this.props.children, function(c) {
self.children[c.key] = c;
});
this._updateChildren(this.props.children);
},
componentWillReceiveProps: function(nextProps) {
this._updateChildren(nextProps.children);
},
/**
* update `this.children` according to the new list of children given
*/
_updateChildren: function(newChildren) {
var self = this;
var oldChildren = this.children;
var oldChildren = this.children || {};
this.children = {};
React.Children.map(nextProps.children, function(c) {
React.Children.toArray(newChildren).forEach(function(c) {
if (oldChildren[c.key]) {
var old = oldChildren[c.key];
var oldNode = ReactDom.findDOMNode(self.nodes[old.key]);
@ -92,22 +97,23 @@ module.exports = React.createClass({
//console.log("start: "+JSON.stringify(startStyles[i]));
}
// and then we animate to the resting state
Velocity(domNode, node.props._restingStyle, transitionOpts[i-1]);
Velocity(domNode, node.props._restingStyle,
transitionOpts[i-1])
.then(() => {
// once we've reached the resting state, hide the element if
// appropriate
domNode.style.visibility = node.props._restingStyle.visibility;
});
//console.log("enter: "+JSON.stringify(node.props._restingStyle));
}
this.nodes[k] = node;
},
render: function() {
var self = this;
var childList = Object.keys(this.children).map(function(k) {
return React.cloneElement(self.children[k], {
ref: self.collectNode.bind(self, self.children[k].key)
});
});
return (
<span>
{childList}
{Object.values(this.children)}
</span>
);
},

View File

@ -64,11 +64,11 @@ module.exports.components['views.login.LoginHeader'] = require('./components/vie
module.exports.components['views.login.PasswordLogin'] = require('./components/views/login/PasswordLogin');
module.exports.components['views.login.RegistrationForm'] = require('./components/views/login/RegistrationForm');
module.exports.components['views.login.ServerConfig'] = require('./components/views/login/ServerConfig');
module.exports.components['views.messages.MAudioBody'] = require('./components/views/messages/MAudioBody');
module.exports.components['views.messages.MFileBody'] = require('./components/views/messages/MFileBody');
module.exports.components['views.messages.MImageBody'] = require('./components/views/messages/MImageBody');
module.exports.components['views.messages.MVideoBody'] = require('./components/views/messages/MVideoBody');
module.exports.components['views.messages.MessageEvent'] = require('./components/views/messages/MessageEvent');
module.exports.components['views.messages.MAudioBody'] = require('./components/views/messages/MAudioBody');
module.exports.components['views.messages.TextualBody'] = require('./components/views/messages/TextualBody');
module.exports.components['views.messages.TextualEvent'] = require('./components/views/messages/TextualEvent');
module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody');
@ -85,6 +85,7 @@ module.exports.components['views.rooms.MemberTile'] = require('./components/view
module.exports.components['views.rooms.MessageComposer'] = require('./components/views/rooms/MessageComposer');
module.exports.components['views.rooms.MessageComposerInput'] = require('./components/views/rooms/MessageComposerInput');
module.exports.components['views.rooms.PresenceLabel'] = require('./components/views/rooms/PresenceLabel');
module.exports.components['views.rooms.ReadReceiptMarker'] = require('./components/views/rooms/ReadReceiptMarker');
module.exports.components['views.rooms.RoomHeader'] = require('./components/views/rooms/RoomHeader');
module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList');
module.exports.components['views.rooms.RoomNameEditor'] = require('./components/views/rooms/RoomNameEditor');

View File

@ -81,6 +81,10 @@ module.exports = React.createClass({
// the event after which we are showing a disappearing read marker
// animation
this.currentGhostEventId = null;
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations
this._readReceiptMap = {};
},
/* get the DOM node representing the given event */
@ -346,6 +350,7 @@ module.exports = React.createClass({
<EventTile mxEvent={mxEv} continuation={continuation}
onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
eventSendStatus={mxEv.status}
last={last} isSelectedEvent={highlight}/>
</li>

View File

@ -31,7 +31,7 @@ var KeyCode = require('../../KeyCode');
var PAGINATE_SIZE = 20;
var INITIAL_SIZE = 20;
var TIMELINE_CAP = 500; // the most events to show in a timeline
var TIMELINE_CAP = 250; // the most events to show in a timeline
var DEBUG = false;
@ -241,11 +241,25 @@ var TimelinePanel = React.createClass({
if (this.unmounted) { return; }
debuglog("TimelinePanel: paginate complete backwards:"+backwards+"; success:"+r);
this.setState({
var newState = {
[paginatingKey]: false,
[canPaginateKey]: r,
events: this._getEvents(),
});
};
// moving the window in this direction may mean that we can now
// paginate in the other where we previously could not.
var otherDirection = backwards ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS;
var canPaginateOtherWayKey = backwards ? 'canForwardPaginate' : 'canBackPaginate';
if (!this.state[canPaginateOtherWayKey] &&
this._timelineWindow.canPaginate(otherDirection)) {
debuglog('TimelinePanel: can now', otherDirection, 'paginate again');
newState[canPaginateOtherWayKey] = true;
}
this.setState(newState);
return r;
});
},

View File

@ -84,11 +84,12 @@ module.exports = React.createClass({
findLink: function(nodes) {
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];
if (node.tagName === "A" && node.getAttribute("href") &&
(node.getAttribute("href").startsWith("http://") ||
node.getAttribute("href").startsWith("https://")))
if (node.tagName === "A" && node.getAttribute("href"))
{
return node;
return this.isLinkPreviewable(node) ? node : undefined;
}
else if (node.tagName === "PRE" || node.tagName === "CODE") {
return;
}
else if (node.children && node.children.length) {
return this.findLink(node.children)
@ -96,6 +97,37 @@ module.exports = React.createClass({
}
},
isLinkPreviewable: function(node) {
// don't try to preview relative links
if (!node.getAttribute("href").startsWith("http://") &&
!node.getAttribute("href").startsWith("https://"))
{
return false;
}
// as a random heuristic to avoid highlighting things like "foo.pl"
// we require the linked text to either include a / (either from http://
// or from a full foo.bar/baz style schemeless URL) - or be a markdown-style
// link, in which case we check the target text differs from the link value.
// TODO: make this configurable?
if (node.textContent.indexOf("/") > -1)
{
return node;
}
else {
var url = node.getAttribute("href");
var host = url.match(/^https?:\/\/(.*?)(\/|$)/)[1];
if (node.textContent.trim().startsWith(host)) {
// it's a "foo.pl" style link
return;
}
else {
// it's a [foo bar](http://foo.com) style link
return node;
}
}
},
onCancelClick: function(event) {
this.setState({ widgetHidden: true });
// FIXME: persist this somewhere smarter than local storage

View File

@ -17,7 +17,6 @@ limitations under the License.
'use strict';
var React = require('react');
var ReactDom = require('react-dom');
var classNames = require("classnames");
var sdk = require('../../../index');
@ -25,8 +24,6 @@ var MatrixClientPeg = require('../../../MatrixClientPeg')
var TextForEvent = require('../../../TextForEvent');
var ContextualMenu = require('../../../ContextualMenu');
var Velociraptor = require('../../../Velociraptor');
require('../../../VelocityBounce');
var dispatcher = require("../../../dispatcher");
var ObjectUtils = require('../../../ObjectUtils');
@ -113,6 +110,12 @@ module.exports = React.createClass({
/* a list of Room Members whose read-receipts we should show */
readReceipts: React.PropTypes.arrayOf(React.PropTypes.object),
/* opaque readreceipt info for each userId; used by ReadReceiptMarker
* to manage its animations. Should be an empty object when the room
* first loads
*/
readReceiptMap: React.PropTypes.object,
/* the status of this event - ie, mxEvent.status. Denormalised to here so
* that we can tell when it changes. */
eventSendStatus: React.PropTypes.string,
@ -122,6 +125,15 @@ module.exports = React.createClass({
return {menu: false, allReadAvatars: false};
},
componentWillMount: function() {
// don't do RR animations until we are mounted
this._suppressReadReceiptAnimation = true;
},
componentDidMount: function() {
this._suppressReadReceiptAnimation = false;
},
shouldComponentUpdate: function (nextProps, nextState) {
if (!ObjectUtils.shallowEqual(this.state, nextState)) {
return true;
@ -217,80 +229,53 @@ module.exports = React.createClass({
},
getReadAvatars: function() {
var ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
var avatars = [];
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var left = 0;
var reorderTransitionOpts = {
duration: 100,
easing: 'easeOut'
};
var receipts = this.props.readReceipts || [];
for (var i = 0; i < receipts.length; ++i) {
var member = receipts[i];
// Using react refs here would mean both getting Velociraptor to expose
// them and making them scoped to the whole RoomView. Not impossible, but
// getElementById seems simpler at least for a first cut.
var oldAvatarDomNode = document.getElementById('mx_readAvatar'+member.userId);
var startStyles = [];
var enterTransitionOpts = [];
var oldNodeTop = -15; // For avatars that weren't on screen, act as if they were just off the top
if (oldAvatarDomNode) {
oldNodeTop = oldAvatarDomNode.getBoundingClientRect().top;
var hidden = true;
if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) {
hidden = false;
}
if (this.readAvatarNode) {
var topOffset = oldNodeTop - this.readAvatarNode.getBoundingClientRect().top;
var userId = member.userId;
var readReceiptInfo;
if (oldAvatarDomNode && oldAvatarDomNode.style.left !== '0px') {
var leftOffset = oldAvatarDomNode.style.left;
// start at the old height and in the old h pos
startStyles.push({ top: topOffset, left: leftOffset });
enterTransitionOpts.push(reorderTransitionOpts);
if (this.props.readReceiptMap) {
readReceiptInfo = this.props.readReceiptMap[userId];
if (!readReceiptInfo) {
readReceiptInfo = {};
this.props.readReceiptMap[userId] = readReceiptInfo;
}
// then shift to the rightmost column,
// and then it will drop down to its resting position
startStyles.push({ top: topOffset, left: '0px' });
enterTransitionOpts.push({
duration: bounce ? Math.min(Math.log(Math.abs(topOffset)) * 200, 3000) : 300,
easing: bounce ? 'easeOutBounce' : 'easeOutCubic',
});
}
var style = {
left: left+'px',
top: '0px',
visibility: ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) ? 'visible' : 'hidden'
};
//console.log("i = " + i + ", MAX_READ_AVATARS = " + MAX_READ_AVATARS + ", allReadAvatars = " + this.state.allReadAvatars + " visibility = " + style.visibility);
// add to the start so the most recent is on the end (ie. ends up rightmost)
avatars.unshift(
<MemberAvatar key={member.userId} member={member}
width={14} height={14} resizeMethod="crop"
style={style}
startStyle={startStyles}
enterTransitionOpts={enterTransitionOpts}
id={'mx_readAvatar'+member.userId}
<ReadReceiptMarker key={userId} member={member}
leftOffset={left} hidden={hidden}
readReceiptInfo={readReceiptInfo}
suppressAnimation={this._suppressReadReceiptAnimation}
onClick={this.toggleAllReadAvatars}
/>
);
// TODO: we keep the extra read avatars in the dom to make animation simpler
// we could optimise this to reduce the dom size.
if (i < MAX_READ_AVATARS - 1 || this.state.allReadAvatars) { // XXX: where does this -1 come from? is it to make the max'th avatar animate properly?
if (!hidden) {
left -= 15;
}
}
var editButton;
var remText;
if (!this.state.allReadAvatars) {
var remainder = receipts.length - MAX_READ_AVATARS;
var remText;
if (i >= MAX_READ_AVATARS - 1) left -= 15;
if (remainder > 0) {
remText = <span className="mx_EventTile_readAvatarRemainder"
onClick={this.toggleAllReadAvatars}
@ -305,19 +290,13 @@ module.exports = React.createClass({
);
}
return <span className="mx_EventTile_readAvatars" ref={this.collectReadAvatarNode}>
return <span className="mx_EventTile_readAvatars">
{ editButton }
{ remText }
<Velociraptor transition={ reorderTransitionOpts }>
{ avatars }
</Velociraptor>
{ avatars }
</span>;
},
collectReadAvatarNode: function(node) {
this.readAvatarNode = ReactDom.findDOMNode(node);
},
onMemberAvatarClick: function(event) {
dispatcher.dispatch({
action: 'view_user',

View File

@ -0,0 +1,166 @@
/*
Copyright 2016 OpenMarket 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.
*/
'use strict';
var React = require('react');
var ReactDOM = require('react-dom');
var sdk = require('../../../index');
var Velociraptor = require('../../../Velociraptor');
require('../../../VelocityBounce');
var bounce = false;
try {
if (global.localStorage) {
bounce = global.localStorage.getItem('avatar_bounce') == 'true';
}
} catch (e) {
}
module.exports = React.createClass({
displayName: 'ReadReceiptMarker',
propTypes: {
// the RoomMember to show the RR for
member: React.PropTypes.object.isRequired,
// number of pixels to offset the avatar from the right of its parent;
// typically a negative value.
leftOffset: React.PropTypes.number,
// true to hide the avatar (it will still be animated)
hidden: React.PropTypes.bool,
// don't animate this RR into position
suppressAnimation: React.PropTypes.bool,
// an opaque object for storing information about this user's RR in
// this room
readReceiptInfo: React.PropTypes.object,
// callback for clicks on this RR
onClick: React.PropTypes.func,
},
getDefaultProps: function() {
return {
leftOffset: 0,
}
},
getInitialState: function() {
// if we are going to animate the RR, we don't show it on first render,
// and instead just add a placeholder to the DOM; once we've been
// mounted, we start an animation which moves the RR from its old
// position.
return {
suppressDisplay: !this.props.suppressAnimation,
}
},
componentWillUnmount: function() {
// before we remove the rr, store its location in the map, so that if
// it reappears, it can be animated from the right place.
var rrInfo = this.props.readReceiptInfo;
if (!rrInfo) {
return;
}
var avatarNode = ReactDOM.findDOMNode(this);
rrInfo.top = avatarNode.offsetTop;
rrInfo.left = avatarNode.offsetLeft;
rrInfo.parent = avatarNode.offsetParent;
},
componentDidMount: function() {
if (!this.state.suppressDisplay) {
// we've already done our display - nothing more to do.
return;
}
// treat new RRs as though they were off the top of the screen
var oldTop = -15;
var oldInfo = this.props.readReceiptInfo;
if (oldInfo && oldInfo.parent) {
oldTop = oldInfo.top + oldInfo.parent.getBoundingClientRect().top;
}
var newElement = ReactDOM.findDOMNode(this);
var startTopOffset = oldTop - newElement.offsetParent.getBoundingClientRect().top;
var startStyles = [];
var enterTransitionOpts = [];
if (oldInfo && oldInfo.left) {
// start at the old height and in the old h pos
var leftOffset = oldInfo.left;
startStyles.push({ top: startTopOffset+"px",
left: oldInfo.left+"px" });
var reorderTransitionOpts = {
duration: 100,
easing: 'easeOut'
};
enterTransitionOpts.push(reorderTransitionOpts);
}
// then shift to the rightmost column,
// and then it will drop down to its resting position
startStyles.push({ top: startTopOffset+'px', left: '0px' });
enterTransitionOpts.push({
duration: bounce ? Math.min(Math.log(Math.abs(startTopOffset)) * 200, 3000) : 300,
easing: bounce ? 'easeOutBounce' : 'easeOutCubic',
});
this.setState({
suppressDisplay: false,
startStyles: startStyles,
enterTransitionOpts: enterTransitionOpts,
});
},
render: function() {
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
if (this.state.suppressDisplay) {
return <div/>;
}
var style = {
left: this.props.leftOffset+'px',
top: '0px',
visibility: this.props.hidden ? 'hidden' : 'visible',
};
return (
<Velociraptor>
<MemberAvatar
member={this.props.member}
width={14} height={14} resizeMethod="crop"
style={style}
startStyle={this.state.startStyles}
enterTransitionOpts={this.state.enterTransitionOpts}
onClick={this.props.onClick}
/>
</Velociraptor>
);
},
});

View File

@ -76,7 +76,8 @@ describe('TimelinePanel', function() {
afterEach(function() {
if (parentDiv) {
document.body.removeChild(parentDiv);
ReactDOM.unmountComponentAtNode(parentDiv);
parentDiv.remove();
parentDiv = null;
}
sandbox.restore();
@ -204,4 +205,72 @@ describe('TimelinePanel', function() {
}, 0);
}, 0);
});
it("should let you scroll down again after you've scrolled up", function(done) {
var N_EVENTS = 600;
// sadly, loading all those events takes a while
this.timeout(N_EVENTS * 20);
// client.getRoom is called a /lot/ in this test, so replace
// sinon's spy with a fast noop.
client.getRoom = function(id) { return null; };
// fill the timeline with lots of events
for (var i = 0; i < N_EVENTS; i++) {
timeline.addEvent(mkMessage());
}
var scrollDefer;
var panel = ReactDOM.render(
<TimelinePanel room={room} onScroll={()=>{scrollDefer.resolve()}} />,
parentDiv
);
var messagePanel = ReactTestUtils.findRenderedComponentWithType(
panel, sdk.getComponent('structures.MessagePanel'));
var scrollingDiv = ReactTestUtils.findRenderedDOMComponentWithClass(
panel, "gm-scroll-view");
// helper function which will return a promise which resolves when
// the TimelinePanel fires a scroll event
var awaitScroll = function() {
scrollDefer = q.defer();
return scrollDefer.promise;
};
function backPaginate() {
scrollingDiv.scrollTop = 0;
return awaitScroll().then(() => {
if(scrollingDiv.scrollTop > 0) {
// need to go further
return backPaginate();
}
// hopefully, we got to the start of the timeline
expect(messagePanel.props.backPaginating).toBe(false);
});
}
// let the first round of pagination finish off
awaitScroll().then(() => {
// we should now have loaded the first few events
expect(messagePanel.props.backPaginating).toBe(false);
expect(messagePanel.props.suppressFirstDateSeparator).toBe(true);
// back-paginate until we hit the start
return backPaginate();
}).then(() => {
expect(messagePanel.props.suppressFirstDateSeparator).toBe(false);
var events = scryEventTiles(panel);
expect(events[0].props.mxEvent).toBe(timeline.getEvents()[0])
// we should now be able to scroll down, and paginate in the other
// direction.
scrollingDiv.scrollTop = scrollingDiv.scrollHeight;
return awaitScroll();
}).then(() => {
console.log("done");
}).done(done, done);
});
});

BIN
test/img/edit.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 B

View File

@ -5,6 +5,14 @@
* application to provide
*/
/* this is a convenient place to ensure we load the compatibility libraries we expect our
* app to provide
*/
// for ES6 stuff like startsWith() and Object.values() that babel doesn't do by
// default
require('babel-polyfill');
var sdk = require("../src/index");
var skin = require('../src/component-index.js');

View File

@ -37,13 +37,14 @@ module.exports.stubClient = function() {
getRoom: sinon.stub(),
loginFlows: sinon.stub(),
on: sinon.stub(),
removeListener: sinon.stub(),
paginateEventTimeline: sinon.stub().returns(q()),
sendReadReceipt: sinon.stub().returns(q()),
};
// create the peg
// stub out the methods in MatrixClientPeg
//
// 'sandbox.restore()' doesn't work correctly on inherited methods,
// so we do this for each method
var methods = ['get', 'unset', 'replaceUsingUrls',
@ -51,7 +52,9 @@ module.exports.stubClient = function() {
for (var i = 0; i < methods.length; i++) {
sandbox.stub(peg, methods[i]);
}
peg.get.returns(client);
// MatrixClientPeg.get() is called a /lot/, so implement it with our own
// fast stub function rather than a sinon stub
peg.get = function() { return client; };
return sandbox;
}