diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 318489d9fd..fe9643a7fc 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,49 @@ 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); + if (this.state.searchResults.next_batch) { + debuglog("requesting more search results"); + var searchPromise = MatrixClientPeg.get().backPaginateRoomEventsSearch( + this.state.searchResults); + return this._handleSearchResult(searchPromise); } 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); } }, @@ -486,10 +487,8 @@ module.exports = React.createClass({ this.setState({ searchTerm: term, searchScope: scope, - searchResults: [], + searchResults: {}, searchHighlights: [], - searchCount: null, - searchCanPaginate: null, }); // 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.nextSearchBatch = null; - this._getSearchBatch(term, scope); + // make sure that we don't end up showing results from + // 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 - _getSearchBatch: function(term, scope) { + _handleSearchResult: function(searchPromise) { + 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({ searchInProgress: true, }); - // make sure that we don't end up merging results from - // different searches by keeping a unique id. - // - // 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) { + return searchPromise.then(function(results) { + debuglog("search complete"); + if (!self.state.searching || self.searchId != localSearchId) { console.error("Discarding stale search results"); 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 - // strings which actually got matched for highlighting. - - // 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 ]; + var highlights = results.highlights; + if (highlights.indexOf(self.state.searchTerm) < 0) { + highlights = highlights.concat(self.state.searchTerm); } - // append the new results to our existing results - var events = self.state.searchResults.concat(results.results); + // For overlapping highlights, + // favour longer (more specific) terms first + highlights = highlights.sort(function(a, b) { b.length - a.length }); self.setState({ searchHighlights: highlights, - searchResults: events, - searchCount: results.count, - searchCanPaginate: !!(results.next_batch), + searchResults: results, }); - self.nextSearchBatch = results.next_batch; }, function(error) { var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createDialog(ErrorDialog, { @@ -566,51 +569,27 @@ module.exports = React.createClass({ self.setState({ 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() { var DateSeparator = sdk.getComponent('messages.DateSeparator'); - var cli = MatrixClientPeg.get(); - - var ret = []; - var EventTile = sdk.getComponent('rooms.EventTile'); + var cli = MatrixClientPeg.get(); // XXX: todo: merge overlapping results somehow? // XXX: why doesn't searching on name work? + if (this.state.searchResults.results === undefined) { + // awaiting results + return []; + } - if (this.state.searchCanPaginate === false) { - if (this.state.searchResults.length == 0) { + var ret = []; + + if (!this.state.searchResults.next_batch) { + if (this.state.searchResults.results.length == 0) { ret.push(
  • No results

  • @@ -625,9 +604,10 @@ module.exports = React.createClass({ var lastRoomId; - for (var i = this.state.searchResults.length - 1; i >= 0; i--) { - var result = this.state.searchResults[i]; - var mxEv = new Matrix.MatrixEvent(result.result); + for (var i = this.state.searchResults.results.length - 1; i >= 0; i--) { + var result = this.state.searchResults.results[i]; + + var mxEv = result.context.getEvent(); if (!EventTile.haveTileForEvent(mxEv)) { // XXX: can this ever happen? It will make the result count @@ -638,29 +618,28 @@ module.exports = React.createClass({ var eventId = mxEv.getId(); if (this.state.searchScope === 'All') { - var roomId = result.result.room_id; + var roomId = mxEv.getRoomId(); if(roomId != lastRoomId) { ret.push(
  • Room: { cli.getRoom(roomId).name }

  • ); lastRoomId = roomId; } } - var ts1 = result.result.origin_server_ts; + var ts1 = mxEv.getTs(); ret.push(
  • ); // Rank: {resultList[i].rank} - if (result.context.events_before[0]) { - var mxEv2 = new Matrix.MatrixEvent(result.context.events_before[0]); - if (EventTile.haveTileForEvent(mxEv2)) { - ret.push(
  • ); + var timeline = result.context.getTimeline(); + for (var j = 0; j < timeline.length; j++) { + var ev = timeline[j]; + var highlights; + var contextual = (j != result.context.getOurEventIndex()); + if (!contextual) { + highlights = this.state.searchHighlights; } - } - - ret.push(
  • ); - - if (result.context.events_after[0]) { - var mxEv2 = new Matrix.MatrixEvent(result.context.events_after[0]); - if (EventTile.haveTileForEvent(mxEv2)) { - ret.push(
  • ); + if (EventTile.haveTileForEvent(ev)) { + ret.push(
  • + +
  • ); } } } @@ -1296,7 +1275,7 @@ module.exports = React.createClass({ searchInfo = { searchTerm : this.state.searchTerm, searchScope : this.state.searchScope, - searchCount : this.state.searchCount, + searchCount : this.state.searchResults.count, }; } diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 4a45bf5a39..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,7 +138,7 @@ 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); @@ -135,11 +160,74 @@ module.exports = React.createClass({ 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 @@ -163,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 @@ -206,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() { 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 (
    - +
    ); } diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.js index 13959a16b9..513b4bf192 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.js @@ -109,8 +109,8 @@ module.exports = React.createClass({ var searchStatus; // don't display the search count until the search completes and - // gives us a non-null searchCount. - if (this.props.searchInfo && this.props.searchInfo.searchCount !== null) { + // gives us a valid (possibly zero) searchCount. + if (this.props.searchInfo && this.props.searchInfo.searchCount !== undefined && this.props.searchInfo.searchCount !== null) { searchStatus =
     (~{ this.props.searchInfo.searchCount } results)
    ; }