Merge branch 'develop' into matthew/dynamic-svg

This commit is contained in:
Matthew Hodgson 2016-01-06 01:11:34 +00:00
commit 9e8daba8d7
6 changed files with 174 additions and 49 deletions

View file

@ -43,6 +43,13 @@ var INITIAL_SIZE = 20;
var DEBUG_SCROLL = false; 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({ module.exports = React.createClass({
displayName: 'RoomView', displayName: 'RoomView',
propTypes: { propTypes: {
@ -330,8 +337,6 @@ module.exports = React.createClass({
this.scrollToBottom(); this.scrollToBottom();
this.sendReadReceipt(); this.sendReadReceipt();
this.refs.messagePanel.checkFillState();
}, },
componentDidUpdate: function() { componentDidUpdate: function() {
@ -346,53 +351,48 @@ module.exports = React.createClass({
}, },
_paginateCompleted: function() { _paginateCompleted: function() {
if (DEBUG_SCROLL) console.log("paginate complete"); debuglog("paginate complete");
this.setState({ this.setState({
room: MatrixClientPeg.get().getRoom(this.props.roomId) room: MatrixClientPeg.get().getRoom(this.props.roomId)
}); });
this.setState({paginating: false}); 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) { onSearchResultsFillRequest: function(backwards) {
if (!backwards || this.state.searchInProgress) if (!backwards)
return; return q(false);
if (this.nextSearchBatch) { if (this.nextSearchBatch) {
if (DEBUG_SCROLL) console.log("requesting more search results"); debuglog("requesting more search results");
this._getSearchBatch(this.state.searchTerm, return this._getSearchBatch(this.state.searchTerm,
this.state.searchScope); this.state.searchScope).then(true);
} else { } else {
if (DEBUG_SCROLL) console.log("no more search results"); debuglog("no more search results");
return q(false);
} }
}, },
// set off a pagination request. // set off a pagination request.
onMessageListFillRequest: function(backwards) { onMessageListFillRequest: function(backwards) {
if (!backwards || this.state.paginating) if (!backwards)
return; return q(false);
// Either wind back the message cap (if there are enough events in the // Either wind back the message cap (if there are enough events in the
// timeline to do so), or fire off a pagination request. // timeline to do so), or fire off a pagination request.
if (this.state.messageCap < this.state.room.timeline.length) { if (this.state.messageCap < this.state.room.timeline.length) {
var cap = Math.min(this.state.messageCap + PAGINATE_SIZE, 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}); this.setState({messageCap: cap});
return q(true);
} else if(this.state.room.oldState.paginationToken) { } else if(this.state.room.oldState.paginationToken) {
var cap = this.state.messageCap + PAGINATE_SIZE; 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}); 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.nextSearchBatch = null;
this._getSearchBatch(term, scope); this._getSearchBatch(term, scope).done();
}, },
// fire off a request for a batch of search results // fire off a request for a batch of search results
@ -516,11 +516,11 @@ module.exports = React.createClass({
var self = this; var self = this;
if (DEBUG_SCROLL) console.log("sending search request"); debuglog("sending search request");
MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope), return MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope),
next_batch: this.nextSearchBatch }) next_batch: this.nextSearchBatch })
.then(function(data) { .then(function(data) {
if (DEBUG_SCROLL) console.log("search complete"); debuglog("search complete");
if (!self.state.searching || self.searchId != searchId) { if (!self.state.searching || self.searchId != searchId) {
console.error("Discarding stale search results"); console.error("Discarding stale search results");
return; return;
@ -566,7 +566,7 @@ module.exports = React.createClass({
self.setState({ self.setState({
searchInProgress: false searchInProgress: false
}); });
}).done(); });
}, },
_getSearchCondition: function(term, scope) { _getSearchCondition: function(term, scope) {

View file

@ -17,9 +17,17 @@ limitations under the License.
var React = require("react"); var React = require("react");
var ReactDOM = require("react-dom"); var ReactDOM = require("react-dom");
var GeminiScrollbar = require('react-gemini-scrollbar'); var GeminiScrollbar = require('react-gemini-scrollbar');
var q = require("q");
var DEBUG_SCROLL = false; 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. /* This component implements an intelligent scrolling list.
* *
* It wraps a list of <li> children; when items are added to the start or end * It wraps a list of <li> 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 /* onFillRequest(backwards): a callback which is called on scroll when
* the user nears the start (backwards = true) or end (backwards = * 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, onFillRequest: React.PropTypes.func,
@ -71,25 +88,33 @@ module.exports = React.createClass({
getDefaultProps: function() { getDefaultProps: function() {
return { return {
stickyBottom: true, stickyBottom: true,
onFillRequest: function(backwards) {}, onFillRequest: function(backwards) { return q(false); },
onScroll: function() {}, onScroll: function() {},
}; };
}, },
componentWillMount: function() { componentWillMount: function() {
this._pendingFillRequests = {b: null, f: null};
this.resetScrollState(); this.resetScrollState();
}, },
componentDidMount: function() {
this.checkFillState();
},
componentDidUpdate: function() { componentDidUpdate: function() {
// after adding event tiles, we may need to tweak the scroll (either to // 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 // keep at the bottom of the timeline, or to maintain the view after
// adding events to the top). // adding events to the top).
this._restoreSavedScrollState(); this._restoreSavedScrollState();
// we also re-check the fill state, in case the paginate was inadequate
this.checkFillState();
}, },
onScroll: function(ev) { onScroll: function(ev) {
var sn = this._getScrollNode(); 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 // Sometimes we see attempts to write to scrollTop essentially being
// ignored. (Or rather, it is successfully written, but on the next // ignored. (Or rather, it is successfully written, but on the next
@ -113,26 +138,96 @@ module.exports = React.createClass({
} }
this.scrollState = this._calculateScrollState(); 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.props.onScroll(ev);
this.checkFillState(); 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() { 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. // check the scroll state and send out backfill requests if necessary.
checkFillState: function() { checkFillState: function() {
var sn = this._getScrollNode(); 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) { if (sn.scrollTop < sn.clientHeight) {
// there's less than a screenful of messages left - try to get some // need to back-fill
// more messages. this._maybeFill(true);
this.props.onFillRequest(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 // get the current scroll position of the room, so that it can be
@ -156,13 +251,13 @@ module.exports = React.createClass({
scrollToTop: function() { scrollToTop: function() {
this._getScrollNode().scrollTop = 0; this._getScrollNode().scrollTop = 0;
if (DEBUG_SCROLL) console.log("Scrolled to top"); debuglog("Scrolled to top");
}, },
scrollToBottom: function() { scrollToBottom: function() {
var scrollNode = this._getScrollNode(); var scrollNode = this._getScrollNode();
scrollNode.scrollTop = scrollNode.scrollHeight; 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 // scroll the message list to the node with the given scrollToken. See
@ -199,10 +294,10 @@ module.exports = React.createClass({
this.recentEventScroll = scrollNode.scrollTop; this.recentEventScroll = scrollNode.scrollTop;
} }
if (DEBUG_SCROLL) { debuglog("Scrolled to token", node.dataset.scrollToken, "+",
console.log("Scrolled to token", node.dataset.scrollToken, "+", pixelOffset+":", scrollNode.scrollTop, "(delta: "+scrollDelta+")"); pixelOffset+":", scrollNode.scrollTop,
console.log("recentEventScroll now "+this.recentEventScroll); "(delta: "+scrollDelta+")");
} debuglog("recentEventScroll now "+this.recentEventScroll);
}, },
_calculateScrollState: function() { _calculateScrollState: function() {
@ -213,9 +308,7 @@ module.exports = React.createClass({
// attribute. It is this token which is stored as the // attribute. It is this token which is stored as the
// 'lastDisplayedScrollToken'. // 'lastDisplayedScrollToken'.
var sn = this._getScrollNode(); var atBottom = this.isAtBottom();
// + 1 here to avoid fractional pixel rounding errors
var atBottom = sn.scrollHeight - sn.scrollTop <= sn.clientHeight + 1;
var itemlist = this.refs.itemlist; var itemlist = this.refs.itemlist;
var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect(); var wrapperRect = ReactDOM.findDOMNode(this).getBoundingClientRect();

View file

@ -47,6 +47,7 @@ module.exports = React.createClass({
TileType = tileTypes[msgtype]; TileType = tileTypes[msgtype];
} }
return <TileType mxEvent={this.props.mxEvent} highlights={this.props.highlights} />; return <TileType mxEvent={this.props.mxEvent} highlights={this.props.highlights}
onHighlightClick={this.props.onHighlightClick} />;
}, },
}); });

View file

@ -49,7 +49,8 @@ module.exports = React.createClass({
render: function() { render: function() {
var mxEvent = this.props.mxEvent; var mxEvent = this.props.mxEvent;
var content = mxEvent.getContent(); 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) { switch (content.msgtype) {
case "m.emote": case "m.emote":

View file

@ -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() { getInitialState: function() {
return {menu: false, allReadAvatars: false}; return {menu: false, allReadAvatars: false};
}, },
@ -134,6 +160,9 @@ module.exports = React.createClass({
for (var i = 0; i < receipts.length; ++i) { for (var i = 0; i < receipts.length; ++i) {
var member = room.getMember(receipts[i].userId); var member = room.getMember(receipts[i].userId);
if (!member) {
continue;
}
// Using react refs here would mean both getting Velociraptor to expose // Using react refs here would mean both getting Velociraptor to expose
// them and making them scoped to the whole RoomView. Not impossible, but // them and making them scoped to the whole RoomView. Not impossible, but
@ -280,7 +309,8 @@ module.exports = React.createClass({
{ avatar } { avatar }
{ sender } { sender }
<div className="mx_EventTile_line"> <div className="mx_EventTile_line">
<EventTileType mxEvent={this.props.mxEvent} highlights={this.props.highlights} /> <EventTileType mxEvent={this.props.mxEvent} highlights={this.props.highlights}
onHighlightClick={this.props.onHighlightClick} />
</div> </div>
</div> </div>
); );

View file

@ -254,7 +254,7 @@ module.exports = React.createClass({
} else { } else {
return ( return (
<form onSubmit={this.onPopulateInvite}> <form onSubmit={this.onPopulateInvite}>
<input className="mx_MemberList_invite" ref="invite" placeholder="Invite another user"/> <input className="mx_MemberList_invite" ref="invite" placeholder="Invite user (email)"/>
</form> </form>
); );
} }