1 | /*
|
2 | Copyright 2016 OpenMarket Ltd
|
3 |
|
4 | Licensed under the Apache License, Version 2.0 (the "License");
|
5 | you may not use this file except in compliance with the License.
|
6 | You may obtain a copy of the License at
|
7 |
|
8 | http://www.apache.org/licenses/LICENSE-2.0
|
9 |
|
10 | Unless required by applicable law or agreed to in writing, software
|
11 | distributed under the License is distributed on an "AS IS" BASIS,
|
12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
13 | See the License for the specific language governing permissions and
|
14 | limitations under the License.
|
15 | */
|
16 | ;
|
17 |
|
18 | /** @module timeline-window */
|
19 |
|
20 | var _bluebird = require("bluebird");
|
21 |
|
22 | var _bluebird2 = _interopRequireDefault(_bluebird);
|
23 |
|
24 | function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
25 |
|
26 | var EventTimeline = require("./models/event-timeline");
|
27 |
|
28 | /**
|
29 | * @private
|
30 | */
|
31 | var DEBUG = false;
|
32 |
|
33 | /**
|
34 | * @private
|
35 | */
|
36 | var debuglog = DEBUG ? console.log.bind(console) : function () {};
|
37 |
|
38 | /**
|
39 | * the number of times we ask the server for more events before giving up
|
40 | *
|
41 | * @private
|
42 | */
|
43 | var DEFAULT_PAGINATE_LOOP_LIMIT = 5;
|
44 |
|
45 | /**
|
46 | * Construct a TimelineWindow.
|
47 | *
|
48 | * <p>This abstracts the separate timelines in a Matrix {@link
|
49 | * module:models/room|Room} into a single iterable thing. It keeps track of
|
50 | * the start and endpoints of the window, which can be advanced with the help
|
51 | * of pagination requests.
|
52 | *
|
53 | * <p>Before the window is useful, it must be initialised by calling {@link
|
54 | * module:timeline-window~TimelineWindow#load|load}.
|
55 | *
|
56 | * <p>Note that the window will not automatically extend itself when new events
|
57 | * are received from /sync; you should arrange to call {@link
|
58 | * module:timeline-window~TimelineWindow#paginate|paginate} on {@link
|
59 | * module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events.
|
60 | *
|
61 | * @param {MatrixClient} client MatrixClient to be used for context/pagination
|
62 | * requests.
|
63 | *
|
64 | * @param {EventTimelineSet} timelineSet The timelineSet to track
|
65 | *
|
66 | * @param {Object} [opts] Configuration options for this window
|
67 | *
|
68 | * @param {number} [opts.windowLimit = 1000] maximum number of events to keep
|
69 | * in the window. If more events are retrieved via pagination requests,
|
70 | * excess events will be dropped from the other end of the window.
|
71 | *
|
72 | * @constructor
|
73 | */
|
74 | function TimelineWindow(client, timelineSet, opts) {
|
75 | opts = opts || {};
|
76 | this._client = client;
|
77 | this._timelineSet = timelineSet;
|
78 |
|
79 | // these will be TimelineIndex objects; they delineate the 'start' and
|
80 | // 'end' of the window.
|
81 | //
|
82 | // _start.index is inclusive; _end.index is exclusive.
|
83 | this._start = null;
|
84 | this._end = null;
|
85 |
|
86 | this._eventCount = 0;
|
87 | this._windowLimit = opts.windowLimit || 1000;
|
88 | }
|
89 |
|
90 | /**
|
91 | * Initialise the window to point at a given event, or the live timeline
|
92 | *
|
93 | * @param {string} [initialEventId] If given, the window will contain the
|
94 | * given event
|
95 | * @param {number} [initialWindowSize = 20] Size of the initial window
|
96 | *
|
97 | * @return {module:client.Promise}
|
98 | */
|
99 | TimelineWindow.prototype.load = function (initialEventId, initialWindowSize) {
|
100 | var self = this;
|
101 | initialWindowSize = initialWindowSize || 20;
|
102 |
|
103 | // given an EventTimeline, find the event we were looking for, and initialise our
|
104 | // fields so that the event in question is in the middle of the window.
|
105 | var initFields = function initFields(timeline) {
|
106 | var eventIndex = void 0;
|
107 |
|
108 | var events = timeline.getEvents();
|
109 |
|
110 | if (!initialEventId) {
|
111 | // we were looking for the live timeline: initialise to the end
|
112 | eventIndex = events.length;
|
113 | } else {
|
114 | for (var i = 0; i < events.length; i++) {
|
115 | if (events[i].getId() == initialEventId) {
|
116 | eventIndex = i;
|
117 | break;
|
118 | }
|
119 | }
|
120 |
|
121 | if (eventIndex === undefined) {
|
122 | throw new Error("getEventTimeline result didn't include requested event");
|
123 | }
|
124 | }
|
125 |
|
126 | var endIndex = Math.min(events.length, eventIndex + Math.ceil(initialWindowSize / 2));
|
127 | var startIndex = Math.max(0, endIndex - initialWindowSize);
|
128 | self._start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex());
|
129 | self._end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex());
|
130 | self._eventCount = endIndex - startIndex;
|
131 | };
|
132 |
|
133 | // We avoid delaying the resolution of the promise by a reactor tick if
|
134 | // we already have the data we need, which is important to keep room-switching
|
135 | // feeling snappy.
|
136 | //
|
137 | if (initialEventId) {
|
138 | var prom = this._client.getEventTimeline(this._timelineSet, initialEventId);
|
139 |
|
140 | if (prom.isFulfilled()) {
|
141 | initFields(prom.value());
|
142 | return _bluebird2.default.resolve();
|
143 | } else {
|
144 | return prom.then(initFields);
|
145 | }
|
146 | } else {
|
147 | var tl = this._timelineSet.getLiveTimeline();
|
148 | initFields(tl);
|
149 | return _bluebird2.default.resolve();
|
150 | }
|
151 | };
|
152 |
|
153 | /**
|
154 | * Check if this window can be extended
|
155 | *
|
156 | * <p>This returns true if we either have more events, or if we have a
|
157 | * pagination token which means we can paginate in that direction. It does not
|
158 | * necessarily mean that there are more events available in that direction at
|
159 | * this time.
|
160 | *
|
161 | * @param {string} direction EventTimeline.BACKWARDS to check if we can
|
162 | * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards
|
163 | *
|
164 | * @return {boolean} true if we can paginate in the given direction
|
165 | */
|
166 | TimelineWindow.prototype.canPaginate = function (direction) {
|
167 | var tl = void 0;
|
168 | if (direction == EventTimeline.BACKWARDS) {
|
169 | tl = this._start;
|
170 | } else if (direction == EventTimeline.FORWARDS) {
|
171 | tl = this._end;
|
172 | } else {
|
173 | throw new Error("Invalid direction '" + direction + "'");
|
174 | }
|
175 |
|
176 | if (!tl) {
|
177 | debuglog("TimelineWindow: no timeline yet");
|
178 | return false;
|
179 | }
|
180 |
|
181 | if (direction == EventTimeline.BACKWARDS) {
|
182 | if (tl.index > tl.minIndex()) {
|
183 | return true;
|
184 | }
|
185 | } else {
|
186 | if (tl.index < tl.maxIndex()) {
|
187 | return true;
|
188 | }
|
189 | }
|
190 |
|
191 | return Boolean(tl.timeline.getNeighbouringTimeline(direction) || tl.timeline.getPaginationToken(direction));
|
192 | };
|
193 |
|
194 | /**
|
195 | * Attempt to extend the window
|
196 | *
|
197 | * @param {string} direction EventTimeline.BACKWARDS to extend the window
|
198 | * backwards (towards older events); EventTimeline.FORWARDS to go forwards.
|
199 | *
|
200 | * @param {number} size number of events to try to extend by. If fewer than this
|
201 | * number are immediately available, then we return immediately rather than
|
202 | * making an API call.
|
203 | *
|
204 | * @param {boolean} [makeRequest = true] whether we should make API calls to
|
205 | * fetch further events if we don't have any at all. (This has no effect if
|
206 | * the room already knows about additional events in the relevant direction,
|
207 | * even if there are fewer than 'size' of them, as we will just return those
|
208 | * we already know about.)
|
209 | *
|
210 | * @param {number} [requestLimit = 5] limit for the number of API requests we
|
211 | * should make.
|
212 | *
|
213 | * @return {module:client.Promise} Resolves to a boolean which is true if more events
|
214 | * were successfully retrieved.
|
215 | */
|
216 | TimelineWindow.prototype.paginate = function (direction, size, makeRequest, requestLimit) {
|
217 | // Either wind back the message cap (if there are enough events in the
|
218 | // timeline to do so), or fire off a pagination request.
|
219 |
|
220 | if (makeRequest === undefined) {
|
221 | makeRequest = true;
|
222 | }
|
223 |
|
224 | if (requestLimit === undefined) {
|
225 | requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT;
|
226 | }
|
227 |
|
228 | var tl = void 0;
|
229 | if (direction == EventTimeline.BACKWARDS) {
|
230 | tl = this._start;
|
231 | } else if (direction == EventTimeline.FORWARDS) {
|
232 | tl = this._end;
|
233 | } else {
|
234 | throw new Error("Invalid direction '" + direction + "'");
|
235 | }
|
236 |
|
237 | if (!tl) {
|
238 | debuglog("TimelineWindow: no timeline yet");
|
239 | return _bluebird2.default.resolve(false);
|
240 | }
|
241 |
|
242 | if (tl.pendingPaginate) {
|
243 | return tl.pendingPaginate;
|
244 | }
|
245 |
|
246 | // try moving the cap
|
247 | var count = direction == EventTimeline.BACKWARDS ? tl.retreat(size) : tl.advance(size);
|
248 |
|
249 | if (count) {
|
250 | this._eventCount += count;
|
251 | debuglog("TimelineWindow: increased cap by " + count + " (now " + this._eventCount + ")");
|
252 | // remove some events from the other end, if necessary
|
253 | var excess = this._eventCount - this._windowLimit;
|
254 | if (excess > 0) {
|
255 | this.unpaginate(excess, direction != EventTimeline.BACKWARDS);
|
256 | }
|
257 | return _bluebird2.default.resolve(true);
|
258 | }
|
259 |
|
260 | if (!makeRequest || requestLimit === 0) {
|
261 | // todo: should we return something different to indicate that there
|
262 | // might be more events out there, but we haven't found them yet?
|
263 | return _bluebird2.default.resolve(false);
|
264 | }
|
265 |
|
266 | // try making a pagination request
|
267 | var token = tl.timeline.getPaginationToken(direction);
|
268 | if (!token) {
|
269 | debuglog("TimelineWindow: no token");
|
270 | return _bluebird2.default.resolve(false);
|
271 | }
|
272 |
|
273 | debuglog("TimelineWindow: starting request");
|
274 | var self = this;
|
275 |
|
276 | var prom = this._client.paginateEventTimeline(tl.timeline, {
|
277 | backwards: direction == EventTimeline.BACKWARDS,
|
278 | limit: size
|
279 | }).finally(function () {
|
280 | tl.pendingPaginate = null;
|
281 | }).then(function (r) {
|
282 | debuglog("TimelineWindow: request completed with result " + r);
|
283 | if (!r) {
|
284 | // end of timeline
|
285 | return false;
|
286 | }
|
287 |
|
288 | // recurse to advance the index into the results.
|
289 | //
|
290 | // If we don't get any new events, we want to make sure we keep asking
|
291 | // the server for events for as long as we have a valid pagination
|
292 | // token. In particular, we want to know if we've actually hit the
|
293 | // start of the timeline, or if we just happened to know about all of
|
294 | // the events thanks to https://matrix.org/jira/browse/SYN-645.
|
295 | //
|
296 | // On the other hand, we necessarily want to wait forever for the
|
297 | // server to make its mind up about whether there are other events,
|
298 | // because it gives a bad user experience
|
299 | // (https://github.com/vector-im/vector-web/issues/1204).
|
300 | return self.paginate(direction, size, true, requestLimit - 1);
|
301 | });
|
302 | tl.pendingPaginate = prom;
|
303 | return prom;
|
304 | };
|
305 |
|
306 | /**
|
307 | * Remove `delta` events from the start or end of the timeline.
|
308 | *
|
309 | * @param {number} delta number of events to remove from the timeline
|
310 | * @param {boolean} startOfTimeline if events should be removed from the start
|
311 | * of the timeline.
|
312 | */
|
313 | TimelineWindow.prototype.unpaginate = function (delta, startOfTimeline) {
|
314 | var tl = startOfTimeline ? this._start : this._end;
|
315 |
|
316 | // sanity-check the delta
|
317 | if (delta > this._eventCount || delta < 0) {
|
318 | throw new Error("Attemting to unpaginate " + delta + " events, but " + "only have " + this._eventCount + " in the timeline");
|
319 | }
|
320 |
|
321 | while (delta > 0) {
|
322 | var count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta);
|
323 | if (count <= 0) {
|
324 | // sadness. This shouldn't be possible.
|
325 | throw new Error("Unable to unpaginate any further, but still have " + this._eventCount + " events");
|
326 | }
|
327 |
|
328 | delta -= count;
|
329 | this._eventCount -= count;
|
330 | debuglog("TimelineWindow.unpaginate: dropped " + count + " (now " + this._eventCount + ")");
|
331 | }
|
332 | };
|
333 |
|
334 | /**
|
335 | * Get a list of the events currently in the window
|
336 | *
|
337 | * @return {MatrixEvent[]} the events in the window
|
338 | */
|
339 | TimelineWindow.prototype.getEvents = function () {
|
340 | if (!this._start) {
|
341 | // not yet loaded
|
342 | return [];
|
343 | }
|
344 |
|
345 | var result = [];
|
346 |
|
347 | // iterate through each timeline between this._start and this._end
|
348 | // (inclusive).
|
349 | var timeline = this._start.timeline;
|
350 | while (true) {
|
351 | var events = timeline.getEvents();
|
352 |
|
353 | // For the first timeline in the chain, we want to start at
|
354 | // this._start.index. For the last timeline in the chain, we want to
|
355 | // stop before this._end.index. Otherwise, we want to copy all of the
|
356 | // events in the timeline.
|
357 | //
|
358 | // (Note that both this._start.index and this._end.index are relative
|
359 | // to their respective timelines' BaseIndex).
|
360 | //
|
361 | var startIndex = 0,
|
362 | endIndex = events.length;
|
363 | if (timeline === this._start.timeline) {
|
364 | startIndex = this._start.index + timeline.getBaseIndex();
|
365 | }
|
366 | if (timeline === this._end.timeline) {
|
367 | endIndex = this._end.index + timeline.getBaseIndex();
|
368 | }
|
369 |
|
370 | for (var i = startIndex; i < endIndex; i++) {
|
371 | result.push(events[i]);
|
372 | }
|
373 |
|
374 | // if we're not done, iterate to the next timeline.
|
375 | if (timeline === this._end.timeline) {
|
376 | break;
|
377 | } else {
|
378 | timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS);
|
379 | }
|
380 | }
|
381 |
|
382 | return result;
|
383 | };
|
384 |
|
385 | /**
|
386 | * a thing which contains a timeline reference, and an index into it.
|
387 | *
|
388 | * @constructor
|
389 | * @param {EventTimeline} timeline
|
390 | * @param {number} index
|
391 | * @private
|
392 | */
|
393 | function TimelineIndex(timeline, index) {
|
394 | this.timeline = timeline;
|
395 |
|
396 | // the indexes are relative to BaseIndex, so could well be negative.
|
397 | this.index = index;
|
398 | }
|
399 |
|
400 | /**
|
401 | * @return {number} the minimum possible value for the index in the current
|
402 | * timeline
|
403 | */
|
404 | TimelineIndex.prototype.minIndex = function () {
|
405 | return this.timeline.getBaseIndex() * -1;
|
406 | };
|
407 |
|
408 | /**
|
409 | * @return {number} the maximum possible value for the index in the current
|
410 | * timeline (exclusive - ie, it actually returns one more than the index
|
411 | * of the last element).
|
412 | */
|
413 | TimelineIndex.prototype.maxIndex = function () {
|
414 | return this.timeline.getEvents().length - this.timeline.getBaseIndex();
|
415 | };
|
416 |
|
417 | /**
|
418 | * Try move the index forward, or into the neighbouring timeline
|
419 | *
|
420 | * @param {number} delta number of events to advance by
|
421 | * @return {number} number of events successfully advanced by
|
422 | */
|
423 | TimelineIndex.prototype.advance = function (delta) {
|
424 | if (!delta) {
|
425 | return 0;
|
426 | }
|
427 |
|
428 | // first try moving the index in the current timeline. See if there is room
|
429 | // to do so.
|
430 | var cappedDelta = void 0;
|
431 | if (delta < 0) {
|
432 | // we want to wind the index backwards.
|
433 | //
|
434 | // (this.minIndex() - this.index) is a negative number whose magnitude
|
435 | // is the amount of room we have to wind back the index in the current
|
436 | // timeline. We cap delta to this quantity.
|
437 | cappedDelta = Math.max(delta, this.minIndex() - this.index);
|
438 | if (cappedDelta < 0) {
|
439 | this.index += cappedDelta;
|
440 | return cappedDelta;
|
441 | }
|
442 | } else {
|
443 | // we want to wind the index forwards.
|
444 | //
|
445 | // (this.maxIndex() - this.index) is a (positive) number whose magnitude
|
446 | // is the amount of room we have to wind forward the index in the current
|
447 | // timeline. We cap delta to this quantity.
|
448 | cappedDelta = Math.min(delta, this.maxIndex() - this.index);
|
449 | if (cappedDelta > 0) {
|
450 | this.index += cappedDelta;
|
451 | return cappedDelta;
|
452 | }
|
453 | }
|
454 |
|
455 | // the index is already at the start/end of the current timeline.
|
456 | //
|
457 | // next see if there is a neighbouring timeline to switch to.
|
458 | var neighbour = this.timeline.getNeighbouringTimeline(delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS);
|
459 | if (neighbour) {
|
460 | this.timeline = neighbour;
|
461 | if (delta < 0) {
|
462 | this.index = this.maxIndex();
|
463 | } else {
|
464 | this.index = this.minIndex();
|
465 | }
|
466 |
|
467 | debuglog("paginate: switched to new neighbour");
|
468 |
|
469 | // recurse, using the next timeline
|
470 | return this.advance(delta);
|
471 | }
|
472 |
|
473 | return 0;
|
474 | };
|
475 |
|
476 | /**
|
477 | * Try move the index backwards, or into the neighbouring timeline
|
478 | *
|
479 | * @param {number} delta number of events to retreat by
|
480 | * @return {number} number of events successfully retreated by
|
481 | */
|
482 | TimelineIndex.prototype.retreat = function (delta) {
|
483 | return this.advance(delta * -1) * -1;
|
484 | };
|
485 |
|
486 | /**
|
487 | * The TimelineWindow class.
|
488 | */
|
489 | module.exports.TimelineWindow = TimelineWindow;
|
490 |
|
491 | /**
|
492 | * The TimelineIndex class. exported here for unit testing.
|
493 | */
|
494 | module.exports.TimelineIndex = TimelineIndex;
|
495 | //# sourceMappingURL=timeline-window.js.map |
\ | No newline at end of file |