1 | /*
|
2 | Copyright 2016 OpenMarket Ltd
|
3 | Copyright 2019 The Matrix.org Foundation C.I.C.
|
4 |
|
5 | Licensed under the Apache License, Version 2.0 (the "License");
|
6 | you may not use this file except in compliance with the License.
|
7 | You may obtain a copy of the License at
|
8 |
|
9 | http://www.apache.org/licenses/LICENSE-2.0
|
10 |
|
11 | Unless required by applicable law or agreed to in writing, software
|
12 | distributed under the License is distributed on an "AS IS" BASIS,
|
13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
14 | See the License for the specific language governing permissions and
|
15 | limitations under the License.
|
16 | */
|
17 |
|
18 | /** @module timeline-window */
|
19 |
|
20 | import {EventTimeline} from './models/event-timeline';
|
21 | import {logger} from './logger';
|
22 |
|
23 | /**
|
24 | * @private
|
25 | */
|
26 | const DEBUG = false;
|
27 |
|
28 | /**
|
29 | * @private
|
30 | */
|
31 | const 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 | */
|
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 | export 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 | */
|
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 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 | */
|
160 | TimelineWindow.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 | */
|
180 | TimelineWindow.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 | */
|
219 | TimelineWindow.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 | */
|
263 | TimelineWindow.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 | */
|
346 | TimelineWindow.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 | */
|
377 | TimelineWindow.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 | */
|
431 | export 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 | */
|
442 | TimelineIndex.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 | */
|
451 | TimelineIndex.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 | */
|
461 | TimelineIndex.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 | */
|
521 | TimelineIndex.prototype.retreat = function(delta) {
|
522 | return this.advance(delta * -1) * -1;
|
523 | };
|