From 4b29f02f1c856af9c76d4844db0b3039b909feb3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jul 2021 09:53:55 +0100 Subject: [PATCH 1/3] Convert EventTimeline, EventTimelineSet and TimelineWindow to TS --- src/client.ts | 10 +- src/crypto/CrossSigning.ts | 2 +- src/models/event-context.ts | 6 +- src/models/event-timeline-set.js | 848 -------------------- src/models/event-timeline-set.ts | 874 +++++++++++++++++++++ src/models/event-timeline.js | 398 ---------- src/models/event-timeline.ts | 416 ++++++++++ src/models/event.ts | 2 +- src/models/relations.ts | 2 +- src/{service-types.js => service-types.ts} | 10 +- src/timeline-window.js | 521 ------------ src/timeline-window.ts | 526 +++++++++++++ 12 files changed, 1830 insertions(+), 1785 deletions(-) delete mode 100644 src/models/event-timeline-set.js create mode 100644 src/models/event-timeline-set.ts delete mode 100644 src/models/event-timeline.js create mode 100644 src/models/event-timeline.ts rename src/{service-types.js => service-types.ts} (72%) delete mode 100644 src/timeline-window.js create mode 100644 src/timeline-window.ts 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 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. - */ -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: Record; + private filter?: Filter; + private relations: Record>>; + + /** + * 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. + */ + 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 opts.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. + * + *

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 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>> = 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.js b/src/models/event-timeline.js deleted file mode 100644 index 288659611..000000000 --- a/src/models/event-timeline.js +++ /dev/null @@ -1,398 +0,0 @@ -/* -Copyright 2016, 2017 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 - */ - -import { RoomState } from "./room-state"; - -/** - * Construct a new EventTimeline - * - *

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> = { + [Direction.Backward]: null, + [Direction.Forward]: null, + }; + + /** + * Construct a new EventTimeline + * + *

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 { + // 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 = (timeline: EventTimeline) => { + 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); + this.start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex()); + this.end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex()); + this.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. + */ + public getTimelineIndex(direction: Direction): TimelineIndex { + 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. + */ + public extend(direction: Direction, size: number): boolean { + 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 + */ + 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): Promise { + // 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 prom = this.client.paginateEventTimeline(tl.timeline, { + backwards: direction == EventTimeline.BACKWARDS, + limit: size, + }).finally(function() { + tl.pendingPaginate = null; + }).then((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 this.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. + */ + public unpaginate(delta: number, startOfTimeline: boolean): void { + 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 + */ + public getEvents(): MatrixEvent[] { + 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; + // eslint-disable-next-line no-constant-condition + 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 class TimelineIndex { + public pendingPaginate?: Promise; + + // index: the indexes are relative to BaseIndex, so could well be negative. + constructor(public timeline: EventTimeline, public index: number) {} + + /** + * @return {number} the minimum possible value for the index in the current + * timeline + */ + public minIndex(): number { + 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). + */ + public maxIndex(): number { + 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 + */ + public advance(delta: number): number { + 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 + */ + public retreat(delta: number): number { + return this.advance(delta * -1) * -1; + } +} From 399237e781848558169b3635ddd9c800bc0d409f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jul 2021 10:01:49 +0100 Subject: [PATCH 2/3] use better types --- src/timeline-window.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/timeline-window.ts b/src/timeline-window.ts index 5c16b7213..a03881f5e 100644 --- a/src/timeline-window.ts +++ b/src/timeline-window.ts @@ -265,7 +265,7 @@ export class TimelineWindow { * @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): Promise { + public paginate(direction: Direction, size: number, makeRequest = false, requestLimit = 5): Promise { // Either wind back the message cap (if there are enough events in the // timeline to do so), or fire off a pagination request. From a99c1e96d69277226c197865d71d2f5d4ff2d03d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jul 2021 21:05:58 +0100 Subject: [PATCH 3/3] fix field accesses in tests and default params --- spec/unit/event-timeline.spec.js | 8 ++++---- spec/unit/room.spec.js | 4 ++-- src/timeline-window.ts | 16 ++++++---------- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index ed3bfb4d5..f537f39eb 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -3,8 +3,8 @@ import { EventTimeline } from "../../src/models/event-timeline"; import { RoomState } from "../../src/models/room-state"; function mockRoomStates(timeline) { - timeline._startState = utils.mock(RoomState, "startState"); - timeline._endState = utils.mock(RoomState, "endState"); + timeline.startState = utils.mock(RoomState, "startState"); + timeline.endState = utils.mock(RoomState, "endState"); } describe("EventTimeline", function() { @@ -48,10 +48,10 @@ describe("EventTimeline", function() { }), ]; timeline.initialiseState(events); - expect(timeline._startState.setStateEvents).toHaveBeenCalledWith( + expect(timeline.startState.setStateEvents).toHaveBeenCalledWith( events, ); - expect(timeline._endState.setStateEvents).toHaveBeenCalledWith( + expect(timeline.endState.setStateEvents).toHaveBeenCalledWith( events, ); }); diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 34ec05f24..7675609d3 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -16,9 +16,9 @@ describe("Room", function() { beforeEach(function() { room = new Room(roomId); // mock RoomStates - room.oldState = room.getLiveTimeline()._startState = + room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState"); - room.currentState = room.getLiveTimeline()._endState = + room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); }); diff --git a/src/timeline-window.ts b/src/timeline-window.ts index a03881f5e..21912585d 100644 --- a/src/timeline-window.ts +++ b/src/timeline-window.ts @@ -265,18 +265,14 @@ export class TimelineWindow { * @return {Promise} Resolves to a boolean which is true if more events * were successfully retrieved. */ - public paginate(direction: Direction, size: number, makeRequest = false, requestLimit = 5): Promise { + public paginate( + direction: Direction, + size: number, + makeRequest = true, + requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT, + ): Promise { // 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) {