Skip to content

Commit 1099892

Browse files
committed
Merge pull request element-hq#379 from vector-im/read_receipts
Read receipts
2 parents 714c962 + c63dd37 commit 1099892

File tree

8 files changed

+321
-21
lines changed

8 files changed

+321
-21
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"filesize": "^3.1.2",
2929
"flux": "~2.0.3",
3030
"linkifyjs": "^2.0.0-beta.4",
31-
"matrix-js-sdk": "^0.3.0",
31+
"matrix-js-sdk": "https://github.com/matrix-org/matrix-js-sdk.git#develop",
3232
"matrix-react-sdk": "^0.0.2",
3333
"modernizr": "^3.1.0",
3434
"q": "^1.4.1",
@@ -37,6 +37,7 @@
3737
"react-dnd-html5-backend": "^2.0.0",
3838
"react-dom": "^0.14.2",
3939
"react-gemini-scrollbar": "^2.0.1",
40+
"velocity-animate": "^1.2.3",
4041
"sanitize-html": "^1.0.0"
4142
},
4243
"devDependencies": {

src/Velociraptor.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
var React = require('react');
2+
var ReactDom = require('react-dom');
3+
var Velocity = require('velocity-animate');
4+
5+
/**
6+
* The Velociraptor contains components and animates transitions with velocity.
7+
* It will only pick up direct changes to properties ('left', currently), and so
8+
* will not work for animating positional changes where the position is implicit
9+
* from DOM order. This makes it a lot simpler and lighter: if you need fully
10+
* automatic positional animation, look at react-shuffle or similar libraries.
11+
*/
12+
module.exports = React.createClass({
13+
displayName: 'Velociraptor',
14+
15+
propTypes: {
16+
children: React.PropTypes.array,
17+
transition: React.PropTypes.object,
18+
container: React.PropTypes.string
19+
},
20+
21+
componentWillMount: function() {
22+
this.children = {};
23+
this.nodes = {};
24+
var self = this;
25+
React.Children.map(this.props.children, function(c) {
26+
self.children[c.props.key] = c;
27+
});
28+
},
29+
30+
componentWillReceiveProps: function(nextProps) {
31+
var self = this;
32+
var oldChildren = this.children;
33+
this.children = {};
34+
React.Children.map(nextProps.children, function(c) {
35+
if (oldChildren[c.key]) {
36+
var old = oldChildren[c.key];
37+
var oldNode = ReactDom.findDOMNode(self.nodes[old.key]);
38+
39+
if (oldNode.style.left != c.props.style.left) {
40+
Velocity(oldNode, { left: c.props.style.left }, self.props.transition);
41+
//console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left);
42+
}
43+
self.children[c.key] = old;
44+
} else {
45+
// new element. If it has a startStyle, use that as the style and go through
46+
// the enter animations
47+
var newProps = {
48+
ref: self.collectNode.bind(self, c.key)
49+
};
50+
if (c.props.startStyle && Object.keys(c.props.startStyle).length) {
51+
var startStyle = c.props.startStyle;
52+
if (Array.isArray(startStyle)) {
53+
startStyle = startStyle[0];
54+
}
55+
newProps._restingStyle = c.props.style;
56+
newProps.style = startStyle;
57+
//console.log("mounted@startstyle0: "+JSON.stringify(startStyle));
58+
// apply the enter animations once it's mounted
59+
}
60+
self.children[c.key] = React.cloneElement(c, newProps);
61+
}
62+
});
63+
},
64+
65+
collectNode: function(k, node) {
66+
if (
67+
this.nodes[k] === undefined &&
68+
node.props.startStyle &&
69+
Object.keys(node.props.startStyle).length
70+
) {
71+
var domNode = ReactDom.findDOMNode(node);
72+
var startStyles = node.props.startStyle;
73+
var transitionOpts = node.props.enterTransitionOpts;
74+
if (!Array.isArray(startStyles)) {
75+
startStyles = [ startStyles ];
76+
transitionOpts = [ transitionOpts ];
77+
}
78+
// start from startStyle 1: 0 is the one we gave it
79+
// to start with, so now we animate 1 etc.
80+
for (var i = 1; i < startStyles.length; ++i) {
81+
Velocity(domNode, startStyles[i], transitionOpts[i-1]);
82+
//console.log("start: "+JSON.stringify(startStyles[i]));
83+
}
84+
// and then we animate to the resting state
85+
Velocity(domNode, node.props._restingStyle, transitionOpts[i-1]);
86+
//console.log("enter: "+JSON.stringify(node.props._restingStyle));
87+
}
88+
this.nodes[k] = node;
89+
},
90+
91+
render: function() {
92+
var self = this;
93+
var childList = Object.keys(this.children).map(function(k) {
94+
return React.cloneElement(self.children[k], {
95+
ref: self.collectNode.bind(self, self.children[k].key)
96+
});
97+
});
98+
return (
99+
<span>
100+
{childList}
101+
</span>
102+
);
103+
},
104+
});

src/controllers/organisms/RoomView.js

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ module.exports = {
5353
this.dispatcherRef = dis.register(this.onAction);
5454
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
5555
MatrixClientPeg.get().on("Room.name", this.onRoomName);
56+
MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt);
5657
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
5758
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
5859
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
@@ -71,6 +72,7 @@ module.exports = {
7172
if (MatrixClientPeg.get()) {
7273
MatrixClientPeg.get().removeListener("Room.timeline", this.onRoomTimeline);
7374
MatrixClientPeg.get().removeListener("Room.name", this.onRoomName);
75+
MatrixClientPeg.get().removeListener("Room.receipt", this.onRoomReceipt);
7476
MatrixClientPeg.get().removeListener("RoomMember.typing", this.onRoomMemberTyping);
7577
MatrixClientPeg.get().removeListener("RoomState.members", this.onRoomStateMember);
7678
MatrixClientPeg.get().removeListener("sync", this.onSyncStateChange);
@@ -108,6 +110,9 @@ module.exports = {
108110
// the conf
109111
this._updateConfCallNotification();
110112
break;
113+
case 'user_activity':
114+
this.sendReadReceipt();
115+
break;
111116
}
112117
},
113118

@@ -187,6 +192,12 @@ module.exports = {
187192
}
188193
},
189194

195+
onRoomReceipt: function(receiptEvent, room) {
196+
if (room.roomId == this.props.roomId) {
197+
this.forceUpdate();
198+
}
199+
},
200+
190201
onRoomMemberTyping: function(ev, member) {
191202
this.forceUpdate();
192203
},
@@ -247,6 +258,8 @@ module.exports = {
247258

248259
messageWrapperScroll.scrollTop = messageWrapperScroll.scrollHeight;
249260

261+
this.sendReadReceipt();
262+
250263
this.fillSpace();
251264
}
252265

@@ -529,7 +542,7 @@ module.exports = {
529542
}
530543

531544
ret.unshift(
532-
<li key={mxEv.getId()}><EventTile mxEvent={mxEv} continuation={continuation} last={last}/></li>
545+
<li key={mxEv.getId()} ref={this._collectEventNode.bind(this, mxEv.getId())}><EventTile mxEvent={mxEv} continuation={continuation} last={last}/></li>
533546
);
534547
if (dateSeparator) {
535548
ret.unshift(dateSeparator);
@@ -624,5 +637,59 @@ module.exports = {
624637
uploadingRoomSettings: false,
625638
});
626639
}
640+
},
641+
642+
_collectEventNode: function(eventId, node) {
643+
if (this.eventNodes == undefined) this.eventNodes = {};
644+
this.eventNodes[eventId] = node;
645+
},
646+
647+
_indexForEventId(evId) {
648+
for (var i = 0; i < this.state.room.timeline.length; ++i) {
649+
if (evId == this.state.room.timeline[i].getId()) {
650+
return i;
651+
}
652+
}
653+
return null;
654+
},
655+
656+
sendReadReceipt: function() {
657+
if (!this.state.room) return;
658+
var currentReadUpToEventId = this.state.room.getEventReadUpTo(MatrixClientPeg.get().credentials.userId);
659+
var currentReadUpToEventIndex = this._indexForEventId(currentReadUpToEventId);
660+
661+
var lastReadEventIndex = this._getLastDisplayedEventIndexIgnoringOwn();
662+
if (lastReadEventIndex === null) return;
663+
664+
if (lastReadEventIndex > currentReadUpToEventIndex) {
665+
MatrixClientPeg.get().sendReadReceipt(this.state.room.timeline[lastReadEventIndex]);
666+
}
667+
},
668+
669+
_getLastDisplayedEventIndexIgnoringOwn: function() {
670+
if (this.eventNodes === undefined) return null;
671+
672+
var messageWrapper = this.refs.messagePanel;
673+
if (messageWrapper === undefined) return null;
674+
var wrapperRect = messageWrapper.getDOMNode().getBoundingClientRect();
675+
676+
for (var i = this.state.room.timeline.length-1; i >= 0; --i) {
677+
var ev = this.state.room.timeline[i];
678+
679+
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
680+
continue;
681+
}
682+
683+
var node = this.eventNodes[ev.getId()];
684+
if (!node) continue;
685+
686+
var domNode = node.getDOMNode();
687+
var boundingRect = domNode.getBoundingClientRect();
688+
689+
if (boundingRect.bottom < wrapperRect.bottom) {
690+
return i;
691+
}
692+
}
693+
return null;
627694
}
628695
};

