Merge pull request #379 from vector-im/read_receipts

Read receipts
This commit is contained in:
David Baker 2015-11-18 14:53:29 +00:00
commit 1099892784
8 changed files with 321 additions and 21 deletions

View File

@ -28,7 +28,7 @@
"filesize": "^3.1.2", "filesize": "^3.1.2",
"flux": "~2.0.3", "flux": "~2.0.3",
"linkifyjs": "^2.0.0-beta.4", "linkifyjs": "^2.0.0-beta.4",
"matrix-js-sdk": "^0.3.0", "matrix-js-sdk": "https://github.com/matrix-org/matrix-js-sdk.git#develop",
"matrix-react-sdk": "^0.0.2", "matrix-react-sdk": "^0.0.2",
"modernizr": "^3.1.0", "modernizr": "^3.1.0",
"q": "^1.4.1", "q": "^1.4.1",
@ -37,6 +37,7 @@
"react-dnd-html5-backend": "^2.0.0", "react-dnd-html5-backend": "^2.0.0",
"react-dom": "^0.14.2", "react-dom": "^0.14.2",
"react-gemini-scrollbar": "^2.0.1", "react-gemini-scrollbar": "^2.0.1",
"velocity-animate": "^1.2.3",
"sanitize-html": "^1.0.0" "sanitize-html": "^1.0.0"
}, },
"devDependencies": { "devDependencies": {

104
src/Velociraptor.js Normal file
View File

@ -0,0 +1,104 @@
var React = require('react');
var ReactDom = require('react-dom');
var Velocity = require('velocity-animate');
/**
* The Velociraptor contains components and animates transitions with velocity.
* It will only pick up direct changes to properties ('left', currently), and so
* will not work for animating positional changes where the position is implicit
* from DOM order. This makes it a lot simpler and lighter: if you need fully
* automatic positional animation, look at react-shuffle or similar libraries.
*/
module.exports = React.createClass({
displayName: 'Velociraptor',
propTypes: {
children: React.PropTypes.array,
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.props.key] = c;
});
},
componentWillReceiveProps: function(nextProps) {
var self = this;
var oldChildren = this.children;
this.children = {};
React.Children.map(nextProps.children, function(c) {
if (oldChildren[c.key]) {
var old = oldChildren[c.key];
var oldNode = ReactDom.findDOMNode(self.nodes[old.key]);
if (oldNode.style.left != c.props.style.left) {
Velocity(oldNode, { left: c.props.style.left }, self.props.transition);
//console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
}
self.children[c.key] = old;
} else {
// new element. If it has a startStyle, use that as the style and go through
// the enter animations
var newProps = {
ref: self.collectNode.bind(self, c.key)
};
if (c.props.startStyle && Object.keys(c.props.startStyle).length) {
var startStyle = c.props.startStyle;
if (Array.isArray(startStyle)) {
startStyle = startStyle[0];
}
newProps._restingStyle = c.props.style;
newProps.style = startStyle;
//console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
// apply the enter animations once it's mounted
}
self.children[c.key] = React.cloneElement(c, newProps);
}
});
},
collectNode: function(k, node) {
if (
this.nodes[k] === undefined &&
node.props.startStyle &&
Object.keys(node.props.startStyle).length
) {
var domNode = ReactDom.findDOMNode(node);
var startStyles = node.props.startStyle;
var transitionOpts = node.props.enterTransitionOpts;
if (!Array.isArray(startStyles)) {
startStyles = [ startStyles ];
transitionOpts = [ transitionOpts ];
}
// start from startStyle 1: 0 is the one we gave it
// to start with, so now we animate 1 etc.
for (var i = 1; i < startStyles.length; ++i) {
Velocity(domNode, startStyles[i], transitionOpts[i-1]);
//console.log("start: "+JSON.stringify(startStyles[i]));
}
// and then we animate to the resting state
Velocity(domNode, node.props._restingStyle, transitionOpts[i-1]);
//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}
</span>
);
},
});

View File

