This is an ordered sequence of timelines, which may or may not * be continuous. Each timeline lists a series of events, as well as tracking * the room state at the start and the end of the timeline (if appropriate). * It also tracks forward and backward pagination tokens, as well as containing * links to the next timeline in the sequence. * *
There is one special timeline - the 'live' timeline, which represents the * timeline to which events are being added in real-time as they are received * from the /sync API. Note that you should not retain references to this * timeline - even if it is the current timeline right now, it may not remain * so if the server gives us a timeline gap in /sync. * *
In order that we can find events from their ids later, we also maintain a
* map from event_id to timeline and index.
*
* @param room - Room for this timelineSet. May be null for non-room cases, such as the
* notification timeline.
* @param opts - Options inherited from Room.
* @param client - the Matrix client which owns this EventTimelineSet,
* can be omitted if room is specified.
* @param thread - the thread to which this timeline set relates.
* @param threadListType - the type of thread list represented, if any
* (e.g., All threads or My threads)
*/
public constructor(
public readonly room: Room | undefined,
opts: IOpts = {},
client?: MatrixClient,
public readonly thread?: Thread,
public readonly threadListType: ThreadFilterType | null = null,
) {
super();
this.timelineSupport = Boolean(opts.timelineSupport);
this.liveTimeline = new EventTimeline(this);
this.displayPendingEvents = opts.pendingEvents !== false;
// just a list - *not* ordered.
this.timelines = [this.liveTimeline];
this._eventIdToTimeline = new Map This is used when /sync returns a 'limited' timeline.
*
* @param backPaginationToken - token for back-paginating the new timeline
* @param forwardPaginationToken - token for forward-paginating the old live timeline,
* if absent or null, all timelines are reset.
*
* @remarks
* Fires {@link RoomEvent.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 = new Map Will fire "Room.timeline" for each event added.
*
* @param events - A list of events to add.
*
* @param toStartOfTimeline - True to add these events to the start
* (oldest) instead of the end (newest) of the timeline. If true, the oldest
* event will be the last element of 'events'.
*
* @param timeline - timeline to
* add events to.
*
* @param paginationToken - token for the next batch of events
*
* @remarks
* Fires {@link RoomEvent.Timeline}
*
*/
public addEventsToTimeline(
events: MatrixEvent[],
toStartOfTimeline: boolean,
addToState: boolean,
timeline: EventTimeline,
paginationToken?: string | null,
): 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 (const event of events) {
const eventId = event.getId()!;
const existingTimeline = this._eventIdToTimeline.get(eventId);
if (!existingTimeline) {
// we don't know about this event yet. Just add it to the timeline.
this.addEventToTimeline(event, timeline, {
toStartOfTimeline,
addToState,
});
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 ?? null, direction);
}
}
/**
* Add an event to the end of this live timeline.
*
* @param event - Event to be added
* @param options - addLiveEvent options
*/
public addLiveEvent(
event: MatrixEvent,
{ duplicateStrategy, fromCache, roomState, timelineWasEmpty, addToState }: IAddLiveEventOptions,
): void {
if (this.filter) {
const events = this.filter.filterRoomTimeline([event]);
if (!events.length) {
return;
}
}
const timeline = this._eventIdToTimeline.get(event.getId()!);
if (timeline) {
if (duplicateStrategy === 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
if (!roomState) {
roomState = timeline.getState(EventTimeline.FORWARDS);
}
EventTimeline.setEventMetadata(event, roomState!, 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, {
toStartOfTimeline: false,
fromCache,
roomState,
timelineWasEmpty,
addToState,
});
}
/**
* 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 event - the event to add
* @param timeline - the timeline onto which to add it
* @param options - addEventToTimeline options
*
* @remarks
* Fires {@link RoomEvent.Timeline}
*/
public addEventToTimeline(
event: MatrixEvent,
timeline: EventTimeline,
{ toStartOfTimeline, fromCache = false, roomState, timelineWasEmpty, addToState }: IAddEventToTimelineOptions,
): void {
if (timeline.getTimelineSet() !== this) {
throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " +
"in timelineSet(threadId=${this.thread?.id})`);
}
const eventId = event.getId()!;
this.relations.aggregateParentEvent(event);
this.relations.aggregateChildEvent(event, this);
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
// threaded message should not be in the main timeline).
//
// We can only run this check for timelines with a `room` because `canContain`
// requires it
if (this.room && !this.canContain(event)) {
let eventDebugString = `event=${eventId}`;
if (event.threadRootId) {
eventDebugString += `(belongs to thread=${event.threadRootId})`;
}
logger.warn(
`EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` +
`in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`,
);
return;
}
timeline.addEvent(event, {
toStartOfTimeline,
roomState,
timelineWasEmpty,
addToState,
});
this._eventIdToTimeline.set(eventId, timeline);
const data: IRoomTimelineData = {
timeline: timeline,
liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache,
};
this.emit(RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data);
}
/**
* Insert event to the given timeline, and emit Room.timeline. Assumes
* we have already checked we don't know about this event.
*
* TEMPORARY: until we have recursive relations, we need this function
* to exist to allow us to insert events in timeline order, which is our
* best guess for Sync Order.
* This is a copy of addEventToTimeline above, modified to insert the event
* after the event it relates to, and before any event with a later
* timestamp. This is our best guess at Sync Order.
*
* Will fire "Room.timeline" for each event added.
*
* @internal
*
* @remarks
* Fires {@link RoomEvent.Timeline}
*/
public insertEventIntoTimeline(
event: MatrixEvent,
timeline: EventTimeline,
roomState: RoomState,
addToState: boolean,
): void {
if (timeline.getTimelineSet() !== this) {
throw new Error(`EventTimelineSet.insertEventIntoTimeline: Timeline=${timeline.toString()} does not belong " +
"in timelineSet(threadId=${this.thread?.id})`);
}
const eventId = event.getId()!;
this.relations.aggregateParentEvent(event);
this.relations.aggregateChildEvent(event, this);
// Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
// threaded message should not be in the main timeline).
//
// We can only run this check for timelines with a `room` because `canContain`
// requires it
if (this.room && !this.canContain(event)) {
let eventDebugString = `event=${eventId}`;
if (event.threadRootId) {
eventDebugString += `(belongs to thread=${event.threadRootId})`;
}
logger.warn(
`EventTimelineSet.insertEventIntoTimeline: Ignoring ${eventDebugString} that does not belong ` +
`in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`,
);
return;
}
// Find the event that this event is related to - the "parent"
const parentEventId = event.relationEventId;
if (!parentEventId) {
// Not related to anything - we just append
this.addEventToTimeline(event, timeline, {
toStartOfTimeline: false,
fromCache: false,
timelineWasEmpty: false,
roomState,
addToState,
});
return;
}
const parentEvent = this.findEventById(parentEventId);
const timelineEvents = timeline.getEvents();
// Start searching from the parent event, or if it's not loaded, start
// at the beginning and insert purely using timestamp order.
const parentIndex = parentEvent !== undefined ? timelineEvents.indexOf(parentEvent) : 0;
let insertIndex = parentIndex;
for (; insertIndex < timelineEvents.length; insertIndex++) {
const nextEvent = timelineEvents[insertIndex];
if (nextEvent.getTs() > event.getTs()) {
// We found an event later than ours, so insert before that.
break;
}
}
// If we got to the end of the loop, insertIndex points at the end of
// the list.
timeline.insertEvent(event, insertIndex, roomState, addToState);
this._eventIdToTimeline.set(eventId, timeline);
const data: IRoomTimelineData = {
timeline: timeline,
// The purpose of this method is inserting events in the middle of the
// timeline, so the events are, by definition, not live (whether or not
// we're adding them to the live timeline).
liveEvent: false,
};
this.emit(RoomEvent.Timeline, event, this.room, false, 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 localEvent - the new event to be added to the timeline
* @param oldEventId - the ID of the original event
* @param newEventId - the ID of the replacement event
*
* @remarks
* Fires {@link RoomEvent.Timeline}
*/
public handleRemoteEcho(localEvent: MatrixEvent, oldEventId: string, newEventId: string): void {
// XXX: why don't we infer newEventId from localEvent?
const existingTimeline = this._eventIdToTimeline.get(oldEventId);
if (existingTimeline) {
this._eventIdToTimeline.delete(oldEventId);
this._eventIdToTimeline.set(newEventId, existingTimeline);
} else if (!this.filter || this.filter.filterRoomTimeline([localEvent]).length) {
this.addEventToTimeline(localEvent, this.liveTimeline, {
toStartOfTimeline: false,
addToState: false,
});
}
}
/**
* Removes a single event from this room.
*
* @param eventId - The id of the event to remove
*
* @returns the removed event, or null if the event was not found
* in this room.
*/
public removeEvent(eventId: string): MatrixEvent | null {
const timeline = this._eventIdToTimeline.get(eventId);
if (!timeline) {
return null;
}
const removed = timeline.removeEvent(eventId);
if (removed) {
this._eventIdToTimeline.delete(eventId);
const data = {
timeline: timeline,
};
this.emit(RoomEvent.Timeline, removed, this.room, undefined, true, data);
}
return removed;
}
/**
* Determine where two events appear in the timeline relative to one another
*
* @param eventId1 - The id of the first event
* @param eventId2 - The id of the second event
* @returns -1 if eventId1 precedes eventId2, and +1 eventId1 succeeds
* eventId2. 0 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.get(eventId1);
const timeline2 = this._eventIdToTimeline.get(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: number | undefined = undefined;
let idx2: number | undefined = undefined;
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;
}
}
const difference = idx1! - idx2!;
// Return the sign of difference.
if (difference < 0) {
return -1;
} else if (difference > 0) {
return 1;
} else {
return 0;
}
}
// the events are in different timelines. Iterate through the
// linkedlist to see which comes first.
// first work forwards from timeline1
let tl: EventTimeline | null = 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;
}
/**
* Determine whether a given event can sanely be added to this event timeline set,
* for timeline sets relating to a thread, only return true for events in the same
* thread timeline, for timeline sets not relating to a thread only return true
* for events which should be shown in the main room timeline.
* Requires the `room` property to have been set at EventTimelineSet construction time.
*
* @param event - the event to check whether it belongs to this timeline set.
* @throws Error if `room` was not set when constructing this timeline set.
* @returns whether the event belongs to this timeline set.
*/
public canContain(event: MatrixEvent): boolean {
if (!this.room) {
throw new Error(
"Cannot call `EventTimelineSet::canContain without a `room` set. " +
"Set the room when creating the EventTimelineSet to call this method.",
);
}
const { threadId, shouldLiveInRoom, shouldLiveInThread } = this.room.eventShouldLiveIn(event);
if (this.thread) {
return this.thread.id === threadId;
}
if (!shouldLiveInRoom && !shouldLiveInThread) {
logger.warn(
`EventTimelineSet:canContain event encountered which cannot be added to any timeline roomId=${
this.room?.roomId
} eventId=${event.getId()} threadId=${event.threadRootId}`,
);
}
return shouldLiveInRoom;
}
}