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