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 | import Promise from 'bluebird';
|
21 | const EventTimeline = require("./models/event-timeline");
|
22 |
|
23 | /**
|
24 | * @private
|
25 | */
|
26 | const DEBUG = false;
|
27 |
|
28 | /**
|
29 | * @private
|
30 | */
|
31 | const 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 | */
|
38 | const 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 | */
|
69 | function 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 | */
|
94 | TimelineWindow.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 | */
|
162 | TimelineWindow.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 | */
|
213 | TimelineWindow.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 | */
|
314 | TimelineWindow.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 | */
|
345 | TimelineWindow.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 | */
|
399 | function 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 | */
|
410 | TimelineIndex.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 | */
|
419 | TimelineIndex.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 | */
|
429 | TimelineIndex.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 | */
|
489 | TimelineIndex.prototype.retreat = function(delta) {
|
490 | return this.advance(delta * -1) * -1;
|
491 | };
|
492 |
|
493 | /**
|
494 | * The TimelineWindow class.
|
495 | */
|
496 | module.exports.TimelineWindow = TimelineWindow;
|
497 |
|
498 | /**
|
499 | * The TimelineIndex class. exported here for unit testing.
|
500 | */
|
501 | module.exports.TimelineIndex = TimelineIndex;
|