diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 8c1e0b3e3a..387ed4b409 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -43,6 +43,13 @@ var INITIAL_SIZE = 20;
var DEBUG_SCROLL = false;
+if (DEBUG_SCROLL) {
+ // using bind means that we get to keep useful line numbers in the console
+ var debuglog = console.log.bind(console);
+} else {
+ var debuglog = function () {};
+}
+
module.exports = React.createClass({
displayName: 'RoomView',
propTypes: {
@@ -330,8 +337,6 @@ module.exports = React.createClass({
this.scrollToBottom();
this.sendReadReceipt();
-
- this.refs.messagePanel.checkFillState();
},
componentDidUpdate: function() {
@@ -346,53 +351,48 @@ module.exports = React.createClass({
},
_paginateCompleted: function() {
- if (DEBUG_SCROLL) console.log("paginate complete");
+ debuglog("paginate complete");
this.setState({
room: MatrixClientPeg.get().getRoom(this.props.roomId)
});
this.setState({paginating: false});
-
- // we might not have got enough (or, indeed, any) results from the
- // pagination request, so give the messagePanel a chance to set off
- // another.
-
- if (this.refs.messagePanel) {
- this.refs.messagePanel.checkFillState();
- }
},
onSearchResultsFillRequest: function(backwards) {
- if (!backwards || this.state.searchInProgress)
- return;
+ if (!backwards)
+ return q(false);
if (this.nextSearchBatch) {
- if (DEBUG_SCROLL) console.log("requesting more search results");
- this._getSearchBatch(this.state.searchTerm,
- this.state.searchScope);
+ debuglog("requesting more search results");
+ return this._getSearchBatch(this.state.searchTerm,
+ this.state.searchScope).then(true);
} else {
- if (DEBUG_SCROLL) console.log("no more search results");
+ debuglog("no more search results");
+ return q(false);
}
},
// set off a pagination request.
onMessageListFillRequest: function(backwards) {
- if (!backwards || this.state.paginating)
- return;
+ if (!backwards)
+ return q(false);
// Either wind back the message cap (if there are enough events in the
// timeline to do so), or fire off a pagination request.
if (this.state.messageCap < this.state.room.timeline.length) {
var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, this.state.room.timeline.length);
- if (DEBUG_SCROLL) console.log("winding back message cap to", cap);
+ debuglog("winding back message cap to", cap);
this.setState({messageCap: cap});
+ return q(true);
} else if(this.state.room.oldState.paginationToken) {
var cap = this.state.messageCap + PAGINATE_SIZE;
- if (DEBUG_SCROLL) console.log("starting paginate to cap", cap);
+ debuglog("starting paginate to cap", cap);
this.setState({messageCap: cap, paginating: true});
- MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).finally(this._paginateCompleted).done();
+ return MatrixClientPeg.get().scrollback(this.state.room, PAGINATE_SIZE).
+ finally(this._paginateCompleted).then(true);
}
},
@@ -499,7 +499,7 @@ module.exports = React.createClass({
}
this.nextSearchBatch = null;
- this._getSearchBatch(term, scope);
+ this._getSearchBatch(term, scope).done();
},
// fire off a request for a batch of search results
@@ -516,11 +516,11 @@ module.exports = React.createClass({
var self = this;
- if (DEBUG_SCROLL) console.log("sending search request");
- MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope),
- next_batch: this.nextSearchBatch })
+ debuglog("sending search request");
+ return MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope),
+ next_batch: this.nextSearchBatch })
.then(function(data) {
- if (DEBUG_SCROLL) console.log("search complete");
+ debuglog("search complete");
if (!self.state.searching || self.searchId != searchId) {
console.error("Discarding stale search results");
return;
@@ -566,7 +566,7 @@ module.exports = React.createClass({
self.setState({
searchInProgress: false
});
- }).done();
+ });
},
_getSearchCondition: function(term, scope) {
diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js
index 2c68562ada..052424529a 100644
--- a/src/components/structures/ScrollPanel.js
+++ b/src/components/structures/ScrollPanel.js
@@ -17,9 +17,17 @@ limitations under the License.
var React = require("react");
var ReactDOM = require("react-dom");
var GeminiScrollbar = require('react-gemini-scrollbar');
+var q = require("q");
var DEBUG_SCROLL = false;
+if (DEBUG_SCROLL) {
+ // using bind means that we get to keep useful line numbers in the console
+ var debuglog = console.log.bind(console);
+} else {
+ var debuglog = function () {};
+}
+
/* This component implements an intelligent scrolling list.
*
* It wraps a list of
children; when items are added to the start or end
@@ -51,7 +59,16 @@ module.exports = React.createClass({
/* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards =
- * false) of the list
+ * false) of the list.
+ *
+ * This should return a promise; no more calls will be made until the
+ * promise completes.
+ *
+ * The promise should resolve to true if there is more data to be
+ * retrieved in this direction (in which case onFillRequest may be
+ * called again immediately), or false if there is no more data in this
+ * directon (at this time) - which will stop the pagination cycle until
+ * the user scrolls again.
*/
onFillRequest: React.PropTypes.func,
@@ -71,25 +88,33 @@ module.exports = React.createClass({
getDefaultProps: function() {
return {
stickyBottom: true,
- onFillRequest: function(backwards) {},
+ onFillRequest: function(backwards) { return q(false); },
onScroll: function() {},
};
},
componentWillMount: function() {
+ this._pendingFillRequests = {b: null, f: null};
this.resetScrollState();
},
+ componentDidMount: function() {
+ this.checkFillState();
+ },
+
componentDidUpdate: function() {
// after adding event tiles, we may need to tweak the scroll (either to
// keep at the bottom of the timeline, or to maintain the view after
// adding events to the top).
this._restoreSavedScrollState();
+
+ // we also re-check the fill state, in case the paginate was inadequate
+ this.checkFillState();
},
onScroll: function(ev) {
var sn = this._getScrollNode();
- if (DEBUG_SCROLL) console.log("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll);
+ debuglog("Scroll event: offset now:", sn.scrollTop, "recentEventScroll:", this.recentEventScroll);
// Sometimes we see attempts to write to scrollTop essentially being
// ignored. (Or rather, it is successfully written, but on the next
@@ -113,26 +138,96 @@ module.exports = React.createClass({
}
this.scrollState = this._calculateScrollState();
- if (DEBUG_SCROLL) console.log("Saved scroll state", this.scrollState);
+ debuglog("Saved scroll state", this.scrollState);
this.props.onScroll(ev);
this.checkFillState();
},
+ // return true if the content is fully scrolled down right now; else false.
+ //
+ // Note that if the content hasn't yet been fully populated, this may
+ // spuriously return true even if the user wanted to be looking at earlier
+ // content. So don't call it in render() cycles.
isAtBottom: function() {
- return this.scrollState && this.scrollState.atBottom;
+ var sn = this._getScrollNode();
+ // + 1 here to avoid fractional pixel rounding errors
+ return sn.scrollHeight - sn.scrollTop <= sn.clientHeight + 1;
},
// check the scroll state and send out backfill requests if necessary.
checkFillState: function() {
var sn = this._getScrollNode();
+ // if there is less than a screenful of messages above or below the
+ // viewport, try to get some more messages.
+ //
+ // scrollTop is the number of pixels between the top of the content and
+ // the top of the viewport.
+ //
+ // scrollHeight is the total height of the content.
+ //
+ // clientHeight is the height of the viewport (excluding borders,
+ // margins, and scrollbars).
+ //
+ //
+ // .---------. - -
+ // | | | scrollTop |
+ // .-+---------+-. - - |
+ // | | | | | |
+ // | | | | | clientHeight | scrollHeight
+ // | | | | | |
+ // `-+---------+-' - |
+ // | | |
+ // | | |
+ // `---------' -
+ //
+
if (sn.scrollTop < sn.clientHeight) {
- // there's less than a screenful of messages left - try to get some
- // more messages.
- this.props.onFillRequest(true);
+ // need to back-fill
+ this._maybeFill(true);
}
+ if (sn.scrollTop > sn.scrollHeight - sn.clientHeight * 2) {
+ // need to forward-fill
+ this._maybeFill(false);
+ }
+ },
+
+ // check if there is already a pending fill request. If not, set one off.
+ _maybeFill: function(backwards) {
+ var dir = backwards ? 'b' : 'f';
+ if (this._pendingFillRequests[dir]) {
+ debuglog("ScrollPanel: Already a "+dir+" fill in progress - not starting another");
+ return;
+ }
+
+ debuglog("ScrollPanel: starting "+dir+" fill");
+
+ // onFillRequest can end up calling us recursively (via onScroll
+ // events) so make sure we set this before firing off the call. That
+ // does present the risk that we might not ever actually fire off the
+ // fill request, so wrap it in a try/catch.
+ this._pendingFillRequests[dir] = true;
+ var fillPromise;
+ try {
+ fillPromise = this.props.onFillRequest(backwards);
+ } catch (e) {
+ this._pendingFillRequests[dir] = false;
+ throw e;
+ }
+
+ q.finally(fillPromise, () => {
+ debuglog("ScrollPanel: "+dir+" fill complete");
+ this._pendingFillRequests[dir] = false;
+ }).then((hasMoreResults) => {
+ if (hasMoreResults) {
+ // further pagination requests have been disabled until now, so
+ // it's time to check the fill state again in case the pagination
+ // was insufficient.
+ this.checkFillState();
+ }
+ }).done();
},
// get the current scroll position of the room, so that it can be
@@ -156,13 +251,13 @@ module.exports = React.createClass({
scrollToTop: function() {
this._getScrollNode().scrollTop = 0;
- if (DEBUG_SCROLL) console.log("Scrolled to top");
+ debuglog("Scrolled to top");
},
scrollToBottom: function() {
var scrollNode = this._getScrollNode();
scrollNode.scrollTop = scrollNode.scrollHeight;
- if (DEBUG_SCROLL) console.log("Scrolled to bottom; offset now", scrollNode.scrollTop);
+ debuglog("Scrolled to bottom; offset now", scrollNode.scrollTop);
},
// scroll the message list to the node with the given scrollToken. See
@@ -199,10 +294,10 @@ module.exports = React.createClass({
this.recentEventScroll = scrollNode.scrollTop;
}
- if (DEBUG_SCROLL) {
- console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")");
- console.log("recentEventScroll now "+this.recentEventScroll);
- }
+ debuglog("Scrolled to token", node.dataset.scrollToken, "+",
+ pixelOffset+":", scrollNode.scrollTop,
+ "(delta: "+scrollDelta+")");
+ debuglog("recentEventScroll now "+this.recentEventScroll);
},
_calculateScrollState: function() {
@@ -213,9 +308,7 @@ module.exports = React.createClass({
// attribute. It is this token which is stored as the
// 'lastDisplayedScrollToken'.
- var sn = this._getScrollNode();
- // + 1 here to avoid fractional pixel rounding errors
- var atBottom = sn.scrollHeight - sn.scrollTop <= sn.clientHeight + 1;
+ var atBottom = this.isAtBottom();
var itemlist = this.refs.itemlist;
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();
diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js
index 164bf11930..63e77e9652 100644
--- a/src/components/views/messages/MessageEvent.js
+++ b/src/components/views/messages/MessageEvent.js
@@ -47,6 +47,7 @@ module.exports = React.createClass({
TileType = tileTypes[msgtype];
}
- return ;
+ return ;
},
});
diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js
index 7dd7a46842..0c9710f06e 100644
--- a/src/components/views/messages/TextualBody.js
+++ b/src/components/views/messages/TextualBody.js
@@ -49,7 +49,8 @@ module.exports = React.createClass({
render: function() {
var mxEvent = this.props.mxEvent;
var content = mxEvent.getContent();
- var body = HtmlUtils.bodyToHtml(content, this.props.highlights);
+ var body = HtmlUtils.bodyToHtml(content, this.props.highlights,
+ {onHighlightClick: this.props.onHighlightClick});
switch (content.msgtype) {
case "m.emote":
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index 0b26000207..67daebccbc 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -74,6 +74,32 @@ module.exports = React.createClass({
}
},
+ propTypes: {
+ /* the MatrixEvent to show */
+ mxEvent: React.PropTypes.object.isRequired,
+
+ /* true if this is a continuation of the previous event (which has the
+ * effect of not showing another avatar/displayname
+ */
+ continuation: React.PropTypes.bool,
+
+ /* true if this is the last event in the timeline (which has the effect
+ * of always showing the timestamp)
+ */
+ last: React.PropTypes.bool,
+
+ /* true if this is search context (which has the effect of greying out
+ * the text
+ */
+ contextual: React.PropTypes.bool,
+
+ /* a list of words to highlight */
+ highlights: React.PropTypes.array,
+
+ /* a function to be called when the highlight is clicked */
+ onHighlightClick: React.PropTypes.func,
+ },
+
getInitialState: function() {
return {menu: false, allReadAvatars: false};
},
@@ -134,6 +160,9 @@ module.exports = React.createClass({
for (var i = 0; i < receipts.length; ++i) {
var member = room.getMember(receipts[i].userId);
+ if (!member) {
+ continue;
+ }
// Using react refs here would mean both getting Velociraptor to expose
// them and making them scoped to the whole RoomView. Not impossible, but
@@ -280,7 +309,8 @@ module.exports = React.createClass({
{ avatar }
{ sender }
-
+
);
diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js
index 8ca24f8992..719e4af9b3 100644
--- a/src/components/views/rooms/MemberList.js
+++ b/src/components/views/rooms/MemberList.js
@@ -254,7 +254,7 @@ module.exports = React.createClass({
} else {
return (
);
}