1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-26 17:03:12 +03:00

Convert EventTimeline, EventTimelineSet and TimelineWindow to TS

This commit is contained in:
Michael Telatynski
2021-07-01 09:53:55 +01:00
parent e3a00c2cb4
commit 4b29f02f1c
12 changed files with 1830 additions and 1785 deletions

View File

@@ -4502,7 +4502,7 @@ export class MatrixClient extends EventEmitter {
return Promise.resolve(false); return Promise.resolve(false);
} }
const pendingRequest = eventTimeline._paginationRequests[dir]; const pendingRequest = eventTimeline.paginationRequests[dir];
if (pendingRequest) { if (pendingRequest) {
// already a request in progress - return the existing promise // already a request in progress - return the existing promise
@@ -4551,9 +4551,9 @@ export class MatrixClient extends EventEmitter {
} }
return res.next_token ? true : false; return res.next_token ? true : false;
}).finally(() => { }).finally(() => {
eventTimeline._paginationRequests[dir] = null; eventTimeline.paginationRequests[dir] = null;
}); });
eventTimeline._paginationRequests[dir] = promise; eventTimeline.paginationRequests[dir] = promise;
} else { } else {
const room = this.getRoom(eventTimeline.getRoomId()); const room = this.getRoom(eventTimeline.getRoomId());
if (!room) { if (!room) {
@@ -4585,9 +4585,9 @@ export class MatrixClient extends EventEmitter {
} }
return res.end != res.start; return res.end != res.start;
}).finally(() => { }).finally(() => {
eventTimeline._paginationRequests[dir] = null; eventTimeline.paginationRequests[dir] = null;
}); });
eventTimeline._paginationRequests[dir] = promise; eventTimeline.paginationRequests[dir] = promise;
} }
return promise; return promise;

View File

@@ -292,7 +292,7 @@ export class CrossSigningInfo extends EventEmitter {
CrossSigningLevel.USER_SIGNING | CrossSigningLevel.USER_SIGNING |
CrossSigningLevel.SELF_SIGNING CrossSigningLevel.SELF_SIGNING
); );
} else if (level === 0) { } else if (level === 0 as CrossSigningLevel) {
return; return;
} }

View File

@@ -15,11 +15,7 @@ limitations under the License.
*/ */
import { MatrixEvent } from "./event"; import { MatrixEvent } from "./event";
import { Direction } from "./event-timeline";
enum Direction {
Backward = "b",
Forward = "f",
}
/** /**
* @module models/event-context * @module models/event-context

View File

@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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 <code>opts.pendingEventOrdering</code> 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.
*
* <p>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
*
* <p>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 <b>last</b> 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 <code>eventId</code>, <code>relationType</code> or <code>eventType</code>
* 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.
*/

View File

@@ -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<string, EventTimeline>;
private filter?: Filter;
private relations: Record<string, Record<string, Record<RelationType, Relations>>>;
/**
* 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.
*
* <p>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.
*
* <p>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.
*
* <p>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 <code>opts.pendingEventOrdering</code> 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.
*
* <p>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
*
* <p>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 <b>last</b> 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 <code>eventId</code>, <code>relationType</code> or <code>eventType</code>
* 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<string, Partial<Record<string, Relations>>> = 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.
*/

View File

@@ -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
*
* <p>An EventTimeline represents a contiguous sequence of events in a room.
*
* <p>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.
*
* <p>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.
*
* <p>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
*
* <p>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.
*
* <p>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;
};

View File

@@ -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, Promise<boolean>> = {
[Direction.Backward]: null,
[Direction.Forward]: null,
};
/**
* Construct a new EventTimeline
*
* <p>An EventTimeline represents a contiguous sequence of events in a room.
*
* <p>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.
*
* <p>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.
*
* <p>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
*
* <p>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.
*
* <p>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;
}
}

View File

@@ -112,7 +112,7 @@ interface IAggregatedRelation {
} }
interface IEventRelation { interface IEventRelation {
rel_type: string; rel_type: RelationType | string;
event_id: string; event_id: string;
key?: string; key?: string;
} }

View File

@@ -49,7 +49,7 @@ export class Relations extends EventEmitter {
* notification timeline. * notification timeline.
*/ */
constructor( constructor(
public readonly relationType: RelationType, public readonly relationType: RelationType | string,
public readonly eventType: string, public readonly eventType: string,
private readonly room: Room, private readonly room: Room,
) { ) {

View File

@@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. limitations under the License.
*/ */
export const SERVICE_TYPES = Object.freeze({ export enum SERVICE_TYPES {
IS: 'SERVICE_TYPE_IS', // An Identity Service IS = 'SERVICE_TYPE_IS', // An Identity Service
IM: 'SERVICE_TYPE_IM', // An Integration Manager IM = 'SERVICE_TYPE_IM', // An Integration Manager
}); }

View File

@@ -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.
*
* <p>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.
*
* <p>Before the window is useful, it must be initialised by calling {@link
* module:timeline-window~TimelineWindow#load|load}.
*
* <p>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
*
* <p>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;
};

526
src/timeline-window.ts Normal file
View File

@@ -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.
*
* <p>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.
*
* <p>Before the window is useful, it must be initialised by calling {@link
* module:timeline-window~TimelineWindow#load|load}.
*
* <p>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<any> {
// 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
*
* <p>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<boolean> {
// 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<boolean>;
// 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;
}
}