@ -53,6 +53,7 @@ module.exports = {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName); MatrixClientPeg.get().on("Room.name", this.onRoomName);
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("sync", this.onSyncStateChange);
@ -71,6 +72,7 @@ module.exports = {
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName); MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping); MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember); MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange); MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange);
@ -108,6 +110,9 @@ module.exports = {
// the conf // the conf
this._updateConfCallNotification(); this._updateConfCallNotification();
break; break;
case 'user_activity':
this.sendReadReceipt();
break;
} }
}, },
@ -187,6 +192,12 @@ module.exports = {
} }
}, },
onRoomReceipt: function(receiptEvent, room) {
if (room.roomId == this.props.roomId) {
this.forceUpdate();
}
},
onRoomMemberTyping: function(ev, member) { onRoomMemberTyping: function(ev, member) {
this.forceUpdate(); this.forceUpdate();
}, },
@ -247,6 +258,8 @@ module.exports = {
messageWrapperScroll.scrollTop = messageWrapperScroll.scrollHeight; messageWrapperScroll.scrollTop = messageWrapperScroll.scrollHeight;
this.sendReadReceipt();
this.fillSpace(); this.fillSpace();
} }
@ -529,7 +542,7 @@ module.exports = {
} }
ret.unshift( ret.unshift(
<li key={mxEv.getId()}><EventTile mxEvent={mxEv} continuation={continuation} last={last}/></li> <li key={mxEv.getId()} ref={this._collectEventNode.bind(this, mxEv.getId())}><EventTile mxEvent={mxEv} continuation={continuation} last={last}/></li>
); );
if (dateSeparator) { if (dateSeparator) {
ret.unshift(dateSeparator); ret.unshift(dateSeparator);
@ -624,5 +637,59 @@ module.exports = {
uploadingRoomSettings: false, uploadingRoomSettings: false,
}); });
} }
},
_collectEventNode: function(eventId, node) {
if (this.eventNodes == undefined) this.eventNodes = {};
this.eventNodes[eventId] = node;
},
_indexForEventId(evId) {
for (var i = 0; i < this.state.room.timeline.length; ++i) {
if (evId == this.state.room.timeline[i].getId()) {
return i;
}
}
return null;
},
sendReadReceipt: function() {
if (!this.state.room) return;
var currentReadUpToEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId);
var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn();
if (lastReadEventIndex === null) return;
if (lastReadEventIndex > currentReadUpToEventIndex) {
MatrixClientPeg.get().sendReadReceipt(this.state.room.timeline[lastReadEventIndex]);
}
},
_getLastDisplayedEventIndexIgnoringOwn: function() {
if (this.eventNodes === undefined) return null;
var messageWrapper = this.refs.messagePanel;
if (messageWrapper === undefined) return null;
var wrapperRect = messageWrapper.getDOMNode().getBoundingClientRect();
for (var i = this.state.room.timeline.length-1; i >= 0; --i) {
var ev = this.state.room.timeline[i];
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
continue;
}
var node = this.eventNodes[ev.getId()];
if (!node) continue;
var domNode = node.getDOMNode();
var boundingRect = domNode.getBoundingClientRect();
if (boundingRect.bottom < wrapperRect.bottom) {
return i;
}
}
return null;
} }
}; };

View File

@ -26,5 +26,5 @@ limitations under the License.
} }
.mx_MemberAvatar_image { .mx_MemberAvatar_image {
border-radius: 20px; border-radius: 20px;
} }

View File