src/skins/vector/css/atoms/MemberAvatar.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ limitations under the License.
2626
}
2727

2828
.mx_MemberAvatar_image {
29-
border-radius: 20px;
29+
border-radius: 20px;
3030
}

src/skins/vector/css/molecules/EventTile.css

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ limitations under the License.
4949
.mx_EventTile .mx_MessageTimestamp {
5050
color: #acacac;
5151
font-size: 12px;
52-
float: right;
5352
}
5453

5554
.mx_EventTile_line {
@@ -91,10 +90,16 @@ limitations under the License.
9190

9291
.mx_EventTile_msgOption {
9392
float: right;
93+
text-align: right;
94+
margin-right: 10px;
95+
z-index: 1;
96+
position: relative;
9497
}
9598

9699
.mx_MessageTimestamp {
100+
display: block;
97101
visibility: hidden;
102+
text-align: right;
98103
}
99104

100105
.mx_EventTile_last .mx_MessageTimestamp {
@@ -106,10 +111,10 @@ limitations under the License.
106111
}
107112

108113
.mx_EventTile_editButton {
109-
position: absolute;
110-
right: 1px;
111-
top: 15px;
114+
display: block;
112115
visibility: hidden;
116+
margin-left: auto;
117+
margin-right: 0px;
113118
}
114119

115120
.mx_EventTile:hover .mx_EventTile_editButton {
@@ -123,3 +128,21 @@ limitations under the License.
123128
.mx_EventTile.menu .mx_MessageTimestamp {
124129
visibility: visible;
125130
}
131+
132+
.mx_EventTile_readAvatars {
133+
position: relative;
134+
display: inline-block;
135+
width: 14px;
136+
height: 14px;
137+
}
138+
139+
.mx_EventTile_readAvatars .mx_MemberAvatar {
140+
position: absolute;
141+
display: inline-block;
142+
}
143+
144+
.mx_EventTile_readAvatarRemainder {
145+
color: #acacac;
146+
font-size: 12px;
147+
position: absolute;
148+
}

src/skins/vector/skindex.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ limitations under the License.
2323

2424
var skin = {};
2525

26+
skin['atoms.create_room.CreateRoomButton'] = require('./views/atoms/create_room/CreateRoomButton');
27+
skin['atoms.create_room.Presets'] = require('./views/atoms/create_room/Presets');
28+
skin['atoms.create_room.RoomAlias'] = require('./views/atoms/create_room/RoomAlias');
2629
skin['atoms.EditableText'] = require('./views/atoms/EditableText');
2730
skin['atoms.EnableNotificationsButton'] = require('./views/atoms/EnableNotificationsButton');
2831
skin['atoms.ImageView'] = require('./views/atoms/ImageView');
@@ -31,9 +34,6 @@ skin['atoms.MemberAvatar'] = require('./views/atoms/MemberAvatar');
3134
skin['atoms.MessageTimestamp'] = require('./views/atoms/MessageTimestamp');
3235
skin['atoms.RoomAvatar'] = require('./views/atoms/RoomAvatar');
3336
skin['atoms.Spinner'] = require('./views/atoms/Spinner');
34-
skin['atoms.create_room.CreateRoomButton'] = require('./views/atoms/create_room/CreateRoomButton');
35-
skin['atoms.create_room.Presets'] = require('./views/atoms/create_room/Presets');
36-
skin['atoms.create_room.RoomAlias'] = require('./views/atoms/create_room/RoomAlias');
3737
skin['atoms.voip.VideoFeed'] = require('./views/atoms/voip/VideoFeed');
3838
skin['molecules.BottomLeftMenu'] = require('./views/molecules/BottomLeftMenu');
3939
skin['molecules.BottomLeftMenuTile'] = require('./views/molecules/BottomLeftMenuTile');
@@ -43,18 +43,18 @@ skin['molecules.ChangePassword'] = require('./views/molecules/ChangePassword');
4343
skin['molecules.DateSeparator'] = require('./views/molecules/DateSeparator');
4444
skin['molecules.EventAsTextTile'] = require('./views/molecules/EventAsTextTile');
4545
skin['molecules.EventTile'] = require('./views/molecules/EventTile');
46-
skin['molecules.MEmoteTile'] = require('./views/molecules/MEmoteTile');
47-
skin['molecules.MFileTile'] = require('./views/molecules/MFileTile');
48-
skin['molecules.MImageTile'] = require('./views/molecules/MImageTile');
49-
skin['molecules.MNoticeTile'] = require('./views/molecules/MNoticeTile');
50-
skin['molecules.MRoomMemberTile'] = require('./views/molecules/MRoomMemberTile');
51-
skin['molecules.MTextTile'] = require('./views/molecules/MTextTile');
5246
skin['molecules.MatrixToolbar'] = require('./views/molecules/MatrixToolbar');
5347
skin['molecules.MemberInfo'] = require('./views/molecules/MemberInfo');
5448
skin['molecules.MemberTile'] = require('./views/molecules/MemberTile');
49+
skin['molecules.MEmoteTile'] = require('./views/molecules/MEmoteTile');
5550
skin['molecules.MessageComposer'] = require('./views/molecules/MessageComposer');
5651
skin['molecules.MessageContextMenu'] = require('./views/molecules/MessageContextMenu');
5752
skin['molecules.MessageTile'] = require('./views/molecules/MessageTile');
53+
skin['molecules.MFileTile'] = require('./views/molecules/MFileTile');
54+
skin['molecules.MImageTile'] = require('./views/molecules/MImageTile');
55+
skin['molecules.MNoticeTile'] = require('./views/molecules/MNoticeTile');
56+
skin['molecules.MRoomMemberTile'] = require('./views/molecules/MRoomMemberTile');
57+
skin['molecules.MTextTile'] = require('./views/molecules/MTextTile');
5858
skin['molecules.ProgressBar'] = require('./views/molecules/ProgressBar');
5959
skin['molecules.RoomCreate'] = require('./views/molecules/RoomCreate');
6060
skin['molecules.RoomDropTarget'] = require('./views/molecules/RoomDropTarget');

src/skins/vector/views/atoms/MemberAvatar.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,20 +49,23 @@ module.exports = React.createClass({
4949
initial = this.props.member.name[1].toUpperCase();
5050

5151
return (
52-
<span className="mx_MemberAvatar">
52+
<span className="mx_MemberAvatar" {...this.props}>
5353
<span className="mx_MemberAvatar_initial" aria-hidden="true"
5454
style={{ fontSize: (this.props.width * 0.75) + "px",
5555
width: this.props.width + "px",
5656
lineHeight: this.props.height*1.2 + "px" }}>{ initial }</span>
57-
<img className="mx_MemberAvatar_image" src={this.state.imageUrl}
57+
<img className="mx_MemberAvatar_image" src={this.state.imageUrl} title={this.props.member.name}
5858
onError={this.onError} width={this.props.width} height={this.props.height} />
5959
</span>
6060
);
6161
}
6262
return (
6363
<img className="mx_MemberAvatar mx_MemberAvatar_image" src={this.state.imageUrl}
6464
onError={this.onError}
65-
width={this.props.width} height={this.props.height} />
65+
width={this.props.width} height={this.props.height}
66+
title={this.props.member.name}
67+
{...this.props}
68+
/>
6669
);
6770
}
6871
});

0 commit comments

Comments
 (0)