diff --git a/src/client.ts b/src/client.ts index a0b8cb9e2..c967a17b7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4502,7 +4502,7 @@ export class MatrixClient extends EventEmitter { return Promise.resolve(false); } - const pendingRequest = eventTimeline._paginationRequests[dir]; + const pendingRequest = eventTimeline.paginationRequests[dir]; if (pendingRequest) { // already a request in progress - return the existing promise @@ -4551,9 +4551,9 @@ export class MatrixClient extends EventEmitter { } return res.next_token ? true : false; }).finally(() => { - eventTimeline._paginationRequests[dir] = null; + eventTimeline.paginationRequests[dir] = null; }); - eventTimeline._paginationRequests[dir] = promise; + eventTimeline.paginationRequests[dir] = promise; } else { const room = this.getRoom(eventTimeline.getRoomId()); if (!room) { @@ -4585,9 +4585,9 @@ export class MatrixClient extends EventEmitter { } return res.end != res.start; }).finally(() => { - eventTimeline._paginationRequests[dir] = null; + eventTimeline.paginationRequests[dir] = null; }); - eventTimeline._paginationRequests[dir] = promise; + eventTimeline.paginationRequests[dir] = promise; } return promise; diff --git a/src/crypto/CrossSigning.ts b/src/crypto/CrossSigning.ts index 720a3f5bd..0d4823051 100644 --- a/src/crypto/CrossSigning.ts +++ b/src/crypto/CrossSigning.ts @@ -292,7 +292,7 @@ export class CrossSigningInfo extends EventEmitter { CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING ); - } else if (level === 0) { + } else if (level === 0 as CrossSigningLevel) { return; } diff --git a/src/models/event-context.ts b/src/models/event-context.ts index 95bc83e6c..18c64afee 100644 --- a/src/models/event-context.ts +++ b/src/models/event-context.ts @@ -15,11 +15,7 @@ limitations under the License. */ import { MatrixEvent } from "./event"; - -enum Direction { - Backward = "b", - Forward = "f", -} +import { Direction } from "./event-timeline"; /** * @module models/event-context diff --git a/src/models/event-timeline-set.js b/src/models/event-timeline-set.js deleted file mode 100644 index 5835e5343..000000000 --- a/src/models/event-timeline-set.js +++ /dev/null @@ -1,848 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * @module models/event-timeline-set - */ - -import { EventEmitter } from "events"; -import { EventTimeline } from "./event-timeline"; -import { EventStatus } from "./event"; -import * as utils from "../utils"; -import { logger } from '../logger'; -import { Relations } from './relations'; - -// var DEBUG = false; -const DEBUG = true; - -let debuglog; -if (DEBUG) { - // using bind means that we get to keep useful line numbers in the console - debuglog = logger.log.bind(logger); -} else { - debuglog = function() {}; -} - -/** - * Construct a set of EventTimeline objects, typically on behalf of a given - * room. A room may have multiple EventTimelineSets for different levels - * of filtering. The global notification list is also an EventTimelineSet, but - * lacks a room. - * - *
This is an ordered sequence of timelines, which may or may not - * be continuous. Each timeline lists a series of events, as well as tracking - * the room state at the start and the end of the timeline (if appropriate). - * It also tracks forward and backward pagination tokens, as well as containing - * links to the next timeline in the sequence. - * - *
There is one special timeline - the 'live' timeline, which represents the - * timeline to which events are being added in real-time as they are received - * from the /sync API. Note that you should not retain references to this - * timeline - even if it is the current timeline right now, it may not remain - * so if the server gives us a timeline gap in /sync. - * - *
In order that we can find events from their ids later, we also maintain a
- * map from event_id to timeline and index.
- *
- * @constructor
- * @param {?Room} room
- * Room for this timelineSet. May be null for non-room cases, such as the
- * notification timeline.
- * @param {Object} opts Options inherited from Room.
- *
- * @param {boolean} [opts.timelineSupport = false]
- * Set to true to enable improved timeline support.
- * @param {Object} [opts.filter = null]
- * The filter object, if any, for this timelineSet.
- * @param {boolean} [opts.unstableClientRelationAggregation = false]
- * Optional. Set to true to enable client-side aggregation of event relations
- * via `getRelationsForEvent`.
- * This feature is currently unstable and the API may change without notice.
- */
-export function EventTimelineSet(room, opts) {
- this.room = room;
-
- this._timelineSupport = Boolean(opts.timelineSupport);
- this._liveTimeline = new EventTimeline(this);
- this._unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
-
- // just a list - *not* ordered.
- this._timelines = [this._liveTimeline];
- this._eventIdToTimeline = {};
-
- this._filter = opts.filter || null;
-
- if (this._unstableClientRelationAggregation) {
- // A tree of objects to access a set of relations for an event, as in:
- // this._relations[relatesToEventId][relationType][relationEventType]
- this._relations = {};
- }
-}
-utils.inherits(EventTimelineSet, EventEmitter);
-
-/**
- * Get all the timelines in this set
- * @return {module:models/event-timeline~EventTimeline[]} the timelines in this set
- */
-EventTimelineSet.prototype.getTimelines = function() {
- return this._timelines;
-};
-/**
- * Get the filter object this timeline set is filtered on, if any
- * @return {?Filter} the optional filter for this timelineSet
- */
-EventTimelineSet.prototype.getFilter = function() {
- return this._filter;
-};
-
-/**
- * Set the filter object this timeline set is filtered on
- * (passed to the server when paginating via /messages).
- * @param {Filter} filter the filter for this timelineSet
- */
-EventTimelineSet.prototype.setFilter = function(filter) {
- this._filter = filter;
-};
-
-/**
- * Get the list of pending sent events for this timelineSet's room, filtered
- * by the timelineSet's filter if appropriate.
- *
- * @return {module:models/event.MatrixEvent[]} A list of the sent events
- * waiting for remote echo.
- *
- * @throws If opts.pendingEventOrdering was not 'detached'
- */
-EventTimelineSet.prototype.getPendingEvents = function() {
- if (!this.room) {
- return [];
- }
-
- if (this._filter) {
- return this._filter.filterRoomTimeline(this.room.getPendingEvents());
- } else {
- return this.room.getPendingEvents();
- }
-};
-
-/**
- * Get the live timeline for this room.
- *
- * @return {module:models/event-timeline~EventTimeline} live timeline
- */
-EventTimelineSet.prototype.getLiveTimeline = function() {
- return this._liveTimeline;
-};
-
-/**
- * Return the timeline (if any) this event is in.
- * @param {String} eventId the eventId being sought
- * @return {module:models/event-timeline~EventTimeline} timeline
- */
-EventTimelineSet.prototype.eventIdToTimeline = function(eventId) {
- return this._eventIdToTimeline[eventId];
-};
-
-/**
- * Track a new event as if it were in the same timeline as an old event,
- * replacing it.
- * @param {String} oldEventId event ID of the original event
- * @param {String} newEventId event ID of the replacement event
- */
-EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) {
- const existingTimeline = this._eventIdToTimeline[oldEventId];
- if (existingTimeline) {
- delete this._eventIdToTimeline[oldEventId];
- this._eventIdToTimeline[newEventId] = existingTimeline;
- }
-};
-
-/**
- * Reset the live timeline, and start a new one.
- *
- *
This is used when /sync returns a 'limited' timeline. - * - * @param {string=} backPaginationToken token for back-paginating the new timeline - * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, - * if absent or null, all timelines are reset. - * - * @fires module:client~MatrixClient#event:"Room.timelineReset" - */ -EventTimelineSet.prototype.resetLiveTimeline = function( - backPaginationToken, forwardPaginationToken, -) { - // Each EventTimeline has RoomState objects tracking the state at the start - // and end of that timeline. The copies at the end of the live timeline are - // special because they will have listeners attached to monitor changes to - // the current room state, so we move this RoomState from the end of the - // current live timeline to the end of the new one and, if necessary, - // replace it with a newly created one. We also make a copy for the start - // of the new timeline. - - // if timeline support is disabled, forget about the old timelines - const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken; - - const oldTimeline = this._liveTimeline; - const newTimeline = resetAllTimelines ? - oldTimeline.forkLive(EventTimeline.FORWARDS) : - oldTimeline.fork(EventTimeline.FORWARDS); - - if (resetAllTimelines) { - this._timelines = [newTimeline]; - this._eventIdToTimeline = {}; - } else { - this._timelines.push(newTimeline); - } - - if (forwardPaginationToken) { - // Now set the forward pagination token on the old live timeline - // so it can be forward-paginated. - oldTimeline.setPaginationToken( - forwardPaginationToken, EventTimeline.FORWARDS, - ); - } - - // make sure we set the pagination token before firing timelineReset, - // otherwise clients which start back-paginating will fail, and then get - // stuck without realising that they *can* back-paginate. - newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); - - // Now we can swap the live timeline to the new one. - this._liveTimeline = newTimeline; - this.emit("Room.timelineReset", this.room, this, resetAllTimelines); -}; - -/** - * Get the timeline which contains the given event, if any - * - * @param {string} eventId event ID to look for - * @return {?module:models/event-timeline~EventTimeline} timeline containing - * the given event, or null if unknown - */ -EventTimelineSet.prototype.getTimelineForEvent = function(eventId) { - const res = this._eventIdToTimeline[eventId]; - return (res === undefined) ? null : res; -}; - -/** - * Get an event which is stored in our timelines - * - * @param {string} eventId event ID to look for - * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown - */ -EventTimelineSet.prototype.findEventById = function(eventId) { - const tl = this.getTimelineForEvent(eventId); - if (!tl) { - return undefined; - } - return tl.getEvents().find(function(ev) { - return ev.getId() == eventId; - }); -}; - -/** - * Add a new timeline to this timeline list - * - * @return {module:models/event-timeline~EventTimeline} newly-created timeline - */ -EventTimelineSet.prototype.addTimeline = function() { - if (!this._timelineSupport) { - throw new Error("timeline support is disabled. Set the 'timelineSupport'" + - " parameter to true when creating MatrixClient to enable" + - " it."); - } - - const timeline = new EventTimeline(this); - this._timelines.push(timeline); - return timeline; -}; - -/** - * Add events to a timeline - * - *
Will fire "Room.timeline" for each event added.
- *
- * @param {MatrixEvent[]} events A list of events to add.
- *
- * @param {boolean} toStartOfTimeline True to add these events to the start
- * (oldest) instead of the end (newest) of the timeline. If true, the oldest
- * event will be the last element of 'events'.
- *
- * @param {module:models/event-timeline~EventTimeline} timeline timeline to
- * add events to.
- *
- * @param {string=} paginationToken token for the next batch of events
- *
- * @fires module:client~MatrixClient#event:"Room.timeline"
- *
- */
-EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimeline,
- timeline, paginationToken) {
- if (!timeline) {
- throw new Error(
- "'timeline' not specified for EventTimelineSet.addEventsToTimeline",
- );
- }
-
- if (!toStartOfTimeline && timeline == this._liveTimeline) {
- throw new Error(
- "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " +
- "the live timeline - use Room.addLiveEvents instead",
- );
- }
-
- if (this._filter) {
- events = this._filter.filterRoomTimeline(events);
- if (!events.length) {
- return;
- }
- }
-
- const direction = toStartOfTimeline ? EventTimeline.BACKWARDS :
- EventTimeline.FORWARDS;
- const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS :
- EventTimeline.BACKWARDS;
-
- // Adding events to timelines can be quite complicated. The following
- // illustrates some of the corner-cases.
- //
- // Let's say we start by knowing about four timelines. timeline3 and
- // timeline4 are neighbours:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M] [P] [S] <------> [T]
- //
- // Now we paginate timeline1, and get the following events from the server:
- // [M, N, P, R, S, T, U].
- //
- // 1. First, we ignore event M, since we already know about it.
- //
- // 2. Next, we append N to timeline 1.
- //
- // 3. Next, we don't add event P, since we already know about it,
- // but we do link together the timelines. We now have:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M, N] <---> [P] [S] <------> [T]
- //
- // 4. Now we add event R to timeline2:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M, N] <---> [P, R] [S] <------> [T]
- //
- // Note that we have switched the timeline we are working on from
- // timeline1 to timeline2.
- //
- // 5. We ignore event S, but again join the timelines:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M, N] <---> [P, R] <---> [S] <------> [T]
- //
- // 6. We ignore event T, and the timelines are already joined, so there
- // is nothing to do.
- //
- // 7. Finally, we add event U to timeline4:
- //
- // timeline1 timeline2 timeline3 timeline4
- // [M, N] <---> [P, R] <---> [S] <------> [T, U]
- //
- // The important thing to note in the above is what happened when we
- // already knew about a given event:
- //
- // - if it was appropriate, we joined up the timelines (steps 3, 5).
- // - in any case, we started adding further events to the timeline which
- // contained the event we knew about (steps 3, 5, 6).
- //
- //
- // So much for adding events to the timeline. But what do we want to do
- // with the pagination token?
- //
- // In the case above, we will be given a pagination token which tells us how to
- // get events beyond 'U' - in this case, it makes sense to store this
- // against timeline4. But what if timeline4 already had 'U' and beyond? in
- // that case, our best bet is to throw away the pagination token we were
- // given and stick with whatever token timeline4 had previously. In short,
- // we want to only store the pagination token if the last event we receive
- // is one we didn't previously know about.
- //
- // We make an exception for this if it turns out that we already knew about
- // *all* of the events, and we weren't able to join up any timelines. When
- // that happens, it means our existing pagination token is faulty, since it
- // is only telling us what we already know. Rather than repeatedly
- // paginating with the same token, we might as well use the new pagination
- // token in the hope that we eventually work our way out of the mess.
-
- let didUpdate = false;
- let lastEventWasNew = false;
- for (let i = 0; i < events.length; i++) {
- const event = events[i];
- const eventId = event.getId();
-
- const existingTimeline = this._eventIdToTimeline[eventId];
-
- if (!existingTimeline) {
- // we don't know about this event yet. Just add it to the timeline.
- this.addEventToTimeline(event, timeline, toStartOfTimeline);
- lastEventWasNew = true;
- didUpdate = true;
- continue;
- }
-
- lastEventWasNew = false;
-
- if (existingTimeline == timeline) {
- debuglog("Event " + eventId + " already in timeline " + timeline);
- continue;
- }
-
- const neighbour = timeline.getNeighbouringTimeline(direction);
- if (neighbour) {
- // this timeline already has a neighbour in the relevant direction;
- // let's assume the timelines are already correctly linked up, and
- // skip over to it.
- //
- // there's probably some edge-case here where we end up with an
- // event which is in a timeline a way down the chain, and there is
- // a break in the chain somewhere. But I can't really imagine how
- // that would happen, so I'm going to ignore it for now.
- //
- if (existingTimeline == neighbour) {
- debuglog("Event " + eventId + " in neighbouring timeline - " +
- "switching to " + existingTimeline);
- } else {
- debuglog("Event " + eventId + " already in a different " +
- "timeline " + existingTimeline);
- }
- timeline = existingTimeline;
- continue;
- }
-
- // time to join the timelines.
- logger.info("Already have timeline for " + eventId +
- " - joining timeline " + timeline + " to " +
- existingTimeline);
-
- // Variables to keep the line length limited below.
- const existingIsLive = existingTimeline === this._liveTimeline;
- const timelineIsLive = timeline === this._liveTimeline;
-
- const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive;
- const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive;
-
- if (backwardsIsLive || forwardsIsLive) {
- // The live timeline should never be spliced into a non-live position.
- // We use independent logging to better discover the problem at a glance.
- if (backwardsIsLive) {
- logger.warn(
- "Refusing to set a preceding existingTimeLine on our " +
- "timeline as the existingTimeLine is live (" + existingTimeline + ")",
- );
- }
- if (forwardsIsLive) {
- logger.warn(
- "Refusing to set our preceding timeline on a existingTimeLine " +
- "as our timeline is live (" + timeline + ")",
- );
- }
- continue; // abort splicing - try next event
- }
-
- timeline.setNeighbouringTimeline(existingTimeline, direction);
- existingTimeline.setNeighbouringTimeline(timeline, inverseDirection);
-
- timeline = existingTimeline;
- didUpdate = true;
- }
-
- // see above - if the last event was new to us, or if we didn't find any
- // new information, we update the pagination token for whatever
- // timeline we ended up on.
- if (lastEventWasNew || !didUpdate) {
- if (direction === EventTimeline.FORWARDS && timeline === this._liveTimeline) {
- logger.warn({ lastEventWasNew, didUpdate }); // for debugging
- logger.warn(
- `Refusing to set forwards pagination token of live timeline ` +
- `${timeline} to ${paginationToken}`,
- );
- return;
- }
- timeline.setPaginationToken(paginationToken, direction);
- }
-};
-
-/**
- * Add an event to the end of this live timeline.
- *
- * @param {MatrixEvent} event Event to be added
- * @param {string?} duplicateStrategy 'ignore' or 'replace'
- * @param {boolean} fromCache whether the sync response came from cache
- */
-EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy, fromCache) {
- if (this._filter) {
- const events = this._filter.filterRoomTimeline([event]);
- if (!events.length) {
- return;
- }
- }
-
- const timeline = this._eventIdToTimeline[event.getId()];
- if (timeline) {
- if (duplicateStrategy === "replace") {
- debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " +
- event.getId());
- const tlEvents = timeline.getEvents();
- for (let j = 0; j < tlEvents.length; j++) {
- if (tlEvents[j].getId() === event.getId()) {
- // still need to set the right metadata on this event
- EventTimeline.setEventMetadata(
- event,
- timeline.getState(EventTimeline.FORWARDS),
- false,
- );
-
- if (!tlEvents[j].encryptedType) {
- tlEvents[j] = event;
- }
-
- // XXX: we need to fire an event when this happens.
- break;
- }
- }
- } else {
- debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " +
- event.getId());
- }
- return;
- }
-
- this.addEventToTimeline(event, this._liveTimeline, false, fromCache);
-};
-
-/**
- * Add event to the given timeline, and emit Room.timeline. Assumes
- * we have already checked we don't know about this event.
- *
- * Will fire "Room.timeline" for each event added.
- *
- * @param {MatrixEvent} event
- * @param {EventTimeline} timeline
- * @param {boolean} toStartOfTimeline
- * @param {boolean} fromCache whether the sync response came from cache
- *
- * @fires module:client~MatrixClient#event:"Room.timeline"
- */
-EventTimelineSet.prototype.addEventToTimeline = function(event, timeline,
- toStartOfTimeline, fromCache) {
- const eventId = event.getId();
- timeline.addEvent(event, toStartOfTimeline);
- this._eventIdToTimeline[eventId] = timeline;
-
- this.setRelationsTarget(event);
- this.aggregateRelations(event);
-
- const data = {
- timeline: timeline,
- liveEvent: !toStartOfTimeline && timeline == this._liveTimeline && !fromCache,
- };
- this.emit("Room.timeline", event, this.room,
- Boolean(toStartOfTimeline), false, data);
-};
-
-/**
- * Replaces event with ID oldEventId with one with newEventId, if oldEventId is
- * recognised. Otherwise, add to the live timeline. Used to handle remote echos.
- *
- * @param {MatrixEvent} localEvent the new event to be added to the timeline
- * @param {String} oldEventId the ID of the original event
- * @param {boolean} newEventId the ID of the replacement event
- *
- * @fires module:client~MatrixClient#event:"Room.timeline"
- */
-EventTimelineSet.prototype.handleRemoteEcho = function(localEvent, oldEventId,
- newEventId) {
- // XXX: why don't we infer newEventId from localEvent?
- const existingTimeline = this._eventIdToTimeline[oldEventId];
- if (existingTimeline) {
- delete this._eventIdToTimeline[oldEventId];
- this._eventIdToTimeline[newEventId] = existingTimeline;
- } else {
- if (this._filter) {
- if (this._filter.filterRoomTimeline([localEvent]).length) {
- this.addEventToTimeline(localEvent, this._liveTimeline, false);
- }
- } else {
- this.addEventToTimeline(localEvent, this._liveTimeline, false);
- }
- }
-};
-
-/**
- * Removes a single event from this room.
- *
- * @param {String} eventId The id of the event to remove
- *
- * @return {?MatrixEvent} the removed event, or null if the event was not found
- * in this room.
- */
-EventTimelineSet.prototype.removeEvent = function(eventId) {
- const timeline = this._eventIdToTimeline[eventId];
- if (!timeline) {
- return null;
- }
-
- const removed = timeline.removeEvent(eventId);
- if (removed) {
- delete this._eventIdToTimeline[eventId];
- const data = {
- timeline: timeline,
- };
- this.emit("Room.timeline", removed, this.room, undefined, true, data);
- }
- return removed;
-};
-
-/**
- * Determine where two events appear in the timeline relative to one another
- *
- * @param {string} eventId1 The id of the first event
- * @param {string} eventId2 The id of the second event
-
- * @return {?number} a number less than zero if eventId1 precedes eventId2, and
- * greater than zero if eventId1 succeeds eventId2. zero if they are the
- * same event; null if we can't tell (either because we don't know about one
- * of the events, or because they are in separate timelines which don't join
- * up).
- */
-EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) {
- if (eventId1 == eventId2) {
- // optimise this case
- return 0;
- }
-
- const timeline1 = this._eventIdToTimeline[eventId1];
- const timeline2 = this._eventIdToTimeline[eventId2];
-
- if (timeline1 === undefined) {
- return null;
- }
- if (timeline2 === undefined) {
- return null;
- }
-
- if (timeline1 === timeline2) {
- // both events are in the same timeline - figure out their
- // relative indices
- let idx1;
- let idx2;
- const events = timeline1.getEvents();
- for (let idx = 0; idx < events.length &&
- (idx1 === undefined || idx2 === undefined); idx++) {
- const evId = events[idx].getId();
- if (evId == eventId1) {
- idx1 = idx;
- }
- if (evId == eventId2) {
- idx2 = idx;
- }
- }
- return idx1 - idx2;
- }
-
- // the events are in different timelines. Iterate through the
- // linkedlist to see which comes first.
-
- // first work forwards from timeline1
- let tl = timeline1;
- while (tl) {
- if (tl === timeline2) {
- // timeline1 is before timeline2
- return -1;
- }
- tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS);
- }
-
- // now try backwards from timeline1
- tl = timeline1;
- while (tl) {
- if (tl === timeline2) {
- // timeline2 is before timeline1
- return 1;
- }
- tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS);
- }
-
- // the timelines are not contiguous.
- return null;
-};
-
-/**
- * Get a collection of relations to a given event in this timeline set.
- *
- * @param {String} eventId
- * The ID of the event that you'd like to access relation events for.
- * For example, with annotations, this would be the ID of the event being annotated.
- * @param {String} relationType
- * The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
- * @param {String} eventType
- * The relation event's type, such as "m.reaction", etc.
- * @throws If This is an ordered sequence of timelines, which may or may not
+ * be continuous. Each timeline lists a series of events, as well as tracking
+ * the room state at the start and the end of the timeline (if appropriate).
+ * It also tracks forward and backward pagination tokens, as well as containing
+ * links to the next timeline in the sequence.
+ *
+ * There is one special timeline - the 'live' timeline, which represents the
+ * timeline to which events are being added in real-time as they are received
+ * from the /sync API. Note that you should not retain references to this
+ * timeline - even if it is the current timeline right now, it may not remain
+ * so if the server gives us a timeline gap in /sync.
+ *
+ * In order that we can find events from their ids later, we also maintain a
+ * map from event_id to timeline and index.
+ *
+ * @constructor
+ * @param {?Room} room
+ * Room for this timelineSet. May be null for non-room cases, such as the
+ * notification timeline.
+ * @param {Object} opts Options inherited from Room.
+ *
+ * @param {boolean} [opts.timelineSupport = false]
+ * Set to true to enable improved timeline support.
+ * @param {Object} [opts.filter = null]
+ * The filter object, if any, for this timelineSet.
+ * @param {boolean} [opts.unstableClientRelationAggregation = false]
+ * Optional. Set to true to enable client-side aggregation of event relations
+ * via `getRelationsForEvent`.
+ * This feature is currently unstable and the API may change without notice.
+ */
+ constructor(public readonly room: Room, opts: IOpts) {
+ super();
+
+ this.timelineSupport = Boolean(opts.timelineSupport);
+ this.liveTimeline = new EventTimeline(this);
+ this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation;
+
+ // just a list - *not* ordered.
+ this.timelines = [this.liveTimeline];
+ this._eventIdToTimeline = {};
+
+ this.filter = opts.filter;
+
+ if (this.unstableClientRelationAggregation) {
+ // A tree of objects to access a set of relations for an event, as in:
+ // this.relations[relatesToEventId][relationType][relationEventType]
+ this.relations = {};
+ }
+ }
+
+ /**
+ * Get all the timelines in this set
+ * @return {module:models/event-timeline~EventTimeline[]} the timelines in this set
+ */
+ public getTimelines(): EventTimeline[] {
+ return this.timelines;
+ }
+
+ /**
+ * Get the filter object this timeline set is filtered on, if any
+ * @return {?Filter} the optional filter for this timelineSet
+ */
+ public getFilter(): Filter | undefined {
+ return this.filter;
+ }
+
+ /**
+ * Set the filter object this timeline set is filtered on
+ * (passed to the server when paginating via /messages).
+ * @param {Filter} filter the filter for this timelineSet
+ */
+ public setFilter(filter?: Filter): void {
+ this.filter = filter;
+ }
+
+ /**
+ * Get the list of pending sent events for this timelineSet's room, filtered
+ * by the timelineSet's filter if appropriate.
+ *
+ * @return {module:models/event.MatrixEvent[]} A list of the sent events
+ * waiting for remote echo.
+ *
+ * @throws If This is used when /sync returns a 'limited' timeline.
+ *
+ * @param {string=} backPaginationToken token for back-paginating the new timeline
+ * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline,
+ * if absent or null, all timelines are reset.
+ *
+ * @fires module:client~MatrixClient#event:"Room.timelineReset"
+ */
+ public resetLiveTimeline(backPaginationToken: string, forwardPaginationToken?: string): void {
+ // Each EventTimeline has RoomState objects tracking the state at the start
+ // and end of that timeline. The copies at the end of the live timeline are
+ // special because they will have listeners attached to monitor changes to
+ // the current room state, so we move this RoomState from the end of the
+ // current live timeline to the end of the new one and, if necessary,
+ // replace it with a newly created one. We also make a copy for the start
+ // of the new timeline.
+
+ // if timeline support is disabled, forget about the old timelines
+ const resetAllTimelines = !this.timelineSupport || !forwardPaginationToken;
+
+ const oldTimeline = this.liveTimeline;
+ const newTimeline = resetAllTimelines ?
+ oldTimeline.forkLive(EventTimeline.FORWARDS) :
+ oldTimeline.fork(EventTimeline.FORWARDS);
+
+ if (resetAllTimelines) {
+ this.timelines = [newTimeline];
+ this._eventIdToTimeline = {};
+ } else {
+ this.timelines.push(newTimeline);
+ }
+
+ if (forwardPaginationToken) {
+ // Now set the forward pagination token on the old live timeline
+ // so it can be forward-paginated.
+ oldTimeline.setPaginationToken(
+ forwardPaginationToken, EventTimeline.FORWARDS,
+ );
+ }
+
+ // make sure we set the pagination token before firing timelineReset,
+ // otherwise clients which start back-paginating will fail, and then get
+ // stuck without realising that they *can* back-paginate.
+ newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS);
+
+ // Now we can swap the live timeline to the new one.
+ this.liveTimeline = newTimeline;
+ this.emit("Room.timelineReset", this.room, this, resetAllTimelines);
+ }
+
+ /**
+ * Get the timeline which contains the given event, if any
+ *
+ * @param {string} eventId event ID to look for
+ * @return {?module:models/event-timeline~EventTimeline} timeline containing
+ * the given event, or null if unknown
+ */
+ public getTimelineForEvent(eventId: string): EventTimeline | null {
+ const res = this._eventIdToTimeline[eventId];
+ return (res === undefined) ? null : res;
+ }
+
+ /**
+ * Get an event which is stored in our timelines
+ *
+ * @param {string} eventId event ID to look for
+ * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown
+ */
+ public findEventById(eventId: string): MatrixEvent | undefined {
+ const tl = this.getTimelineForEvent(eventId);
+ if (!tl) {
+ return undefined;
+ }
+ return tl.getEvents().find(function(ev) {
+ return ev.getId() == eventId;
+ });
+ }
+
+ /**
+ * Add a new timeline to this timeline list
+ *
+ * @return {module:models/event-timeline~EventTimeline} newly-created timeline
+ */
+ public addTimeline(): EventTimeline {
+ if (!this.timelineSupport) {
+ throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
+ " parameter to true when creating MatrixClient to enable" +
+ " it.");
+ }
+
+ const timeline = new EventTimeline(this);
+ this.timelines.push(timeline);
+ return timeline;
+ }
+
+ /**
+ * Add events to a timeline
+ *
+ * Will fire "Room.timeline" for each event added.
+ *
+ * @param {MatrixEvent[]} events A list of events to add.
+ *
+ * @param {boolean} toStartOfTimeline True to add these events to the start
+ * (oldest) instead of the end (newest) of the timeline. If true, the oldest
+ * event will be the last element of 'events'.
+ *
+ * @param {module:models/event-timeline~EventTimeline} timeline timeline to
+ * add events to.
+ *
+ * @param {string=} paginationToken token for the next batch of events
+ *
+ * @fires module:client~MatrixClient#event:"Room.timeline"
+ *
+ */
+ public addEventsToTimeline(
+ events: MatrixEvent[],
+ toStartOfTimeline: boolean,
+ timeline: EventTimeline,
+ paginationToken: string,
+ ): void {
+ if (!timeline) {
+ throw new Error(
+ "'timeline' not specified for EventTimelineSet.addEventsToTimeline",
+ );
+ }
+
+ if (!toStartOfTimeline && timeline == this.liveTimeline) {
+ throw new Error(
+ "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " +
+ "the live timeline - use Room.addLiveEvents instead",
+ );
+ }
+
+ if (this.filter) {
+ events = this.filter.filterRoomTimeline(events);
+ if (!events.length) {
+ return;
+ }
+ }
+
+ const direction = toStartOfTimeline ? EventTimeline.BACKWARDS :
+ EventTimeline.FORWARDS;
+ const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS :
+ EventTimeline.BACKWARDS;
+
+ // Adding events to timelines can be quite complicated. The following
+ // illustrates some of the corner-cases.
+ //
+ // Let's say we start by knowing about four timelines. timeline3 and
+ // timeline4 are neighbours:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M] [P] [S] <------> [T]
+ //
+ // Now we paginate timeline1, and get the following events from the server:
+ // [M, N, P, R, S, T, U].
+ //
+ // 1. First, we ignore event M, since we already know about it.
+ //
+ // 2. Next, we append N to timeline 1.
+ //
+ // 3. Next, we don't add event P, since we already know about it,
+ // but we do link together the timelines. We now have:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P] [S] <------> [T]
+ //
+ // 4. Now we add event R to timeline2:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P, R] [S] <------> [T]
+ //
+ // Note that we have switched the timeline we are working on from
+ // timeline1 to timeline2.
+ //
+ // 5. We ignore event S, but again join the timelines:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P, R] <---> [S] <------> [T]
+ //
+ // 6. We ignore event T, and the timelines are already joined, so there
+ // is nothing to do.
+ //
+ // 7. Finally, we add event U to timeline4:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P, R] <---> [S] <------> [T, U]
+ //
+ // The important thing to note in the above is what happened when we
+ // already knew about a given event:
+ //
+ // - if it was appropriate, we joined up the timelines (steps 3, 5).
+ // - in any case, we started adding further events to the timeline which
+ // contained the event we knew about (steps 3, 5, 6).
+ //
+ //
+ // So much for adding events to the timeline. But what do we want to do
+ // with the pagination token?
+ //
+ // In the case above, we will be given a pagination token which tells us how to
+ // get events beyond 'U' - in this case, it makes sense to store this
+ // against timeline4. But what if timeline4 already had 'U' and beyond? in
+ // that case, our best bet is to throw away the pagination token we were
+ // given and stick with whatever token timeline4 had previously. In short,
+ // we want to only store the pagination token if the last event we receive
+ // is one we didn't previously know about.
+ //
+ // We make an exception for this if it turns out that we already knew about
+ // *all* of the events, and we weren't able to join up any timelines. When
+ // that happens, it means our existing pagination token is faulty, since it
+ // is only telling us what we already know. Rather than repeatedly
+ // paginating with the same token, we might as well use the new pagination
+ // token in the hope that we eventually work our way out of the mess.
+
+ let didUpdate = false;
+ let lastEventWasNew = false;
+ for (let i = 0; i < events.length; i++) {
+ const event = events[i];
+ const eventId = event.getId();
+
+ const existingTimeline = this._eventIdToTimeline[eventId];
+
+ if (!existingTimeline) {
+ // we don't know about this event yet. Just add it to the timeline.
+ this.addEventToTimeline(event, timeline, toStartOfTimeline);
+ lastEventWasNew = true;
+ didUpdate = true;
+ continue;
+ }
+
+ lastEventWasNew = false;
+
+ if (existingTimeline == timeline) {
+ debuglog("Event " + eventId + " already in timeline " + timeline);
+ continue;
+ }
+
+ const neighbour = timeline.getNeighbouringTimeline(direction);
+ if (neighbour) {
+ // this timeline already has a neighbour in the relevant direction;
+ // let's assume the timelines are already correctly linked up, and
+ // skip over to it.
+ //
+ // there's probably some edge-case here where we end up with an
+ // event which is in a timeline a way down the chain, and there is
+ // a break in the chain somewhere. But I can't really imagine how
+ // that would happen, so I'm going to ignore it for now.
+ //
+ if (existingTimeline == neighbour) {
+ debuglog("Event " + eventId + " in neighbouring timeline - " +
+ "switching to " + existingTimeline);
+ } else {
+ debuglog("Event " + eventId + " already in a different " +
+ "timeline " + existingTimeline);
+ }
+ timeline = existingTimeline;
+ continue;
+ }
+
+ // time to join the timelines.
+ logger.info("Already have timeline for " + eventId +
+ " - joining timeline " + timeline + " to " +
+ existingTimeline);
+
+ // Variables to keep the line length limited below.
+ const existingIsLive = existingTimeline === this.liveTimeline;
+ const timelineIsLive = timeline === this.liveTimeline;
+
+ const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive;
+ const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive;
+
+ if (backwardsIsLive || forwardsIsLive) {
+ // The live timeline should never be spliced into a non-live position.
+ // We use independent logging to better discover the problem at a glance.
+ if (backwardsIsLive) {
+ logger.warn(
+ "Refusing to set a preceding existingTimeLine on our " +
+ "timeline as the existingTimeLine is live (" + existingTimeline + ")",
+ );
+ }
+ if (forwardsIsLive) {
+ logger.warn(
+ "Refusing to set our preceding timeline on a existingTimeLine " +
+ "as our timeline is live (" + timeline + ")",
+ );
+ }
+ continue; // abort splicing - try next event
+ }
+
+ timeline.setNeighbouringTimeline(existingTimeline, direction);
+ existingTimeline.setNeighbouringTimeline(timeline, inverseDirection);
+
+ timeline = existingTimeline;
+ didUpdate = true;
+ }
+
+ // see above - if the last event was new to us, or if we didn't find any
+ // new information, we update the pagination token for whatever
+ // timeline we ended up on.
+ if (lastEventWasNew || !didUpdate) {
+ if (direction === EventTimeline.FORWARDS && timeline === this.liveTimeline) {
+ logger.warn({ lastEventWasNew, didUpdate }); // for debugging
+ logger.warn(
+ `Refusing to set forwards pagination token of live timeline ` +
+ `${timeline} to ${paginationToken}`,
+ );
+ return;
+ }
+ timeline.setPaginationToken(paginationToken, direction);
+ }
+ }
+
+ /**
+ * Add an event to the end of this live timeline.
+ *
+ * @param {MatrixEvent} event Event to be added
+ * @param {string?} duplicateStrategy 'ignore' or 'replace'
+ * @param {boolean} fromCache whether the sync response came from cache
+ */
+ public addLiveEvent(event: MatrixEvent, duplicateStrategy?: "ignore" | "replace", fromCache = false): void {
+ if (this.filter) {
+ const events = this.filter.filterRoomTimeline([event]);
+ if (!events.length) {
+ return;
+ }
+ }
+
+ const timeline = this._eventIdToTimeline[event.getId()];
+ if (timeline) {
+ if (duplicateStrategy === "replace") {
+ debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " +
+ event.getId());
+ const tlEvents = timeline.getEvents();
+ for (let j = 0; j < tlEvents.length; j++) {
+ if (tlEvents[j].getId() === event.getId()) {
+ // still need to set the right metadata on this event
+ EventTimeline.setEventMetadata(
+ event,
+ timeline.getState(EventTimeline.FORWARDS),
+ false,
+ );
+ tlEvents[j] = event;
+
+ // XXX: we need to fire an event when this happens.
+ break;
+ }
+ }
+ } else {
+ debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " +
+ event.getId());
+ }
+ return;
+ }
+
+ this.addEventToTimeline(event, this.liveTimeline, false, fromCache);
+ }
+
+ /**
+ * Add event to the given timeline, and emit Room.timeline. Assumes
+ * we have already checked we don't know about this event.
+ *
+ * Will fire "Room.timeline" for each event added.
+ *
+ * @param {MatrixEvent} event
+ * @param {EventTimeline} timeline
+ * @param {boolean} toStartOfTimeline
+ * @param {boolean} fromCache whether the sync response came from cache
+ *
+ * @fires module:client~MatrixClient#event:"Room.timeline"
+ */
+ public addEventToTimeline(
+ event: MatrixEvent,
+ timeline: EventTimeline,
+ toStartOfTimeline: boolean,
+ fromCache = false,
+ ) {
+ const eventId = event.getId();
+ timeline.addEvent(event, toStartOfTimeline);
+ this._eventIdToTimeline[eventId] = timeline;
+
+ this.setRelationsTarget(event);
+ this.aggregateRelations(event);
+
+ const data = {
+ timeline: timeline,
+ liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache,
+ };
+ this.emit("Room.timeline", event, this.room,
+ Boolean(toStartOfTimeline), false, data);
+ }
+
+ /**
+ * Replaces event with ID oldEventId with one with newEventId, if oldEventId is
+ * recognised. Otherwise, add to the live timeline. Used to handle remote echos.
+ *
+ * @param {MatrixEvent} localEvent the new event to be added to the timeline
+ * @param {String} oldEventId the ID of the original event
+ * @param {boolean} newEventId the ID of the replacement event
+ *
+ * @fires module:client~MatrixClient#event:"Room.timeline"
+ */
+ public handleRemoteEcho(
+ localEvent: MatrixEvent,
+ oldEventId: string,
+ newEventId: string,
+ ): void {
+ // XXX: why don't we infer newEventId from localEvent?
+ const existingTimeline = this._eventIdToTimeline[oldEventId];
+ if (existingTimeline) {
+ delete this._eventIdToTimeline[oldEventId];
+ this._eventIdToTimeline[newEventId] = existingTimeline;
+ } else {
+ if (this.filter) {
+ if (this.filter.filterRoomTimeline([localEvent]).length) {
+ this.addEventToTimeline(localEvent, this.liveTimeline, false);
+ }
+ } else {
+ this.addEventToTimeline(localEvent, this.liveTimeline, false);
+ }
+ }
+ }
+
+ /**
+ * Removes a single event from this room.
+ *
+ * @param {String} eventId The id of the event to remove
+ *
+ * @return {?MatrixEvent} the removed event, or null if the event was not found
+ * in this room.
+ */
+ public removeEvent(eventId: string): MatrixEvent | null {
+ const timeline = this._eventIdToTimeline[eventId];
+ if (!timeline) {
+ return null;
+ }
+
+ const removed = timeline.removeEvent(eventId);
+ if (removed) {
+ delete this._eventIdToTimeline[eventId];
+ const data = {
+ timeline: timeline,
+ };
+ this.emit("Room.timeline", removed, this.room, undefined, true, data);
+ }
+ return removed;
+ }
+
+ /**
+ * Determine where two events appear in the timeline relative to one another
+ *
+ * @param {string} eventId1 The id of the first event
+ * @param {string} eventId2 The id of the second event
+
+ * @return {?number} a number less than zero if eventId1 precedes eventId2, and
+ * greater than zero if eventId1 succeeds eventId2. zero if they are the
+ * same event; null if we can't tell (either because we don't know about one
+ * of the events, or because they are in separate timelines which don't join
+ * up).
+ */
+ public compareEventOrdering(eventId1: string, eventId2: string): number | null {
+ if (eventId1 == eventId2) {
+ // optimise this case
+ return 0;
+ }
+
+ const timeline1 = this._eventIdToTimeline[eventId1];
+ const timeline2 = this._eventIdToTimeline[eventId2];
+
+ if (timeline1 === undefined) {
+ return null;
+ }
+ if (timeline2 === undefined) {
+ return null;
+ }
+
+ if (timeline1 === timeline2) {
+ // both events are in the same timeline - figure out their
+ // relative indices
+ let idx1;
+ let idx2;
+ const events = timeline1.getEvents();
+ for (let idx = 0; idx < events.length &&
+ (idx1 === undefined || idx2 === undefined); idx++) {
+ const evId = events[idx].getId();
+ if (evId == eventId1) {
+ idx1 = idx;
+ }
+ if (evId == eventId2) {
+ idx2 = idx;
+ }
+ }
+ return idx1 - idx2;
+ }
+
+ // the events are in different timelines. Iterate through the
+ // linkedlist to see which comes first.
+
+ // first work forwards from timeline1
+ let tl = timeline1;
+ while (tl) {
+ if (tl === timeline2) {
+ // timeline1 is before timeline2
+ return -1;
+ }
+ tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS);
+ }
+
+ // now try backwards from timeline1
+ tl = timeline1;
+ while (tl) {
+ if (tl === timeline2) {
+ // timeline2 is before timeline1
+ return 1;
+ }
+ tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS);
+ }
+
+ // the timelines are not contiguous.
+ return null;
+ }
+
+ /**
+ * Get a collection of relations to a given event in this timeline set.
+ *
+ * @param {String} eventId
+ * The ID of the event that you'd like to access relation events for.
+ * For example, with annotations, this would be the ID of the event being annotated.
+ * @param {String} relationType
+ * The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
+ * @param {String} eventType
+ * The relation event's type, such as "m.reaction", etc.
+ * @throws If An EventTimeline represents a contiguous sequence of events in a room.
- *
- * As well as keeping track of the events themselves, it stores the state of
- * the room at the beginning and end of the timeline, and pagination tokens for
- * going backwards and forwards in the timeline.
- *
- * In order that clients can meaningfully maintain an index into a timeline,
- * the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is
- * incremented when events are prepended to the timeline. The index of an event
- * relative to baseIndex therefore remains constant.
- *
- * Once a timeline joins up with its neighbour, they are linked together into a
- * doubly-linked list.
- *
- * @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of
- * @constructor
- */
-export function EventTimeline(eventTimelineSet) {
- this._eventTimelineSet = eventTimelineSet;
- this._roomId = eventTimelineSet.room ? eventTimelineSet.room.roomId : null;
- this._events = [];
- this._baseIndex = 0;
- this._startState = new RoomState(this._roomId);
- this._startState.paginationToken = null;
- this._endState = new RoomState(this._roomId);
- this._endState.paginationToken = null;
-
- this._prevTimeline = null;
- this._nextTimeline = null;
-
- // this is used by client.js
- this._paginationRequests = { 'b': null, 'f': null };
-
- this._name = this._roomId + ":" + new Date().toISOString();
-}
-
-/**
- * Symbolic constant for methods which take a 'direction' argument:
- * refers to the start of the timeline, or backwards in time.
- */
-EventTimeline.BACKWARDS = "b";
-
-/**
- * Symbolic constant for methods which take a 'direction' argument:
- * refers to the end of the timeline, or forwards in time.
- */
-EventTimeline.FORWARDS = "f";
-
-/**
- * Initialise the start and end state with the given events
- *
- * This can only be called before any events are added.
- *
- * @param {MatrixEvent[]} stateEvents list of state events to initialise the
- * state with.
- * @throws {Error} if an attempt is made to call this after addEvent is called.
- */
-EventTimeline.prototype.initialiseState = function(stateEvents) {
- if (this._events.length > 0) {
- throw new Error("Cannot initialise state after events are added");
- }
-
- // We previously deep copied events here and used different copies in
- // the oldState and state events: this decision seems to date back
- // quite a way and was apparently made to fix a bug where modifications
- // made to the start state leaked through to the end state.
- // This really shouldn't be possible though: the events themselves should
- // not change. Duplicating the events uses a lot of extra memory,
- // so we now no longer do it. To assert that they really do never change,
- // freeze them! Note that we can't do this for events in general:
- // although it looks like the only things preventing us are the
- // 'status' flag, forwardLooking (which is only set once when adding to the
- // timeline) and possibly the sender (which seems like it should never be
- // reset but in practice causes a lot of the tests to break).
- for (const e of stateEvents) {
- Object.freeze(e);
- }
-
- this._startState.setStateEvents(stateEvents);
- this._endState.setStateEvents(stateEvents);
-};
-
-/**
- * Forks the (live) timeline, taking ownership of the existing directional state of this timeline.
- * All attached listeners will keep receiving state updates from the new live timeline state.
- * The end state of this timeline gets replaced with an independent copy of the current RoomState,
- * and will need a new pagination token if it ever needs to paginate forwards.
-
- * @param {string} direction EventTimeline.BACKWARDS to get the state at the
- * start of the timeline; EventTimeline.FORWARDS to get the state at the end
- * of the timeline.
- *
- * @return {EventTimeline} the new timeline
- */
-EventTimeline.prototype.forkLive = function(direction) {
- const forkState = this.getState(direction);
- const timeline = new EventTimeline(this._eventTimelineSet);
- timeline._startState = forkState.clone();
- // Now clobber the end state of the new live timeline with that from the
- // previous live timeline. It will be identical except that we'll keep
- // using the same RoomMember objects for the 'live' set of members with any
- // listeners still attached
- timeline._endState = forkState;
- // Firstly, we just stole the current timeline's end state, so it needs a new one.
- // Make an immutable copy of the state so back pagination will get the correct sentinels.
- this._endState = forkState.clone();
- return timeline;
-};
-
-/**
- * Creates an independent timeline, inheriting the directional state from this timeline.
- *
- * @param {string} direction EventTimeline.BACKWARDS to get the state at the
- * start of the timeline; EventTimeline.FORWARDS to get the state at the end
- * of the timeline.
- *
- * @return {EventTimeline} the new timeline
- */
-EventTimeline.prototype.fork = function(direction) {
- const forkState = this.getState(direction);
- const timeline = new EventTimeline(this._eventTimelineSet);
- timeline._startState = forkState.clone();
- timeline._endState = forkState.clone();
- return timeline;
-};
-
-/**
- * Get the ID of the room for this timeline
- * @return {string} room ID
- */
-EventTimeline.prototype.getRoomId = function() {
- return this._roomId;
-};
-
-/**
- * Get the filter for this timeline's timelineSet (if any)
- * @return {Filter} filter
- */
-EventTimeline.prototype.getFilter = function() {
- return this._eventTimelineSet.getFilter();
-};
-
-/**
- * Get the timelineSet for this timeline
- * @return {EventTimelineSet} timelineSet
- */
-EventTimeline.prototype.getTimelineSet = function() {
- return this._eventTimelineSet;
-};
-
-/**
- * Get the base index.
- *
- * This is an index which is incremented when events are prepended to the
- * timeline. An individual event therefore stays at the same index in the array
- * relative to the base index (although note that a given event's index may
- * well be less than the base index, thus giving that event a negative relative
- * index).
- *
- * @return {number}
- */
-EventTimeline.prototype.getBaseIndex = function() {
- return this._baseIndex;
-};
-
-/**
- * Get the list of events in this context
- *
- * @return {MatrixEvent[]} An array of MatrixEvents
- */
-EventTimeline.prototype.getEvents = function() {
- return this._events;
-};
-
-/**
- * Get the room state at the start/end of the timeline
- *
- * @param {string} direction EventTimeline.BACKWARDS to get the state at the
- * start of the timeline; EventTimeline.FORWARDS to get the state at the end
- * of the timeline.
- *
- * @return {RoomState} state at the start/end of the timeline
- */
-EventTimeline.prototype.getState = function(direction) {
- if (direction == EventTimeline.BACKWARDS) {
- return this._startState;
- } else if (direction == EventTimeline.FORWARDS) {
- return this._endState;
- } else {
- throw new Error("Invalid direction '" + direction + "'");
- }
-};
-
-/**
- * Get a pagination token
- *
- * @param {string} direction EventTimeline.BACKWARDS to get the pagination
- * token for going backwards in time; EventTimeline.FORWARDS to get the
- * pagination token for going forwards in time.
- *
- * @return {?string} pagination token
- */
-EventTimeline.prototype.getPaginationToken = function(direction) {
- return this.getState(direction).paginationToken;
-};
-
-/**
- * Set a pagination token
- *
- * @param {?string} token pagination token
- *
- * @param {string} direction EventTimeline.BACKWARDS to set the pagination
- * token for going backwards in time; EventTimeline.FORWARDS to set the
- * pagination token for going forwards in time.
- */
-EventTimeline.prototype.setPaginationToken = function(token, direction) {
- this.getState(direction).paginationToken = token;
-};
-
-/**
- * Get the next timeline in the series
- *
- * @param {string} direction EventTimeline.BACKWARDS to get the previous
- * timeline; EventTimeline.FORWARDS to get the next timeline.
- *
- * @return {?EventTimeline} previous or following timeline, if they have been
- * joined up.
- */
-EventTimeline.prototype.getNeighbouringTimeline = function(direction) {
- if (direction == EventTimeline.BACKWARDS) {
- return this._prevTimeline;
- } else if (direction == EventTimeline.FORWARDS) {
- return this._nextTimeline;
- } else {
- throw new Error("Invalid direction '" + direction + "'");
- }
-};
-
-/**
- * Set the next timeline in the series
- *
- * @param {EventTimeline} neighbour previous/following timeline
- *
- * @param {string} direction EventTimeline.BACKWARDS to set the previous
- * timeline; EventTimeline.FORWARDS to set the next timeline.
- *
- * @throws {Error} if an attempt is made to set the neighbouring timeline when
- * it is already set.
- */
-EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) {
- if (this.getNeighbouringTimeline(direction)) {
- throw new Error("timeline already has a neighbouring timeline - " +
- "cannot reset neighbour (direction: " + direction + ")");
- }
-
- if (direction == EventTimeline.BACKWARDS) {
- this._prevTimeline = neighbour;
- } else if (direction == EventTimeline.FORWARDS) {
- this._nextTimeline = neighbour;
- } else {
- throw new Error("Invalid direction '" + direction + "'");
- }
-
- // make sure we don't try to paginate this timeline
- this.setPaginationToken(null, direction);
-};
-
-/**
- * Add a new event to the timeline, and update the state
- *
- * @param {MatrixEvent} event new event
- * @param {boolean} atStart true to insert new event at the start
- */
-EventTimeline.prototype.addEvent = function(event, atStart) {
- const stateContext = atStart ? this._startState : this._endState;
-
- // only call setEventMetadata on the unfiltered timelineSets
- const timelineSet = this.getTimelineSet();
- if (timelineSet.room &&
- timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
- EventTimeline.setEventMetadata(event, stateContext, atStart);
-
- // modify state
- if (event.isState()) {
- stateContext.setStateEvents([event]);
- // it is possible that the act of setting the state event means we
- // can set more metadata (specifically sender/target props), so try
- // it again if the prop wasn't previously set. It may also mean that
- // the sender/target is updated (if the event set was a room member event)
- // so we want to use the *updated* member (new avatar/name) instead.
- //
- // However, we do NOT want to do this on member events if we're going
- // back in time, else we'll set the .sender value for BEFORE the given
- // member event, whereas we want to set the .sender value for the ACTUAL
- // member event itself.
- if (!event.sender || (event.getType() === "m.room.member" && !atStart)) {
- EventTimeline.setEventMetadata(event, stateContext, atStart);
- }
- }
- }
-
- let insertIndex;
-
- if (atStart) {
- insertIndex = 0;
- } else {
- insertIndex = this._events.length;
- }
-
- this._events.splice(insertIndex, 0, event); // insert element
- if (atStart) {
- this._baseIndex++;
- }
-};
-
-/**
- * Static helper method to set sender and target properties
- *
- * @param {MatrixEvent} event the event whose metadata is to be set
- * @param {RoomState} stateContext the room state to be queried
- * @param {bool} toStartOfTimeline if true the event's forwardLooking flag is set false
- */
-EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline) {
- // set sender and target properties
- event.sender = stateContext.getSentinelMember(
- event.getSender(),
- );
- if (event.getType() === "m.room.member") {
- event.target = stateContext.getSentinelMember(
- event.getStateKey(),
- );
- }
- if (event.isState()) {
- // room state has no concept of 'old' or 'current', but we want the
- // room state to regress back to previous values if toStartOfTimeline
- // is set, which means inspecting prev_content if it exists. This
- // is done by toggling the forwardLooking flag.
- if (toStartOfTimeline) {
- event.forwardLooking = false;
- }
- }
-};
-
-/**
- * Remove an event from the timeline
- *
- * @param {string} eventId ID of event to be removed
- * @return {?MatrixEvent} removed event, or null if not found
- */
-EventTimeline.prototype.removeEvent = function(eventId) {
- for (let i = this._events.length - 1; i >= 0; i--) {
- const ev = this._events[i];
- if (ev.getId() == eventId) {
- this._events.splice(i, 1);
- if (i < this._baseIndex) {
- this._baseIndex--;
- }
- return ev;
- }
- }
- return null;
-};
-
-/**
- * Return a string to identify this timeline, for debugging
- *
- * @return {string} name for this timeline
- */
-EventTimeline.prototype.toString = function() {
- return this._name;
-};
-
diff --git a/src/models/event-timeline.ts b/src/models/event-timeline.ts
new file mode 100644
index 000000000..92a9b9633
--- /dev/null
+++ b/src/models/event-timeline.ts
@@ -0,0 +1,416 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * @module models/event-timeline
+ */
+
+import { RoomState } from "./room-state";
+import { EventTimelineSet } from "./event-timeline-set";
+import { MatrixEvent } from "./event";
+import { Filter } from "../filter";
+
+export enum Direction {
+ Backward = "b",
+ Forward = "f",
+}
+
+export class EventTimeline {
+ /**
+ * Symbolic constant for methods which take a 'direction' argument:
+ * refers to the start of the timeline, or backwards in time.
+ */
+ static BACKWARDS = Direction.Backward;
+
+ /**
+ * Symbolic constant for methods which take a 'direction' argument:
+ * refers to the end of the timeline, or forwards in time.
+ */
+ static FORWARDS = Direction.Forward;
+
+ /**
+ * Static helper method to set sender and target properties
+ *
+ * @param {MatrixEvent} event the event whose metadata is to be set
+ * @param {RoomState} stateContext the room state to be queried
+ * @param {boolean} toStartOfTimeline if true the event's forwardLooking flag is set false
+ */
+ static setEventMetadata(event: MatrixEvent, stateContext: RoomState, toStartOfTimeline: boolean): void {
+ // set sender and target properties
+ event.sender = stateContext.getSentinelMember(
+ event.getSender(),
+ );
+ if (event.getType() === "m.room.member") {
+ event.target = stateContext.getSentinelMember(
+ event.getStateKey(),
+ );
+ }
+ if (event.isState()) {
+ // room state has no concept of 'old' or 'current', but we want the
+ // room state to regress back to previous values if toStartOfTimeline
+ // is set, which means inspecting prev_content if it exists. This
+ // is done by toggling the forwardLooking flag.
+ if (toStartOfTimeline) {
+ event.forwardLooking = false;
+ }
+ }
+ }
+
+ private readonly roomId: string | null;
+ private readonly name: string;
+ private events: MatrixEvent[] = [];
+ private baseIndex = 0;
+ private startState: RoomState;
+ private endState: RoomState;
+ private prevTimeline?: EventTimeline;
+ private nextTimeline?: EventTimeline;
+ public paginationRequests: Record An EventTimeline represents a contiguous sequence of events in a room.
+ *
+ * As well as keeping track of the events themselves, it stores the state of
+ * the room at the beginning and end of the timeline, and pagination tokens for
+ * going backwards and forwards in the timeline.
+ *
+ * In order that clients can meaningfully maintain an index into a timeline,
+ * the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is
+ * incremented when events are prepended to the timeline. The index of an event
+ * relative to baseIndex therefore remains constant.
+ *
+ * Once a timeline joins up with its neighbour, they are linked together into a
+ * doubly-linked list.
+ *
+ * @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of
+ * @constructor
+ */
+ constructor(private readonly eventTimelineSet: EventTimelineSet) {
+ this.roomId = eventTimelineSet.room?.roomId ?? null;
+ this.startState = new RoomState(this.roomId);
+ this.startState.paginationToken = null;
+ this.endState = new RoomState(this.roomId);
+ this.endState.paginationToken = null;
+
+ this.prevTimeline = null;
+ this.nextTimeline = null;
+
+ // this is used by client.js
+ this.paginationRequests = { 'b': null, 'f': null };
+
+ this.name = this.roomId + ":" + new Date().toISOString();
+ }
+
+ /**
+ * Initialise the start and end state with the given events
+ *
+ * This can only be called before any events are added.
+ *
+ * @param {MatrixEvent[]} stateEvents list of state events to initialise the
+ * state with.
+ * @throws {Error} if an attempt is made to call this after addEvent is called.
+ */
+ public initialiseState(stateEvents: MatrixEvent[]): void {
+ if (this.events.length > 0) {
+ throw new Error("Cannot initialise state after events are added");
+ }
+
+ // We previously deep copied events here and used different copies in
+ // the oldState and state events: this decision seems to date back
+ // quite a way and was apparently made to fix a bug where modifications
+ // made to the start state leaked through to the end state.
+ // This really shouldn't be possible though: the events themselves should
+ // not change. Duplicating the events uses a lot of extra memory,
+ // so we now no longer do it. To assert that they really do never change,
+ // freeze them! Note that we can't do this for events in general:
+ // although it looks like the only things preventing us are the
+ // 'status' flag, forwardLooking (which is only set once when adding to the
+ // timeline) and possibly the sender (which seems like it should never be
+ // reset but in practice causes a lot of the tests to break).
+ for (const e of stateEvents) {
+ Object.freeze(e);
+ }
+
+ this.startState.setStateEvents(stateEvents);
+ this.endState.setStateEvents(stateEvents);
+ }
+
+ /**
+ * Forks the (live) timeline, taking ownership of the existing directional state of this timeline.
+ * All attached listeners will keep receiving state updates from the new live timeline state.
+ * The end state of this timeline gets replaced with an independent copy of the current RoomState,
+ * and will need a new pagination token if it ever needs to paginate forwards.
+
+ * @param {string} direction EventTimeline.BACKWARDS to get the state at the
+ * start of the timeline; EventTimeline.FORWARDS to get the state at the end
+ * of the timeline.
+ *
+ * @return {EventTimeline} the new timeline
+ */
+ public forkLive(direction: Direction): EventTimeline {
+ const forkState = this.getState(direction);
+ const timeline = new EventTimeline(this.eventTimelineSet);
+ timeline.startState = forkState.clone();
+ // Now clobber the end state of the new live timeline with that from the
+ // previous live timeline. It will be identical except that we'll keep
+ // using the same RoomMember objects for the 'live' set of members with any
+ // listeners still attached
+ timeline.endState = forkState;
+ // Firstly, we just stole the current timeline's end state, so it needs a new one.
+ // Make an immutable copy of the state so back pagination will get the correct sentinels.
+ this.endState = forkState.clone();
+ return timeline;
+ }
+
+ /**
+ * Creates an independent timeline, inheriting the directional state from this timeline.
+ *
+ * @param {string} direction EventTimeline.BACKWARDS to get the state at the
+ * start of the timeline; EventTimeline.FORWARDS to get the state at the end
+ * of the timeline.
+ *
+ * @return {EventTimeline} the new timeline
+ */
+ public fork(direction: Direction): EventTimeline {
+ const forkState = this.getState(direction);
+ const timeline = new EventTimeline(this.eventTimelineSet);
+ timeline.startState = forkState.clone();
+ timeline.endState = forkState.clone();
+ return timeline;
+ }
+
+ /**
+ * Get the ID of the room for this timeline
+ * @return {string} room ID
+ */
+ public getRoomId(): string {
+ return this.roomId;
+ }
+
+ /**
+ * Get the filter for this timeline's timelineSet (if any)
+ * @return {Filter} filter
+ */
+ public getFilter(): Filter {
+ return this.eventTimelineSet.getFilter();
+ }
+
+ /**
+ * Get the timelineSet for this timeline
+ * @return {EventTimelineSet} timelineSet
+ */
+ public getTimelineSet(): EventTimelineSet {
+ return this.eventTimelineSet;
+ }
+
+ /**
+ * Get the base index.
+ *
+ * This is an index which is incremented when events are prepended to the
+ * timeline. An individual event therefore stays at the same index in the array
+ * relative to the base index (although note that a given event's index may
+ * well be less than the base index, thus giving that event a negative relative
+ * index).
+ *
+ * @return {number}
+ */
+ public getBaseIndex(): number {
+ return this.baseIndex;
+ }
+
+ /**
+ * Get the list of events in this context
+ *
+ * @return {MatrixEvent[]} An array of MatrixEvents
+ */
+ public getEvents(): MatrixEvent[] {
+ return this.events;
+ }
+
+ /**
+ * Get the room state at the start/end of the timeline
+ *
+ * @param {string} direction EventTimeline.BACKWARDS to get the state at the
+ * start of the timeline; EventTimeline.FORWARDS to get the state at the end
+ * of the timeline.
+ *
+ * @return {RoomState} state at the start/end of the timeline
+ */
+ public getState(direction: Direction): RoomState {
+ if (direction == EventTimeline.BACKWARDS) {
+ return this.startState;
+ } else if (direction == EventTimeline.FORWARDS) {
+ return this.endState;
+ } else {
+ throw new Error("Invalid direction '" + direction + "'");
+ }
+ }
+
+ /**
+ * Get a pagination token
+ *
+ * @param {string} direction EventTimeline.BACKWARDS to get the pagination
+ * token for going backwards in time; EventTimeline.FORWARDS to get the
+ * pagination token for going forwards in time.
+ *
+ * @return {?string} pagination token
+ */
+ public getPaginationToken(direction: Direction): string | null {
+ return this.getState(direction).paginationToken;
+ }
+
+ /**
+ * Set a pagination token
+ *
+ * @param {?string} token pagination token
+ *
+ * @param {string} direction EventTimeline.BACKWARDS to set the pagination
+ * token for going backwards in time; EventTimeline.FORWARDS to set the
+ * pagination token for going forwards in time.
+ */
+ public setPaginationToken(token: string, direction: Direction): void {
+ this.getState(direction).paginationToken = token;
+ }
+
+ /**
+ * Get the next timeline in the series
+ *
+ * @param {string} direction EventTimeline.BACKWARDS to get the previous
+ * timeline; EventTimeline.FORWARDS to get the next timeline.
+ *
+ * @return {?EventTimeline} previous or following timeline, if they have been
+ * joined up.
+ */
+ public getNeighbouringTimeline(direction: Direction): EventTimeline {
+ if (direction == EventTimeline.BACKWARDS) {
+ return this.prevTimeline;
+ } else if (direction == EventTimeline.FORWARDS) {
+ return this.nextTimeline;
+ } else {
+ throw new Error("Invalid direction '" + direction + "'");
+ }
+ }
+
+ /**
+ * Set the next timeline in the series
+ *
+ * @param {EventTimeline} neighbour previous/following timeline
+ *
+ * @param {string} direction EventTimeline.BACKWARDS to set the previous
+ * timeline; EventTimeline.FORWARDS to set the next timeline.
+ *
+ * @throws {Error} if an attempt is made to set the neighbouring timeline when
+ * it is already set.
+ */
+ public setNeighbouringTimeline(neighbour: EventTimeline, direction: Direction): void {
+ if (this.getNeighbouringTimeline(direction)) {
+ throw new Error("timeline already has a neighbouring timeline - " +
+ "cannot reset neighbour (direction: " + direction + ")");
+ }
+
+ if (direction == EventTimeline.BACKWARDS) {
+ this.prevTimeline = neighbour;
+ } else if (direction == EventTimeline.FORWARDS) {
+ this.nextTimeline = neighbour;
+ } else {
+ throw new Error("Invalid direction '" + direction + "'");
+ }
+
+ // make sure we don't try to paginate this timeline
+ this.setPaginationToken(null, direction);
+ }
+
+ /**
+ * Add a new event to the timeline, and update the state
+ *
+ * @param {MatrixEvent} event new event
+ * @param {boolean} atStart true to insert new event at the start
+ */
+ public addEvent(event: MatrixEvent, atStart: boolean): void {
+ const stateContext = atStart ? this.startState : this.endState;
+
+ // only call setEventMetadata on the unfiltered timelineSets
+ const timelineSet = this.getTimelineSet();
+ if (timelineSet.room &&
+ timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
+ EventTimeline.setEventMetadata(event, stateContext, atStart);
+
+ // modify state
+ if (event.isState()) {
+ stateContext.setStateEvents([event]);
+ // it is possible that the act of setting the state event means we
+ // can set more metadata (specifically sender/target props), so try
+ // it again if the prop wasn't previously set. It may also mean that
+ // the sender/target is updated (if the event set was a room member event)
+ // so we want to use the *updated* member (new avatar/name) instead.
+ //
+ // However, we do NOT want to do this on member events if we're going
+ // back in time, else we'll set the .sender value for BEFORE the given
+ // member event, whereas we want to set the .sender value for the ACTUAL
+ // member event itself.
+ if (!event.sender || (event.getType() === "m.room.member" && !atStart)) {
+ EventTimeline.setEventMetadata(event, stateContext, atStart);
+ }
+ }
+ }
+
+ let insertIndex;
+
+ if (atStart) {
+ insertIndex = 0;
+ } else {
+ insertIndex = this.events.length;
+ }
+
+ this.events.splice(insertIndex, 0, event); // insert element
+ if (atStart) {
+ this.baseIndex++;
+ }
+ }
+
+ /**
+ * Remove an event from the timeline
+ *
+ * @param {string} eventId ID of event to be removed
+ * @return {?MatrixEvent} removed event, or null if not found
+ */
+ public removeEvent(eventId: string): MatrixEvent | null {
+ for (let i = this.events.length - 1; i >= 0; i--) {
+ const ev = this.events[i];
+ if (ev.getId() == eventId) {
+ this.events.splice(i, 1);
+ if (i < this.baseIndex) {
+ this.baseIndex--;
+ }
+ return ev;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return a string to identify this timeline, for debugging
+ *
+ * @return {string} name for this timeline
+ */
+ public toString(): string {
+ return this.name;
+ }
+}
diff --git a/src/models/event.ts b/src/models/event.ts
index 490988787..73648dca9 100644
--- a/src/models/event.ts
+++ b/src/models/event.ts
@@ -112,7 +112,7 @@ interface IAggregatedRelation {
}
interface IEventRelation {
- rel_type: string;
+ rel_type: RelationType | string;
event_id: string;
key?: string;
}
diff --git a/src/models/relations.ts b/src/models/relations.ts
index 288ef3616..37beeb31d 100644
--- a/src/models/relations.ts
+++ b/src/models/relations.ts
@@ -49,7 +49,7 @@ export class Relations extends EventEmitter {
* notification timeline.
*/
constructor(
- public readonly relationType: RelationType,
+ public readonly relationType: RelationType | string,
public readonly eventType: string,
private readonly room: Room,
) {
diff --git a/src/service-types.js b/src/service-types.ts
similarity index 72%
rename from src/service-types.js
rename to src/service-types.ts
index 0803b9247..79dc99937 100644
--- a/src/service-types.js
+++ b/src/service-types.ts
@@ -1,5 +1,5 @@
/*
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-export const SERVICE_TYPES = Object.freeze({
- IS: 'SERVICE_TYPE_IS', // An Identity Service
- IM: 'SERVICE_TYPE_IM', // An Integration Manager
-});
+export enum SERVICE_TYPES {
+ IS = 'SERVICE_TYPE_IS', // An Identity Service
+ IM = 'SERVICE_TYPE_IM', // An Integration Manager
+}
diff --git a/src/timeline-window.js b/src/timeline-window.js
deleted file mode 100644
index 0fc9f3ae4..000000000
--- a/src/timeline-window.js
+++ /dev/null
@@ -1,521 +0,0 @@
-/*
-Copyright 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-/** @module timeline-window */
-
-import { EventTimeline } from './models/event-timeline';
-import { logger } from './logger';
-
-/**
- * @private
- */
-const DEBUG = false;
-
-/**
- * @private
- */
-const debuglog = DEBUG ? logger.log.bind(logger) : function() {};
-
-/**
- * the number of times we ask the server for more events before giving up
- *
- * @private
- */
-const DEFAULT_PAGINATE_LOOP_LIMIT = 5;
-
-/**
- * Construct a TimelineWindow.
- *
- * This abstracts the separate timelines in a Matrix {@link
- * module:models/room|Room} into a single iterable thing. It keeps track of
- * the start and endpoints of the window, which can be advanced with the help
- * of pagination requests.
- *
- * Before the window is useful, it must be initialised by calling {@link
- * module:timeline-window~TimelineWindow#load|load}.
- *
- * Note that the window will not automatically extend itself when new events
- * are received from /sync; you should arrange to call {@link
- * module:timeline-window~TimelineWindow#paginate|paginate} on {@link
- * module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events.
- *
- * @param {MatrixClient} client MatrixClient to be used for context/pagination
- * requests.
- *
- * @param {EventTimelineSet} timelineSet The timelineSet to track
- *
- * @param {Object} [opts] Configuration options for this window
- *
- * @param {number} [opts.windowLimit = 1000] maximum number of events to keep
- * in the window. If more events are retrieved via pagination requests,
- * excess events will be dropped from the other end of the window.
- *
- * @constructor
- */
-export function TimelineWindow(client, timelineSet, opts) {
- opts = opts || {};
- this._client = client;
- this._timelineSet = timelineSet;
-
- // these will be TimelineIndex objects; they delineate the 'start' and
- // 'end' of the window.
- //
- // _start.index is inclusive; _end.index is exclusive.
- this._start = null;
- this._end = null;
-
- this._eventCount = 0;
- this._windowLimit = opts.windowLimit || 1000;
-}
-
-/**
- * Initialise the window to point at a given event, or the live timeline
- *
- * @param {string} [initialEventId] If given, the window will contain the
- * given event
- * @param {number} [initialWindowSize = 20] Size of the initial window
- *
- * @return {Promise}
- */
-TimelineWindow.prototype.load = function(initialEventId, initialWindowSize) {
- const self = this;
- initialWindowSize = initialWindowSize || 20;
-
- // given an EventTimeline, find the event we were looking for, and initialise our
- // fields so that the event in question is in the middle of the window.
- const initFields = function(timeline) {
- let eventIndex;
-
- const events = timeline.getEvents();
-
- if (!initialEventId) {
- // we were looking for the live timeline: initialise to the end
- eventIndex = events.length;
- } else {
- for (let i = 0; i < events.length; i++) {
- if (events[i].getId() == initialEventId) {
- eventIndex = i;
- break;
- }
- }
-
- if (eventIndex === undefined) {
- throw new Error("getEventTimeline result didn't include requested event");
- }
- }
-
- const endIndex = Math.min(events.length,
- eventIndex + Math.ceil(initialWindowSize / 2));
- const startIndex = Math.max(0, endIndex - initialWindowSize);
- self._start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex());
- self._end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex());
- self._eventCount = endIndex - startIndex;
- };
-
- // We avoid delaying the resolution of the promise by a reactor tick if
- // we already have the data we need, which is important to keep room-switching
- // feeling snappy.
- //
- if (initialEventId) {
- const timeline = this._timelineSet.getTimelineForEvent(initialEventId);
- if (timeline) {
- // hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does.
- initFields(timeline);
- return Promise.resolve(timeline);
- }
-
- const prom = this._client.getEventTimeline(this._timelineSet, initialEventId);
- return prom.then(initFields);
- } else {
- const tl = this._timelineSet.getLiveTimeline();
- initFields(tl);
- return Promise.resolve();
- }
-};
-
-/**
- * Get the TimelineIndex of the window in the given direction.
- *
- * @param {string} direction EventTimeline.BACKWARDS to get the TimelineIndex
- * at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at
- * the end.
- *
- * @return {TimelineIndex} The requested timeline index if one exists, null
- * otherwise.
- */
-TimelineWindow.prototype.getTimelineIndex = function(direction) {
- if (direction == EventTimeline.BACKWARDS) {
- return this._start;
- } else if (direction == EventTimeline.FORWARDS) {
- return this._end;
- } else {
- throw new Error("Invalid direction '" + direction + "'");
- }
-};
-
-/**
- * Try to extend the window using events that are already in the underlying
- * TimelineIndex.
- *
- * @param {string} direction EventTimeline.BACKWARDS to try extending it
- * backwards; EventTimeline.FORWARDS to try extending it forwards.
- * @param {number} size number of events to try to extend by.
- *
- * @return {boolean} true if the window was extended, false otherwise.
- */
-TimelineWindow.prototype.extend = function(direction, size) {
- const tl = this.getTimelineIndex(direction);
-
- if (!tl) {
- debuglog("TimelineWindow: no timeline yet");
- return false;
- }
-
- const count = (direction == EventTimeline.BACKWARDS) ?
- tl.retreat(size) : tl.advance(size);
-
- if (count) {
- this._eventCount += count;
- debuglog("TimelineWindow: increased cap by " + count +
- " (now " + this._eventCount + ")");
- // remove some events from the other end, if necessary
- const excess = this._eventCount - this._windowLimit;
- if (excess > 0) {
- this.unpaginate(excess, direction != EventTimeline.BACKWARDS);
- }
- return true;
- }
-
- return false;
-};
-
-/**
- * Check if this window can be extended
- *
- * This returns true if we either have more events, or if we have a
- * pagination token which means we can paginate in that direction. It does not
- * necessarily mean that there are more events available in that direction at
- * this time.
- *
- * @param {string} direction EventTimeline.BACKWARDS to check if we can
- * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards
- *
- * @return {boolean} true if we can paginate in the given direction
- */
-TimelineWindow.prototype.canPaginate = function(direction) {
- const tl = this.getTimelineIndex(direction);
-
- if (!tl) {
- debuglog("TimelineWindow: no timeline yet");
- return false;
- }
-
- if (direction == EventTimeline.BACKWARDS) {
- if (tl.index > tl.minIndex()) {
- return true;
- }
- } else {
- if (tl.index < tl.maxIndex()) {
- return true;
- }
- }
-
- return Boolean(tl.timeline.getNeighbouringTimeline(direction) ||
- tl.timeline.getPaginationToken(direction));
-};
-
-/**
- * Attempt to extend the window
- *
- * @param {string} direction EventTimeline.BACKWARDS to extend the window
- * backwards (towards older events); EventTimeline.FORWARDS to go forwards.
- *
- * @param {number} size number of events to try to extend by. If fewer than this
- * number are immediately available, then we return immediately rather than
- * making an API call.
- *
- * @param {boolean} [makeRequest = true] whether we should make API calls to
- * fetch further events if we don't have any at all. (This has no effect if
- * the room already knows about additional events in the relevant direction,
- * even if there are fewer than 'size' of them, as we will just return those
- * we already know about.)
- *
- * @param {number} [requestLimit = 5] limit for the number of API requests we
- * should make.
- *
- * @return {Promise} Resolves to a boolean which is true if more events
- * were successfully retrieved.
- */
-TimelineWindow.prototype.paginate = function(direction, size, makeRequest,
- requestLimit) {
- // Either wind back the message cap (if there are enough events in the
- // timeline to do so), or fire off a pagination request.
-
- if (makeRequest === undefined) {
- makeRequest = true;
- }
-
- if (requestLimit === undefined) {
- requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT;
- }
-
- const tl = this.getTimelineIndex(direction);
-
- if (!tl) {
- debuglog("TimelineWindow: no timeline yet");
- return Promise.resolve(false);
- }
-
- if (tl.pendingPaginate) {
- return tl.pendingPaginate;
- }
-
- // try moving the cap
- if (this.extend(direction, size)) {
- return Promise.resolve(true);
- }
-
- if (!makeRequest || requestLimit === 0) {
- // todo: should we return something different to indicate that there
- // might be more events out there, but we haven't found them yet?
- return Promise.resolve(false);
- }
-
- // try making a pagination request
- const token = tl.timeline.getPaginationToken(direction);
- if (!token) {
- debuglog("TimelineWindow: no token");
- return Promise.resolve(false);
- }
-
- debuglog("TimelineWindow: starting request");
- const self = this;
-
- const prom = this._client.paginateEventTimeline(tl.timeline, {
- backwards: direction == EventTimeline.BACKWARDS,
- limit: size,
- }).finally(function() {
- tl.pendingPaginate = null;
- }).then(function(r) {
- debuglog("TimelineWindow: request completed with result " + r);
- if (!r) {
- // end of timeline
- return false;
- }
-
- // recurse to advance the index into the results.
- //
- // If we don't get any new events, we want to make sure we keep asking
- // the server for events for as long as we have a valid pagination
- // token. In particular, we want to know if we've actually hit the
- // start of the timeline, or if we just happened to know about all of
- // the events thanks to https://matrix.org/jira/browse/SYN-645.
- //
- // On the other hand, we necessarily want to wait forever for the
- // server to make its mind up about whether there are other events,
- // because it gives a bad user experience
- // (https://github.com/vector-im/vector-web/issues/1204).
- return self.paginate(direction, size, true, requestLimit - 1);
- });
- tl.pendingPaginate = prom;
- return prom;
-};
-
-/**
- * Remove `delta` events from the start or end of the timeline.
- *
- * @param {number} delta number of events to remove from the timeline
- * @param {boolean} startOfTimeline if events should be removed from the start
- * of the timeline.
- */
-TimelineWindow.prototype.unpaginate = function(delta, startOfTimeline) {
- const tl = startOfTimeline ? this._start : this._end;
-
- // sanity-check the delta
- if (delta > this._eventCount || delta < 0) {
- throw new Error("Attemting to unpaginate " + delta + " events, but " +
- "only have " + this._eventCount + " in the timeline");
- }
-
- while (delta > 0) {
- const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta);
- if (count <= 0) {
- // sadness. This shouldn't be possible.
- throw new Error(
- "Unable to unpaginate any further, but still have " +
- this._eventCount + " events");
- }
-
- delta -= count;
- this._eventCount -= count;
- debuglog("TimelineWindow.unpaginate: dropped " + count +
- " (now " + this._eventCount + ")");
- }
-};
-
-/**
- * Get a list of the events currently in the window
- *
- * @return {MatrixEvent[]} the events in the window
- */
-TimelineWindow.prototype.getEvents = function() {
- if (!this._start) {
- // not yet loaded
- return [];
- }
-
- const result = [];
-
- // iterate through each timeline between this._start and this._end
- // (inclusive).
- let timeline = this._start.timeline;
- while (true) {
- const events = timeline.getEvents();
-
- // For the first timeline in the chain, we want to start at
- // this._start.index. For the last timeline in the chain, we want to
- // stop before this._end.index. Otherwise, we want to copy all of the
- // events in the timeline.
- //
- // (Note that both this._start.index and this._end.index are relative
- // to their respective timelines' BaseIndex).
- //
- let startIndex = 0;
- let endIndex = events.length;
- if (timeline === this._start.timeline) {
- startIndex = this._start.index + timeline.getBaseIndex();
- }
- if (timeline === this._end.timeline) {
- endIndex = this._end.index + timeline.getBaseIndex();
- }
-
- for (let i = startIndex; i < endIndex; i++) {
- result.push(events[i]);
- }
-
- // if we're not done, iterate to the next timeline.
- if (timeline === this._end.timeline) {
- break;
- } else {
- timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS);
- }
- }
-
- return result;
-};
-
-/**
- * a thing which contains a timeline reference, and an index into it.
- *
- * @constructor
- * @param {EventTimeline} timeline
- * @param {number} index
- * @private
- */
-export function TimelineIndex(timeline, index) {
- this.timeline = timeline;
-
- // the indexes are relative to BaseIndex, so could well be negative.
- this.index = index;
-}
-
-/**
- * @return {number} the minimum possible value for the index in the current
- * timeline
- */
-TimelineIndex.prototype.minIndex = function() {
- return this.timeline.getBaseIndex() * -1;
-};
-
-/**
- * @return {number} the maximum possible value for the index in the current
- * timeline (exclusive - ie, it actually returns one more than the index
- * of the last element).
- */
-TimelineIndex.prototype.maxIndex = function() {
- return this.timeline.getEvents().length - this.timeline.getBaseIndex();
-};
-
-/**
- * Try move the index forward, or into the neighbouring timeline
- *
- * @param {number} delta number of events to advance by
- * @return {number} number of events successfully advanced by
- */
-TimelineIndex.prototype.advance = function(delta) {
- if (!delta) {
- return 0;
- }
-
- // first try moving the index in the current timeline. See if there is room
- // to do so.
- let cappedDelta;
- if (delta < 0) {
- // we want to wind the index backwards.
- //
- // (this.minIndex() - this.index) is a negative number whose magnitude
- // is the amount of room we have to wind back the index in the current
- // timeline. We cap delta to this quantity.
- cappedDelta = Math.max(delta, this.minIndex() - this.index);
- if (cappedDelta < 0) {
- this.index += cappedDelta;
- return cappedDelta;
- }
- } else {
- // we want to wind the index forwards.
- //
- // (this.maxIndex() - this.index) is a (positive) number whose magnitude
- // is the amount of room we have to wind forward the index in the current
- // timeline. We cap delta to this quantity.
- cappedDelta = Math.min(delta, this.maxIndex() - this.index);
- if (cappedDelta > 0) {
- this.index += cappedDelta;
- return cappedDelta;
- }
- }
-
- // the index is already at the start/end of the current timeline.
- //
- // next see if there is a neighbouring timeline to switch to.
- const neighbour = this.timeline.getNeighbouringTimeline(
- delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS);
- if (neighbour) {
- this.timeline = neighbour;
- if (delta < 0) {
- this.index = this.maxIndex();
- } else {
- this.index = this.minIndex();
- }
-
- debuglog("paginate: switched to new neighbour");
-
- // recurse, using the next timeline
- return this.advance(delta);
- }
-
- return 0;
-};
-
-/**
- * Try move the index backwards, or into the neighbouring timeline
- *
- * @param {number} delta number of events to retreat by
- * @return {number} number of events successfully retreated by
- */
-TimelineIndex.prototype.retreat = function(delta) {
- return this.advance(delta * -1) * -1;
-};
diff --git a/src/timeline-window.ts b/src/timeline-window.ts
new file mode 100644
index 000000000..5c16b7213
--- /dev/null
+++ b/src/timeline-window.ts
@@ -0,0 +1,526 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/** @module timeline-window */
+
+import { Direction, EventTimeline } from './models/event-timeline';
+import { logger } from './logger';
+import { MatrixClient } from "./client";
+import { EventTimelineSet } from "./models/event-timeline-set";
+import { MatrixEvent } from "./models/event";
+
+/**
+ * @private
+ */
+const DEBUG = false;
+
+/**
+ * @private
+ */
+const debuglog = DEBUG ? logger.log.bind(logger) : function() {};
+
+/**
+ * the number of times we ask the server for more events before giving up
+ *
+ * @private
+ */
+const DEFAULT_PAGINATE_LOOP_LIMIT = 5;
+
+interface IOpts {
+ windowLimit?: number;
+}
+
+export class TimelineWindow {
+ private readonly windowLimit: number;
+ // these will be TimelineIndex objects; they delineate the 'start' and
+ // 'end' of the window.
+ //
+ // start.index is inclusive; end.index is exclusive.
+ private start?: TimelineIndex = null;
+ private end?: TimelineIndex = null;
+ private eventCount = 0;
+
+ /**
+ * Construct a TimelineWindow.
+ *
+ * This abstracts the separate timelines in a Matrix {@link
+ * module:models/room|Room} into a single iterable thing. It keeps track of
+ * the start and endpoints of the window, which can be advanced with the help
+ * of pagination requests.
+ *
+ * Before the window is useful, it must be initialised by calling {@link
+ * module:timeline-window~TimelineWindow#load|load}.
+ *
+ * Note that the window will not automatically extend itself when new events
+ * are received from /sync; you should arrange to call {@link
+ * module:timeline-window~TimelineWindow#paginate|paginate} on {@link
+ * module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events.
+ *
+ * @param {MatrixClient} client MatrixClient to be used for context/pagination
+ * requests.
+ *
+ * @param {EventTimelineSet} timelineSet The timelineSet to track
+ *
+ * @param {Object} [opts] Configuration options for this window
+ *
+ * @param {number} [opts.windowLimit = 1000] maximum number of events to keep
+ * in the window. If more events are retrieved via pagination requests,
+ * excess events will be dropped from the other end of the window.
+ *
+ * @constructor
+ */
+ constructor(
+ private readonly client: MatrixClient,
+ private readonly timelineSet: EventTimelineSet,
+ opts: IOpts = {},
+ ) {
+ this.windowLimit = opts.windowLimit || 1000;
+ }
+
+ /**
+ * Initialise the window to point at a given event, or the live timeline
+ *
+ * @param {string} [initialEventId] If given, the window will contain the
+ * given event
+ * @param {number} [initialWindowSize = 20] Size of the initial window
+ *
+ * @return {Promise}
+ */
+ public load(initialEventId: string, initialWindowSize = 20): Promise This returns true if we either have more events, or if we have a
+ * pagination token which means we can paginate in that direction. It does not
+ * necessarily mean that there are more events available in that direction at
+ * this time.
+ *
+ * @param {string} direction EventTimeline.BACKWARDS to check if we can
+ * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards
+ *
+ * @return {boolean} true if we can paginate in the given direction
+ */
+ public canPaginate(direction: Direction): boolean {
+ const tl = this.getTimelineIndex(direction);
+
+ if (!tl) {
+ debuglog("TimelineWindow: no timeline yet");
+ return false;
+ }
+
+ if (direction == EventTimeline.BACKWARDS) {
+ if (tl.index > tl.minIndex()) {
+ return true;
+ }
+ } else {
+ if (tl.index < tl.maxIndex()) {
+ return true;
+ }
+ }
+
+ return Boolean(tl.timeline.getNeighbouringTimeline(direction) ||
+ tl.timeline.getPaginationToken(direction));
+ }
+
+ /**
+ * Attempt to extend the window
+ *
+ * @param {string} direction EventTimeline.BACKWARDS to extend the window
+ * backwards (towards older events); EventTimeline.FORWARDS to go forwards.
+ *
+ * @param {number} size number of events to try to extend by. If fewer than this
+ * number are immediately available, then we return immediately rather than
+ * making an API call.
+ *
+ * @param {boolean} [makeRequest = true] whether we should make API calls to
+ * fetch further events if we don't have any at all. (This has no effect if
+ * the room already knows about additional events in the relevant direction,
+ * even if there are fewer than 'size' of them, as we will just return those
+ * we already know about.)
+ *
+ * @param {number} [requestLimit = 5] limit for the number of API requests we
+ * should make.
+ *
+ * @return {Promise} Resolves to a boolean which is true if more events
+ * were successfully retrieved.
+ */
+ public paginate(direction: Direction, size: number, makeRequest: boolean, requestLimit: number): PromiseeventId, relationType or eventType
- * are not valid.
- *
- * @returns {?Relations}
- * A container for relation events or undefined if there are no relation events for
- * the relationType.
- */
-EventTimelineSet.prototype.getRelationsForEvent = function(
- eventId, relationType, eventType,
-) {
- if (!this._unstableClientRelationAggregation) {
- throw new Error("Client-side relation aggregation is disabled");
- }
-
- if (!eventId || !relationType || !eventType) {
- throw new Error("Invalid arguments for `getRelationsForEvent`");
- }
-
- // debuglog("Getting relations for: ", eventId, relationType, eventType);
-
- const relationsForEvent = this._relations[eventId] || {};
- const relationsWithRelType = relationsForEvent[relationType] || {};
- return relationsWithRelType[eventType];
-};
-
-/**
- * Set an event as the target event if any Relations exist for it already
- *
- * @param {MatrixEvent} event
- * The event to check as relation target.
- */
-EventTimelineSet.prototype.setRelationsTarget = function(event) {
- if (!this._unstableClientRelationAggregation) {
- return;
- }
-
- const relationsForEvent = this._relations[event.getId()];
- if (!relationsForEvent) {
- return;
- }
-
- for (const relationsWithRelType of Object.values(relationsForEvent)) {
- for (const relationsWithEventType of Object.values(relationsWithRelType)) {
- relationsWithEventType.setTargetEvent(event);
- }
- }
-};
-
-/**
- * Add relation events to the relevant relation collection.
- *
- * @param {MatrixEvent} event
- * The new relation event to be aggregated.
- */
-EventTimelineSet.prototype.aggregateRelations = function(event) {
- if (!this._unstableClientRelationAggregation) {
- return;
- }
-
- if (event.isRedacted() || event.status === EventStatus.CANCELLED) {
- return;
- }
-
- // If the event is currently encrypted, wait until it has been decrypted.
- if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
- event.once("Event.decrypted", () => {
- this.aggregateRelations(event);
- });
- return;
- }
-
- const relation = event.getRelation();
- if (!relation) {
- return;
- }
-
- const relatesToEventId = relation.event_id;
- const relationType = relation.rel_type;
- const eventType = event.getType();
-
- // debuglog("Aggregating relation: ", event.getId(), eventType, relation);
-
- let relationsForEvent = this._relations[relatesToEventId];
- if (!relationsForEvent) {
- relationsForEvent = this._relations[relatesToEventId] = {};
- }
- let relationsWithRelType = relationsForEvent[relationType];
- if (!relationsWithRelType) {
- relationsWithRelType = relationsForEvent[relationType] = {};
- }
- let relationsWithEventType = relationsWithRelType[eventType];
-
- let relatesToEvent;
- if (!relationsWithEventType) {
- relationsWithEventType = relationsWithRelType[eventType] = new Relations(
- relationType,
- eventType,
- this.room,
- );
- relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId);
- if (relatesToEvent) {
- relationsWithEventType.setTargetEvent(relatesToEvent);
- }
- }
-
- relationsWithEventType.addEvent(event);
-};
-
-/**
- * Fires whenever the timeline in a room is updated.
- * @event module:client~MatrixClient#"Room.timeline"
- * @param {MatrixEvent} event The matrix event which caused this event to fire.
- * @param {?Room} room The room, if any, whose timeline was updated.
- * @param {boolean} toStartOfTimeline True if this event was added to the start
- * @param {boolean} removed True if this event has just been removed from the timeline
- * (beginning; oldest) of the timeline e.g. due to pagination.
- *
- * @param {object} data more data about the event
- *
- * @param {module:models/event-timeline.EventTimeline} data.timeline the timeline the
- * event was added to/removed from
- *
- * @param {boolean} data.liveEvent true if the event was a real-time event
- * added to the end of the live timeline
- *
- * @example
- * matrixClient.on("Room.timeline",
- * function(event, room, toStartOfTimeline, removed, data) {
- * if (!toStartOfTimeline && data.liveEvent) {
- * var messageToAppend = room.timeline.[room.timeline.length - 1];
- * }
- * });
- */
-
-/**
- * Fires whenever the live timeline in a room is reset.
- *
- * When we get a 'limited' sync (for example, after a network outage), we reset
- * the live timeline to be empty before adding the recent events to the new
- * timeline. This event is fired after the timeline is reset, and before the
- * new events are added.
- *
- * @event module:client~MatrixClient#"Room.timelineReset"
- * @param {Room} room The room whose live timeline was reset, if any
- * @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset
- * @param {boolean} resetAllTimelines True if all timelines were reset.
- */
diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts
new file mode 100644
index 000000000..d24252516
--- /dev/null
+++ b/src/models/event-timeline-set.ts
@@ -0,0 +1,874 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * @module models/event-timeline-set
+ */
+
+import { EventEmitter } from "events";
+
+import { EventTimeline } from "./event-timeline";
+import { EventStatus, MatrixEvent } from "./event";
+import { logger } from '../logger';
+import { Relations } from './relations';
+import { Room } from "./room";
+import { Filter } from "../filter";
+import { EventType, RelationType } from "../@types/event";
+
+// var DEBUG = false;
+const DEBUG = true;
+
+let debuglog;
+if (DEBUG) {
+ // using bind means that we get to keep useful line numbers in the console
+ debuglog = logger.log.bind(logger);
+} else {
+ debuglog = function() {};
+}
+
+interface IOpts {
+ timelineSupport?: boolean;
+ filter?: Filter;
+ unstableClientRelationAggregation?: boolean;
+}
+
+export class EventTimelineSet extends EventEmitter {
+ private readonly timelineSupport: boolean;
+ private unstableClientRelationAggregation: boolean;
+ private liveTimeline: EventTimeline;
+ private timelines: EventTimeline[];
+ private _eventIdToTimeline: Recordopts.pendingEventOrdering was not 'detached'
+ */
+ public getPendingEvents(): MatrixEvent[] {
+ if (!this.room) {
+ return [];
+ }
+
+ if (this.filter) {
+ return this.filter.filterRoomTimeline(this.room.getPendingEvents());
+ } else {
+ return this.room.getPendingEvents();
+ }
+ }
+
+ /**
+ * Get the live timeline for this room.
+ *
+ * @return {module:models/event-timeline~EventTimeline} live timeline
+ */
+ public getLiveTimeline(): EventTimeline {
+ return this.liveTimeline;
+ }
+
+ /**
+ * Return the timeline (if any) this event is in.
+ * @param {String} eventId the eventId being sought
+ * @return {module:models/event-timeline~EventTimeline} timeline
+ */
+ public eventIdToTimeline(eventId: string): EventTimeline {
+ return this._eventIdToTimeline[eventId];
+ }
+
+ /**
+ * Track a new event as if it were in the same timeline as an old event,
+ * replacing it.
+ * @param {String} oldEventId event ID of the original event
+ * @param {String} newEventId event ID of the replacement event
+ */
+ public replaceEventId(oldEventId: string, newEventId: string): void {
+ const existingTimeline = this._eventIdToTimeline[oldEventId];
+ if (existingTimeline) {
+ delete this._eventIdToTimeline[oldEventId];
+ this._eventIdToTimeline[newEventId] = existingTimeline;
+ }
+ }
+
+ /**
+ * Reset the live timeline, and start a new one.
+ *
+ * eventId, relationType or eventType
+ * are not valid.
+ *
+ * @returns {?Relations}
+ * A container for relation events or undefined if there are no relation events for
+ * the relationType.
+ */
+ public getRelationsForEvent(
+ eventId: string,
+ relationType: RelationType,
+ eventType: EventType | string,
+ ): Relations | undefined {
+ if (!this.unstableClientRelationAggregation) {
+ throw new Error("Client-side relation aggregation is disabled");
+ }
+
+ if (!eventId || !relationType || !eventType) {
+ throw new Error("Invalid arguments for `getRelationsForEvent`");
+ }
+
+ // debuglog("Getting relations for: ", eventId, relationType, eventType);
+
+ const relationsForEvent = this.relations[eventId] || {};
+ const relationsWithRelType = relationsForEvent[relationType] || {};
+ return relationsWithRelType[eventType];
+ }
+
+ /**
+ * Set an event as the target event if any Relations exist for it already
+ *
+ * @param {MatrixEvent} event
+ * The event to check as relation target.
+ */
+ public setRelationsTarget(event: MatrixEvent): void {
+ if (!this.unstableClientRelationAggregation) {
+ return;
+ }
+
+ const relationsForEvent = this.relations[event.getId()];
+ if (!relationsForEvent) {
+ return;
+ }
+
+ for (const relationsWithRelType of Object.values(relationsForEvent)) {
+ for (const relationsWithEventType of Object.values(relationsWithRelType)) {
+ relationsWithEventType.setTargetEvent(event);
+ }
+ }
+ }
+
+ /**
+ * Add relation events to the relevant relation collection.
+ *
+ * @param {MatrixEvent} event
+ * The new relation event to be aggregated.
+ */
+ public aggregateRelations(event: MatrixEvent): void {
+ if (!this.unstableClientRelationAggregation) {
+ return;
+ }
+
+ if (event.isRedacted() || event.status === EventStatus.CANCELLED) {
+ return;
+ }
+
+ // If the event is currently encrypted, wait until it has been decrypted.
+ if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
+ event.once("Event.decrypted", () => {
+ this.aggregateRelations(event);
+ });
+ return;
+ }
+
+ const relation = event.getRelation();
+ if (!relation) {
+ return;
+ }
+
+ const relatesToEventId = relation.event_id;
+ const relationType = relation.rel_type;
+ const eventType = event.getType();
+
+ // debuglog("Aggregating relation: ", event.getId(), eventType, relation);
+
+ let relationsForEvent: Record