diff --git a/spec/integ/matrix-client-syncing.spec.js b/spec/integ/matrix-client-syncing.spec.js index 6adb35a50c0..8703ef29584 100644 --- a/spec/integ/matrix-client-syncing.spec.js +++ b/spec/integ/matrix-client-syncing.spec.js @@ -1,5 +1,6 @@ import { MatrixEvent } from "../../src/models/event"; import { EventTimeline } from "../../src/models/event-timeline"; +import { EventType } from "../../src/@types/event"; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; @@ -461,6 +462,211 @@ describe("MatrixClient syncing", function() { xit("should update the room topic", function() { }); + + fdescribe("onMarkerStateEvent", () => { + it('no marker means the timeline does not need a refresh (check a sane default)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }), + ], + prev_batch: "pagTok", + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + }, + }), + ], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + fit('when this is our first sync for the room, there is no timeline to refresh', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkEvent({ + type: EventType.Marker, room: roomOne, user: otherUserId, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ], + prev_batch: "pagTok", + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + }, + }), + ], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(false); + }); + + it('a new marker event should mark the timeline as needing a refresh', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }), + ], + prev_batch: "pagTok", + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + }, + }), + ], + }, + }; + + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkEvent({ + type: EventType.Marker, room: roomOne, user: otherUserId, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ], + prev_batch: "pagTok", + }, + }; + + const markerEventId = nextSyncData.rooms.join[roomOne].timeline.events[0].event_id; + + let emitCount = 0; + client.on("Room.historyImportedWithinTimeline", function(markerEvent, room) { + expect(markerEvent.getId()).toEqual(markerEventId); + expect(room.roomId).toEqual(roomOne); + emitCount += 1; + }); + + httpBackend.when("GET", "/sync").respond(200, syncData); + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(2), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(true); + // Make sure "Room.historyImportedWithinTimeline" was emitted + expect(emitCount).toEqual(1); + expect(room.getLastMarkerEventIdProcessed()).toEqual(markerEventId); + }); + + it('marker event sent far back in the scroll back but since our last sync will cause the timeline to refresh', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [ + // TODO: Update this scenario to match test title + utils.mkEvent({ + type: EventType.Marker, room: roomOne, user: otherUserId, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ], + prev_batch: "pagTok", + }, + state: { + events: [ + utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + }, + }), + ], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, syncData); + + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + const room = client.getRoom(roomOne); + expect(room.getTimelineNeedsRefresh()).toEqual(true); + }); + }); }); describe("timeline", function() { diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index c9311d0e387..d32287e3e14 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -49,10 +49,14 @@ describe("EventTimeline", function() { ]; timeline.initialiseState(events); expect(timeline.startState.setStateEvents).toHaveBeenCalledWith( - events, + events, { + fromInitialState: true, + }, ); expect(timeline.endState.setStateEvents).toHaveBeenCalledWith( - events, + events, { + fromInitialState: true, + }, ); }); @@ -73,7 +77,7 @@ describe("EventTimeline", function() { expect(function() { timeline.initialiseState(state); }).not.toThrow(); - timeline.addEvent(event, false); + timeline.addEvent(event, { toStartOfTimeline: false }); expect(function() { timeline.initialiseState(state); }).toThrow(); @@ -149,9 +153,9 @@ describe("EventTimeline", function() { ]; it("should be able to add events to the end", function() { - timeline.addEvent(events[0], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], false); + timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents()[0]).toEqual(events[0]); @@ -159,9 +163,9 @@ describe("EventTimeline", function() { }); it("should be able to add events to the start", function() { - timeline.addEvent(events[0], true); + timeline.addEvent(events[0], { toStartOfTimeline: true }); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], true); + timeline.addEvent(events[1], { toStartOfTimeline: true }); expect(timeline.getBaseIndex()).toEqual(initialIndex + 1); expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents()[0]).toEqual(events[1]); @@ -203,9 +207,9 @@ describe("EventTimeline", function() { content: { name: "Old Room Name" }, }); - timeline.addEvent(newEv, false); + timeline.addEvent(newEv, { toStartOfTimeline: false }); expect(newEv.sender).toEqual(sentinel); - timeline.addEvent(oldEv, true); + timeline.addEvent(oldEv, { toStartOfTimeline: true }); expect(oldEv.sender).toEqual(oldSentinel); }); @@ -242,9 +246,9 @@ describe("EventTimeline", function() { const oldEv = utils.mkMembership({ room: roomId, mship: "ban", user: userB, skey: userA, event: true, }); - timeline.addEvent(newEv, false); + timeline.addEvent(newEv, { toStartOfTimeline: false }); expect(newEv.target).toEqual(sentinel); - timeline.addEvent(oldEv, true); + timeline.addEvent(oldEv, { toStartOfTimeline: true }); expect(oldEv.target).toEqual(oldSentinel); }); @@ -262,13 +266,17 @@ describe("EventTimeline", function() { }), ]; - timeline.addEvent(events[0], false); - timeline.addEvent(events[1], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). - toHaveBeenCalledWith([events[0]]); + toHaveBeenCalledWith([events[0]], { + fromInitialState: undefined, + }); expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). - toHaveBeenCalledWith([events[1]]); + toHaveBeenCalledWith([events[1]], { + fromInitialState: undefined, + }); expect(events[0].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true); @@ -291,13 +299,17 @@ describe("EventTimeline", function() { }), ]; - timeline.addEvent(events[0], true); - timeline.addEvent(events[1], true); + timeline.addEvent(events[0], { toStartOfTimeline: true }); + timeline.addEvent(events[1], { toStartOfTimeline: true }); expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). - toHaveBeenCalledWith([events[0]]); + toHaveBeenCalledWith([events[0]], { + fromInitialState: undefined, + }); expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). - toHaveBeenCalledWith([events[1]]); + toHaveBeenCalledWith([events[1]], { + fromInitialState: undefined, + }); expect(events[0].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false); @@ -324,8 +336,8 @@ describe("EventTimeline", function() { ]; it("should remove events", function() { - timeline.addEvent(events[0], false); - timeline.addEvent(events[1], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false }); expect(timeline.getEvents().length).toEqual(2); let ev = timeline.removeEvent(events[0].getId()); @@ -338,9 +350,9 @@ describe("EventTimeline", function() { }); it("should update baseIndex", function() { - timeline.addEvent(events[0], false); - timeline.addEvent(events[1], true); - timeline.addEvent(events[2], false); + timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: true }); + timeline.addEvent(events[2], { toStartOfTimeline: false }); expect(timeline.getEvents().length).toEqual(3); expect(timeline.getBaseIndex()).toEqual(1); @@ -358,11 +370,11 @@ describe("EventTimeline", function() { // further addEvent(ev, false) calls made the index increase. it("should not make baseIndex assplode when removing the last event", function() { - timeline.addEvent(events[0], true); + timeline.addEvent(events[0], { toStartOfTimeline: true }); timeline.removeEvent(events[0].getId()); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], false); - timeline.addEvent(events[2], false); + timeline.addEvent(events[1], { toStartOfTimeline: false }); + timeline.addEvent(events[2], { toStartOfTimeline: false }); expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getEvents().length).toEqual(2); }); diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index 261f7572d91..cefa3c5fe59 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -3,6 +3,7 @@ import { makeBeaconEvent, makeBeaconInfoEvent } from "../test-utils/beacon"; import { filterEmitCallsByEventType } from "../test-utils/emitter"; import { RoomState, RoomStateEvent } from "../../src/models/room-state"; import { BeaconEvent, getBeaconInfoIdentifier } from "../../src/models/beacon"; +import { EventType } from "../../src/@types/event"; describe("RoomState", function() { const roomId = "!foo:bar"; @@ -252,6 +253,29 @@ describe("RoomState", function() { ); }); + it("should emit 'RoomStateEvent.Marker' for each marker event", function() { + const events = [ + utils.mkEvent({ + event: true, + type: EventType.Marker, + room: roomId, + user: userA, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ]; + let emitCount = 0; + state.on("RoomStateEvent.Marker", function(markerEvent, setStateOptions) { + expect(markerEvent).toEqual(events[emitCount]); + expect(setStateOptions).toEqual({ fromInitialState: true }); + emitCount += 1; + }); + state.setStateEvents(events, { fromInitialState: true }); + expect(emitCount).toEqual(1); + }); + describe('beacon events', () => { it('adds new beacon info events to state and emits', () => { const beaconEvent = makeBeaconInfoEvent(userA, roomId); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 85f4c21d572..bb7b3887ea4 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -209,7 +209,9 @@ describe("Room", function() { it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() { expect(function() { - room.addLiveEvents(events, "foo"); + room.addLiveEvents(events, { + duplicateStrategy: "foo", + }); }).toThrow(); }); @@ -221,7 +223,9 @@ describe("Room", function() { dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); - room.addLiveEvents([dupe], DuplicateStrategy.Replace); + room.addLiveEvents([dupe], { + duplicateStrategy: DuplicateStrategy.Replace, + }); expect(room.timeline[0]).toEqual(dupe); }); @@ -233,7 +237,9 @@ describe("Room", function() { dupe.event.event_id = events[0].getId(); room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); - room.addLiveEvents([dupe], "ignore"); + room.addLiveEvents([dupe], { + duplicateStrategy: "ignore", + }); expect(room.timeline[0]).toEqual(events[0]); }); @@ -266,9 +272,11 @@ describe("Room", function() { room.addLiveEvents(events); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[0]], + { fromInitialState: false }, ); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[1]], + { fromInitialState: false }, ); expect(events[0].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true); @@ -470,9 +478,11 @@ describe("Room", function() { room.addEventsToTimeline(events, true, room.getLiveTimeline()); expect(room.oldState.setStateEvents).toHaveBeenCalledWith( [events[0]], + { fromInitialState: false }, ); expect(room.oldState.setStateEvents).toHaveBeenCalledWith( [events[1]], + { fromInitialState: false }, ); expect(events[0].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false); diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index c9466412c83..a0d61207301 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -35,13 +35,15 @@ function createTimeline(numEvents, baseIndex) { return timeline; } -function addEventsToTimeline(timeline, numEvents, atStart) { +function addEventsToTimeline(timeline, numEvents, toStartOfTimeline) { for (let i = 0; i < numEvents; i++) { timeline.addEvent( utils.mkMessage({ room: ROOM_ID, user: USER_ID, event: true, - }), atStart, + }), { + toStartOfTimeline, + }, ); } } diff --git a/src/@types/event.ts b/src/@types/event.ts index e5eac34f948..95fbb66943c 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -87,6 +87,8 @@ export enum EventType { RoomKeyRequest = "m.room_key_request", ForwardedRoomKey = "m.forwarded_room_key", Dummy = "m.dummy", + + Marker = "org.matrix.msc2716.marker", // MSC2716 } export enum RelationType { diff --git a/src/client.ts b/src/client.ts index bc55145e557..5b26cd3c95b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -790,6 +790,7 @@ type RoomEvents = RoomEvent.Name | RoomEvent.Receipt | RoomEvent.Tags | RoomEvent.LocalEchoUpdated + | RoomEvent.historyImportedWithinTimeline | RoomEvent.AccountData | RoomEvent.MyMembership | RoomEvent.Timeline @@ -799,6 +800,7 @@ type RoomStateEvents = RoomStateEvent.Events | RoomStateEvent.Members | RoomStateEvent.NewMember | RoomStateEvent.Update + | RoomStateEvent.Marker ; type CryptoEvents = CryptoEvent.KeySignatureUploadFailure diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index 13ea8c458f2..9973dc08738 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -56,6 +56,40 @@ export interface IRoomTimelineData { liveEvent?: boolean; } +export interface IAddLiveEventOptions { + /** 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 only. */ + duplicateStrategy?: DuplicateStrategy; + /** Whether the sync response came from cache */ + fromCache?: boolean; + /** The state events to reconcile metadata from */ + roomState?: RoomState; + /** Whether the state is part of the first state snapshot we're seeing in + * the room. This could be happen in a variety of cases: + * 1. From the initial sync + * 2. It's the first state we're seeing after joining the room + * 3. Or whether it's coming from `syncFromCache` */ + fromInitialState?: boolean; +} + +export interface IAddEventToTimelineOptions { + toStartOfTimeline: boolean; + /** Whether the sync response came from cache */ + fromCache?: boolean; + /** The state events to reconcile metadata from */ + roomState?: RoomState; + /** Whether the state is part of the first state snapshot we're seeing in + * the room. This could be happen in a variety of cases: + * 1. From the initial sync + * 2. It's the first state we're seeing after joining the room + * 3. Or whether it's coming from `syncFromCache` */ + fromInitialState?: boolean; +} + type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset; export type EventTimelineSetHandlerMap = { @@ -431,7 +465,9 @@ export class EventTimelineSet extends TypedEventEmitter { return ev.getType(); })); if (this.events.length > 0) { throw new Error("Cannot initialise state after events are added"); } @@ -152,8 +167,11 @@ export class EventTimeline { Object.freeze(e); } - this.startState.setStateEvents(stateEvents); - this.endState.setStateEvents(stateEvents); + const setStateOptions: ISetStateOptions = { + fromInitialState: true, + }; + this.startState.setStateEvents(stateEvents, setStateOptions); + this.endState.setStateEvents(stateEvents, setStateOptions); } /** @@ -345,24 +363,33 @@ export class EventTimeline { * 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 + * @param {IAddEventOptions} options addEvent options */ - public addEvent(event: MatrixEvent, atStart: boolean, stateContext?: RoomState): void { - if (!stateContext) { - stateContext = atStart ? this.startState : this.endState; + public addEvent( + event: MatrixEvent, + { + toStartOfTimeline, + roomState, + fromInitialState, + }: IAddEventOptions, + ): void { + if (!roomState) { + roomState = toStartOfTimeline ? this.startState : this.endState; } const timelineSet = this.getTimelineSet(); if (timelineSet.room) { - EventTimeline.setEventMetadata(event, stateContext, atStart); + EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline); // modify state but only on unfiltered timelineSets if ( event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet ) { - stateContext.setStateEvents([event]); + roomState.setStateEvents([event], { + fromInitialState: fromInitialState, + }); // 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 @@ -373,22 +400,22 @@ export class EventTimeline { // 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); + if (!event.sender || (event.getType() === "m.room.member" && !toStartOfTimeline)) { + EventTimeline.setEventMetadata(event, roomState, toStartOfTimeline); } } } let insertIndex; - if (atStart) { + if (toStartOfTimeline) { insertIndex = 0; } else { insertIndex = this.events.length; } this.events.splice(insertIndex, 0, event); // insert element - if (atStart) { + if (toStartOfTimeline) { this.baseIndex++; } } diff --git a/src/models/room-state.ts b/src/models/room-state.ts index 5719034397c..248c7428d31 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -39,12 +39,24 @@ enum OobStatus { Finished, } +export interface ISetStateOptions { + /** Whether the sync response came from cache */ + fromCache?: boolean; + /** Whether the state is part of the first state snapshot we're seeing in + * the room. This could be happen in a variety of cases: + * 1. From the initial sync + * 2. It's the first state we're seeing after joining the room + * 3. Or whether it's coming from `syncFromCache` */ + fromInitialState?: boolean; +} + export enum RoomStateEvent { Events = "RoomState.events", Members = "RoomState.members", NewMember = "RoomState.newMember", Update = "RoomState.update", // signals batches of updates without specificity BeaconLiveness = "RoomState.BeaconLiveness", + Marker = "RoomState.org.matrix.msc2716.marker", } export type RoomStateEventHandlerMap = { @@ -54,6 +66,7 @@ export type RoomStateEventHandlerMap = { [RoomStateEvent.Update]: (state: RoomState) => void; [RoomStateEvent.BeaconLiveness]: (state: RoomState, hasLiveBeacons: boolean) => void; [BeaconEvent.New]: (event: MatrixEvent, beacon: Beacon) => void; + [RoomStateEvent.Marker]: (event: MatrixEvent, setStateOptions: ISetStateOptions) => void; }; type EmittedEvents = RoomStateEvent | BeaconEvent; @@ -312,16 +325,24 @@ export class RoomState extends TypedEventEmitter } /** - * Add an array of one or more state MatrixEvents, overwriting - * any existing state with the same {type, stateKey} tuple. Will fire - * "RoomState.events" for every event added. May fire "RoomState.members" - * if there are m.room.member events. + * Add an array of one or more state MatrixEvents, overwriting any existing + * state with the same {type, stateKey} tuple. Will fire "RoomState.events" + * for every event added. May fire "RoomState.members" if there are + * m.room.member events. May fire "RoomStateEvent.Marker" if there are + * EventType.Marker events. * @param {MatrixEvent[]} stateEvents a list of state events for this room. + * @param {boolean} fromInitialState whether the stateEvents are from the first + * sync in the room or a sync we already know about (syncFromCache) * @fires module:client~MatrixClient#event:"RoomState.members" * @fires module:client~MatrixClient#event:"RoomState.newMember" * @fires module:client~MatrixClient#event:"RoomState.events" + * @fires module:client~MatrixClient#event:"RoomStateEvent.Marker" */ - public setStateEvents(stateEvents: MatrixEvent[]) { + public setStateEvents(stateEvents: MatrixEvent[], setStateOptions?: ISetStateOptions) { + Error.stackTraceLimit = Infinity; + console.log(`setStateEvents fromInitialState=${setStateOptions && setStateOptions.fromInitialState} stateEvents:\n`, stateEvents.map((ev) => { + return `\t${ev.getType()} ${ev.getId()}`; + }).join('\n'), new Error().stack); this.updateModifiedTime(); // update the core event dict @@ -401,6 +422,8 @@ export class RoomState extends TypedEventEmitter // assume all our sentinels are now out-of-date this.sentinels = {}; + } else if (event.getType() === EventType.Marker) { + this.emit(RoomStateEvent.Marker, event, setStateOptions); } }); diff --git a/src/models/room.ts b/src/models/room.ts index af3dd4758b8..e6f566c1533 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -48,6 +48,7 @@ import { } from "./thread"; import { Method } from "../http-api"; import { TypedEventEmitter } from "./typed-event-emitter"; +import { IAddLiveEventOptions } from "./event-timeline-set"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -164,6 +165,7 @@ export enum RoomEvent { LocalEchoUpdated = "Room.localEchoUpdated", Timeline = "Room.timeline", TimelineReset = "Room.timelineReset", + historyImportedWithinTimeline = "Room.historyImportedWithinTimeline", } type EmittedEvents = RoomEvent @@ -172,6 +174,7 @@ type EmittedEvents = RoomEvent | ThreadEvent.NewReply | RoomEvent.Timeline | RoomEvent.TimelineReset + | RoomEvent.historyImportedWithinTimeline | MatrixEventEvent.BeforeRedaction; export type RoomEventHandlerMap = { @@ -188,6 +191,10 @@ export type RoomEventHandlerMap = { oldEventId?: string, oldStatus?: EventStatus, ) => void; + [RoomEvent.historyImportedWithinTimeline]: ( + markerEvent: MatrixEvent, + room: Room, + ) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; } & ThreadHandlerMap & MatrixEventHandlerMap; @@ -205,6 +212,8 @@ export class Room extends TypedEventEmitter public readonly threadsTimelineSets: EventTimelineSet[] = []; // any filtered timeline sets we're maintaining for this room private readonly filteredTimelineSets: Record = {}; // filter_id: timelineSet + private timelineNeedsRefresh = false; + private lastMarkerEventIdProcessed: string = null; private readonly pendingEventList?: MatrixEvent[]; // read by megolm via getter; boolean value - null indicates "use global value" private blacklistUnverifiedDevices: boolean = null; @@ -442,6 +451,19 @@ export class Room extends TypedEventEmitter return Promise.allSettled(decryptionPromises) as unknown as Promise; } + /** + * Gets the creator of the room + * @returns {string} The creator of the room, or null if it could not be determined + */ + public getRoomCreator(): string | null { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + if (!createEvent) { + return null; + } + const roomCreator = createEvent.getContent()['creator']; + return roomCreator; + } + /** * Gets the version of the room * @returns {string} The version of the room, or null if it could not be determined @@ -1007,6 +1029,43 @@ export class Room extends TypedEventEmitter return this.getUnfilteredTimelineSet().addTimeline(); } + /** + * Whether the timeline needs to be refreshed in order to pull in new + * historical messages that were imported. + * @param {Boolean} value The value to set + */ + public setTimelineNeedsRefresh(value: boolean): void { + this.timelineNeedsRefresh = value; + } + + /** + * Whether the timeline needs to be refreshed in order to pull in new + * historical messages that were imported. + * @return {Boolean} . + */ + public getTimelineNeedsRefresh(): boolean { + return this.timelineNeedsRefresh; + } + + /** + * Get the last marker event ID proccessed + * + * @return {String} the last marker event ID proccessed or null if none have + * been processed + */ + public getLastMarkerEventIdProcessed(): string | null { + return this.lastMarkerEventIdProcessed; + } + + /** + * Set the last marker event ID proccessed + * + * @param {String} eventId The marker event ID to set + */ + public setLastMarkerEventIdProcessed(eventId: string): void { + this.lastMarkerEventIdProcessed = eventId; + } + /** * Get an event which is stored in our unfiltered timeline set, or in a thread * @@ -1463,7 +1522,9 @@ export class Room extends TypedEventEmitter return event.getSender() === this.client.getUserId(); }); if (filterType !== ThreadFilterType.My || currentUserParticipated) { - timelineSet.getLiveTimeline().addEvent(thread.rootEvent, false); + timelineSet.getLiveTimeline().addEvent(thread.rootEvent, { + toStartOfTimeline: false, + }); } }); } @@ -1510,22 +1571,20 @@ export class Room extends TypedEventEmitter let latestMyThreadsRootEvent: MatrixEvent; const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); for (const rootEvent of threadRoots) { - this.threadsTimelineSets[0].addLiveEvent( - rootEvent, - DuplicateStrategy.Ignore, - false, + this.threadsTimelineSets[0].addLiveEvent(rootEvent, { + duplicateStrategy: DuplicateStrategy.Ignore, + fromCache: false, roomState, - ); + }); const threadRelationship = rootEvent .getServerAggregatedRelation(RelationType.Thread); if (threadRelationship.current_user_participated) { - this.threadsTimelineSets[1].addLiveEvent( - rootEvent, - DuplicateStrategy.Ignore, - false, + this.threadsTimelineSets[1].addLiveEvent(rootEvent, { + duplicateStrategy: DuplicateStrategy.Ignore, + fromCache: false, roomState, - ); + }); latestMyThreadsRootEvent = rootEvent; } @@ -1756,7 +1815,7 @@ export class Room extends TypedEventEmitter timelineSet.addEventToTimeline( thread.rootEvent, timelineSet.getLiveTimeline(), - toStartOfTimeline, + { toStartOfTimeline }, ); } } @@ -1839,15 +1898,14 @@ export class Room extends TypedEventEmitter * "Room.timeline". * * @param {MatrixEvent} event Event to be added - * @param {string?} duplicateStrategy 'ignore' or 'replace' - * @param {boolean} fromCache whether the sync response came from cache + * @param {IAddLiveEventOptions} options addLiveEvent options * @fires module:client~MatrixClient#event:"Room.timeline" * @private */ - private addLiveEvent(event: MatrixEvent, duplicateStrategy: DuplicateStrategy, fromCache = false): void { + private addLiveEvent(event: MatrixEvent, addLiveEventOptions: IAddLiveEventOptions = {}): void { // add to our timeline sets for (let i = 0; i < this.timelineSets.length; i++) { - this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); + this.timelineSets[i].addLiveEvent(event, addLiveEventOptions); } // synthesize and inject implicit read receipts @@ -1933,11 +1991,15 @@ export class Room extends TypedEventEmitter if (timelineSet.getFilter()) { if (timelineSet.getFilter().filterRoomTimeline([event]).length) { timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); + timelineSet.getLiveTimeline(), { + toStartOfTimeline: false, + }); } } else { timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); + timelineSet.getLiveTimeline(), { + toStartOfTimeline: false, + }); } } } @@ -2174,18 +2236,11 @@ export class Room extends TypedEventEmitter * they will go to the end of the timeline. * * @param {MatrixEvent[]} events A list of events to add. - * - * @param {string} duplicateStrategy Optional. 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 only. - * - * @param {boolean} fromCache whether the sync response came from cache + * @param {IAddLiveEventOptions} options addLiveEvent options * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. */ - public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache = false): void { + public addLiveEvents(events: MatrixEvent[], addLiveEventOptions: IAddLiveEventOptions = {}): void { + const { duplicateStrategy } = addLiveEventOptions; if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); } @@ -2226,7 +2281,7 @@ export class Room extends TypedEventEmitter } if (shouldLiveInRoom) { - this.addLiveEvent(events[i], duplicateStrategy, fromCache); + this.addLiveEvent(events[i], addLiveEventOptions); } } diff --git a/src/models/thread.ts b/src/models/thread.ts index 14a036b3046..60c1d013b85 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -172,9 +172,11 @@ export class Thread extends TypedEventEmitter { this.timelineSet.addEventToTimeline( event, this.liveTimeline, - toStartOfTimeline, - false, - this.roomState, + { + toStartOfTimeline, + fromCache: false, + roomState: this.roomState, + }, ); } } diff --git a/src/sync.ts b/src/sync.ts index 30df2cb7e6c..df3b6582061 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -54,6 +54,7 @@ import { IPushRules } from "./@types/PushRules"; import { RoomStateEvent } from "./models/room-state"; import { RoomMemberEvent } from "./models/room-member"; import { BeaconEvent } from "./models/beacon"; +import { ISetStateOptions } from "./models/room-state"; const DEBUG = true; @@ -77,6 +78,13 @@ export enum SyncState { Reconnecting = "RECONNECTING", } +// Room versions where "insertion", "batch", and "marker" events are controlled +// by power-levels. MSC2716 is supported in existing room versions but they +// should only have special meaning when the room creator sends them. +const MSC2716_ROOM_VERSIONS = [ + 'org.matrix.msc2716v3', +]; + function getFilterName(userId: string, suffix?: string): string { // scope this on the user ID because people may login on many accounts // and they all need to be stored! @@ -202,6 +210,7 @@ export class SyncApi { RoomEvent.Receipt, RoomEvent.Tags, RoomEvent.LocalEchoUpdated, + RoomEvent.historyImportedWithinTimeline, RoomEvent.AccountData, RoomEvent.MyMembership, RoomEvent.Timeline, @@ -240,6 +249,96 @@ export class SyncApi { RoomMemberEvent.Membership, ]); }); + + room.currentState.on(RoomStateEvent.Marker, (markerEvent, setStateOptions) => { + this.onMarkerStateEvent(room, markerEvent, setStateOptions); + }); + } + + /** When we see the marker state change in the room, we know there is some + * new historical messages imported by MSC2716 `/batch_send` somewhere in + * the room and we need to throw away the timeline to make sure the + * historical messages are shown when we paginate `/messages` again. + * @param {Room} room The room where the marker event was sent + * @param {MatrixEvent} markerEvent The new marker event + * @param {ISetStateOptions} setStateOptions When `fromInitialState` is set + * as `true`, the given marker event will be ignored + */ + private onMarkerStateEvent( + room: Room, + markerEvent: MatrixEvent, + { fromInitialState }: ISetStateOptions = {}, + ): void { + // We don't want to refresh the timeline: + // 1. If it's persons first time syncing the room, they won't have + // any old events cached to refresh. This could be from initial + // sync or just the first time syncing the room since joining. + // 2. If we're re-hydrating from `syncFromCache` because we already + // processed any marker event state that was in the cache + if (fromInitialState) { + logger.debug( + `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` + + `because it's from initial state.`, + ); + return; + } + + const isValidMsc2716Event = + // Check whether the room version directly supports MSC2716, in + // which case, "marker" events are already auth'ed by + // power_levels + MSC2716_ROOM_VERSIONS.includes(room.getVersion()) || + // MSC2716 is also supported in all existing room versions but + // special meaning should only be given to "insertion", "batch", + // and "marker" events when they come from the room creator + markerEvent.getSender() === room.getRoomCreator(); + + if (!isValidMsc2716Event) { + logger.debug( + `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` + + `MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` + + `by the room creator.`, + ); + } + + // Don't process a marker event multiple times; we only need to + // throw the timeline away once, when we see a marker. All of the + // historical content will be in the `/messsages` responses from + // here on out. + const markerAlreadyProcessed = markerEvent.getId() === room.getLastMarkerEventIdProcessed(); + if (markerAlreadyProcessed) { + logger.debug( + `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` + + `it has already been processed.`, + ); + } + + // It would be nice if we could also specifically tell whether the + // historical messages actually affected the locally cached client + // timeline or not. The problem is we can't see the prev_events of + // the base insertion event that the marker was pointing to because + // prev_events aren't available in the client API's. In most cases, + // the history won't be in people's locally cached timelines in the + // client, so we don't need to bother everyone about refreshing + // their timeline. This works for a v1 though and there are use + // cases like initially bootstrapping your bridged room where people + // are likely to encounter the historical messages affecting their + // current timeline (think someone signing up for Beeper and + // importing their Whatsapp history). + if ( + isValidMsc2716Event && + !markerAlreadyProcessed + ) { + // Saw new marker event, let's let the clients know they should + // refresh the timeline. + logger.debug( + `MarkerState: Timeline needs to be refreshed because ` + + `a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`, + ); + room.setTimelineNeedsRefresh(true); + room.emit(RoomEvent.historyImportedWithinTimeline, markerEvent, room); + room.setLastMarkerEventIdProcessed(markerEvent.getId()); + } } /** @@ -251,6 +350,7 @@ export class SyncApi { room.currentState.removeAllListeners(RoomStateEvent.Events); room.currentState.removeAllListeners(RoomStateEvent.Members); room.currentState.removeAllListeners(RoomStateEvent.NewMember); + room.currentState.removeAllListeners(RoomStateEvent.Marker); } /** @@ -1635,7 +1735,11 @@ export class SyncApi { // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. - room.addLiveEvents(timelineEventList || [], null, fromCache); + + room.addLiveEvents(timelineEventList || [], { + fromCache, + fromInitialState: timelineWasEmpty || fromCache + }); this.client.processBeaconEvents(room, timelineEventList); }