diff options
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts')
-rw-r--r-- | includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts | 906 |
1 files changed, 906 insertions, 0 deletions
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts new file mode 100644 index 0000000..5cb0499 --- /dev/null +++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts @@ -0,0 +1,906 @@ +/* +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. +*/ + +import { EventTimeline, IAddEventOptions } from "./event-timeline"; +import { MatrixEvent } from "./event"; +import { logger } from "../logger"; +import { Room, RoomEvent } from "./room"; +import { Filter } from "../filter"; +import { RoomState } from "./room-state"; +import { TypedEventEmitter } from "./typed-event-emitter"; +import { RelationsContainer } from "./relations-container"; +import { MatrixClient } from "../client"; +import { Thread, ThreadFilterType } from "./thread"; + +const DEBUG = true; + +/* istanbul ignore next */ +let debuglog: (...args: any[]) => void; +if (DEBUG) { + // using bind means that we get to keep useful line numbers in the console + debuglog = logger.log.bind(logger); +} else { + /* istanbul ignore next */ + debuglog = function (): void {}; +} + +interface IOpts { + // Set to true to enable improved timeline support. + timelineSupport?: boolean; + // The filter object, if any, for this timelineSet. + filter?: Filter; + pendingEvents?: boolean; +} + +export enum DuplicateStrategy { + Ignore = "ignore", + Replace = "replace", +} + +export interface IRoomTimelineData { + // the timeline the event was added to/removed from + timeline: EventTimeline; + // true if the event was a real-time event added to the end of the live timeline + liveEvent?: boolean; +} + +export interface IAddEventToTimelineOptions + extends Pick<IAddEventOptions, "toStartOfTimeline" | "roomState" | "timelineWasEmpty"> { + /** Whether the sync response came from cache */ + fromCache?: boolean; +} + +export interface IAddLiveEventOptions + extends Pick<IAddEventToTimelineOptions, "fromCache" | "roomState" | "timelineWasEmpty"> { + /** Applies to events in the timeline only. If this is 'replace' then if a + * duplicate is encountered, the event passed to this function will replace + * the existing event in the timeline. If this is not specified, or is + * 'ignore', then the event passed to this function will be ignored + * entirely, preserving the existing event in the timeline. Events are + * identical based on their event ID <b>only</b>. */ + duplicateStrategy?: DuplicateStrategy; +} + +type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset; + +export type EventTimelineSetHandlerMap = { + /** + * Fires whenever the timeline in a room is updated. + * @param event - The matrix event which caused this event to fire. + * @param room - The room, if any, whose timeline was updated. + * @param toStartOfTimeline - True if this event was added to the start + * @param removed - True if this event has just been removed from the timeline + * (beginning; oldest) of the timeline e.g. due to pagination. + * + * @param data - more data about the event + * + * @example + * ``` + * matrixClient.on("Room.timeline", + * function(event, room, toStartOfTimeline, removed, data) { + * if (!toStartOfTimeline && data.liveEvent) { + * var messageToAppend = room.timeline.[room.timeline.length - 1]; + * } + * }); + * ``` + */ + [RoomEvent.Timeline]: ( + event: MatrixEvent, + room: Room | undefined, + toStartOfTimeline: boolean | undefined, + removed: boolean, + data: IRoomTimelineData, + ) => void; + /** + * 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. + * + * @param room - The room whose live timeline was reset, if any + * @param timelineSet - timelineSet room whose live timeline was reset + * @param resetAllTimelines - True if all timelines were reset. + */ + [RoomEvent.TimelineReset]: ( + room: Room | undefined, + eventTimelineSet: EventTimelineSet, + resetAllTimelines: boolean, + ) => void; +}; + +export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTimelineSetHandlerMap> { + public readonly relations: RelationsContainer; + private readonly timelineSupport: boolean; + private readonly displayPendingEvents: boolean; + private liveTimeline: EventTimeline; + private timelines: EventTimeline[]; + private _eventIdToTimeline = new Map<string, EventTimeline>(); + private filter?: Filter; + + /** + * 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. + * + * @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 isThreadTimeline - Whether this timeline set relates to a thread list timeline + * (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<string, EventTimeline>(); + + this.filter = opts.filter; + + this.relations = this.room?.relations ?? new RelationsContainer(room?.client ?? client!); + } + + /** + * Get all the timelines in this set + * @returns the timelines in this set + */ + public getTimelines(): EventTimeline[] { + return this.timelines; + } + + /** + * Get the filter object this timeline set is filtered on, if any + * @returns 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 - 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. + * + * @returns A list of the sent events + * waiting for remote echo. + * + * @throws If `opts.pendingEventOrdering` was not 'detached' + */ + public getPendingEvents(): MatrixEvent[] { + if (!this.room || !this.displayPendingEvents) { + return []; + } + + return this.room.getPendingEvents(); + } + /** + * Get the live timeline for this room. + * + * @returns live timeline + */ + public getLiveTimeline(): EventTimeline { + return this.liveTimeline; + } + + /** + * Set the live timeline for this room. + * + * @returns live timeline + */ + public setLiveTimeline(timeline: EventTimeline): void { + this.liveTimeline = timeline; + } + + /** + * Return the timeline (if any) this event is in. + * @param eventId - the eventId being sought + * @returns timeline + */ + public eventIdToTimeline(eventId: string): EventTimeline | undefined { + return this._eventIdToTimeline.get(eventId); + } + + /** + * Track a new event as if it were in the same timeline as an old event, + * replacing it. + * @param oldEventId - event ID of the original event + * @param newEventId - event ID of the replacement event + */ + public replaceEventId(oldEventId: string, newEventId: string): void { + const existingTimeline = this._eventIdToTimeline.get(oldEventId); + if (existingTimeline) { + this._eventIdToTimeline.delete(oldEventId); + this._eventIdToTimeline.set(newEventId, existingTimeline); + } + } + + /** + * Reset the live timeline, and start a new one. + * + * <p>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<string, EventTimeline>(); + } 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 ?? null, EventTimeline.BACKWARDS); + + // Now we can swap the live timeline to the new one. + this.liveTimeline = newTimeline; + this.emit(RoomEvent.TimelineReset, this.room, this, resetAllTimelines); + } + + /** + * Get the timeline which contains the given event, if any + * + * @param eventId - event ID to look for + * @returns timeline containing + * the given event, or null if unknown + */ + public getTimelineForEvent(eventId?: string): EventTimeline | null { + if (eventId === null || eventId === undefined) { + return null; + } + const res = this._eventIdToTimeline.get(eventId); + return res === undefined ? null : res; + } + + /** + * Get an event which is stored in our timelines + * + * @param eventId - event ID to look for + * @returns 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 + * + * @returns 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 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 <b>last</b> 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, + 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, + }); + 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 }: IAddLiveEventOptions, + ): void; + /** + * @deprecated In favor of the overload with `IAddLiveEventOptions` + */ + public addLiveEvent( + event: MatrixEvent, + duplicateStrategy?: DuplicateStrategy, + fromCache?: boolean, + roomState?: RoomState, + ): void; + public addLiveEvent( + event: MatrixEvent, + duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions, + fromCache = false, + roomState?: RoomState, + ): void { + let duplicateStrategy = (duplicateStrategyOrOpts as DuplicateStrategy) || DuplicateStrategy.Ignore; + let timelineWasEmpty: boolean | undefined; + if (typeof duplicateStrategyOrOpts === "object") { + ({ + duplicateStrategy = DuplicateStrategy.Ignore, + fromCache = false, + roomState, + timelineWasEmpty, + } = duplicateStrategyOrOpts); + } else if (duplicateStrategyOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + logger.warn( + "Overload deprecated: " + + "`EventTimelineSet.addLiveEvent(event, duplicateStrategy?, fromCache?, roomState?)` " + + "is deprecated in favor of the overload with " + + "`EventTimelineSet.addLiveEvent(event, IAddLiveEventOptions)`", + ); + } + + 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, + }); + } + + /** + * 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 options - addEventToTimeline options + * + * @remarks + * Fires {@link RoomEvent.Timeline} + */ + public addEventToTimeline( + event: MatrixEvent, + timeline: EventTimeline, + { toStartOfTimeline, fromCache, roomState, timelineWasEmpty }: IAddEventToTimelineOptions, + ): void; + /** + * @deprecated In favor of the overload with `IAddEventToTimelineOptions` + */ + public addEventToTimeline( + event: MatrixEvent, + timeline: EventTimeline, + toStartOfTimeline: boolean, + fromCache?: boolean, + roomState?: RoomState, + ): void; + public addEventToTimeline( + event: MatrixEvent, + timeline: EventTimeline, + toStartOfTimelineOrOpts: boolean | IAddEventToTimelineOptions, + fromCache = false, + roomState?: RoomState, + ): void { + let toStartOfTimeline = !!toStartOfTimelineOrOpts; + let timelineWasEmpty: boolean | undefined; + if (typeof toStartOfTimelineOrOpts === "object") { + ({ toStartOfTimeline, fromCache = false, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts); + } else if (toStartOfTimelineOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + logger.warn( + "Overload deprecated: " + + "`EventTimelineSet.addEventToTimeline(event, timeline, toStartOfTimeline, fromCache?, roomState?)` " + + "is deprecated in favor of the overload with " + + "`EventTimelineSet.addEventToTimeline(event, timeline, IAddEventToTimelineOptions)`", + ); + } + + if (timeline.getTimelineSet() !== this) { + throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " + + "in timelineSet(threadId=${this.thread?.id})`); + } + + // 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=${event.getId()}`; + 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; + } + + const eventId = event.getId()!; + timeline.addEvent(event, { + toStartOfTimeline, + roomState, + timelineWasEmpty, + }); + this._eventIdToTimeline.set(eventId, timeline); + + this.relations.aggregateParentEvent(event); + this.relations.aggregateChildEvent(event, this); + + const data: IRoomTimelineData = { + timeline: timeline, + liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache, + }; + this.emit(RoomEvent.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 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, + }); + } + } + + /** + * 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 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.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; + } + } + 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: 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 } = this.room.eventShouldLiveIn(event); + + if (this.thread) { + return this.thread.id === threadId; + } + return shouldLiveInRoom; + } +} |