diff --git a/spec/integ/matrix-client-event-timeline.spec.js b/spec/integ/matrix-client-event-timeline.spec.js index 6df4a9a813c..454a4d30323 100644 --- a/spec/integ/matrix-client-event-timeline.spec.js +++ b/spec/integ/matrix-client-event-timeline.spec.js @@ -540,6 +540,77 @@ describe("MatrixClient event timelines", function() { }); }); + describe("getLatestTimeline", function() { + it("should create a new timeline for new events", function() { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + const latestMessageId = 'event1:bar'; + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .respond(200, function() { + return { + chunk: [{ + event_id: latestMessageId, + }], + }; + }); + + httpBackend.when("GET", `/rooms/!foo%3Abar/context/${encodeURIComponent(latestMessageId)}`) + .respond(200, function() { + return { + start: "start_token", + events_before: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [ + ROOM_NAME_EVENT, + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + }); + + return Promise.all([ + client.getLatestTimeline(timelineSet).then(function(tl) { + // Instead of this assertion logic, we could just add a spy + // for `getEventTimeline` and make sure it's called with the + // correct parameters. This doesn't feel too bad to make sure + // `getLatestTimeline` is doing the right thing though. + expect(tl.getEvents().length).toEqual(4); + for (let i = 0; i < 4; i++) { + expect(tl.getEvents()[i].event).toEqual(EVENTS[i]); + expect(tl.getEvents()[i].sender.name).toEqual(userName); + } + expect(tl.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token"); + expect(tl.getPaginationToken(EventTimeline.FORWARDS)) + .toEqual("end_token"); + }), + httpBackend.flushAllExpected(), + ]); + }); + + it("should throw error when /messages does not return a message", () => { + const room = client.getRoom(roomId); + const timelineSet = room.getTimelineSets()[0]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .respond(200, () => { + return { + chunk: [ + // No messages to return + ], + }; + }); + + return Promise.all([ + expect(client.getLatestTimeline(timelineSet)).rejects.toThrow(), + httpBackend.flushAllExpected(), + ]); + }); + }); + describe("paginateEventTimeline", function() { it("should allow you to paginate backwards", function() { const room = client.getRoom(roomId); diff --git a/spec/integ/matrix-client-room-timeline.spec.js b/spec/integ/matrix-client-room-timeline.spec.js index edb38175b36..acf751a8c09 100644 --- a/spec/integ/matrix-client-room-timeline.spec.js +++ b/spec/integ/matrix-client-room-timeline.spec.js @@ -1,5 +1,6 @@ import * as utils from "../test-utils/test-utils"; import { EventStatus } from "../../src/models/event"; +import { RoomEvent } from "../../src"; import { TestClient } from "../TestClient"; describe("MatrixClient room timelines", function() { @@ -579,7 +580,7 @@ describe("MatrixClient room timelines", function() { }); }); - it("should emit a 'Room.timelineReset' event", function() { + it("should emit a `RoomEvent.TimelineReset` event when the sync response is `limited`", function() { const eventData = [ utils.mkMessage({ user: userId, room: roomId }), ]; @@ -608,4 +609,271 @@ describe("MatrixClient room timelines", function() { }); }); }); + + describe('Refresh live timeline', () => { + const initialSyncEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + utils.mkMessage({ user: userId, room: roomId }), + utils.mkMessage({ user: userId, room: roomId }), + ]; + + const contextUrl = `/rooms/${encodeURIComponent(roomId)}/context/` + + `${encodeURIComponent(initialSyncEventData[2].event_id)}`; + const contextResponse = { + start: "start_token", + events_before: [initialSyncEventData[1], initialSyncEventData[0]], + event: initialSyncEventData[2], + events_after: [], + state: [ + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + + let room; + beforeEach(async () => { + setNextSyncData(initialSyncEventData); + + // Create a room from the sync + await Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 1), + ]); + + // Get the room after the first sync so the room is created + room = client.getRoom(roomId); + expect(room).toBeTruthy(); + }); + + it('should clear and refresh messages in timeline', async () => { + // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` + // to construct a new timeline from. + httpBackend.when("GET", contextUrl) + .respond(200, function() { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + + return contextResponse; + }); + + // Refresh the timeline. + await Promise.all([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + + // Make sure the message are visible + const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId()); + expect(resultantEventIdsInTimeline).toEqual([ + initialSyncEventData[0].event_id, + initialSyncEventData[1].event_id, + initialSyncEventData[2].event_id, + ]); + }); + + it('Perfectly merges timelines if a sync finishes while refreshing the timeline', async () => { + // `/context` request for `refreshLiveTimeline()` -> + // `getEventTimeline()` to construct a new timeline from. + // + // We only resolve this request after we detect that the timeline + // was reset(when it goes blank) and force a sync to happen in the + // middle of all of this refresh timeline logic. We want to make + // sure the sync pagination still works as expected after messing + // the refresh timline logic messes with the pagination tokens. + httpBackend.when("GET", contextUrl) + .respond(200, () => { + // Now finally return and make the `/context` request respond + return contextResponse; + }); + + // Wait for the timeline to reset(when it goes blank) which means + // it's in the middle of the refrsh logic right before the + // `getEventTimeline()` -> `/context`. Then simulate a racey `/sync` + // to happen in the middle of all of this refresh timeline logic. We + // want to make sure the sync pagination still works as expected + // after messing the refresh timline logic messes with the + // pagination tokens. + // + // We define this here so the event listener is in place before we + // call `room.refreshLiveTimeline()`. + const racingSyncEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + ]; + const waitForRaceySyncAfterResetPromise = new Promise((resolve, reject) => { + let eventFired = false; + // Throw a more descriptive error if this part of the test times out. + const failTimeout = setTimeout(() => { + if (eventFired) { + reject(new Error( + 'TestError: `RoomEvent.TimelineReset` fired but we timed out trying to make' + + 'a `/sync` happen in time.', + )); + } else { + reject(new Error( + 'TestError: Timed out while waiting for `RoomEvent.TimelineReset` to fire.', + )); + } + }, 4000 /* FIXME: Is there a way to reference the current timeout of this test in Jest? */); + + room.on(RoomEvent.TimelineReset, async () => { + try { + eventFired = true; + + // The timeline should be cleared at this point in the refresh + expect(room.getUnfilteredTimelineSet().getLiveTimeline().getEvents().length).toEqual(0); + + // Then make a `/sync` happen by sending a message and seeing that it + // shows up (simulate a /sync naturally racing with us). + setNextSyncData(racingSyncEventData); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + await Promise.all([ + httpBackend.flush("/sync", 1), + utils.syncPromise(client, 1), + ]); + // Make sure the timeline has the racey sync data + const afterRaceySyncTimelineEvents = room + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents(); + const afterRaceySyncTimelineEventIds = afterRaceySyncTimelineEvents + .map((event) => event.getId()); + expect(afterRaceySyncTimelineEventIds).toEqual([ + racingSyncEventData[0].event_id, + ]); + + clearTimeout(failTimeout); + resolve(); + } catch (err) { + reject(err); + } + }); + }); + + // Refresh the timeline. Just start the function, we will wait for + // it to finish after the racey sync. + const refreshLiveTimelinePromise = room.refreshLiveTimeline(); + + await waitForRaceySyncAfterResetPromise; + + await Promise.all([ + refreshLiveTimelinePromise, + // Then flush the remaining `/context` to left the refresh logic complete + httpBackend.flushAllExpected(), + ]); + + // Make sure sync pagination still works by seeing a new message show up + // after refreshing the timeline. + const afterRefreshEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + ]; + setNextSyncData(afterRefreshEventData); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + await Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 1), + ]); + + // Make sure the timeline includes the the events from the `/sync` + // that raced and beat us in the middle of everything and the + // `/sync` after the refresh. Since the `/sync` beat us to create + // the timeline, `initialSyncEventData` won't be visible unless we + // paginate backwards with `/messages`. + const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId()); + expect(resultantEventIdsInTimeline).toEqual([ + racingSyncEventData[0].event_id, + afterRefreshEventData[0].event_id, + ]); + }); + + it('Timeline recovers after `/context` request to generate new timeline fails', async () => { + // `/context` request for `refreshLiveTimeline()` -> `getEventTimeline()` + // to construct a new timeline from. + httpBackend.when("GET", contextUrl) + .respond(500, function() { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + + return { + errcode: 'TEST_FAKE_ERROR', + error: 'We purposely intercepted this /context request to make it fail ' + + 'in order to test whether the refresh timeline code is resilient', + }; + }); + + // Refresh the timeline and expect it to fail + const settledFailedRefreshPromises = await Promise.allSettled([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + // We only expect `TEST_FAKE_ERROR` here. Anything else is + // unexpected and should fail the test. + if (settledFailedRefreshPromises[0].status === 'fulfilled') { + throw new Error('Expected the /context request to fail with a 500'); + } else if (settledFailedRefreshPromises[0].reason.errcode !== 'TEST_FAKE_ERROR') { + throw settledFailedRefreshPromises[0].reason; + } + + // The timeline will be empty after we refresh the timeline and fail + // to construct a new timeline. + expect(room.timeline.length).toEqual(0); + + // `/messages` request for `refreshLiveTimeline()` -> + // `getLatestTimeline()` to construct a new timeline from. + httpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`) + .respond(200, function() { + return { + chunk: [{ + // The latest message in the room + event_id: initialSyncEventData[2].event_id, + }], + }; + }); + // `/context` request for `refreshLiveTimeline()` -> + // `getLatestTimeline()` -> `getEventTimeline()` to construct a new + // timeline from. + httpBackend.when("GET", contextUrl) + .respond(200, function() { + // The timeline should be cleared at this point in the refresh + expect(room.timeline.length).toEqual(0); + + return contextResponse; + }); + + // Refresh the timeline again but this time it should pass + await Promise.all([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + + // Make sure sync pagination still works by seeing a new message show up + // after refreshing the timeline. + const afterRefreshEventData = [ + utils.mkMessage({ user: userId, room: roomId }), + ]; + setNextSyncData(afterRefreshEventData); + httpBackend.when("GET", "/sync").respond(200, function() { + return NEXT_SYNC_DATA; + }); + await Promise.all([ + httpBackend.flushAllExpected(), + utils.syncPromise(client, 1), + ]); + + // Make sure the message are visible + const resultantEventsInTimeline = room.getUnfilteredTimelineSet().getLiveTimeline().getEvents(); + const resultantEventIdsInTimeline = resultantEventsInTimeline.map((event) => event.getId()); + expect(resultantEventIdsInTimeline).toEqual([ + initialSyncEventData[0].event_id, + initialSyncEventData[1].event_id, + initialSyncEventData[2].event_id, + afterRefreshEventData[0].event_id, + ]); + }); + }); }); diff --git a/spec/integ/matrix-client-syncing.spec.js b/spec/integ/matrix-client-syncing.spec.js index 7c33f0948ea..0c571707ad3 100644 --- a/spec/integ/matrix-client-syncing.spec.js +++ b/spec/integ/matrix-client-syncing.spec.js @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventTimeline, MatrixEvent, RoomEvent } from "../../src"; +import { EventTimeline, MatrixEvent, RoomEvent, RoomStateEvent, RoomMemberEvent } from "../../src"; +import { UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; @@ -76,7 +77,7 @@ describe("MatrixClient syncing", function() { }); }); - it("should emit Room.myMembership for invite->leave->invite cycles", async () => { + it("should emit RoomEvent.MyMembership for invite->leave->invite cycles", async () => { const roomId = "!cycles:example.org"; // First sync: an invite @@ -298,7 +299,7 @@ describe("MatrixClient syncing", function() { httpBackend.when("GET", "/sync").respond(200, syncData); let latestFiredName = null; - client.on("RoomMember.name", function(event, m) { + client.on(RoomMemberEvent.Name, function(event, m) { if (m.userId === userC && m.roomId === roomOne) { latestFiredName = m.name; } @@ -582,6 +583,477 @@ describe("MatrixClient syncing", function() { xit("should update the room topic", function() { }); + + describe("onMarkerStateEvent", () => { + const normalMessageEvent = utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello", + }); + + it('new marker event *NOT* from the room creator in a subsequent syncs ' + + 'should *NOT* mark the timeline as needing a refresh', async () => { + const roomCreateEvent = utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + room_version: '9', + }, + }); + const normalFirstSync = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + normalFirstSync.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + // In subsequent syncs, a marker event in timeline + // range should normally trigger + // `timelineNeedsRefresh=true` but this marker isn't + // being sent by the room creator so it has no + // special meaning in existing room versions. + utils.mkEvent({ + type: UNSTABLE_MSC2716_MARKER.name, + room: roomOne, + // The important part we're testing is here! + // `userC` is not the room creator. + user: userC, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ], + prev_batch: "pagTok", + }, + }; + + // Ensure the marker is being sent by someone who is not the room creator + // because this is the main thing we're testing in this spec. + const markerEvent = nextSyncData.rooms.join[roomOne].timeline.events[0]; + expect(markerEvent.sender).toBeDefined(); + expect(markerEvent.sender).not.toEqual(roomCreateEvent.sender); + + httpBackend.when("GET", "/sync").respond(200, normalFirstSync); + 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(false); + }); + + [{ + label: 'In existing room versions (when the room creator sends the MSC2716 events)', + roomVersion: '9', + }, { + label: 'In a MSC2716 supported room version', + roomVersion: 'org.matrix.msc2716v3', + }].forEach((testMeta) => { + describe(testMeta.label, () => { + const roomCreateEvent = utils.mkEvent({ + type: "m.room.create", room: roomOne, user: otherUserId, + content: { + creator: otherUserId, + room_version: testMeta.roomVersion, + }, + }); + + const markerEventFromRoomCreator = utils.mkEvent({ + type: UNSTABLE_MSC2716_MARKER.name, room: roomOne, user: otherUserId, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }); + + const normalFirstSync = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + normalFirstSync.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + it('no marker event in sync response '+ + 'should *NOT* mark the timeline as needing a refresh (check for a sane default)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + 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('marker event already sent within timeline range when you join ' + + 'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [markerEventFromRoomCreator], + prev_batch: "pagTok", + }, + state: { + events: [roomCreateEvent], + }, + }; + + 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('marker event already sent before joining (in state) ' + + 'should *NOT* mark the timeline as needing a refresh (timelineWasEmpty)', async () => { + const syncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + syncData.rooms.join[roomOne] = { + timeline: { + events: [normalMessageEvent], + prev_batch: "pagTok", + }, + state: { + events: [ + roomCreateEvent, + markerEventFromRoomCreator, + ], + }, + }; + + 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('new marker event in a subsequent syncs timeline range ' + + 'should mark the timeline as needing a refresh', async () => { + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + // In subsequent syncs, a marker event in timeline + // range should trigger `timelineNeedsRefresh=true` + markerEventFromRoomCreator, + ], + prev_batch: "pagTok", + }, + }; + + const markerEventId = nextSyncData.rooms.join[roomOne].timeline.events[0].event_id; + + // Only do the first sync + httpBackend.when("GET", "/sync").respond(200, normalFirstSync); + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // Get the room after the first sync so the room is created + const room = client.getRoom(roomOne); + + let emitCount = 0; + room.on(RoomEvent.HistoryImportedWithinTimeline, function(markerEvent, room) { + expect(markerEvent.getId()).toEqual(markerEventId); + expect(room.roomId).toEqual(roomOne); + emitCount += 1; + }); + + // Now do a subsequent sync with the marker event + httpBackend.when("GET", "/sync").respond(200, nextSyncData); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + expect(room.getTimelineNeedsRefresh()).toEqual(true); + // Make sure `RoomEvent.HistoryImportedWithinTimeline` was emitted + expect(emitCount).toEqual(1); + }); + + // Mimic a marker event being sent far back in the scroll back but since our last sync + it('new marker event in sync state should mark the timeline as needing a refresh', async () => { + const nextSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + nextSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "hello again", + }), + ], + prev_batch: "pagTok", + }, + state: { + events: [ + // In subsequent syncs, a marker event in state + // should trigger `timelineNeedsRefresh=true` + markerEventFromRoomCreator, + ], + }, + }; + + httpBackend.when("GET", "/sync").respond(200, normalFirstSync); + 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 the state listeners work and events are re-emitted properly from + // the client regardless if we reset and refresh the timeline. + describe('state listeners and re-registered when RoomEvent.CurrentStateUpdated is fired', () => { + const EVENTS = [ + utils.mkMessage({ + room: roomOne, user: userA, msg: "we", + }), + utils.mkMessage({ + room: roomOne, user: userA, msg: "could", + }), + utils.mkMessage({ + room: roomOne, user: userA, msg: "be", + }), + utils.mkMessage({ + room: roomOne, user: userA, msg: "heroes", + }), + ]; + + const SOME_STATE_EVENT = utils.mkEvent({ + event: true, + type: 'org.matrix.test_state', + room: roomOne, + user: userA, + skey: "", + content: { + "foo": "bar", + }, + }); + + const USER_MEMBERSHIP_EVENT = utils.mkMembership({ + room: roomOne, mship: "join", user: userA, + }); + + // This appears to work even if we comment out + // `RoomEvent.CurrentStateUpdated` part which triggers everything to + // re-listen after the `room.currentState` reference changes. I'm + // not sure how it's getting re-emitted. + it("should be able to listen to state events even after " + + "the timeline is reset during `limited` sync response", async () => { + // Create a room from the sync + httpBackend.when("GET", "/sync").respond(200, syncData); + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // Get the room after the first sync so the room is created + const room = client.getRoom(roomOne); + expect(room).toBeTruthy(); + + let stateEventEmitCount = 0; + client.on(RoomStateEvent.Update, () => { + stateEventEmitCount += 1; + }); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can listen to the room state events before the reset + expect(stateEventEmitCount).toEqual(1); + + // Make a `limited` sync which will cause a `room.resetLiveTimeline` + const limitedSyncData = { + next_batch: "batch_token", + rooms: { + join: {}, + }, + }; + limitedSyncData.rooms.join[roomOne] = { + timeline: { + events: [ + utils.mkMessage({ + room: roomOne, user: otherUserId, msg: "world", + }), + ], + // The important part, make the sync `limited` + limited: true, + prev_batch: "newerTok", + }, + }; + httpBackend.when("GET", "/sync").respond(200, limitedSyncData); + + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // This got incremented again from processing the sync response + expect(stateEventEmitCount).toEqual(2); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can still listen to the room state events after the reset + expect(stateEventEmitCount).toEqual(3); + }); + + // Make sure it re-registers the state listeners after the + // `room.currentState` reference changes + it("should be able to listen to state events even after " + + "refreshing the timeline", async () => { + const testClientWithTimelineSupport = new TestClient( + selfUserId, + "DEVICE", + selfAccessToken, + undefined, + { timelineSupport: true }, + ); + httpBackend = testClientWithTimelineSupport.httpBackend; + httpBackend.when("GET", "/versions").respond(200, {}); + httpBackend.when("GET", "/pushrules").respond(200, {}); + httpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + client = testClientWithTimelineSupport.client; + + // Create a room from the sync + httpBackend.when("GET", "/sync").respond(200, syncData); + client.startClient(); + await Promise.all([ + httpBackend.flushAllExpected(), + awaitSyncEvent(), + ]); + + // Get the room after the first sync so the room is created + const room = client.getRoom(roomOne); + expect(room).toBeTruthy(); + + let stateEventEmitCount = 0; + client.on(RoomStateEvent.Update, () => { + stateEventEmitCount += 1; + }); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can listen to the room state events before the reset + expect(stateEventEmitCount).toEqual(1); + + const eventsInRoom = syncData.rooms.join[roomOne].timeline.events; + const contextUrl = `/rooms/${encodeURIComponent(roomOne)}/context/` + + `${encodeURIComponent(eventsInRoom[0].event_id)}`; + httpBackend.when("GET", contextUrl) + .respond(200, function() { + return { + start: "start_token", + events_before: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [ + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + }); + + // Refresh the timeline. This will cause the `room.currentState` + // reference to change + await Promise.all([ + room.refreshLiveTimeline(), + httpBackend.flushAllExpected(), + ]); + + // Cause `RoomStateEvent.Update` to be fired + room.currentState.setStateEvents([SOME_STATE_EVENT]); + // Make sure we can still listen to the room state events after the reset + expect(stateEventEmitCount).toEqual(2); + }); + }); }); describe("timeline", function() { @@ -637,7 +1109,7 @@ describe("MatrixClient syncing", function() { awaitSyncEvent(), ]).then(function() { const room = client.getRoom(roomTwo); - expect(room).toBeDefined(); + expect(room).toBeTruthy(); const tok = room.getLiveTimeline() .getPaginationToken(EventTimeline.BACKWARDS); expect(tok).toEqual("roomtwotok"); @@ -666,7 +1138,7 @@ describe("MatrixClient syncing", function() { let resetCallCount = 0; // the token should be set *before* timelineReset is emitted - client.on("Room.timelineReset", function(room) { + client.on(RoomEvent.TimelineReset, function(room) { resetCallCount++; const tl = room.getLiveTimeline(); diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index 82cadddf8f5..eaa723940b9 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -23,6 +23,7 @@ import { MatrixEvent, MatrixEventEvent, Room, + DuplicateStrategy, } from '../../src'; describe('EventTimelineSet', () => { @@ -73,6 +74,76 @@ describe('EventTimelineSet', () => { }) as MatrixEvent; }); + describe('addLiveEvent', () => { + it("Adds event to the live timeline in the timeline set", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addLiveEvent(messageEvent); + expect(liveTimeline.getEvents().length).toStrictEqual(1); + }); + + it("should replace a timeline event if dupe strategy is 'replace'", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addLiveEvent(messageEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + }); + expect(liveTimeline.getEvents().length).toStrictEqual(1); + + // make a duplicate + const duplicateMessageEvent = utils.mkMessage({ + room: roomId, user: userA, msg: "dupe", event: true, + }) as MatrixEvent; + duplicateMessageEvent.event.event_id = messageEvent.getId(); + + // Adding the duplicate event should replace the `messageEvent` + // because it has the same `event_id` and duplicate strategy is + // replace. + eventTimelineSet.addLiveEvent(duplicateMessageEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + }); + + const eventsInLiveTimeline = liveTimeline.getEvents(); + expect(eventsInLiveTimeline.length).toStrictEqual(1); + expect(eventsInLiveTimeline[0]).toStrictEqual(duplicateMessageEvent); + }); + + it("Make sure legacy overload passing options directly as parameters still works", () => { + expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Replace, false)).not.toThrow(); + expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Ignore, true)).not.toThrow(); + }); + }); + + describe('addEventToTimeline', () => { + it("Adds event to timeline", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, { + toStartOfTimeline: true, + }); + expect(liveTimeline.getEvents().length).toStrictEqual(1); + }); + + it("Make sure legacy overload passing options directly as parameters still works", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(() => { + eventTimelineSet.addEventToTimeline( + messageEvent, + liveTimeline, + true, + ); + }).not.toThrow(); + expect(() => { + eventTimelineSet.addEventToTimeline( + messageEvent, + liveTimeline, + true, + false, + ); + }).not.toThrow(); + }); + }); + describe('aggregateRelations', () => { describe('with unencrypted events', () => { beforeEach(() => { diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index c9311d0e387..ed5047c111e 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -50,9 +50,11 @@ describe("EventTimeline", function() { timeline.initialiseState(events); expect(timeline.startState.setStateEvents).toHaveBeenCalledWith( events, + { timelineWasEmpty: undefined }, ); expect(timeline.endState.setStateEvents).toHaveBeenCalledWith( events, + { timelineWasEmpty: undefined }, ); }); @@ -73,7 +75,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 +151,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 +161,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 +205,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 +244,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 +264,13 @@ 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]], { timelineWasEmpty: undefined }); expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). - toHaveBeenCalledWith([events[1]]); + toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined }); expect(events[0].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true); @@ -291,13 +293,13 @@ 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]], { timelineWasEmpty: undefined }); expect(timeline.getState(EventTimeline.BACKWARDS).setStateEvents). - toHaveBeenCalledWith([events[1]]); + toHaveBeenCalledWith([events[1]], { timelineWasEmpty: undefined }); expect(events[0].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false); @@ -305,6 +307,11 @@ describe("EventTimeline", function() { expect(timeline.getState(EventTimeline.FORWARDS).setStateEvents). not.toHaveBeenCalled(); }); + + it("Make sure legacy overload passing options directly as parameters still works", () => { + expect(() => timeline.addEvent(events[0], { toStartOfTimeline: true })).not.toThrow(); + expect(() => timeline.addEvent(events[0], { stateContext: new RoomState() })).not.toThrow(); + }); }); describe("removeEvent", function() { @@ -324,8 +331,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 +345,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 +365,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 b353b7aa36e..b54121431bb 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -3,7 +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, RelationType } from "../../src/@types/event"; +import { EventType, RelationType, UNSTABLE_MSC2716_MARKER } from "../../src/@types/event"; import { MatrixEvent, MatrixEventEvent, @@ -258,6 +258,29 @@ describe("RoomState", function() { ); }); + it("should emit `RoomStateEvent.Marker` for each marker event", function() { + const events = [ + utils.mkEvent({ + event: true, + type: UNSTABLE_MSC2716_MARKER.name, + room: roomId, + user: userA, + skey: "", + content: { + "m.insertion_id": "$abc", + }, + }), + ]; + let emitCount = 0; + state.on("RoomState.Marker", function(markerEvent, markerFoundOptions) { + expect(markerEvent).toEqual(events[emitCount]); + expect(markerFoundOptions).toEqual({ timelineWasEmpty: true }); + emitCount += 1; + }); + state.setStateEvents(events, { timelineWasEmpty: 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 54d0f41dcb4..921feae1af4 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -133,6 +133,27 @@ describe("Room", function() { room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); }); + describe('getCreator', () => { + it("should return the creator from m.room.create", function() { + room.currentState.getStateEvents.mockImplementation(function(type, key) { + if (type === EventType.RoomCreate && key === "") { + return utils.mkEvent({ + event: true, + type: EventType.RoomCreate, + skey: "", + room: roomId, + user: userA, + content: { + creator: userA, + }, + }); + } + }); + const roomCreator = room.getCreator(); + expect(roomCreator).toStrictEqual(userA); + }); + }); + describe("getAvatarUrl", function() { const hsUrl = "https://my.home.server"; @@ -196,22 +217,17 @@ describe("Room", function() { }) as MatrixEvent, ]; - it("should call RoomState.setTypingEvent on m.typing events", function() { - const typing = utils.mkEvent({ - room: roomId, - type: EventType.Typing, - event: true, - content: { - user_ids: [userA], - }, - }); - room.addEphemeralEvents([typing]); - expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing); + it("Make sure legacy overload passing options directly as parameters still works", () => { + expect(() => room.addLiveEvents(events, DuplicateStrategy.Replace, false)).not.toThrow(); + expect(() => room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).not.toThrow(); + expect(() => room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false)).toThrow(); }); it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function() { expect(function() { - room.addLiveEvents(events, "foo"); + room.addLiveEvents(events, { + duplicateStrategy: "foo", + }); }).toThrow(); }); @@ -223,7 +239,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); }); @@ -235,7 +253,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]); }); @@ -268,9 +288,11 @@ describe("Room", function() { room.addLiveEvents(events); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[0]], + { timelineWasEmpty: undefined }, ); expect(room.currentState.setStateEvents).toHaveBeenCalledWith( [events[1]], + { timelineWasEmpty: undefined }, ); expect(events[0].forwardLooking).toBe(true); expect(events[1].forwardLooking).toBe(true); @@ -341,6 +363,21 @@ describe("Room", function() { }); }); + describe('addEphemeralEvents', () => { + it("should call RoomState.setTypingEvent on m.typing events", function() { + const typing = utils.mkEvent({ + room: roomId, + type: EventType.Typing, + event: true, + content: { + user_ids: [userA], + }, + }); + room.addEphemeralEvents([typing]); + expect(room.currentState.setTypingEvent).toHaveBeenCalledWith(typing); + }); + }); + describe("addEventsToTimeline", function() { const events = [ utils.mkMessage({ @@ -472,9 +509,11 @@ describe("Room", function() { room.addEventsToTimeline(events, true, room.getLiveTimeline()); expect(room.oldState.setStateEvents).toHaveBeenCalledWith( [events[0]], + { timelineWasEmpty: undefined }, ); expect(room.oldState.setStateEvents).toHaveBeenCalledWith( [events[1]], + { timelineWasEmpty: undefined }, ); expect(events[0].forwardLooking).toBe(false); expect(events[1].forwardLooking).toBe(false); @@ -520,6 +559,23 @@ describe("Room", function() { it("should reset the legacy timeline fields", function() { room.addLiveEvents([events[0], events[1]]); expect(room.timeline.length).toEqual(2); + + const oldStateBeforeRunningReset = room.oldState; + let oldStateUpdateEmitCount = 0; + room.on(RoomEvent.OldStateUpdated, function(room, previousOldState, oldState) { + expect(previousOldState).toBe(oldStateBeforeRunningReset); + expect(oldState).toBe(room.oldState); + oldStateUpdateEmitCount += 1; + }); + + const currentStateBeforeRunningReset = room.currentState; + let currentStateUpdateEmitCount = 0; + room.on(RoomEvent.CurrentStateUpdated, function(room, previousCurrentState, currentState) { + expect(previousCurrentState).toBe(currentStateBeforeRunningReset); + expect(currentState).toBe(room.currentState); + currentStateUpdateEmitCount += 1; + }); + room.resetLiveTimeline('sometoken', 'someothertoken'); room.addLiveEvents([events[2]]); @@ -529,6 +585,10 @@ describe("Room", function() { newLiveTimeline.getState(EventTimeline.BACKWARDS)); expect(room.currentState).toEqual( newLiveTimeline.getState(EventTimeline.FORWARDS)); + // Make sure `RoomEvent.OldStateUpdated` was emitted + expect(oldStateUpdateEmitCount).toEqual(1); + // Make sure `RoomEvent.OldStateUpdated` was emitted if necessary + expect(currentStateUpdateEmitCount).toEqual(timelineSupport ? 1 : 0); }); it("should emit Room.timelineReset event and set the correct " + diff --git a/spec/unit/timeline-window.spec.js b/spec/unit/timeline-window.spec.js index c9466412c83..4fc78234446 100644 --- a/spec/unit/timeline-window.spec.js +++ b/spec/unit/timeline-window.spec.js @@ -35,13 +35,14 @@ 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..dac2770ade3 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -151,6 +151,14 @@ export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc */ export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch"); +/** + * Marker event type to point back at imported historical content in a room. See + * [MSC2716](https://github.com/matrix-org/matrix-spec-proposals/pull/2716). + * Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker"); + /** * Functional members type for declaring a purpose of room members (e.g. helpful bots). * Note that this reference is UNSTABLE and subject to breaking changes, including its diff --git a/src/client.ts b/src/client.ts index 04555b7c16b..e0f40a8a2ba 100644 --- a/src/client.ts +++ b/src/client.ts @@ -815,6 +815,7 @@ type RoomEvents = RoomEvent.Name | RoomEvent.Receipt | RoomEvent.Tags | RoomEvent.LocalEchoUpdated + | RoomEvent.HistoryImportedWithinTimeline | RoomEvent.AccountData | RoomEvent.MyMembership | RoomEvent.Timeline @@ -824,6 +825,7 @@ type RoomStateEvents = RoomStateEvent.Events | RoomStateEvent.Members | RoomStateEvent.NewMember | RoomStateEvent.Update + | RoomStateEvent.Marker ; type CryptoEvents = CryptoEvent.KeySignatureUploadFailure @@ -5309,7 +5311,8 @@ export class MatrixClient extends TypedEventEmitter { + // don't allow any timeline support unless it's been enabled. + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable it."); + } + + const messagesPath = utils.encodeUri( + "/rooms/$roomId/messages", { + $roomId: timelineSet.room.roomId, + }, + ); + + const params: Record = { + dir: 'b', + }; + if (this.clientOpts.lazyLoadMembers) { + params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + } + + const res = await this.http.authedRequest(undefined, Method.Get, messagesPath, params); + const event = res.chunk?.[0]; + if (!event) { + throw new Error("No message returned from /messages when trying to construct getLatestTimeline"); + } + + return this.getEventTimeline(timelineSet, event.event_id); + } + /** * Makes a request to /messages with the appropriate lazy loading filter set. * XXX: if we do get rid of scrollback (as it's not used at the moment), diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index 8e5049c5f09..9b3273d929e 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -18,7 +18,7 @@ limitations under the License. * @module models/event-timeline-set */ -import { EventTimeline } from "./event-timeline"; +import { EventTimeline, IAddEventOptions } from "./event-timeline"; import { EventStatus, MatrixEvent, MatrixEventEvent } from "./event"; import { logger } from '../logger'; import { Relations } from './relations'; @@ -55,6 +55,23 @@ export interface IRoomTimelineData { liveEvent?: boolean; } +export interface IAddEventToTimelineOptions + extends Pick { + /** Whether the sync response came from cache */ + fromCache?: boolean; +} + +export interface IAddLiveEventOptions + extends Pick { + /** 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; +} + type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset; export type EventTimelineSetHandlerMap = { @@ -180,6 +197,15 @@ export class EventTimelineSet extends TypedEventEmitter { + // This is a separate interface without any extra stuff currently added on + // top of `IMarkerFoundOptions` just because it feels like they have + // different concerns. One shouldn't necessarily look to add to + // `IMarkerFoundOptions` just because they want to add an extra option to + // `initialiseState`. +} + +export interface IAddEventOptions extends Pick { + /** Whether to insert the new event at the start of the timeline where the + * oldest events are (timeline is in chronological order, oldest to most + * recent) */ + toStartOfTimeline: boolean; + /** The state events to reconcile metadata from */ + roomState?: RoomState; +} + export enum Direction { Backward = "b", Forward = "f", @@ -131,7 +149,7 @@ export class EventTimeline { * state with. * @throws {Error} if an attempt is made to call this after addEvent is called. */ - public initialiseState(stateEvents: MatrixEvent[]): void { + public initialiseState(stateEvents: MatrixEvent[], { timelineWasEmpty }: IInitialiseStateOptions = {}): void { if (this.events.length > 0) { throw new Error("Cannot initialise state after events are added"); } @@ -152,8 +170,12 @@ export class EventTimeline { Object.freeze(e); } - this.startState.setStateEvents(stateEvents); - this.endState.setStateEvents(stateEvents); + this.startState.setStateEvents(stateEvents, { + timelineWasEmpty, + }); + this.endState.setStateEvents(stateEvents, { + timelineWasEmpty, + }); } /** @@ -345,24 +367,60 @@ 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, + timelineWasEmpty, + }: IAddEventOptions, + ): void; + /** + * @deprecated In favor of the overload with `IAddEventOptions` + */ + public addEvent( + event: MatrixEvent, + toStartOfTimeline: boolean, + roomState?: RoomState + ): void; + public addEvent( + event: MatrixEvent, + toStartOfTimelineOrOpts: boolean | IAddEventOptions, + roomState?: RoomState, + ): void { + let toStartOfTimeline = !!toStartOfTimelineOrOpts; + let timelineWasEmpty: boolean; + if (typeof (toStartOfTimelineOrOpts) === 'object') { + ({ toStartOfTimeline, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts); + } else if (toStartOfTimelineOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + logger.warn( + 'Overload deprecated: ' + + '`EventTimeline.addEvent(event, toStartOfTimeline, roomState?)` ' + + 'is deprecated in favor of the overload with `EventTimeline.addEvent(event, IAddEventOptions)`', + ); + } + + 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], { + timelineWasEmpty, + }); // 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 +431,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 30b87f487b9..02632b1aece 100644 --- a/src/models/room-state.ts +++ b/src/models/room-state.ts @@ -21,7 +21,7 @@ limitations under the License. import { RoomMember } from "./room-member"; import { logger } from '../logger'; import * as utils from "../utils"; -import { EventType } from "../@types/event"; +import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event"; import { MatrixEvent, MatrixEventEvent } from "./event"; import { MatrixClient } from "../client"; import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials"; @@ -30,6 +30,22 @@ import { Beacon, BeaconEvent, BeaconEventHandlerMap, getBeaconInfoIdentifier, Be import { TypedReEmitter } from "../ReEmitter"; import { M_BEACON, M_BEACON_INFO } from "../@types/beacon"; +export interface IMarkerFoundOptions { + /** Whether the timeline was empty before the marker event arrived 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` + * + * A marker event refers to `UNSTABLE_MSC2716_MARKER` and indicates that + * history was imported somewhere back in time. It specifically points to an + * MSC2716 insertion event where the history was imported at. Marker events + * are sent as state events so they are easily discoverable by clients and + * homeservers and don't get lost in timeline gaps. + */ + timelineWasEmpty?: boolean; +} + // possible statuses for out-of-band member loading enum OobStatus { NotStarted, @@ -43,6 +59,7 @@ export enum RoomStateEvent { NewMember = "RoomState.newMember", Update = "RoomState.update", // signals batches of updates without specificity BeaconLiveness = "RoomState.BeaconLiveness", + Marker = "RoomState.Marker", } export type RoomStateEventHandlerMap = { @@ -51,6 +68,7 @@ export type RoomStateEventHandlerMap = { [RoomStateEvent.NewMember]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void; [RoomStateEvent.Update]: (state: RoomState) => void; [RoomStateEvent.BeaconLiveness]: (state: RoomState, hasLiveBeacons: boolean) => void; + [RoomStateEvent.Marker]: (event: MatrixEvent, setStateOptions: IMarkerFoundOptions) => void; [BeaconEvent.New]: (event: MatrixEvent, beacon: Beacon) => void; }; @@ -314,16 +332,19 @@ 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 + * UNSTABLE_MSC2716_MARKER events. * @param {MatrixEvent[]} stateEvents a list of state events for this room. + * @param {IMarkerFoundOptions} markerFoundOptions * @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[], markerFoundOptions?: IMarkerFoundOptions) { this.updateModifiedTime(); // update the core event dict @@ -403,6 +424,8 @@ export class RoomState extends TypedEventEmitter // assume all our sentinels are now out-of-date this.sentinels = {}; + } else if (UNSTABLE_MSC2716_MARKER.matches(event.getType())) { + this.emit(RoomStateEvent.Marker, event, markerFoundOptions); } }); diff --git a/src/models/room.ts b/src/models/room.ts index 6e286e794c8..eeb2325f536 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -18,7 +18,7 @@ limitations under the License. * @module models/room */ -import { EventTimelineSet, DuplicateStrategy } from "./event-timeline-set"; +import { EventTimelineSet, DuplicateStrategy, IAddLiveEventOptions } from "./event-timeline-set"; import { Direction, EventTimeline } from "./event-timeline"; import { getHttpUriForMxc } from "../content-repo"; import * as utils from "../utils"; @@ -165,6 +165,10 @@ export enum RoomEvent { LocalEchoUpdated = "Room.localEchoUpdated", Timeline = "Room.timeline", TimelineReset = "Room.timelineReset", + TimelineRefresh = "Room.TimelineRefresh", + OldStateUpdated = "Room.OldStateUpdated", + CurrentStateUpdated = "Room.CurrentStateUpdated", + HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline", } type EmittedEvents = RoomEvent @@ -173,6 +177,10 @@ type EmittedEvents = RoomEvent | ThreadEvent.NewReply | RoomEvent.Timeline | RoomEvent.TimelineReset + | RoomEvent.TimelineRefresh + | RoomEvent.HistoryImportedWithinTimeline + | RoomEvent.OldStateUpdated + | RoomEvent.CurrentStateUpdated | MatrixEventEvent.BeforeRedaction; export type RoomEventHandlerMap = { @@ -189,6 +197,13 @@ export type RoomEventHandlerMap = { oldEventId?: string, oldStatus?: EventStatus, ) => void; + [RoomEvent.OldStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; + [RoomEvent.CurrentStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void; + [RoomEvent.HistoryImportedWithinTimeline]: ( + markerEvent: MatrixEvent, + room: Room, + ) => void; + [RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; } & ThreadHandlerMap & MatrixEventHandlerMap; @@ -206,6 +221,7 @@ 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 readonly pendingEventList?: MatrixEvent[]; // read by megolm via getter; boolean value - null indicates "use global value" private blacklistUnverifiedDevices: boolean = null; @@ -441,6 +457,15 @@ 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 getCreator(): string | null { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + return createEvent?.getContent()['creator'] ?? null; + } + /** * Gets the version of the room * @returns {string} The version of the room, or null if it could not be determined @@ -897,6 +922,108 @@ export class Room extends TypedEventEmitter }); } + /** + * Empty out the current live timeline and re-request it. This is used when + * historical messages are imported into the room via MSC2716 `/batch_send + * because the client may already have that section of the timeline loaded. + * We need to force the client to throw away their current timeline so that + * when they back paginate over the area again with the historical messages + * in between, it grabs the newly imported messages. We can listen for + * `UNSTABLE_MSC2716_MARKER`, in order to tell when historical messages are ready + * to be discovered in the room and the timeline needs a refresh. The SDK + * emits a `RoomEvent.HistoryImportedWithinTimeline` event when we detect a + * valid marker and can check the needs refresh status via + * `room.getTimelineNeedsRefresh()`. + */ + public async refreshLiveTimeline(): Promise { + const liveTimelineBefore = this.getLiveTimeline(); + const forwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.FORWARDS); + const backwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.BACKWARDS); + const eventsBefore = liveTimelineBefore.getEvents(); + const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1]; + logger.log( + `[refreshLiveTimeline for ${this.roomId}] at ` + + `mostRecentEventInTimeline=${mostRecentEventInTimeline && mostRecentEventInTimeline.getId()} ` + + `liveTimelineBefore=${liveTimelineBefore.toString()} ` + + `forwardPaginationToken=${forwardPaginationToken} ` + + `backwardPaginationToken=${backwardPaginationToken}`, + ); + + // Get the main TimelineSet + const timelineSet = this.getUnfilteredTimelineSet(); + + let newTimeline: EventTimeline; + // If there isn't any event in the timeline, let's go fetch the latest + // event and construct a timeline from it. + // + // This should only really happen if the user ran into an error + // with refreshing the timeline before which left them in a blank + // timeline from `resetLiveTimeline`. + if (!mostRecentEventInTimeline) { + newTimeline = await this.client.getLatestTimeline(timelineSet); + } else { + // Empty out all of `this.timelineSets`. But we also need to keep the + // same `timelineSet` references around so the React code updates + // properly and doesn't ignore the room events we emit because it checks + // that the `timelineSet` references are the same. We need the + // `timelineSet` empty so that the `client.getEventTimeline(...)` call + // later, will call `/context` and create a new timeline instead of + // returning the same one. + this.resetLiveTimeline(null, null); + + // Make the UI timeline show the new blank live timeline we just + // reset so that if the network fails below it's showing the + // accurate state of what we're working with instead of the + // disconnected one in the TimelineWindow which is just hanging + // around by reference. + this.emit(RoomEvent.TimelineRefresh, this, timelineSet); + + // Use `client.getEventTimeline(...)` to construct a new timeline from a + // `/context` response state and events for the most recent event before + // we reset everything. The `timelineSet` we pass in needs to be empty + // in order for this function to call `/context` and generate a new + // timeline. + newTimeline = await this.client.getEventTimeline(timelineSet, mostRecentEventInTimeline.getId()); + } + + // If a racing `/sync` beat us to creating a new timeline, use that + // instead because it's the latest in the room and any new messages in + // the scrollback will include the history. + const liveTimeline = timelineSet.getLiveTimeline(); + if (!liveTimeline || ( + liveTimeline.getPaginationToken(Direction.Forward) === null && + liveTimeline.getPaginationToken(Direction.Backward) === null && + liveTimeline.getEvents().length === 0 + )) { + logger.log(`[refreshLiveTimeline for ${this.roomId}] using our new live timeline`); + // Set the pagination token back to the live sync token (`null`) instead + // of using the `/context` historical token (ex. `t12-13_0_0_0_0_0_0_0_0`) + // so that it matches the next response from `/sync` and we can properly + // continue the timeline. + newTimeline.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS); + + // Set our new fresh timeline as the live timeline to continue syncing + // forwards and back paginating from. + timelineSet.setLiveTimeline(newTimeline); + // Fixup `this.oldstate` so that `scrollback` has the pagination tokens + // available + this.fixUpLegacyTimelineFields(); + } else { + logger.log( + `[refreshLiveTimeline for ${this.roomId}] \`/sync\` or some other request beat us to creating a new ` + + `live timeline after we reset it. We'll use that instead since any events in the scrollback from ` + + `this timeline will include the history.`, + ); + } + + // The timeline has now been refreshed ✅ + this.setTimelineNeedsRefresh(false); + + // Emit an event which clients can react to and re-load the timeline + // from the SDK + this.emit(RoomEvent.TimelineRefresh, this, timelineSet); + } + /** * Reset the live timeline of all timelineSets, and start new ones. * @@ -924,6 +1051,9 @@ export class Room extends TypedEventEmitter * @private */ private fixUpLegacyTimelineFields(): void { + const previousOldState = this.oldState; + const previousCurrentState = this.currentState; + // maintain this.timeline as a reference to the live timeline, // and this.oldState and this.currentState as references to the // state at the start and end of that timeline. These are more @@ -933,6 +1063,17 @@ export class Room extends TypedEventEmitter .getState(EventTimeline.BACKWARDS); this.currentState = this.getLiveTimeline() .getState(EventTimeline.FORWARDS); + + // Let people know to register new listeners for the new state + // references. The reference won't necessarily change every time so only + // emit when we see a change. + if (previousOldState !== this.oldState) { + this.emit(RoomEvent.OldStateUpdated, this, previousOldState, this.oldState); + } + + if (previousCurrentState !== this.currentState) { + this.emit(RoomEvent.CurrentStateUpdated, this, previousCurrentState, this.currentState); + } } /** @@ -1000,6 +1141,24 @@ 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 an event which is stored in our unfiltered timeline set, or in a thread * @@ -1454,7 +1613,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, + }); } }); } @@ -1501,22 +1662,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; } @@ -1778,15 +1937,20 @@ 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 { + const { duplicateStrategy, timelineWasEmpty, fromCache } = addLiveEventOptions; + // 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, { + duplicateStrategy, + fromCache, + timelineWasEmpty, + }); } // synthesize and inject implicit read receipts @@ -1872,11 +2036,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, + }); } } } @@ -2113,18 +2281,38 @@ 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; + /** + * @deprecated In favor of the overload with `IAddLiveEventOptions` + */ + public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void; + public addLiveEvents( + events: MatrixEvent[], + duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions, + fromCache = false, + ): void { + let duplicateStrategy = duplicateStrategyOrOpts as DuplicateStrategy; + let timelineWasEmpty: boolean; + if (typeof (duplicateStrategyOrOpts) === 'object') { + ({ + duplicateStrategy, + fromCache = false, + /* roomState, (not used here) */ + timelineWasEmpty, + } = duplicateStrategyOrOpts); + } else if (duplicateStrategyOrOpts !== undefined) { + // Deprecation warning + // FIXME: Remove after 2023-06-01 (technical debt) + logger.warn( + 'Overload deprecated: ' + + '`Room.addLiveEvents(events, duplicateStrategy?, fromCache?)` ' + + 'is deprecated in favor of the overload with `Room.addLiveEvents(events, IAddLiveEventOptions)`', + ); + } + if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); } @@ -2162,7 +2350,11 @@ export class Room extends TypedEventEmitter eventsByThread[threadId]?.push(event); if (shouldLiveInRoom) { - this.addLiveEvent(event, duplicateStrategy, fromCache); + this.addLiveEvent(event, { + duplicateStrategy, + fromCache, + timelineWasEmpty, + }); } } diff --git a/src/models/thread.ts b/src/models/thread.ts index 6ac5c985d4d..4fb8bed4212 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -199,9 +199,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 794fd88b250..69da493eb0f 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -51,7 +51,7 @@ import { MatrixError, Method } from "./http-api"; import { ISavedSync } from "./store"; import { EventType } from "./@types/event"; import { IPushRules } from "./@types/PushRules"; -import { RoomStateEvent } from "./models/room-state"; +import { RoomState, RoomStateEvent, IMarkerFoundOptions } from "./models/room-state"; import { RoomMemberEvent } from "./models/room-member"; import { BeaconEvent } from "./models/beacon"; import { IEventsResponse } from "./@types/requests"; @@ -71,14 +71,32 @@ const BUFFER_PERIOD_MS = 80 * 1000; const FAILED_SYNC_ERROR_THRESHOLD = 3; export enum SyncState { + /** Emitted after we try to sync more than `FAILED_SYNC_ERROR_THRESHOLD` + * times and are still failing. Or when we enounter a hard error like the + * token being invalid. */ Error = "ERROR", + /** Emitted after the first sync events are ready (this could even be sync + * events from the cache) */ Prepared = "PREPARED", + /** Emitted when the sync loop is no longer running */ Stopped = "STOPPED", + /** Emitted after each sync request happens */ Syncing = "SYNCING", + /** Emitted after a connectivity error and we're ready to start syncing again */ Catchup = "CATCHUP", + /** Emitted for each time we try reconnecting. Will switch to `Error` after + * we reach the `FAILED_SYNC_ERROR_THRESHOLD` + */ 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! @@ -205,6 +223,15 @@ export class SyncApi { RoomEvent.TimelineReset, ]); this.registerStateListeners(room); + // Register listeners again after the state reference changes + room.on(RoomEvent.CurrentStateUpdated, (targetRoom, previousCurrentState) => { + if (targetRoom !== room) { + return; + } + + this.deregisterStateListeners(previousCurrentState); + this.registerStateListeners(room); + }); return room; } @@ -237,17 +264,89 @@ export class SyncApi { RoomMemberEvent.Membership, ]); }); + + room.currentState.on(RoomStateEvent.Marker, (markerEvent, markerFoundOptions) => { + this.onMarkerStateEvent(room, markerEvent, markerFoundOptions); + }); } /** - * @param {Room} room + * @param {RoomState} roomState The roomState to clear listeners from * @private */ - private deregisterStateListeners(room: Room): void { + private deregisterStateListeners(roomState: RoomState): void { // could do with a better way of achieving this. - room.currentState.removeAllListeners(RoomStateEvent.Events); - room.currentState.removeAllListeners(RoomStateEvent.Members); - room.currentState.removeAllListeners(RoomStateEvent.NewMember); + roomState.removeAllListeners(RoomStateEvent.Events); + roomState.removeAllListeners(RoomStateEvent.Members); + roomState.removeAllListeners(RoomStateEvent.NewMember); + roomState.removeAllListeners(RoomStateEvent.Marker); + } + + /** 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 `timelineWasEmpty` is set + * as `true`, the given marker event will be ignored + */ + private onMarkerStateEvent( + room: Room, + markerEvent: MatrixEvent, + { timelineWasEmpty }: IMarkerFoundOptions = {}, + ): void { + // We don't need to refresh the timeline if it was empty before the + // marker arrived. This could be happen in a variety of cases: + // 1. From the initial sync + // 2. If it's from the first state we're seeing after joining the room + // 3. Or whether it's coming from `syncFromCache` + if (timelineWasEmpty) { + logger.debug( + `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` + + `because the timeline was empty before the marker arrived which means there is nothing to refresh.`, + ); + 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.getCreator(); + + // 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) { + // 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); + } else { + 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.`, + ); + } } /** @@ -1248,7 +1347,6 @@ export class SyncApi { } if (limited) { - this.deregisterStateListeners(room); room.resetLiveTimeline( joinObj.timeline.prev_batch, this.opts.canResetEntireTimeline(room.roomId) ? @@ -1259,8 +1357,6 @@ export class SyncApi { // reason to stop incrementally tracking notifications and // reset the timeline. client.resetNotifTimelineSet(); - - this.registerStateListeners(room); } } @@ -1584,7 +1680,9 @@ export class SyncApi { for (const ev of stateEventList) { this.client.getPushActionsForEvent(ev); } - liveTimeline.initialiseState(stateEventList); + liveTimeline.initialiseState(stateEventList, { + timelineWasEmpty, + }); } this.resolveInvites(room); @@ -1622,7 +1720,10 @@ 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, + timelineWasEmpty, + }); this.client.processBeaconEvents(room, timelineEventList); }