diff --git a/package.json b/package.json
index fb7558ad..700e00c8 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"filesize": "^3.1.2",
"flux": "~2.0.3",
"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",
"modernizr": "^3.1.0",
"q": "^1.4.1",
@@ -37,6 +37,7 @@
"react-dnd-html5-backend": "^2.0.0",
"react-dom": "^0.14.2",
"react-gemini-scrollbar": "^2.0.1",
+ "velocity-animate": "^1.2.3",
"sanitize-html": "^1.0.0"
},
"devDependencies": {
diff --git a/src/Velociraptor.js b/src/Velociraptor.js
new file mode 100644
index 00000000..81ecd9e5
--- /dev/null
+++ b/src/Velociraptor.js
@@ -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 (
+
+ {childList}
+
+ );
+ },
+});
diff --git a/src/controllers/organisms/RoomView.js b/src/controllers/organisms/RoomView.js
index e603198a..cdc0638b 100644
--- a/src/controllers/organisms/RoomView.js
+++ b/src/controllers/organisms/RoomView.js
@@ -53,6 +53,7 @@ module.exports = {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().on("Room.name", this.onRoomName);
+ MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
@@ -71,6 +72,7 @@ module.exports = {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
+ MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange);
@@ -108,6 +110,9 @@ module.exports = {
// the conf
this._updateConfCallNotification();
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) {
this.forceUpdate();
},
@@ -247,6 +258,8 @@ module.exports = {
messageWrapperScroll.scrollTop = messageWrapperScroll.scrollHeight;
+ this.sendReadReceipt();
+
this.fillSpace();
}
@@ -529,7 +542,7 @@ module.exports = {
}
ret.unshift(
-
+
);
if (dateSeparator) {
ret.unshift(dateSeparator);
@@ -624,5 +637,59 @@ module.exports = {
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;
}
};
diff --git a/src/skins/vector/css/atoms/MemberAvatar.css b/src/skins/vector/css/atoms/MemberAvatar.css
index 34ef1393..95ce8201 100644
--- a/src/skins/vector/css/atoms/MemberAvatar.css
+++ b/src/skins/vector/css/atoms/MemberAvatar.css
@@ -26,5 +26,5 @@ limitations under the License.
}
.mx_MemberAvatar_image {
- border-radius: 20px;
+ border-radius: 20px;
}
diff --git a/src/skins/vector/css/molecules/EventTile.css b/src/skins/vector/css/molecules/EventTile.css
index d99bd4e1..7d4acada 100644
--- a/src/skins/vector/css/molecules/EventTile.css
+++ b/src/skins/vector/css/molecules/EventTile.css
@@ -49,7 +49,6 @@ limitations under the License.
.mx_EventTile .mx_MessageTimestamp {
color: #acacac;
font-size: 12px;
- float: right;
}
.mx_EventTile_line {
@@ -91,10 +90,16 @@ limitations under the License.
.mx_EventTile_msgOption {
float: right;
+ text-align: right;
+ margin-right: 10px;
+ z-index: 1;
+ position: relative;
}
.mx_MessageTimestamp {
+ display: block;
visibility: hidden;
+ text-align: right;
}
.mx_EventTile_last .mx_MessageTimestamp {
@@ -106,10 +111,10 @@ limitations under the License.
}
.mx_EventTile_editButton {
- position: absolute;
- right: 1px;
- top: 15px;
+ display: block;
visibility: hidden;
+ margin-left: auto;
+ margin-right: 0px;
}
.mx_EventTile:hover .mx_EventTile_editButton {
@@ -123,3 +128,21 @@ limitations under the License.
.mx_EventTile.menu .mx_MessageTimestamp {
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;
+}
diff --git a/src/skins/vector/skindex.js b/src/skins/vector/skindex.js
index b1ac8499..45d60f73 100644
--- a/src/skins/vector/skindex.js
+++ b/src/skins/vector/skindex.js
@@ -23,6 +23,9 @@ limitations under the License.
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.EnableNotificationsButton'] = require('./views/atoms/EnableNotificationsButton');
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.RoomAvatar'] = require('./views/atoms/RoomAvatar');
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['molecules.BottomLeftMenu'] = require('./views/molecules/BottomLeftMenu');
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.EventAsTextTile'] = require('./views/molecules/EventAsTextTile');
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.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.MImageTile'] = require('./views/molecules/MImageTile');
skin['molecules.MNoticeTile'] = require('./views/molecules/MNoticeTile');
skin['molecules.MRoomMemberTile'] = require('./views/molecules/MRoomMemberTile');
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.RoomCreate'] = require('./views/molecules/RoomCreate');
skin['molecules.RoomDropTarget'] = require('./views/molecules/RoomDropTarget');
diff --git a/src/skins/vector/views/atoms/MemberAvatar.js b/src/skins/vector/views/atoms/MemberAvatar.js
index c8606cd7..c719d70c 100644
--- a/src/skins/vector/views/atoms/MemberAvatar.js
+++ b/src/skins/vector/views/atoms/MemberAvatar.js
@@ -49,12 +49,12 @@ module.exports = React.createClass({
initial = this.props.member.name[1].toUpperCase();
return (
-
+
{ initial }
-
);
@@ -62,7 +62,10 @@ module.exports = React.createClass({
return (
+ width={this.props.width} height={this.props.height}
+ title={this.props.member.name}
+ {...this.props}
+ />
);
}
});
diff --git a/src/skins/vector/views/molecules/EventTile.js b/src/skins/vector/views/molecules/EventTile.js
index c5cb8195..39722c7c 100644
--- a/src/skins/vector/views/molecules/EventTile.js
+++ b/src/skins/vector/views/molecules/EventTile.js
@@ -17,15 +17,19 @@ limitations under the License.
'use strict';
var React = require('react');
+var ReactDom = require('react-dom');
var classNames = require("classnames");
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 ContextualMenu = require('../../../../ContextualMenu');
var TextForEvent = require('matrix-react-sdk/lib/TextForEvent');
+var Velociraptor = require('../../../../Velociraptor');
+
var eventTileTypes = {
'm.room.message': 'molecules.MessageTile',
'm.room.member' : 'molecules.EventAsTextTile',
@@ -36,6 +40,8 @@ var eventTileTypes = {
'm.room.topic' : 'molecules.EventAsTextTile',
};
+var MAX_READ_AVATARS = 5;
+
module.exports = React.createClass({
displayName: 'EventTile',
mixins: [EventTileController],
@@ -55,6 +61,10 @@ module.exports = React.createClass({
return {menu: false};
},
+ componentDidUpdate: function() {
+ this.readAvatarRect = ReactDom.findDOMNode(this.readAvatarNode).getBoundingClientRect();
+ },
+
onEditClicked: function(e) {
var MessageContextMenu = sdk.getComponent('molecules.MessageContextMenu');
var buttonRect = e.target.getBoundingClientRect()
@@ -72,6 +82,93 @@ module.exports = React.createClass({
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(
+
+ );
+ left -= 15;
+ if (i + 1 >= MAX_READ_AVATARS) {
+ break;
+ }
+ }
+ var remainder = receipts.length - MAX_READ_AVATARS;
+ var remText;
+ if (remainder > 0) {
+ remText = +{ remainder };
+ }
+
+ return
+ {remText}
+
+ {avatars}
+
+ ;
+ },
+
+ collectReadAvatarNode: function(node) {
+ this.readAvatarNode = node;
+ },
+
render: function() {
var MessageTimestamp = sdk.getComponent('atoms.MessageTimestamp');
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.file') aux = "uploaded a file";
+ var readAvatars = this.getReadAvatars();
+
var avatar, sender;
if (!this.props.continuation) {
if (this.props.mxEvent.sender) {
@@ -127,11 +226,14 @@ module.exports = React.createClass({
}
return (
+
+ { editButton }
+ { timestamp }
+ { readAvatars }
+
{ avatar }
{ sender }
- { timestamp }
- { editButton }