UNPKG

17.3 kBJavaScriptView Raw
1/*
2Copyright 2016 OpenMarket Ltd
3Copyright 2019 The Matrix.org Foundation C.I.C.
4
5Licensed under the Apache License, Version 2.0 (the "License");
6you may not use this file except in compliance with the License.
7You may obtain a copy of the License at
8
9 http://www.apache.org/licenses/LICENSE-2.0
10
11Unless required by applicable law or agreed to in writing, software
12distributed under the License is distributed on an "AS IS" BASIS,
13WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14See the License for the specific language governing permissions and
15limitations under the License.
16*/
17
18/** @module timeline-window */
19
20import {EventTimeline} from './models/event-timeline';
21import {logger} from './logger';
22
23/**
24 * @private
25 */
26const DEBUG = false;
27
28/**
29 * @private
30 */
31const debuglog = DEBUG ? logger.log.bind(logger) : 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 */
69export 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 {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 timeline = this._timelineSet.getTimelineForEvent(initialEventId);
135 if (timeline) {
136 // hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does.
137 initFields(timeline);
138 return Promise.resolve(timeline);
139 }
140
141 const prom = this._client.getEventTimeline(this._timelineSet, initialEventId);
142 return prom.then(initFields);
143 } else {
144 const tl = this._timelineSet.getLiveTimeline();
145 initFields(tl);
146 return Promise.resolve();
147 }
148};
149
150/**
151 * Get the TimelineIndex of the window in the given direction.
152 *
153 * @param {string} direction EventTimeline.BACKWARDS to get the TimelineIndex
154 * at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at
155 * the end.
156 *
157 * @return {TimelineIndex} The requested timeline index if one exists, null
158 * otherwise.
159 */
160TimelineWindow.prototype.getTimelineIndex = function(direction) {
161 if (direction == EventTimeline.BACKWARDS) {
162 return this._start;
163 } else if (direction == EventTimeline.FORWARDS) {
164 return this._end;
165 } else {
166 throw new Error("Invalid direction '" + direction + "'");
167 }
168};
169
170/**
171 * Try to extend the window using events that are already in the underlying
172 * TimelineIndex.
173 *
174 * @param {string} direction EventTimeline.BACKWARDS to try extending it
175 * backwards; EventTimeline.FORWARDS to try extending it forwards.
176 * @param {number} size number of events to try to extend by.
177 *
178 * @return {boolean} true if the window was extended, false otherwise.
179 */
180TimelineWindow.prototype.extend = function(direction, size) {
181 const tl = this.getTimelineIndex(direction);
182
183 if (!tl) {
184 debuglog("TimelineWindow: no timeline yet");
185 return false;
186 }
187
188 const count = (direction == EventTimeline.BACKWARDS) ?
189 tl.retreat(size) : tl.advance(size);
190
191 if (count) {
192 this._eventCount += count;
193 debuglog("TimelineWindow: increased cap by " + count +
194 " (now " + this._eventCount + ")");
195 // remove some events from the other end, if necessary
196 const excess = this._eventCount - this._windowLimit;
197 if (excess > 0) {
198 this.unpaginate(excess, direction != EventTimeline.BACKWARDS);
199 }
200 return true;
201 }
202
203 return false;
204};
205
206/**
207 * Check if this window can be extended
208 *
209 * <p>This returns true if we either have more events, or if we have a
210 * pagination token which means we can paginate in that direction. It does not
211 * necessarily mean that there are more events available in that direction at
212 * this time.
213 *
214 * @param {string} direction EventTimeline.BACKWARDS to check if we can
215 * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards
216 *
217 * @return {boolean} true if we can paginate in the given direction
218 */
219TimelineWindow.prototype.canPaginate = function(direction) {
220 const tl = this.getTimelineIndex(direction);
221
222 if (!tl) {
223 debuglog("TimelineWindow: no timeline yet");
224 return false;
225 }
226
227 if (direction == EventTimeline.BACKWARDS) {
228 if (tl.index > tl.minIndex()) {
229 return true;
230 }
231 } else {
232 if (tl.index < tl.maxIndex()) {
233 return true;
234 }
235 }
236
237 return Boolean(tl.timeline.getNeighbouringTimeline(direction) ||
238 tl.timeline.getPaginationToken(direction));
239};
240
241/**
242 * Attempt to extend the window
243 *
244 * @param {string} direction EventTimeline.BACKWARDS to extend the window
245 * backwards (towards older events); EventTimeline.FORWARDS to go forwards.
246 *
247 * @param {number} size number of events to try to extend by. If fewer than this
248 * number are immediately available, then we return immediately rather than
249 * making an API call.
250 *
251 * @param {boolean} [makeRequest = true] whether we should make API calls to
252 * fetch further events if we don't have any at all. (This has no effect if
253 * the room already knows about additional events in the relevant direction,
254 * even if there are fewer than 'size' of them, as we will just return those
255 * we already know about.)
256 *
257 * @param {number} [requestLimit = 5] limit for the number of API requests we
258 * should make.
259 *
260 * @return {Promise} Resolves to a boolean which is true if more events
261 * were successfully retrieved.
262 */
263TimelineWindow.prototype.paginate = function(direction, size, makeRequest,
264 requestLimit) {
265 // Either wind back the message cap (if there are enough events in the
266 // timeline to do so), or fire off a pagination request.
267
268 if (makeRequest === undefined) {
269 makeRequest = true;
270 }
271
272 if (requestLimit === undefined) {
273 requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT;
274 }
275
276 const tl = this.getTimelineIndex(direction);
277
278 if (!tl) {
279 debuglog("TimelineWindow: no timeline yet");
280 return Promise.resolve(false);
281 }
282
283 if (tl.pendingPaginate) {
284 return tl.pendingPaginate;
285 }
286
287 // try moving the cap
288 if (this.extend(direction, size)) {
289 return Promise.resolve(true);
290 }
291
292 if (!makeRequest || requestLimit === 0) {
293 // todo: should we return something different to indicate that there
294 // might be more events out there, but we haven't found them yet?
295 return Promise.resolve(false);
296 }
297
298 // try making a pagination request
299 const token = tl.timeline.getPaginationToken(direction);
300 if (!token) {
301 debuglog("TimelineWindow: no token");
302 return Promise.resolve(false);
303 }
304
305 debuglog("TimelineWindow: starting request");
306 const self = this;
307
308 const prom = this._client.paginateEventTimeline(tl.timeline, {
309 backwards: direction == EventTimeline.BACKWARDS,
310 limit: size,
311 }).finally(function() {
312 tl.pendingPaginate = null;
313 }).then(function(r) {
314 debuglog("TimelineWindow: request completed with result " + r);
315 if (!r) {
316 // end of timeline
317 return false;
318 }
319
320 // recurse to advance the index into the results.
321 //
322 // If we don't get any new events, we want to make sure we keep asking
323 // the server for events for as long as we have a valid pagination
324 // token. In particular, we want to know if we've actually hit the
325 // start of the timeline, or if we just happened to know about all of
326 // the events thanks to https://matrix.org/jira/browse/SYN-645.
327 //
328 // On the other hand, we necessarily want to wait forever for the
329 // server to make its mind up about whether there are other events,
330 // because it gives a bad user experience
331 // (https://github.com/vector-im/vector-web/issues/1204).
332 return self.paginate(direction, size, true, requestLimit - 1);
333 });
334 tl.pendingPaginate = prom;
335 return prom;
336};
337
338
339/**
340 * Remove `delta` events from the start or end of the timeline.
341 *
342 * @param {number} delta number of events to remove from the timeline
343 * @param {boolean} startOfTimeline if events should be removed from the start
344 * of the timeline.
345 */
346TimelineWindow.prototype.unpaginate = function(delta, startOfTimeline) {
347 const tl = startOfTimeline ? this._start : this._end;
348
349 // sanity-check the delta
350 if (delta > this._eventCount || delta < 0) {
351 throw new Error("Attemting to unpaginate " + delta + " events, but " +
352 "only have " + this._eventCount + " in the timeline");
353 }
354
355 while (delta > 0) {
356 const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta);
357 if (count <= 0) {
358 // sadness. This shouldn't be possible.
359 throw new Error(
360 "Unable to unpaginate any further, but still have " +
361 this._eventCount + " events");
362 }
363
364 delta -= count;
365 this._eventCount -= count;
366 debuglog("TimelineWindow.unpaginate: dropped " + count +
367 " (now " + this._eventCount + ")");
368 }
369};
370
371
372/**
373 * Get a list of the events currently in the window
374 *
375 * @return {MatrixEvent[]} the events in the window
376 */
377TimelineWindow.prototype.getEvents = function() {
378 if (!this._start) {
379 // not yet loaded
380 return [];
381 }
382
383 const result = [];
384
385 // iterate through each timeline between this._start and this._end
386 // (inclusive).
387 let timeline = this._start.timeline;
388 while (true) {
389 const events = timeline.getEvents();
390
391 // For the first timeline in the chain, we want to start at
392 // this._start.index. For the last timeline in the chain, we want to
393 // stop before this._end.index. Otherwise, we want to copy all of the
394 // events in the timeline.
395 //
396 // (Note that both this._start.index and this._end.index are relative
397 // to their respective timelines' BaseIndex).
398 //
399 let startIndex = 0, endIndex = events.length;
400 if (timeline === this._start.timeline) {
401 startIndex = this._start.index + timeline.getBaseIndex();
402 }
403 if (timeline === this._end.timeline) {
404 endIndex = this._end.index + timeline.getBaseIndex();
405 }
406
407 for (let i = startIndex; i < endIndex; i++) {
408 result.push(events[i]);
409 }
410
411 // if we're not done, iterate to the next timeline.
412 if (timeline === this._end.timeline) {
413 break;
414 } else {
415 timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS);
416 }
417 }
418
419 return result;
420};
421
422
423/**
424 * a thing which contains a timeline reference, and an index into it.
425 *
426 * @constructor
427 * @param {EventTimeline} timeline
428 * @param {number} index
429 * @private
430 */
431export function TimelineIndex(timeline, index) {
432 this.timeline = timeline;
433
434 // the indexes are relative to BaseIndex, so could well be negative.
435 this.index = index;
436}
437
438/**
439 * @return {number} the minimum possible value for the index in the current
440 * timeline
441 */
442TimelineIndex.prototype.minIndex = function() {
443 return this.timeline.getBaseIndex() * -1;
444};
445
446/**
447 * @return {number} the maximum possible value for the index in the current
448 * timeline (exclusive - ie, it actually returns one more than the index
449 * of the last element).
450 */
451TimelineIndex.prototype.maxIndex = function() {
452 return this.timeline.getEvents().length - this.timeline.getBaseIndex();
453};
454
455/**
456 * Try move the index forward, or into the neighbouring timeline
457 *
458 * @param {number} delta number of events to advance by
459 * @return {number} number of events successfully advanced by
460 */
461TimelineIndex.prototype.advance = function(delta) {
462 if (!delta) {
463 return 0;
464 }
465
466 // first try moving the index in the current timeline. See if there is room
467 // to do so.
468 let cappedDelta;
469 if (delta < 0) {
470 // we want to wind the index backwards.
471 //
472 // (this.minIndex() - this.index) is a negative number whose magnitude
473 // is the amount of room we have to wind back the index in the current
474 // timeline. We cap delta to this quantity.
475 cappedDelta = Math.max(delta, this.minIndex() - this.index);
476 if (cappedDelta < 0) {
477 this.index += cappedDelta;
478 return cappedDelta;
479 }
480 } else {
481 // we want to wind the index forwards.
482 //
483 // (this.maxIndex() - this.index) is a (positive) number whose magnitude
484 // is the amount of room we have to wind forward the index in the current
485 // timeline. We cap delta to this quantity.
486 cappedDelta = Math.min(delta, this.maxIndex() - this.index);
487 if (cappedDelta > 0) {
488 this.index += cappedDelta;
489 return cappedDelta;
490 }
491 }
492
493 // the index is already at the start/end of the current timeline.
494 //
495 // next see if there is a neighbouring timeline to switch to.
496 const neighbour = this.timeline.getNeighbouringTimeline(
497 delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS);
498 if (neighbour) {
499 this.timeline = neighbour;
500 if (delta < 0) {
501 this.index = this.maxIndex();
502 } else {
503 this.index = this.minIndex();
504 }
505
506 debuglog("paginate: switched to new neighbour");
507
508 // recurse, using the next timeline
509 return this.advance(delta);
510 }
511
512 return 0;
513};
514
515/**
516 * Try move the index backwards, or into the neighbouring timeline
517 *
518 * @param {number} delta number of events to retreat by
519 * @return {number} number of events successfully retreated by
520 */
521TimelineIndex.prototype.retreat = function(delta) {
522 return this.advance(delta * -1) * -1;
523};