diff --git a/lib/client.js b/lib/client.js index e66d25b51..f2a6a9768 100644 --- a/lib/client.js +++ b/lib/client.js @@ -12,6 +12,7 @@ var q = require("q"); var httpApi = require("./http-api"); var MatrixEvent = require("./models/event").MatrixEvent; var EventStatus = require("./models/event").EventStatus; +var SearchResult = require("./models/search-result"); var StubStore = require("./store/stub"); var webRtcCall = require("./webrtc/call"); var utils = require("./utils"); @@ -1901,6 +1902,73 @@ MatrixClient.prototype.scrollback = function(room, limit, callback) { return defer.promise; }; +/** + * Take an EventContext, and back/forward-fill results. + * + * @param {module:models/event-context.EventContext} eventContext context + * object to be updated + * @param {Object} opts + * @param {boolean} opts.backwards true to fill backwards, false to go forwards + * @param {boolean} opts.limit number of events to request + * + * @return {module:client.Promise} Resolves: updated EventContext object + * @return {Error} Rejects: with an error response. + */ +MatrixClient.prototype.paginateEventContext = function(eventContext, opts) { + // TODO: we should implement a backoff (as per scrollback()) to deal more + // nicely with HTTP errors. + opts = opts || {}; + var backwards = opts.backwards || false; + + var token = eventContext.getPaginateToken(backwards); + if (!token) { + // no more results. + return q.reject(new Error("No paginate token")); + } + + var dir = backwards ? 'b' : 'f'; + var pendingRequest = eventContext._paginateRequests[dir]; + + if (pendingRequest) { + // already a request in progress - return the existing promise + return pendingRequest; + } + + var path = utils.encodeUri( + "/rooms/$roomId/messages", {$roomId: eventContext.getEvent().getRoomId()} + ); + var params = { + from: token, + limit: ('limit' in opts) ? opts.limit : 30, + dir: dir + }; + + var self = this; + var promise = + self._http.authedRequest(undefined, "GET", path, params + ).then(function(res) { + var token = res.end; + if (res.chunk.length === 0) { + token = null; + } else { + var matrixEvents = utils.map(res.chunk, self.getEventMapper()); + if (backwards) { + // eventContext expects the events in timeline order, but + // back-pagination returns them in reverse order. + matrixEvents.reverse(); + } + eventContext.addEvents(matrixEvents, backwards); + } + eventContext.setPaginateToken(token, backwards); + return eventContext; + }).finally(function() { + eventContext._paginateRequests[dir] = null; + }); + eventContext._paginateRequests[dir] = promise; + + return promise; +}; + // Registration/Login operations // ============================= @@ -2095,6 +2163,122 @@ MatrixClient.prototype.searchMessageText = function(opts, callback) { }, callback); }; +/** + * Perform a server-side search for room events. + * + * The returned promise resolves to an object containing the fields: + * + * * {number} count: estimate of the number of results + * * {string} next_batch: token for back-pagination; if undefined, there are + * no more results + * * {Array} highlights: a list of words to highlight from the stemming + * algorithm + * * {Array} results: a list of results + * + * Each entry in the results list is a {module:models/search-result.SearchResult}. + * + * @param {Object} opts + * @param {string} opts.term the term to search for + * @param {Object} opts.filter a JSON filter object to pass in the request + * @return {module:client.Promise} Resolves: result object + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixClient.prototype.searchRoomEvents = function(opts) { + // TODO: support groups + + var body = { + search_categories: { + room_events: { + search_term: opts.term, + filter: opts.filter, + order_by: "recent", + event_context: { + before_limit: 1, + after_limit: 1, + include_profile: true, + } + } + } + }; + + var searchResults = { + _query: body, + results: [], + highlights: [], + }; + + return this.search({body: body}).then( + this._processRoomEventsSearch.bind(this, searchResults) + ); +}; + +/** + * Take a result from an earlier searchRoomEvents call, and backfill results. + * + * @param {object} searchResults the results object to be updated + * @return {module:client.Promise} Resolves: updated result object + * @return {Error} Rejects: with an error response. + */ +MatrixClient.prototype.backPaginateRoomEventsSearch = function(searchResults) { + // TODO: we should implement a backoff (as per scrollback()) to deal more + // nicely with HTTP errors. + + if (!searchResults.next_batch) { + return q.reject(new Error("Cannot backpaginate event search any further")); + } + + if (searchResults.pendingRequest) { + // already a request in progress - return the existing promise + return searchResults.pendingRequest; + } + + var searchOpts = { + body: searchResults._query, + next_batch: searchResults.next_batch, + }; + + var promise = this.search(searchOpts).then( + this._processRoomEventsSearch.bind(this, searchResults) + ).finally(function() { + searchResults.pendingRequest = null; + }); + searchResults.pendingRequest = promise; + + return promise; +}; + +/** + * helper for searchRoomEvents and backPaginateRoomEventsSearch. Processes the + * response from the API call and updates the searchResults + * + * @param {Object} searchResults + * @param {Object} response + * @return {Object} searchResults + * @private + */ +MatrixClient.prototype._processRoomEventsSearch = function(searchResults, response) { + var room_events = response.search_categories.room_events; + + searchResults.count = room_events.count; + searchResults.next_batch = room_events.next_batch; + + // combine the highlight list with our existing list; build an object + // to avoid O(N^2) fail + var highlights = {}; + room_events.highlights.forEach(function(hl) { highlights[hl] = 1; }); + searchResults.highlights.forEach(function(hl) { highlights[hl] = 1; }); + + // turn it back into a list. + searchResults.highlights = Object.keys(highlights); + + // append the new results to our existing results + for (var i = 0; i < room_events.results.length; i++) { + var sr = SearchResult.fromJson(room_events.results[i], this.getEventMapper()); + searchResults.results.push(sr); + } + return searchResults; +}; + /** * Perform a server-side search. * @param {Object} opts diff --git a/lib/models/event-context.js b/lib/models/event-context.js new file mode 100644 index 000000000..71ec12cb4 --- /dev/null +++ b/lib/models/event-context.js @@ -0,0 +1,104 @@ +"use strict"; + +/** + * @module models/event-context + */ + +/** + * Construct a new EventContext + * + * An eventcontext is used for circumstances such as search results, when we + * have a particular event of interest, and a bunch of events before and after + * it. + * + * It also stores pagination tokens for going backwards and forwards in the + * timeline. + * + * @param {MatrixEvent} ourEvent the event at the centre of this context + * + * @constructor + */ +function EventContext(ourEvent) { + this._timeline = [ourEvent]; + this._ourEventIndex = 0; + this._paginateTokens = {b: null, f: null}; + + // this is used by MatrixClient to keep track of active requests + this._paginateRequests = {b: null, f: null}; +} + +/** + * Get the main event of interest + * + * This is a convenience function for getTimeline()[getOurEventIndex()]. + * + * @return {MatrixEvent} The event at the centre of this context. + */ +EventContext.prototype.getEvent = function() { + return this._timeline[this._ourEventIndex]; +}; + +/** + * Get the list of events in this context + * + * @return {Array} An array of MatrixEvents + */ +EventContext.prototype.getTimeline = function() { + return this._timeline; +}; + +/** + * Get the index in the timeline of our event + * + * @return {Number} + */ +EventContext.prototype.getOurEventIndex = function() { + return this._ourEventIndex; +}; + +/** + * Get a pagination token. + * + * @param {boolean} backwards true to get the pagination token for going + * backwards in time + * @return {string} + */ +EventContext.prototype.getPaginateToken = function(backwards) { + return this._paginateTokens[backwards ? 'b' : 'f']; +}; + +/** + * Set a pagination token. + * + * Generally this will be used only by the matrix js sdk. + * + * @param {string} token pagination token + * @param {boolean} backwards true to set the pagination token for going + * backwards in time + */ +EventContext.prototype.setPaginateToken = function(token, backwards) { + this._paginateTokens[backwards ? 'b' : 'f'] = token; +}; + +/** + * Add more events to the timeline + * + * @param {Array} events new events, in timeline order + * @param {boolean} atStart true to insert new events at the start + */ +EventContext.prototype.addEvents = function(events, atStart) { + // TODO: should we share logic with Room.addEventsToTimeline? + // Should Room even use EventContext? + + if (atStart) { + this._timeline = events.concat(this._timeline); + this._ourEventIndex += events.length; + } else { + this._timeline = this._timeline.concat(events); + } +}; + +/** + * The EventContext class + */ +module.exports = EventContext; diff --git a/lib/models/search-result.js b/lib/models/search-result.js new file mode 100644 index 000000000..675e5e2d4 --- /dev/null +++ b/lib/models/search-result.js @@ -0,0 +1,51 @@ +"use strict"; + +/** + * @module models/search-result + */ + +var EventContext = require("./event-context"); +var utils = require("../utils"); + +/** + * Construct a new SearchResult + * + * @param {number} rank where this SearchResult ranks in the results + * @param {event-context.EventContext} eventContext the matching event and its + * context + * + * @constructor + */ +function SearchResult(rank, eventContext) { + this.rank = rank; + this.context = eventContext; +} + +/** + * Create a SearchResponse from the response to /search + * @static + * @param {Object} jsonObj + * @param {function} eventMapper + * @return {SearchResult} + */ + +SearchResult.fromJson = function(jsonObj, eventMapper) { + var jsonContext = jsonObj.context || {}; + var events_before = jsonContext.events_before || []; + var events_after = jsonContext.events_after || []; + + var context = new EventContext(eventMapper(jsonObj.result)); + + context.setPaginateToken(jsonContext.start, true); + context.addEvents(utils.map(events_before, eventMapper), true); + context.addEvents(utils.map(events_after, eventMapper), false); + context.setPaginateToken(jsonContext.end, false); + + return new SearchResult(jsonObj.rank, context); +}; + + +/** + * The SearchResult class + */ +module.exports = SearchResult;