Merge branch 'develop' into kegan/guest-access

This commit is contained in:
Kegan Dougal 2016-01-06 13:59:33 +00:00
commit afbb451d4a
4 changed files with 202 additions and 135 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,49 @@ 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.state.searchResults.next_batch) {
if (DEBUG_SCROLL) console.log("requesting more search results"); debuglog("requesting more search results");
this._getSearchBatch(this.state.searchTerm, var searchPromise = MatrixClientPeg.get().backPaginateRoomEventsSearch(
this.state.searchScope); this.state.searchResults);
return this._handleSearchResult(searchPromise);
} 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);
} }
}, },
@ -486,10 +487,8 @@ module.exports = React.createClass({
this.setState({ this.setState({
searchTerm: term, searchTerm: term,
searchScope: scope, searchScope: scope,
searchResults: [], searchResults: {},
searchHighlights: [], searchHighlights: [],
searchCount: null,
searchCanPaginate: null,
}); });
// if we already have a search panel, we need to tell it to forget // if we already have a search panel, we need to tell it to forget
@ -498,64 +497,68 @@ module.exports = React.createClass({
this.refs.searchResultsPanel.resetScrollState(); this.refs.searchResultsPanel.resetScrollState();
} }
this.nextSearchBatch = null; // make sure that we don't end up showing results from
this._getSearchBatch(term, scope); // an aborted search by keeping a unique id.
//
// todo: should cancel any previous search requests.
this.searchId = new Date().getTime();
var filter;
if (scope === "Room") {
filter = {
// XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :(
rooms: [
this.props.roomId
]
};
}
debuglog("sending search request");
var searchPromise = MatrixClientPeg.get().searchRoomEvents({
filter: filter,
term: term,
});
this._handleSearchResult(searchPromise).done();
}, },
// fire off a request for a batch of search results _handleSearchResult: function(searchPromise) {
_getSearchBatch: function(term, scope) { var self = this;
// keep a record of the current search id, so that if the search terms
// change before we get a response, we can ignore the results.
var localSearchId = this.searchId;
this.setState({ this.setState({
searchInProgress: true, searchInProgress: true,
}); });
// make sure that we don't end up merging results from return searchPromise.then(function(results) {
// different searches by keeping a unique id. debuglog("search complete");
// if (!self.state.searching || self.searchId != localSearchId) {
// todo: should cancel any previous search requests.
var searchId = this.searchId = new Date().getTime();
var self = this;
if (DEBUG_SCROLL) console.log("sending search request");
MatrixClientPeg.get().search({ body: this._getSearchCondition(term, scope),
next_batch: this.nextSearchBatch })
.then(function(data) {
if (DEBUG_SCROLL) console.log("search complete");
if (!self.state.searching || self.searchId != searchId) {
console.error("Discarding stale search results"); console.error("Discarding stale search results");
return; return;
} }
var results = data.search_categories.room_events; // postgres on synapse returns us precise details of the strings
// which actually got matched for highlighting.
//
// In either case, we want to highlight the literal search term
// whether it was used by the search engine or not.
// postgres on synapse returns us precise details of the var highlights = results.highlights;
// strings which actually got matched for highlighting. if (highlights.indexOf(self.state.searchTerm) < 0) {
highlights = highlights.concat(self.state.searchTerm);
// combine the highlight list with our existing list; build an object
// to avoid O(N^2) fail
var highlights = {};
results.highlights.forEach(function(hl) { highlights[hl] = 1; });
self.state.searchHighlights.forEach(function(hl) { highlights[hl] = 1; });
// turn it back into an ordered list. For overlapping highlights,
// favour longer (more specific) terms first
highlights = Object.keys(highlights).sort(function(a, b) { b.length - a.length });
// sqlite doesn't give us any highlights, so just try to highlight the literal search term
if (highlights.length == 0) {
highlights = [ term ];
} }
// append the new results to our existing results // For overlapping highlights,
var events = self.state.searchResults.concat(results.results); // favour longer (more specific) terms first
highlights = highlights.sort(function(a, b) { b.length - a.length });
self.setState({ self.setState({
searchHighlights: highlights, searchHighlights: highlights,
searchResults: events, searchResults: results,
searchCount: results.count,
searchCanPaginate: !!(results.next_batch),
}); });
self.nextSearchBatch = results.next_batch;
}, function(error) { }, function(error) {
var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
@ -566,51 +569,27 @@ module.exports = React.createClass({
self.setState({ self.setState({
searchInProgress: false searchInProgress: false
}); });
}).done(); });
}, },
_getSearchCondition: function(term, scope) {
var filter;
if (scope === "Room") {
filter = {
// XXX: it's unintuitive that the filter for searching doesn't have the same shape as the v2 filter API :(
rooms: [
this.props.roomId
]
};
}
return {
search_categories: {
room_events: {
search_term: term,
filter: filter,
order_by: "recent",
event_context: {
before_limit: 1,
after_limit: 1,
include_profile: true,
}
}
}
}
},
getSearchResultTiles: function() { getSearchResultTiles: function() {
var DateSeparator = sdk.getComponent('messages.DateSeparator'); var DateSeparator = sdk.getComponent('messages.DateSeparator');
var cli = MatrixClientPeg.get();
var ret = [];
var EventTile = sdk.getComponent('rooms.EventTile'); var EventTile = sdk.getComponent('rooms.EventTile');
var cli = MatrixClientPeg.get();
// XXX: todo: merge overlapping results somehow? // XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work? // XXX: why doesn't searching on name work?
if (this.state.searchResults.results === undefined) {
// awaiting results
return [];
}
if (this.state.searchCanPaginate === false) { var ret = [];
if (this.state.searchResults.length == 0) {
if (!this.state.searchResults.next_batch) {
if (this.state.searchResults.results.length == 0) {
ret.push(<li key="search-top-marker"> ret.push(<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">No results</h2> <h2 className="mx_RoomView_topMarker">No results</h2>
</li> </li>
@ -625,9 +604,10 @@ module.exports = React.createClass({
var lastRoomId; var lastRoomId;
for (var i = this.state.searchResults.length - 1; i >= 0; i--) { for (var i = this.state.searchResults.results.length - 1; i >= 0; i--) {
var result = this.state.searchResults[i]; var result = this.state.searchResults.results[i];
var mxEv = new Matrix.MatrixEvent(result.result);
var mxEv = result.context.getEvent();
if (!EventTile.haveTileForEvent(mxEv)) { if (!EventTile.haveTileForEvent(mxEv)) {
// XXX: can this ever happen? It will make the result count // XXX: can this ever happen? It will make the result count
@ -638,29 +618,28 @@ module.exports = React.createClass({
var eventId = mxEv.getId(); var eventId = mxEv.getId();
if (this.state.searchScope === 'All') { if (this.state.searchScope === 'All') {
var roomId = result.result.room_id; var roomId = mxEv.getRoomId();
if(roomId != lastRoomId) { if(roomId != lastRoomId) {
ret.push(<li key={eventId + "-room"}><h1>Room: { cli.getRoom(roomId).name }</h1></li>); ret.push(<li key={eventId + "-room"}><h1>Room: { cli.getRoom(roomId).name }</h1></li>);
lastRoomId = roomId; lastRoomId = roomId;
} }
} }
var ts1 = result.result.origin_server_ts; var ts1 = mxEv.getTs();
ret.push(<li key={ts1 + "-search"}><DateSeparator ts={ts1}/></li>); // Rank: {resultList[i].rank} ret.push(<li key={ts1 + "-search"}><DateSeparator ts={ts1}/></li>); // Rank: {resultList[i].rank}
if (result.context.events_before[0]) { var timeline = result.context.getTimeline();
var mxEv2 = new Matrix.MatrixEvent(result.context.events_before[0]); for (var j = 0; j < timeline.length; j++) {
if (EventTile.haveTileForEvent(mxEv2)) { var ev = timeline[j];
ret.push(<li key={eventId+"-1"} data-scroll-token={eventId+"-1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>); var highlights;
var contextual = (j != result.context.getOurEventIndex());
if (!contextual) {
highlights = this.state.searchHighlights;
} }
} if (EventTile.haveTileForEvent(ev)) {
ret.push(<li key={eventId+"+"+j} data-scroll-token={eventId+"+"+j}>
ret.push(<li key={eventId+"+0"} data-scroll-token={eventId+"+0"}><EventTile mxEvent={mxEv} highlights={this.state.searchHighlights}/></li>); <EventTile mxEvent={ev} contextual={contextual} highlights={highlights} />
</li>);
if (result.context.events_after[0]) {
var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]);
if (EventTile.haveTileForEvent(mxEv2)) {
ret.push(<li key={eventId+"+1"} data-scroll-token={eventId+"+1"}><EventTile mxEvent={mxEv2} contextual={true} /></li>);
} }
} }
} }
@ -1296,7 +1275,7 @@ module.exports = React.createClass({
searchInfo = { searchInfo = {
searchTerm : this.state.searchTerm, searchTerm : this.state.searchTerm,
searchScope : this.state.searchScope, searchScope : this.state.searchScope,
searchCount : this.state.searchCount, searchCount : this.state.searchResults.count,
}; };
} }

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,7 +138,7 @@ 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);
@ -135,11 +160,74 @@ module.exports = React.createClass({
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
@ -163,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
@ -206,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() {

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>
); );
} }

View file

@ -109,8 +109,8 @@ module.exports = React.createClass({
var searchStatus; var searchStatus;
// don't display the search count until the search completes and // don't display the search count until the search completes and
// gives us a non-null searchCount. // gives us a valid (possibly zero) searchCount.
if (this.props.searchInfo && this.props.searchInfo.searchCount !== null) { if (this.props.searchInfo && this.props.searchInfo.searchCount !== undefined && this.props.searchInfo.searchCount !== null) {
searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;(~{ this.props.searchInfo.searchCount } results)</div>; searchStatus = <div className="mx_RoomHeader_searchStatus">&nbsp;(~{ this.props.searchInfo.searchCount } results)</div>;
} }