Rewrite Read-receipt animation

... hopefully fixing https://github.com/vector-im/vector-web/issues/1437 in the
process.

The idea here is that, when we remove a read-receipt from the DOM, we stash its
position in a map. Then, when the read-receipt appears again attached to
another event, we know where to start the transition.
This commit is contained in:
Richard van der Hoff 2016-04-20 23:03:05 +01:00
parent 267bf10e69
commit fa34dee091
4 changed files with 208 additions and 57 deletions

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.PasswordLogin'] = require('./components/views/login/PasswordLogin');
module.exports.components['views.login.RegistrationForm'] = require('./components/views/login/RegistrationForm'); 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.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.MFileBody'] = require('./components/views/messages/MFileBody');
module.exports.components['views.messages.MImageBody'] = require('./components/views/messages/MImageBody'); 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.MVideoBody'] = require('./components/views/messages/MVideoBody');
module.exports.components['views.messages.MessageEvent'] = require('./components/views/messages/MessageEvent'); 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.TextualBody'] = require('./components/views/messages/TextualBody');
module.exports.components['views.messages.TextualEvent'] = require('./components/views/messages/TextualEvent'); module.exports.components['views.messages.TextualEvent'] = require('./components/views/messages/TextualEvent');
module.exports.components['views.messages.UnknownBody'] = require('./components/views/messages/UnknownBody'); 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.MessageComposer'] = require('./components/views/rooms/MessageComposer');
module.exports.components['views.rooms.MessageComposerInput'] = require('./components/views/rooms/MessageComposerInput'); 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.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.RoomHeader'] = require('./components/views/rooms/RoomHeader');
module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList'); module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList');
module.exports.components['views.rooms.RoomNameEditor'] = require('./components/views/rooms/RoomNameEditor'); 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 // the event after which we are showing a disappearing read marker
// animation // animation
this.currentGhostEventId = null; 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 */ /* get the DOM node representing the given event */
@ -346,6 +350,7 @@ module.exports = React.createClass({
<EventTile mxEvent={mxEv} continuation={continuation} <EventTile mxEvent={mxEv} continuation={continuation}
onWidgetLoad={this._onWidgetLoad} onWidgetLoad={this._onWidgetLoad}
readReceipts={readReceipts} readReceipts={readReceipts}
readReceiptMap={this._readReceiptMap}
eventSendStatus={mxEv.status} eventSendStatus={mxEv.status}
last={last} isSelectedEvent={highlight}/> last={last} isSelectedEvent={highlight}/>
</li> </li>

View file

@ -17,7 +17,6 @@ limitations under the License.
'use strict'; 'use strict';
var React = require('react'); var React = require('react');
var ReactDom = require('react-dom');
var classNames = require("classnames"); var classNames = require("classnames");
var sdk = require('../../../index'); var sdk = require('../../../index');
@ -25,8 +24,6 @@ var MatrixClientPeg = require('../../../MatrixClientPeg')
var TextForEvent = require('../../../TextForEvent'); var TextForEvent = require('../../../TextForEvent');
var ContextualMenu = require('../../../ContextualMenu'); var ContextualMenu = require('../../../ContextualMenu');
var Velociraptor = require('../../../Velociraptor');
require('../../../VelocityBounce');
var dispatcher = require("../../../dispatcher"); var dispatcher = require("../../../dispatcher");
var ObjectUtils = require('../../../ObjectUtils'); var ObjectUtils = require('../../../ObjectUtils');
@ -113,6 +110,12 @@ module.exports = React.createClass({
/* a list of Room Members whose read-receipts we should show */ /* a list of Room Members whose read-receipts we should show */
readReceipts: React.PropTypes.arrayOf(React.PropTypes.object), 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 /* the status of this event - ie, mxEvent.status. Denormalised to here so
* that we can tell when it changes. */ * that we can tell when it changes. */
eventSendStatus: React.PropTypes.string, eventSendStatus: React.PropTypes.string,
@ -122,6 +125,15 @@ module.exports = React.createClass({
return {menu: false, allReadAvatars: false}; 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) { shouldComponentUpdate: function (nextProps, nextState) {
if (!ObjectUtils.shallowEqual(this.state, nextState)) { if (!ObjectUtils.shallowEqual(this.state, nextState)) {
return true; return true;
@ -217,80 +229,53 @@ module.exports = React.createClass({
}, },
getReadAvatars: function() { getReadAvatars: function() {
var ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
var avatars = []; var avatars = [];
var MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
var left = 0; var left = 0;
var reorderTransitionOpts = {
duration: 100,
easing: 'easeOut'
};
var receipts = this.props.readReceipts || []; var receipts = this.props.readReceipts || [];
for (var i = 0; i < receipts.length; ++i) { for (var i = 0; i < receipts.length; ++i) {
var member = receipts[i]; var member = receipts[i];
// Using react refs here would mean both getting Velociraptor to expose var hidden = true;
// them and making them scoped to the whole RoomView. Not impossible, but if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) {
// getElementById seems simpler at least for a first cut. hidden = false;
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;
} }
if (this.readAvatarNode) { var userId = member.userId;
var topOffset = oldNodeTop - this.readAvatarNode.getBoundingClientRect().top; var readReceiptInfo;
if (oldAvatarDomNode && oldAvatarDomNode.style.left !== '0px') { if (this.props.readReceiptMap) {
var leftOffset = oldAvatarDomNode.style.left; readReceiptInfo = this.props.readReceiptMap[userId];
// start at the old height and in the old h pos if (!readReceiptInfo) {
startStyles.push({ top: topOffset, left: leftOffset }); readReceiptInfo = {};
enterTransitionOpts.push(reorderTransitionOpts); 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); //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) // add to the start so the most recent is on the end (ie. ends up rightmost)
avatars.unshift( avatars.unshift(
<MemberAvatar key={member.userId} member={member} <ReadReceiptMarker key={userId} member={member}
width={14} height={14} resizeMethod="crop" leftOffset={left} hidden={hidden}
style={style} readReceiptInfo={readReceiptInfo}
startStyle={startStyles} suppressAnimation={this._suppressReadReceiptAnimation}
enterTransitionOpts={enterTransitionOpts}
id={'mx_readAvatar'+member.userId}
onClick={this.toggleAllReadAvatars} onClick={this.toggleAllReadAvatars}
/> />
); );
// TODO: we keep the extra read avatars in the dom to make animation simpler // TODO: we keep the extra read avatars in the dom to make animation simpler
// we could optimise this to reduce the dom size. // 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; left -= 15;
} }
} }
var editButton; var editButton;
var remText;
if (!this.state.allReadAvatars) { if (!this.state.allReadAvatars) {
var remainder = receipts.length - MAX_READ_AVATARS; var remainder = receipts.length - MAX_READ_AVATARS;
var remText;
if (i >= MAX_READ_AVATARS - 1) left -= 15;
if (remainder > 0) { if (remainder > 0) {
remText = <span className="mx_EventTile_readAvatarRemainder" remText = <span className="mx_EventTile_readAvatarRemainder"
onClick={this.toggleAllReadAvatars} 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 } { editButton }
{ remText } { remText }
<Velociraptor transition={ reorderTransitionOpts }> { avatars }
{ avatars }
</Velociraptor>
</span>; </span>;
}, },
collectReadAvatarNode: function(node) {
this.readAvatarNode = ReactDom.findDOMNode(node);
},
onMemberAvatarClick: function(event) { onMemberAvatarClick: function(event) {
dispatcher.dispatch({ dispatcher.dispatch({
action: 'view_user', 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>
);
},
});