@ -49,7 +49,6 @@ limitations under the License.
.mx_EventTile .mx_MessageTimestamp { .mx_EventTile .mx_MessageTimestamp {
color: #acacac; color: #acacac;
font-size: 12px; font-size: 12px;
float: right;
} }
.mx_EventTile_line { .mx_EventTile_line {
@ -91,10 +90,16 @@ limitations under the License.
.mx_EventTile_msgOption { .mx_EventTile_msgOption {
float: right; float: right;
text-align: right;
margin-right: 10px;
z-index: 1;
position: relative;
} }
.mx_MessageTimestamp { .mx_MessageTimestamp {
display: block;
visibility: hidden; visibility: hidden;
text-align: right;
} }
.mx_EventTile_last .mx_MessageTimestamp { .mx_EventTile_last .mx_MessageTimestamp {
@ -106,10 +111,10 @@ limitations under the License.
} }
.mx_EventTile_editButton { .mx_EventTile_editButton {
position: absolute; display: block;
right: 1px;
top: 15px;
visibility: hidden; visibility: hidden;
margin-left: auto;
margin-right: 0px;
} }
.mx_EventTile:hover .mx_EventTile_editButton { .mx_EventTile:hover .mx_EventTile_editButton {
@ -123,3 +128,21 @@ limitations under the License.
.mx_EventTile.menu .mx_MessageTimestamp { .mx_EventTile.menu .mx_MessageTimestamp {
visibility: visible; visibility: visible;
} }
.mx_EventTile_readAvatars {
position: relative;
display: inline-block;
width: 14px;
height: 14px;
}
.mx_EventTile_readAvatars .mx_MemberAvatar {
position: absolute;
display: inline-block;
}
.mx_EventTile_readAvatarRemainder {
color: #acacac;
font-size: 12px;
position: absolute;
}

View File

@ -23,6 +23,9 @@ limitations under the License.
var skin = {}; var skin = {};
skin['atoms.create_room.CreateRoomButton'] = require('./views/atoms/create_room/CreateRoomButton');
skin['atoms.create_room.Presets'] = require('./views/atoms/create_room/Presets');
skin['atoms.create_room.RoomAlias'] = require('./views/atoms/create_room/RoomAlias');
skin['atoms.EditableText'] = require('./views/atoms/EditableText'); skin['atoms.EditableText'] = require('./views/atoms/EditableText');
skin['atoms.EnableNotificationsButton'] = require('./views/atoms/EnableNotificationsButton'); skin['atoms.EnableNotificationsButton'] = require('./views/atoms/EnableNotificationsButton');
skin['atoms.ImageView'] = require('./views/atoms/ImageView'); skin['atoms.ImageView'] = require('./views/atoms/ImageView');
@ -31,9 +34,6 @@ skin['atoms.MemberAvatar'] = require('./views/atoms/MemberAvatar');
skin['atoms.MessageTimestamp'] = require('./views/atoms/MessageTimestamp'); skin['atoms.MessageTimestamp'] = require('./views/atoms/MessageTimestamp');
skin['atoms.RoomAvatar'] = require('./views/atoms/RoomAvatar'); skin['atoms.RoomAvatar'] = require('./views/atoms/RoomAvatar');
skin['atoms.Spinner'] = require('./views/atoms/Spinner'); skin['atoms.Spinner'] = require('./views/atoms/Spinner');
skin['atoms.create_room.CreateRoomButton'] = require('./views/atoms/create_room/CreateRoomButton');
skin['atoms.create_room.Presets'] = require('./views/atoms/create_room/Presets');
skin['atoms.create_room.RoomAlias'] = require('./views/atoms/create_room/RoomAlias');
skin['atoms.voip.VideoFeed'] = require('./views/atoms/voip/VideoFeed'); skin['atoms.voip.VideoFeed'] = require('./views/atoms/voip/VideoFeed');
skin['molecules.BottomLeftMenu'] = require('./views/molecules/BottomLeftMenu'); skin['molecules.BottomLeftMenu'] = require('./views/molecules/BottomLeftMenu');
skin['molecules.BottomLeftMenuTile'] = require('./views/molecules/BottomLeftMenuTile'); skin['molecules.BottomLeftMenuTile'] = require('./views/molecules/BottomLeftMenuTile');
@ -43,18 +43,18 @@ skin['molecules.ChangePassword'] = require('./views/molecules/ChangePassword');
skin['molecules.DateSeparator'] = require('./views/molecules/DateSeparator'); skin['molecules.DateSeparator'] = require('./views/molecules/DateSeparator');
skin['molecules.EventAsTextTile'] = require('./views/molecules/EventAsTextTile'); skin['molecules.EventAsTextTile'] = require('./views/molecules/EventAsTextTile');
skin['molecules.EventTile'] = require('./views/molecules/EventTile'); skin['molecules.EventTile'] = require('./views/molecules/EventTile');
skin['molecules.MatrixToolbar'] = require('./views/molecules/MatrixToolbar');
skin['molecules.MemberInfo'] = require('./views/molecules/MemberInfo');
skin['molecules.MemberTile'] = require('./views/molecules/MemberTile');
skin['molecules.MEmoteTile'] = require('./views/molecules/MEmoteTile'); skin['molecules.MEmoteTile'] = require('./views/molecules/MEmoteTile');
skin['molecules.MessageComposer'] = require('./views/molecules/MessageComposer');
skin['molecules.MessageContextMenu'] = require('./views/molecules/MessageContextMenu');
skin['molecules.MessageTile'] = require('./views/molecules/MessageTile');
skin['molecules.MFileTile'] = require('./views/molecules/MFileTile'); skin['molecules.MFileTile'] = require('./views/molecules/MFileTile');
skin['molecules.MImageTile'] = require('./views/molecules/MImageTile'); skin['molecules.MImageTile'] = require('./views/molecules/MImageTile');
skin['molecules.MNoticeTile'] = require('./views/molecules/MNoticeTile'); skin['molecules.MNoticeTile'] = require('./views/molecules/MNoticeTile');
skin['molecules.MRoomMemberTile'] = require('./views/molecules/MRoomMemberTile'); skin['molecules.MRoomMemberTile'] = require('./views/molecules/MRoomMemberTile');
skin['molecules.MTextTile'] = require('./views/molecules/MTextTile'); skin['molecules.MTextTile'] = require('./views/molecules/MTextTile');
skin['molecules.MatrixToolbar'] = require('./views/molecules/MatrixToolbar');
skin['molecules.MemberInfo'] = require('./views/molecules/MemberInfo');
skin['molecules.MemberTile'] = require('./views/molecules/MemberTile');
skin['molecules.MessageComposer'] = require('./views/molecules/MessageComposer');
skin['molecules.MessageContextMenu'] = require('./views/molecules/MessageContextMenu');
skin['molecules.MessageTile'] = require('./views/molecules/MessageTile');
skin['molecules.ProgressBar'] = require('./views/molecules/ProgressBar'); skin['molecules.ProgressBar'] = require('./views/molecules/ProgressBar');
skin['molecules.RoomCreate'] = require('./views/molecules/RoomCreate'); skin['molecules.RoomCreate'] = require('./views/molecules/RoomCreate');
skin['molecules.RoomDropTarget'] = require('./views/molecules/RoomDropTarget'); skin['molecules.RoomDropTarget'] = require('./views/molecules/RoomDropTarget');

View File

@ -49,12 +49,12 @@ module.exports = React.createClass({
initial = this.props.member.name[1].toUpperCase(); initial = this.props.member.name[1].toUpperCase();
return ( return (
<span className="mx_MemberAvatar"> <span className="mx_MemberAvatar" {...this.props}>
<span className="mx_MemberAvatar_initial" aria-hidden="true" <span className="mx_MemberAvatar_initial" aria-hidden="true"
style={{ fontSize: (this.props.width * 0.75) + "px", style={{ fontSize: (this.props.width * 0.75) + "px",
width: this.props.width + "px", width: this.props.width + "px",
lineHeight: this.props.height*1.2 + "px" }}>{ initial }</span> lineHeight: this.props.height*1.2 + "px" }}>{ initial }</span>
<img className="mx_MemberAvatar_image" src={this.state.imageUrl} <img className="mx_MemberAvatar_image" src={this.state.imageUrl} title={this.props.member.name}
onError={this.onError} width={this.props.width} height={this.props.height} /> onError={this.onError} width={this.props.width} height={this.props.height} />
</span> </span>
); );
@ -62,7 +62,10 @@ module.exports = React.createClass({
return ( return (
<img className="mx_MemberAvatar mx_MemberAvatar_image" src={this.state.imageUrl} <img className="mx_MemberAvatar mx_MemberAvatar_image" src={this.state.imageUrl}
onError={this.onError} onError={this.onError}
width={this.props.width} height={this.props.height} /> width={this.props.width} height={this.props.height}
title={this.props.member.name}
{...this.props}
/>
); );
} }
}); });

View File

@ -17,15 +17,19 @@ 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('matrix-react-sdk') var sdk = require('matrix-react-sdk')
var MatrixClientPeg = require('matrix-react-sdk/lib/MatrixClientPeg')
var EventTileController = require('matrix-react-sdk/lib/controllers/molecules/EventTile') var EventTileController = require('matrix-react-sdk/lib/controllers/molecules/EventTile')
var ContextualMenu = require('../../../../ContextualMenu'); var ContextualMenu = require('../../../../ContextualMenu');
var TextForEvent = require('matrix-react-sdk/lib/TextForEvent'); var TextForEvent = require('matrix-react-sdk/lib/TextForEvent');
var Velociraptor = require('../../../../Velociraptor');
var eventTileTypes = { var eventTileTypes = {
'm.room.message': 'molecules.MessageTile', 'm.room.message': 'molecules.MessageTile',
'm.room.member' : 'molecules.EventAsTextTile', 'm.room.member' : 'molecules.EventAsTextTile',
@ -36,6 +40,8 @@ var eventTileTypes = {
'm.room.topic' : 'molecules.EventAsTextTile', 'm.room.topic' : 'molecules.EventAsTextTile',
}; };
var MAX_READ_AVATARS = 5;
module.exports = React.createClass({ module.exports = React.createClass({
displayName: 'EventTile', displayName: 'EventTile',
mixins: [EventTileController], mixins: [EventTileController],
@ -55,6 +61,10 @@ module.exports = React.createClass({
return {menu: false}; return {menu: false};
}, },
componentDidUpdate: function() {
this.readAvatarRect = ReactDom.findDOMNode(this.readAvatarNode).getBoundingClientRect();
},
onEditClicked: function(e) { onEditClicked: function(e) {
var MessageContextMenu = sdk.getComponent('molecules.MessageContextMenu'); var MessageContextMenu = sdk.getComponent('molecules.MessageContextMenu');
var buttonRect = e.target.getBoundingClientRect() var buttonRect = e.target.getBoundingClientRect()
@ -72,6 +82,93 @@ module.exports = React.createClass({
this.setState({menu: true}); this.setState({menu: true});
}, },
getReadAvatars: function() {
var avatars = [];
var room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (!room) return [];
var myUserId = MatrixClientPeg.get().credentials.userId;
// get list of read receipts, sorted most recent first
var receipts = room.getReceiptsForEvent(this.props.mxEvent).filter(function(r) {
return r.type === "m.read" && r.userId != myUserId;
}).sort(function(r1, r2) {
return r2.data.ts - r1.data.ts;
});
var MemberAvatar = sdk.getComponent('atoms.MemberAvatar');
var left = 0;
var reorderTransitionOpts = {
duration: 100,
easing: 'easeOut'
};
for (var i = 0; i < receipts.length; ++i) {
var member = room.getMember(receipts[i].userId);
// 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 = [];
if (oldAvatarDomNode && this.readAvatarRect) {
var oldRect = oldAvatarDomNode.getBoundingClientRect();
var topOffset = oldRect.top - this.readAvatarRect.top;
if (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);
}
// 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: 300,
easing: 'easeOutCubic',
});
}
// 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={ { left: left+'px', top: '0px' } }
startStyle={startStyles}
enterTransitionOpts={enterTransitionOpts}
id={'mx_readAvatar'+member.userId}
/>
);
left -= 15;
if (i + 1 >= MAX_READ_AVATARS) {
break;
}
}
var remainder = receipts.length - MAX_READ_AVATARS;
var remText;
if (remainder > 0) {
remText = <span className="mx_EventTile_readAvatarRemainder" style={ {left: left} }>+{ remainder }</span>;
}
return <span className="mx_EventTile_readAvatars" ref={this.collectReadAvatarNode}>
{remText}
<Velociraptor transition={reorderTransitionOpts}>
{avatars}
</Velociraptor>
</span>;
},
collectReadAvatarNode: function(node) {
this.readAvatarNode = node;
},
render: function() { render: function() {
var MessageTimestamp = sdk.getComponent('atoms.MessageTimestamp'); var MessageTimestamp = sdk.getComponent('atoms.MessageTimestamp');
var SenderProfile = sdk.getComponent('molecules.SenderProfile'); var SenderProfile = sdk.getComponent('molecules.SenderProfile');
@ -112,6 +209,8 @@ module.exports = React.createClass({
else if (msgtype === 'm.video') aux = "sent a video"; else if (msgtype === 'm.video') aux = "sent a video";
else if (msgtype === 'm.file') aux = "uploaded a file"; else if (msgtype === 'm.file') aux = "uploaded a file";
var readAvatars = this.getReadAvatars();
var avatar, sender; var avatar, sender;
if (!this.props.continuation) { if (!this.props.continuation) {
if (this.props.mxEvent.sender) { if (this.props.mxEvent.sender) {
@ -127,11 +226,14 @@ module.exports = React.createClass({
} }
return ( return (
<div className={classes}> <div className={classes}>
<div className="mx_EventTile_msgOption">
{ editButton }
{ timestamp }
{ readAvatars }
</div>
{ avatar } { avatar }
{ sender } { sender }
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
{ timestamp }
{ editButton }
<EventTileType mxEvent={this.props.mxEvent} searchTerm={this.props.searchTerm} /> <EventTileType mxEvent={this.props.mxEvent} searchTerm={this.props.searchTerm} />
</div> </div>
</div> </div>