UNPKG

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