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.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

@ -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>
);
},
});