From 5bcd26e5062d2e4a6724a8f260e1760dbf06273c Mon Sep 17 00:00:00 2001 From: David Baker Date: Wed, 27 Nov 2024 11:40:41 +0000 Subject: [PATCH] Support MSC4222 `state_after` (#4487) * WIP support for state_after * Fix sliding sync sdk / embedded tests * Allow both state & state_after to be undefined Since it must have allowed state to be undefined previously: the test had it as such. * Fix limited sync handling * Need to use state_after being undefined if state can be undefined anyway * Make sliding sync sdk tests pass * Remove deprecated interfaces & backwards-compat code * Remove useless assignment * Use updates unstable prefix * Clarify docs * Remove additional semi-backwards compatible overload * Update unstable prefixes * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix test Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add test for MSC4222 behaviour Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Improve coverage Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Tidy Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add comments to explain why things work as they are. * Fix sync accumulator for state_after sync handling Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Revert "Fix room state being updated with old (now overwritten) state and emitting for those updates. (#4242)" This reverts commit 957329b21821c0f632de6c04fff53144f7c0e5dd. * Fix Sync Accumulator toJSON putting start timeline state in state_after field Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Add test case Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: Hugh Nimmo-Smith Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> Co-authored-by: Timo --- spec/integ/crypto/crypto.spec.ts | 4 +- .../matrix-client-event-timeline.spec.ts | 12 +- spec/integ/matrix-client-methods.spec.ts | 57 +- spec/integ/matrix-client-syncing.spec.ts | 167 ++- ...matrix-client-unread-notifications.spec.ts | 2 +- spec/integ/sliding-sync-sdk.spec.ts | 14 +- spec/test-utils/test-utils.ts | 2 +- spec/test-utils/thread.ts | 2 +- spec/unit/embedded.spec.ts | 8 +- spec/unit/event-mapper.spec.ts | 4 +- spec/unit/event-timeline-set.spec.ts | 26 +- spec/unit/event-timeline.spec.ts | 50 +- spec/unit/matrix-client.spec.ts | 52 +- spec/unit/models/event.spec.ts | 12 +- spec/unit/models/room-receipts.spec.ts | 44 +- spec/unit/models/thread.spec.ts | 2 +- spec/unit/notifications.spec.ts | 2 +- spec/unit/relations.spec.ts | 8 +- spec/unit/room.spec.ts | 1041 ++++++++++------- spec/unit/sync-accumulator.spec.ts | 163 ++- spec/unit/timeline-window.spec.ts | 6 +- spec/unit/webrtc/groupCall.spec.ts | 23 +- src/client.ts | 22 +- src/embedded.ts | 37 +- src/models/event-timeline-set.ts | 55 +- src/models/event-timeline.ts | 13 +- src/models/event.ts | 4 +- src/models/room.ts | 24 +- src/models/thread.ts | 6 +- src/sliding-sync-sdk.ts | 19 +- src/sync-accumulator.ts | 49 +- src/sync.ts | 158 ++- 32 files changed, 1348 insertions(+), 740 deletions(-) diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index d72b3d6a262..5b219d1d51c 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -1327,7 +1327,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, const syncResponse = getSyncResponse(["@bob:xyz"]); // Every 2 messages in the room, the session should be rotated - syncResponse.rooms[Category.Join][ROOM_ID].state.events[0].content = { + syncResponse.rooms[Category.Join][ROOM_ID].state!.events[0].content = { algorithm: "m.megolm.v1.aes-sha2", rotation_period_msgs: 2, }; @@ -1383,7 +1383,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string, const oneHourInMs = 60 * 60 * 1000; // Every 1h the session should be rotated - syncResponse.rooms[Category.Join][ROOM_ID].state.events[0].content = { + syncResponse.rooms[Category.Join][ROOM_ID].state!.events[0].content = { algorithm: "m.megolm.v1.aes-sha2", rotation_period_ms: oneHourInMs, }; diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index 5785732422d..f7e3662cde4 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -1144,7 +1144,7 @@ describe("MatrixClient event timelines", function () { const prom = emitPromise(room, ThreadEvent.Update); // Assume we're seeing the reply while loading backlog - await room.addLiveEvents([THREAD_REPLY2]); + await room.addLiveEvents([THREAD_REPLY2], { addToState: false }); httpBackend .when( "GET", @@ -1155,7 +1155,7 @@ describe("MatrixClient event timelines", function () { }); await flushHttp(prom); // but while loading the metadata, a new reply has arrived - await room.addLiveEvents([THREAD_REPLY3]); + await room.addLiveEvents([THREAD_REPLY3], { addToState: false }); const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!; // then the events should still be all in the right order expect(thread.events.map((it) => it.getId())).toEqual([ @@ -1247,7 +1247,7 @@ describe("MatrixClient event timelines", function () { const prom = emitPromise(room, ThreadEvent.Update); // Assume we're seeing the reply while loading backlog - await room.addLiveEvents([THREAD_REPLY2]); + await room.addLiveEvents([THREAD_REPLY2], { addToState: false }); httpBackend .when( "GET", @@ -1263,7 +1263,7 @@ describe("MatrixClient event timelines", function () { }); await flushHttp(prom); // but while loading the metadata, a new reply has arrived - await room.addLiveEvents([THREAD_REPLY3]); + await room.addLiveEvents([THREAD_REPLY3], { addToState: false }); const thread = room.getThread(THREAD_ROOT_UPDATED.event_id!)!; // then the events should still be all in the right order expect(thread.events.map((it) => it.getId())).toEqual([ @@ -1560,7 +1560,7 @@ describe("MatrixClient event timelines", function () { thread.initialEventsFetched = true; const prom = emitPromise(room, ThreadEvent.NewReply); respondToEvent(THREAD_ROOT_UPDATED); - await room.addLiveEvents([THREAD_REPLY2]); + await room.addLiveEvents([THREAD_REPLY2], { addToState: false }); await httpBackend.flushAllExpected(); await prom; expect(thread.length).toBe(2); @@ -1685,7 +1685,7 @@ describe("MatrixClient event timelines", function () { thread.initialEventsFetched = true; const prom = emitPromise(room, ThreadEvent.Update); respondToEvent(THREAD_ROOT_UPDATED); - await room.addLiveEvents([THREAD_REPLY_REACTION]); + await room.addLiveEvents([THREAD_REPLY_REACTION], { addToState: false }); await httpBackend.flushAllExpected(); await prom; expect(thread.length).toBe(1); // reactions don't count towards the length of a thread diff --git a/spec/integ/matrix-client-methods.spec.ts b/spec/integ/matrix-client-methods.spec.ts index 72793711f62..b5347e3b559 100644 --- a/spec/integ/matrix-client-methods.spec.ts +++ b/spec/integ/matrix-client-methods.spec.ts @@ -168,14 +168,17 @@ describe("MatrixClient", function () { type: "test", content: {}, }); - room.addLiveEvents([ - utils.mkMembership({ - user: userId, - room: roomId, - mship: KnownMembership.Join, - event: true, - }), - ]); + room.addLiveEvents( + [ + utils.mkMembership({ + user: userId, + room: roomId, + mship: KnownMembership.Join, + event: true, + }), + ], + { addToState: true }, + ); httpBackend.verifyNoOutstandingRequests(); store.storeRoom(room); @@ -188,14 +191,17 @@ describe("MatrixClient", function () { const roomId = "!roomId:server"; const roomAlias = "#my-fancy-room:server"; const room = new Room(roomId, client, userId); - room.addLiveEvents([ - utils.mkMembership({ - user: userId, - room: roomId, - mship: KnownMembership.Join, - event: true, - }), - ]); + room.addLiveEvents( + [ + utils.mkMembership({ + user: userId, + room: roomId, + mship: KnownMembership.Join, + event: true, + }), + ], + { addToState: true }, + ); store.storeRoom(room); // The method makes a request to resolve the alias @@ -275,14 +281,17 @@ describe("MatrixClient", function () { content: {}, }); - room.addLiveEvents([ - utils.mkMembership({ - user: userId, - room: roomId, - mship: KnownMembership.Knock, - event: true, - }), - ]); + room.addLiveEvents( + [ + utils.mkMembership({ + user: userId, + room: roomId, + mship: KnownMembership.Knock, + event: true, + }), + ], + { addToState: true }, + ); httpBackend.verifyNoOutstandingRequests(); store.storeRoom(room); diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 1630b38ab36..d3156f0fb12 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -556,7 +556,7 @@ describe("MatrixClient syncing", () => { }); it("should resolve incoming invites from /sync", () => { - syncData.rooms.join[roomOne].state.events.push( + syncData.rooms.join[roomOne].state!.events.push( utils.mkMembership({ room: roomOne, mship: KnownMembership.Invite, @@ -589,7 +589,7 @@ describe("MatrixClient syncing", () => { name: "The Ghost", }) as IMinimalEvent, ]; - syncData.rooms.join[roomOne].state.events.push( + syncData.rooms.join[roomOne].state!.events.push( utils.mkMembership({ room: roomOne, mship: KnownMembership.Invite, @@ -617,7 +617,7 @@ describe("MatrixClient syncing", () => { name: "The Ghost", }) as IMinimalEvent, ]; - syncData.rooms.join[roomOne].state.events.push( + syncData.rooms.join[roomOne].state!.events.push( utils.mkMembership({ room: roomOne, mship: KnownMembership.Invite, @@ -644,7 +644,7 @@ describe("MatrixClient syncing", () => { }); it("should no-op if resolveInvitesToProfiles is not set", () => { - syncData.rooms.join[roomOne].state.events.push( + syncData.rooms.join[roomOne].state!.events.push( utils.mkMembership({ room: roomOne, mship: KnownMembership.Invite, @@ -1373,6 +1373,114 @@ describe("MatrixClient syncing", () => { expect(stateEventEmitCount).toEqual(2); }); }); + + describe("msc4222", () => { + const roomOneSyncOne = { + "timeline": { + events: [ + utils.mkMessage({ + room: roomOne, + user: otherUserId, + msg: "hello", + }), + ], + }, + "org.matrix.msc4222.state_after": { + events: [ + utils.mkEvent({ + type: "m.room.name", + room: roomOne, + user: otherUserId, + content: { + name: "Initial room name", + }, + }), + utils.mkMembership({ + room: roomOne, + mship: KnownMembership.Join, + user: otherUserId, + }), + utils.mkMembership({ + room: roomOne, + mship: KnownMembership.Join, + user: selfUserId, + }), + utils.mkEvent({ + type: "m.room.create", + room: roomOne, + user: selfUserId, + content: {}, + }), + ], + }, + }; + const roomOneSyncTwo = { + "org.matrix.msc4222.state_after": { + events: [ + utils.mkEvent({ + type: "m.room.topic", + room: roomOne, + user: selfUserId, + content: { topic: "A new room topic" }, + }), + ], + }, + "state": { + events: [ + utils.mkEvent({ + type: "m.room.name", + room: roomOne, + user: selfUserId, + content: { name: "A new room name" }, + }), + ], + }, + }; + + it("should ignore state events in timeline when state_after is present", async () => { + httpBackend!.when("GET", "/sync").respond(200, { + rooms: { + join: { [roomOne]: roomOneSyncOne }, + }, + }); + httpBackend!.when("GET", "/sync").respond(200, { + rooms: { + join: { [roomOne]: roomOneSyncTwo }, + }, + }); + + client!.startClient(); + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent(2)]).then(() => { + const room = client!.getRoom(roomOne)!; + expect(room.name).toEqual("Initial room name"); + expect(room.currentState.getStateEvents("m.room.topic", "")?.getContent().topic).toBe( + "A new room topic", + ); + }); + }); + + it("should respect state events in state_after for left rooms", async () => { + httpBackend!.when("GET", "/sync").respond(200, { + rooms: { + join: { [roomOne]: roomOneSyncOne }, + }, + }); + httpBackend!.when("GET", "/sync").respond(200, { + rooms: { + leave: { [roomOne]: roomOneSyncTwo }, + }, + }); + + client!.startClient(); + return Promise.all([httpBackend!.flushAllExpected(), awaitSyncEvent(2)]).then(() => { + const room = client!.getRoom(roomOne)!; + expect(room.name).toEqual("Initial room name"); + expect(room.currentState.getStateEvents("m.room.topic", "")?.getContent().topic).toBe( + "A new room topic", + ); + }); + }); + }); }); describe("timeline", () => { @@ -2274,6 +2382,57 @@ describe("MatrixClient syncing", () => { }), ]); }); + + describe("msc4222", () => { + it("should respect state events in state_after for left rooms", async () => { + httpBackend!.when("POST", "/filter").respond(200, { + filter_id: "another_id", + }); + + httpBackend!.when("GET", "/sync").respond(200, { + rooms: { + leave: { + [roomOne]: { + "org.matrix.msc4222.state_after": { + events: [ + utils.mkEvent({ + type: "m.room.topic", + room: roomOne, + user: selfUserId, + content: { topic: "A new room topic" }, + }), + ], + }, + "state": { + events: [ + utils.mkEvent({ + type: "m.room.name", + room: roomOne, + user: selfUserId, + content: { name: "A new room name" }, + }), + ], + }, + }, + }, + }, + }); + + const [[room]] = await Promise.all([ + client!.syncLeftRooms(), + + // first flush the filter request; this will make syncLeftRooms make its /sync call + httpBackend!.flush("/filter").then(() => { + return httpBackend!.flushAllExpected(); + }), + ]); + + expect(room.name).toEqual("Empty room"); + expect(room.currentState.getStateEvents("m.room.topic", "")?.getContent().topic).toBe( + "A new room topic", + ); + }); + }); }); describe("peek", () => { diff --git a/spec/integ/matrix-client-unread-notifications.spec.ts b/spec/integ/matrix-client-unread-notifications.spec.ts index 8518d086432..a9124e1b067 100644 --- a/spec/integ/matrix-client-unread-notifications.spec.ts +++ b/spec/integ/matrix-client-unread-notifications.spec.ts @@ -128,7 +128,7 @@ describe("MatrixClient syncing", () => { const thread = mkThread({ room, client: client!, authorId: selfUserId, participantUserIds: [selfUserId] }); const threadReply = thread.events.at(-1)!; - await room.addLiveEvents([thread.rootEvent]); + await room.addLiveEvents([thread.rootEvent], { addToState: false }); // Initialize read receipt datastructure before testing the reaction room.addReceiptToStructure(thread.rootEvent.getId()!, ReceiptType.Read, selfUserId, { ts: 1 }, false); diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index aaf4a1a04a3..f6264530088 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -601,13 +601,13 @@ describe("SlidingSyncSdk", () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { initial: true, name: "Room with Invite", - required_state: [], - timeline: [ + required_state: [ mkOwnStateEvent(EventType.RoomCreate, {}, ""), mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId), mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Invite }, invitee), ], + timeline: [], }); await httpBackend!.flush("/profile", 1, 1000); await emitPromise(client!, RoomMemberEvent.Name); @@ -921,13 +921,12 @@ describe("SlidingSyncSdk", () => { const roomId = "!room:id"; mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { name: "Room with typing", - required_state: [], - timeline: [ + required_state: [ mkOwnStateEvent(EventType.RoomCreate, {}, ""), mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId), mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), - mkOwnEvent(EventType.RoomMessage, { body: "hello" }), ], + timeline: [mkOwnEvent(EventType.RoomMessage, { body: "hello" })], initial: true, }); await emitPromise(client!, ClientEvent.Room); @@ -962,13 +961,12 @@ describe("SlidingSyncSdk", () => { const roomId = "!room:id"; mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { name: "Room with typing", - required_state: [], - timeline: [ + required_state: [ mkOwnStateEvent(EventType.RoomCreate, {}, ""), mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId), mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), - mkOwnEvent(EventType.RoomMessage, { body: "hello" }), ], + timeline: [mkOwnEvent(EventType.RoomMessage, { body: "hello" })], initial: true, }); const room = client!.getRoom(roomId)!; diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 08a2f9f779e..22e7006e47c 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -86,7 +86,7 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I }; for (let i = 0; i < roomMembers.length; i++) { - roomResponse.state.events.push( + roomResponse.state!.events.push( mkMembershipCustom({ membership: KnownMembership.Join, sender: roomMembers[i], diff --git a/spec/test-utils/thread.ts b/spec/test-utils/thread.ts index 50d76886324..be91d9063c5 100644 --- a/spec/test-utils/thread.ts +++ b/spec/test-utils/thread.ts @@ -178,6 +178,6 @@ export const populateThread = ({ }: MakeThreadProps): MakeThreadResult => { const ret = mkThread({ room, client, authorId, participantUserIds, length, ts }); ret.thread.initialEventsFetched = true; - room.addLiveEvents(ret.events); + room.addLiveEvents(ret.events, { addToState: false }); return ret; }; diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index 69b3c43827e..c5ef3a6a2c6 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -261,7 +261,7 @@ describe("RoomWidgetClient", () => { expect(injectSpy).toHaveBeenCalled(); const call = injectSpy.mock.calls[0] as any; - const injectedEv = call[2][0]; + const injectedEv = call[3][0]; expect(injectedEv.getType()).toBe("org.matrix.rageshake_request"); expect(injectedEv.getUnsigned().transaction_id).toBe("widgetTxId"); }); @@ -287,7 +287,7 @@ describe("RoomWidgetClient", () => { expect(injectSpy).toHaveBeenCalled(); const call = injectSpy.mock.calls[0] as any; - const injectedEv = call[2][0]; + const injectedEv = call[3][0]; expect(injectedEv.getType()).toBe("org.matrix.rageshake_request"); expect(injectedEv.getUnsigned().transaction_id).toBe("widgetTxId"); }); @@ -326,13 +326,13 @@ describe("RoomWidgetClient", () => { // it has been called with the event sent by ourselves const call = injectSpy.mock.calls[0] as any; - const injectedEv = call[2][0]; + const injectedEv = call[3][0]; expect(injectedEv.getType()).toBe("org.matrix.rageshake_request"); expect(injectedEv.getUnsigned().transaction_id).toBe("widgetTxId"); // It has been called by the event we blocked because of our send right afterwards const call2 = injectSpy.mock.calls[1] as any; - const injectedEv2 = call2[2][0]; + const injectedEv2 = call2[3][0]; expect(injectedEv2.getType()).toBe("org.matrix.rageshake_request"); expect(injectedEv2.getUnsigned().transaction_id).toBe("4567"); }); diff --git a/spec/unit/event-mapper.spec.ts b/spec/unit/event-mapper.spec.ts index fddd63e83c9..a2331316b69 100644 --- a/spec/unit/event-mapper.spec.ts +++ b/spec/unit/event-mapper.spec.ts @@ -74,7 +74,7 @@ describe("eventMapperFor", function () { const event = mapper(eventDefinition); expect(event).toBeInstanceOf(MatrixEvent); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); expect(room.findEventById(eventId)).toBe(event); const event2 = mapper(eventDefinition); @@ -109,7 +109,7 @@ describe("eventMapperFor", function () { room.oldState.setStateEvents([event]); room.currentState.setStateEvents([event]); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); expect(room.findEventById(eventId)).toBe(event); const event2 = mapper(eventDefinition); diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index c89ddc4606e..61b30edb36d 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -104,7 +104,7 @@ describe("EventTimelineSet", () => { it("Adds event to the live timeline in the timeline set", () => { const liveTimeline = eventTimelineSet.getLiveTimeline(); expect(liveTimeline.getEvents().length).toStrictEqual(0); - eventTimelineSet.addLiveEvent(messageEvent); + eventTimelineSet.addLiveEvent(messageEvent, { addToState: false }); expect(liveTimeline.getEvents().length).toStrictEqual(1); }); @@ -113,6 +113,7 @@ describe("EventTimelineSet", () => { expect(liveTimeline.getEvents().length).toStrictEqual(0); eventTimelineSet.addLiveEvent(messageEvent, { duplicateStrategy: DuplicateStrategy.Replace, + addToState: false, }); expect(liveTimeline.getEvents().length).toStrictEqual(1); @@ -130,6 +131,7 @@ describe("EventTimelineSet", () => { // replace. eventTimelineSet.addLiveEvent(duplicateMessageEvent, { duplicateStrategy: DuplicateStrategy.Replace, + addToState: false, }); const eventsInLiveTimeline = liveTimeline.getEvents(); @@ -144,6 +146,7 @@ describe("EventTimelineSet", () => { expect(liveTimeline.getEvents().length).toStrictEqual(0); eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, { toStartOfTimeline: true, + addToState: false, }); expect(liveTimeline.getEvents().length).toStrictEqual(1); }); @@ -151,10 +154,17 @@ describe("EventTimelineSet", () => { it("Make sure legacy overload passing options directly as parameters still works", () => { const liveTimeline = eventTimelineSet.getLiveTimeline(); expect(() => { - eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, true); + eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, { + toStartOfTimeline: true, + addToState: false, + }); }).not.toThrow(); expect(() => { - eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, true, false); + eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, { + toStartOfTimeline: true, + fromCache: false, + addToState: false, + }); }).not.toThrow(); }); @@ -167,11 +177,13 @@ describe("EventTimelineSet", () => { expect(liveTimeline.getEvents().length).toStrictEqual(0); eventTimelineSet.addEventToTimeline(reactionEvent, liveTimeline, { toStartOfTimeline: true, + addToState: false, }); expect(liveTimeline.getEvents().length).toStrictEqual(0); eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline, { toStartOfTimeline: true, + addToState: false, }); expect(liveTimeline.getEvents()).toHaveLength(1); const [event] = liveTimeline.getEvents(); @@ -202,6 +214,7 @@ describe("EventTimelineSet", () => { expect(() => { eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline2, { toStartOfTimeline: true, + addToState: false, }); }).toThrow(); }); @@ -214,6 +227,7 @@ describe("EventTimelineSet", () => { eventTimelineSet.addEventToTimeline(threadedReplyEvent, liveTimeline, { toStartOfTimeline: true, + addToState: false, }); expect(liveTimeline.getEvents().length).toStrictEqual(0); }); @@ -232,6 +246,7 @@ describe("EventTimelineSet", () => { eventTimelineSetForThread.addEventToTimeline(normalMessage, liveTimeline, { toStartOfTimeline: true, + addToState: false, }); expect(liveTimeline.getEvents().length).toStrictEqual(0); }); @@ -248,6 +263,7 @@ describe("EventTimelineSet", () => { expect(nonRoomEventTimeline.getEvents().length).toStrictEqual(0); nonRoomEventTimelineSet.addEventToTimeline(messageEvent, nonRoomEventTimeline, { toStartOfTimeline: true, + addToState: false, }); expect(nonRoomEventTimeline.getEvents().length).toStrictEqual(1); }); @@ -257,7 +273,7 @@ describe("EventTimelineSet", () => { describe("aggregateRelations", () => { describe("with unencrypted events", () => { beforeEach(() => { - eventTimelineSet.addEventsToTimeline([messageEvent, replyEvent], true, eventTimeline, "foo"); + eventTimelineSet.addEventsToTimeline([messageEvent, replyEvent], true, false, eventTimeline, "foo"); }); itShouldReturnTheRelatedEvents(); @@ -279,7 +295,7 @@ describe("EventTimelineSet", () => { replyEventShouldAttemptDecryptionSpy.mockReturnValue(true); replyEventIsDecryptionFailureSpy = jest.spyOn(messageEvent, "isDecryptionFailure"); - eventTimelineSet.addEventsToTimeline([messageEvent, replyEvent], true, eventTimeline, "foo"); + eventTimelineSet.addEventsToTimeline([messageEvent, replyEvent], true, false, eventTimeline, "foo"); }); it("should not return the related events", () => { diff --git a/spec/unit/event-timeline.spec.ts b/spec/unit/event-timeline.spec.ts index 52ef23c3625..fab355eca69 100644 --- a/spec/unit/event-timeline.spec.ts +++ b/spec/unit/event-timeline.spec.ts @@ -98,7 +98,7 @@ describe("EventTimeline", function () { expect(function () { timeline.initialiseState(state); }).not.toThrow(); - timeline.addEvent(event, { toStartOfTimeline: false }); + timeline.addEvent(event, { toStartOfTimeline: false, addToState: false }); expect(function () { timeline.initialiseState(state); }).toThrow(); @@ -182,9 +182,9 @@ describe("EventTimeline", function () { ]; it("should be able to add events to the end", function () { - timeline.addEvent(events[0], { toStartOfTimeline: false }); + timeline.addEvent(events[0], { toStartOfTimeline: false, addToState: false }); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false, addToState: false }); expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents()[0]).toEqual(events[0]); @@ -192,9 +192,9 @@ describe("EventTimeline", function () { }); it("should be able to add events to the start", function () { - timeline.addEvent(events[0], { toStartOfTimeline: true }); + timeline.addEvent(events[0], { toStartOfTimeline: true, addToState: false }); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], { toStartOfTimeline: true }); + timeline.addEvent(events[1], { toStartOfTimeline: true, addToState: false }); expect(timeline.getBaseIndex()).toEqual(initialIndex + 1); expect(timeline.getEvents().length).toEqual(2); expect(timeline.getEvents()[0]).toEqual(events[1]); @@ -238,9 +238,9 @@ describe("EventTimeline", function () { content: { name: "Old Room Name" }, }); - timeline.addEvent(newEv, { toStartOfTimeline: false }); + timeline.addEvent(newEv, { toStartOfTimeline: false, addToState: false }); expect(newEv.sender).toEqual(sentinel); - timeline.addEvent(oldEv, { toStartOfTimeline: true }); + timeline.addEvent(oldEv, { toStartOfTimeline: true, addToState: false }); expect(oldEv.sender).toEqual(oldSentinel); }); @@ -280,9 +280,9 @@ describe("EventTimeline", function () { skey: userA, event: true, }); - timeline.addEvent(newEv, { toStartOfTimeline: false }); + timeline.addEvent(newEv, { toStartOfTimeline: false, addToState: false }); expect(newEv.target).toEqual(sentinel); - timeline.addEvent(oldEv, { toStartOfTimeline: true }); + timeline.addEvent(oldEv, { toStartOfTimeline: true, addToState: false }); expect(oldEv.target).toEqual(oldSentinel); }); @@ -308,8 +308,8 @@ describe("EventTimeline", function () { }), ]; - timeline.addEvent(events[0], { toStartOfTimeline: false }); - timeline.addEvent(events[1], { toStartOfTimeline: false }); + timeline.addEvent(events[0], { toStartOfTimeline: false, addToState: true }); + timeline.addEvent(events[1], { toStartOfTimeline: false, addToState: true }); expect(timeline.getState(EventTimeline.FORWARDS)!.setStateEvents).toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined, @@ -347,8 +347,8 @@ describe("EventTimeline", function () { }), ]; - timeline.addEvent(events[0], { toStartOfTimeline: true }); - timeline.addEvent(events[1], { toStartOfTimeline: true }); + timeline.addEvent(events[0], { toStartOfTimeline: true, addToState: true }); + timeline.addEvent(events[1], { toStartOfTimeline: true, addToState: true }); expect(timeline.getState(EventTimeline.BACKWARDS)!.setStateEvents).toHaveBeenCalledWith([events[0]], { timelineWasEmpty: undefined, @@ -365,11 +365,15 @@ describe("EventTimeline", function () { ); 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], { toStartOfTimeline: true, addToState: false })).not.toThrow(); // @ts-ignore stateContext is not a valid param expect(() => timeline.addEvent(events[0], { stateContext: new RoomState(roomId) })).not.toThrow(); expect(() => - timeline.addEvent(events[0], { toStartOfTimeline: false, roomState: new RoomState(roomId) }), + timeline.addEvent(events[0], { + toStartOfTimeline: false, + addToState: false, + roomState: new RoomState(roomId), + }), ).not.toThrow(); }); }); @@ -397,8 +401,8 @@ describe("EventTimeline", function () { ]; it("should remove events", function () { - timeline.addEvent(events[0], { toStartOfTimeline: false }); - timeline.addEvent(events[1], { toStartOfTimeline: false }); + timeline.addEvent(events[0], { toStartOfTimeline: false, addToState: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false, addToState: false }); expect(timeline.getEvents().length).toEqual(2); let ev = timeline.removeEvent(events[0].getId()!); @@ -411,9 +415,9 @@ describe("EventTimeline", function () { }); it("should update baseIndex", function () { - timeline.addEvent(events[0], { toStartOfTimeline: false }); - timeline.addEvent(events[1], { toStartOfTimeline: true }); - timeline.addEvent(events[2], { toStartOfTimeline: false }); + timeline.addEvent(events[0], { toStartOfTimeline: false, addToState: false }); + timeline.addEvent(events[1], { toStartOfTimeline: true, addToState: false }); + timeline.addEvent(events[2], { toStartOfTimeline: false, addToState: false }); expect(timeline.getEvents().length).toEqual(3); expect(timeline.getBaseIndex()).toEqual(1); @@ -430,11 +434,11 @@ describe("EventTimeline", function () { // - removing the last event got baseIndex into such a state that // 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], { toStartOfTimeline: true }); + timeline.addEvent(events[0], { toStartOfTimeline: true, addToState: false }); timeline.removeEvent(events[0].getId()!); const initialIndex = timeline.getBaseIndex(); - timeline.addEvent(events[1], { toStartOfTimeline: false }); - timeline.addEvent(events[2], { toStartOfTimeline: false }); + timeline.addEvent(events[1], { toStartOfTimeline: false, addToState: false }); + timeline.addEvent(events[2], { toStartOfTimeline: false, addToState: false }); expect(timeline.getBaseIndex()).toEqual(initialIndex); expect(timeline.getEvents().length).toEqual(2); }); diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 902a79b23ef..6096090414e 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -2799,24 +2799,28 @@ describe("MatrixClient", function () { roomCreateEvent(room1.roomId, replacedByCreate1.roomId), predecessorEvent(room1.roomId, replacedByDynamicPredecessor1.roomId), ], - {}, + { addToState: true }, ); room2.addLiveEvents( [ roomCreateEvent(room2.roomId, replacedByCreate2.roomId), predecessorEvent(room2.roomId, replacedByDynamicPredecessor2.roomId), ], - {}, + { addToState: true }, ); - replacedByCreate1.addLiveEvents([tombstoneEvent(room1.roomId, replacedByCreate1.roomId)], {}); - replacedByCreate2.addLiveEvents([tombstoneEvent(room2.roomId, replacedByCreate2.roomId)], {}); + replacedByCreate1.addLiveEvents([tombstoneEvent(room1.roomId, replacedByCreate1.roomId)], { + addToState: true, + }); + replacedByCreate2.addLiveEvents([tombstoneEvent(room2.roomId, replacedByCreate2.roomId)], { + addToState: true, + }); replacedByDynamicPredecessor1.addLiveEvents( [tombstoneEvent(room1.roomId, replacedByDynamicPredecessor1.roomId)], - {}, + { addToState: true }, ); replacedByDynamicPredecessor2.addLiveEvents( [tombstoneEvent(room2.roomId, replacedByDynamicPredecessor2.roomId)], - {}, + { addToState: true }, ); return { @@ -2854,10 +2858,10 @@ describe("MatrixClient", function () { const room2 = new Room("room2", client, "@daryl:alexandria.example.com"); client.store = new StubStore(); client.store.getRooms = () => [room1, replacedRoom1, replacedRoom2, room2]; - room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], {}); - room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], {}); - replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], {}); - replacedRoom2.addLiveEvents([tombstoneEvent(room2.roomId, replacedRoom2.roomId)], {}); + room1.addLiveEvents([roomCreateEvent(room1.roomId, replacedRoom1.roomId)], { addToState: true }); + room2.addLiveEvents([roomCreateEvent(room2.roomId, replacedRoom2.roomId)], { addToState: true }); + replacedRoom1.addLiveEvents([tombstoneEvent(room1.roomId, replacedRoom1.roomId)], { addToState: true }); + replacedRoom2.addLiveEvents([tombstoneEvent(room2.roomId, replacedRoom2.roomId)], { addToState: true }); // When we ask for the visible rooms const rooms = client.getVisibleRooms(); @@ -2937,15 +2941,15 @@ describe("MatrixClient", function () { const room4 = new Room("room4", client, "@michonne:hawthorne.example.com"); if (creates) { - room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)]); - room3.addLiveEvents([roomCreateEvent(room3.roomId, room2.roomId)]); - room4.addLiveEvents([roomCreateEvent(room4.roomId, room3.roomId)]); + room2.addLiveEvents([roomCreateEvent(room2.roomId, room1.roomId)], { addToState: true }); + room3.addLiveEvents([roomCreateEvent(room3.roomId, room2.roomId)], { addToState: true }); + room4.addLiveEvents([roomCreateEvent(room4.roomId, room3.roomId)], { addToState: true }); } if (tombstones) { - room1.addLiveEvents([tombstoneEvent(room2.roomId, room1.roomId)], {}); - room2.addLiveEvents([tombstoneEvent(room3.roomId, room2.roomId)], {}); - room3.addLiveEvents([tombstoneEvent(room4.roomId, room3.roomId)], {}); + room1.addLiveEvents([tombstoneEvent(room2.roomId, room1.roomId)], { addToState: true }); + room2.addLiveEvents([tombstoneEvent(room3.roomId, room2.roomId)], { addToState: true }); + room3.addLiveEvents([tombstoneEvent(room4.roomId, room3.roomId)], { addToState: true }); } mocked(store.getRoom).mockImplementation((roomId: string) => { @@ -2980,17 +2984,17 @@ describe("MatrixClient", function () { const dynRoom4 = new Room("dynRoom4", client, "@rick:grimes.example.com"); const dynRoom5 = new Room("dynRoom5", client, "@rick:grimes.example.com"); - dynRoom1.addLiveEvents([tombstoneEvent(dynRoom2.roomId, dynRoom1.roomId)], {}); - dynRoom2.addLiveEvents([predecessorEvent(dynRoom2.roomId, dynRoom1.roomId)]); + dynRoom1.addLiveEvents([tombstoneEvent(dynRoom2.roomId, dynRoom1.roomId)], { addToState: true }); + dynRoom2.addLiveEvents([predecessorEvent(dynRoom2.roomId, dynRoom1.roomId)], { addToState: true }); - dynRoom2.addLiveEvents([tombstoneEvent(room3.roomId, dynRoom2.roomId)], {}); - room3.addLiveEvents([predecessorEvent(room3.roomId, dynRoom2.roomId)]); + dynRoom2.addLiveEvents([tombstoneEvent(room3.roomId, dynRoom2.roomId)], { addToState: true }); + room3.addLiveEvents([predecessorEvent(room3.roomId, dynRoom2.roomId)], { addToState: true }); - room3.addLiveEvents([tombstoneEvent(dynRoom4.roomId, room3.roomId)], {}); - dynRoom4.addLiveEvents([predecessorEvent(dynRoom4.roomId, room3.roomId)]); + room3.addLiveEvents([tombstoneEvent(dynRoom4.roomId, room3.roomId)], { addToState: true }); + dynRoom4.addLiveEvents([predecessorEvent(dynRoom4.roomId, room3.roomId)], { addToState: true }); - dynRoom4.addLiveEvents([tombstoneEvent(dynRoom5.roomId, dynRoom4.roomId)], {}); - dynRoom5.addLiveEvents([predecessorEvent(dynRoom5.roomId, dynRoom4.roomId)]); + dynRoom4.addLiveEvents([tombstoneEvent(dynRoom5.roomId, dynRoom4.roomId)], { addToState: true }); + dynRoom5.addLiveEvents([predecessorEvent(dynRoom5.roomId, dynRoom4.roomId)], { addToState: true }); mocked(store.getRoom) .mockClear() diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index 56f31482786..0234eab3e09 100644 --- a/spec/unit/models/event.spec.ts +++ b/spec/unit/models/event.spec.ts @@ -99,7 +99,7 @@ describe("MatrixEvent", () => { const room = new Room("!roomid:e.xyz", mockClient, "myname"); const ev = createEvent("$event1:server"); - await room.addLiveEvents([ev]); + await room.addLiveEvents([ev], { addToState: false }); await room.createThreadsTimelineSets(); expect(ev.threadRootId).toBeUndefined(); expect(mainTimelineLiveEventIds(room)).toEqual([ev.getId()]); @@ -120,7 +120,7 @@ describe("MatrixEvent", () => { const threadRoot = createEvent("$threadroot:server"); const ev = createThreadedEvent("$event1:server", threadRoot.getId()!); - await room.addLiveEvents([threadRoot, ev]); + await room.addLiveEvents([threadRoot, ev], { addToState: false }); await room.createThreadsTimelineSets(); expect(threadRoot.threadRootId).toEqual(threadRoot.getId()); expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]); @@ -143,7 +143,7 @@ describe("MatrixEvent", () => { const threadRoot = createEvent("$threadroot:server"); const ev = createThreadedEvent("$event1:server", threadRoot.getId()!); - await room.addLiveEvents([threadRoot, ev]); + await room.addLiveEvents([threadRoot, ev], { addToState: false }); await room.createThreadsTimelineSets(); expect(ev.threadRootId).toEqual(threadRoot.getId()); expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]); @@ -167,7 +167,7 @@ describe("MatrixEvent", () => { const ev = createThreadedEvent("$event1:server", threadRoot.getId()!); const reaction = createReactionEvent("$reaction:server", ev.getId()!); - await room.addLiveEvents([threadRoot, ev, reaction]); + await room.addLiveEvents([threadRoot, ev, reaction], { addToState: false }); await room.createThreadsTimelineSets(); expect(reaction.threadRootId).toEqual(threadRoot.getId()); expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]); @@ -191,7 +191,7 @@ describe("MatrixEvent", () => { const ev = createThreadedEvent("$event1:server", threadRoot.getId()!); const edit = createEditEvent("$edit:server", ev.getId()!); - await room.addLiveEvents([threadRoot, ev, edit]); + await room.addLiveEvents([threadRoot, ev, edit], { addToState: false }); await room.createThreadsTimelineSets(); expect(edit.threadRootId).toEqual(threadRoot.getId()); expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]); @@ -217,7 +217,7 @@ describe("MatrixEvent", () => { const reply2 = createReplyEvent("$reply2:server", reply1.getId()!); const reaction = createReactionEvent("$reaction:server", reply2.getId()!); - await room.addLiveEvents([threadRoot, ev, reply1, reply2, reaction]); + await room.addLiveEvents([threadRoot, ev, reply1, reply2, reaction], { addToState: false }); await room.createThreadsTimelineSets(); expect(reaction.threadRootId).toEqual(threadRoot.getId()); expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]); diff --git a/spec/unit/models/room-receipts.spec.ts b/spec/unit/models/room-receipts.spec.ts index d11b16d033c..b352c0defd7 100644 --- a/spec/unit/models/room-receipts.spec.ts +++ b/spec/unit/models/room-receipts.spec.ts @@ -36,7 +36,7 @@ describe("RoomReceipts", () => { // Given there are no receipts in the room const room = createRoom(); const [event] = createEvent(); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); // When I ask about any event, then it is unread expect(room.hasUserReadEvent(readerId, event.getId()!)).toBe(false); @@ -46,7 +46,7 @@ describe("RoomReceipts", () => { // Given there are no receipts in the room const room = createRoom(); const [event] = createEventSentBy(readerId); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); // When I ask about an event I sent, it is read (because a synthetic // receipt was created and stored in RoomReceipts) @@ -57,7 +57,7 @@ describe("RoomReceipts", () => { // Given my event exists and is unread const room = createRoom(); const [event, eventId] = createEvent(); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); expect(room.hasUserReadEvent(readerId, eventId)).toBe(false); // When we receive a receipt for this event+user @@ -72,7 +72,7 @@ describe("RoomReceipts", () => { const room = createRoom(); const [event1, event1Id] = createEvent(); const [event2] = createEvent(); - room.addLiveEvents([event1, event2]); + room.addLiveEvents([event1, event2], { addToState: false }); // When we receive a receipt for the later event room.addReceipt(createReceipt(readerId, event2)); @@ -86,7 +86,7 @@ describe("RoomReceipts", () => { const room = createRoom(); const [oldEvent, oldEventId] = createEvent(); const [liveEvent] = createEvent(); - room.addLiveEvents([liveEvent]); + room.addLiveEvents([liveEvent], { addToState: false }); createOldTimeline(room, [oldEvent]); // When we receive a receipt for the live event @@ -120,7 +120,7 @@ describe("RoomReceipts", () => { const room = createRoom(); const [event1] = createEvent(); const [event2, event2Id] = createEvent(); - room.addLiveEvents([event1, event2]); + room.addLiveEvents([event1, event2], { addToState: false }); // When we receive a receipt for the earlier event room.addReceipt(createReceipt(readerId, event1)); @@ -133,7 +133,7 @@ describe("RoomReceipts", () => { // Given my event exists and is unread const room = createRoom(); const [event, eventId] = createEvent(); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); expect(room.hasUserReadEvent(readerId, eventId)).toBe(false); // When we receive a receipt for another user @@ -151,7 +151,7 @@ describe("RoomReceipts", () => { const room = createRoom(); const [previousEvent] = createEvent(); const [myEvent] = createEventSentBy(readerId); - room.addLiveEvents([previousEvent, myEvent]); + room.addLiveEvents([previousEvent, myEvent], { addToState: false }); // And I just received a receipt for the previous event room.addReceipt(createReceipt(readerId, previousEvent)); @@ -165,7 +165,7 @@ describe("RoomReceipts", () => { const room = createRoom(); const [myEvent] = createEventSentBy(readerId); const [laterEvent] = createEvent(); - room.addLiveEvents([myEvent, laterEvent]); + room.addLiveEvents([myEvent, laterEvent], { addToState: false }); // When I ask about the later event, it is unread (because it's after the synthetic receipt) expect(room.hasUserReadEvent(readerId, laterEvent.getId()!)).toBe(false); @@ -177,7 +177,7 @@ describe("RoomReceipts", () => { const [event1] = createEvent(); const [event2, event2Id] = createEvent(); const [event3, event3Id] = createEvent(); - room.addLiveEvents([event1, event2, event3]); + room.addLiveEvents([event1, event2, event3], { addToState: false }); // When we receive receipts for the older events out of order room.addReceipt(createReceipt(readerId, event2)); @@ -192,7 +192,7 @@ describe("RoomReceipts", () => { // Given my event exists and is unread const room = createRoom(); const [event, eventId] = createEvent(); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); expect(room.hasUserReadEvent(readerId, eventId)).toBe(false); // When we receive a receipt for this event+user @@ -208,7 +208,7 @@ describe("RoomReceipts", () => { const [root, rootId] = createEvent(); const [event, eventId] = createThreadedEvent(root); setupThread(room, root); - room.addLiveEvents([root, event]); + room.addLiveEvents([root, event], { addToState: false }); expect(room.hasUserReadEvent(readerId, eventId)).toBe(false); // When we receive a receipt for this event on this thread @@ -225,7 +225,7 @@ describe("RoomReceipts", () => { const [event1, event1Id] = createThreadedEvent(root); const [event2] = createThreadedEvent(root); setupThread(room, root); - room.addLiveEvents([root, event1, event2]); + room.addLiveEvents([root, event1, event2], { addToState: false }); // When we receive a receipt for the later event room.addReceipt(createThreadedReceipt(readerId, event2, rootId)); @@ -241,7 +241,7 @@ describe("RoomReceipts", () => { const [event1] = createThreadedEvent(root); const [event2, event2Id] = createThreadedEvent(root); setupThread(room, root); - room.addLiveEvents([root, event1, event2]); + room.addLiveEvents([root, event1, event2], { addToState: false }); // When we receive a receipt for the earlier event room.addReceipt(createThreadedReceipt(readerId, event1, rootId)); @@ -256,7 +256,7 @@ describe("RoomReceipts", () => { const [root, rootId] = createEvent(); const [event, eventId] = createThreadedEvent(root); setupThread(room, root); - room.addLiveEvents([root, event]); + room.addLiveEvents([root, event], { addToState: false }); expect(room.hasUserReadEvent(readerId, eventId)).toBe(false); // When we receive a receipt for another user @@ -278,7 +278,7 @@ describe("RoomReceipts", () => { const [thread2] = createThreadedEvent(root2); setupThread(room, root1); setupThread(room, root2); - room.addLiveEvents([root1, root2, thread1, thread2]); + room.addLiveEvents([root1, root2, thread1, thread2], { addToState: false }); // When we receive a receipt for the later event room.addReceipt(createThreadedReceipt(readerId, thread2, root2.getId()!)); @@ -295,7 +295,7 @@ describe("RoomReceipts", () => { const [event2, event2Id] = createThreadedEvent(root); const [event3, event3Id] = createThreadedEvent(root); setupThread(room, root); - room.addLiveEvents([root, event1, event2, event3]); + room.addLiveEvents([root, event1, event2, event3], { addToState: false }); // When we receive receipts for the older events out of order room.addReceipt(createThreadedReceipt(readerId, event2, rootId)); @@ -329,7 +329,7 @@ describe("RoomReceipts", () => { const [thread2b, thread2bId] = createThreadedEvent(main2); setupThread(room, main1); setupThread(room, main2); - room.addLiveEvents([main1, thread1a, thread1b, main2, thread2a, main3, thread2b]); + room.addLiveEvents([main1, thread1a, thread1b, main2, thread2a, main3, thread2b], { addToState: false }); // And the timestamps on the events are consistent with the order above main1.event.origin_server_ts = 1; @@ -377,7 +377,7 @@ describe("RoomReceipts", () => { // Add the event to the room // The receipt is removed from the dangling state - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); // Then the event is read expect(room.hasUserReadEvent(readerId, eventId)).toBe(true); @@ -398,7 +398,7 @@ describe("RoomReceipts", () => { // Add the events to the room // The receipt is removed from the dangling state - room.addLiveEvents([root, event]); + room.addLiveEvents([root, event], { addToState: false }); // Then the event is read expect(room.hasUserReadEvent(readerId, eventId)).toBe(true); @@ -418,7 +418,7 @@ describe("RoomReceipts", () => { // Add the event to the room // The two receipts should be processed - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); // Then the event is read // We expect that the receipt of `otherUserId` didn't replace/erase the receipt of `readerId` @@ -528,7 +528,7 @@ function createThreadedReceipt(userId: string, referencedEvent: MatrixEvent, thr */ function createOldTimeline(room: Room, events: MatrixEvent[]) { const oldTimeline = room.getUnfilteredTimelineSet().addTimeline(); - room.getUnfilteredTimelineSet().addEventsToTimeline(events, true, oldTimeline); + room.getUnfilteredTimelineSet().addEventsToTimeline(events, true, false, oldTimeline); } /** diff --git a/spec/unit/models/thread.spec.ts b/spec/unit/models/thread.spec.ts index 38b9959acd2..f7dab002a00 100644 --- a/spec/unit/models/thread.spec.ts +++ b/spec/unit/models/thread.spec.ts @@ -801,7 +801,7 @@ async function createThread(client: MatrixClient, user: string, roomId: string): // Ensure the root is in the room timeline root.setThreadId(root.getId()); - await room.addLiveEvents([root]); + await room.addLiveEvents([root], { addToState: false }); // Create the thread and wait for it to be initialised const thread = room.createThread(root.getId()!, root, [], false); diff --git a/spec/unit/notifications.spec.ts b/spec/unit/notifications.spec.ts index 4c8421dd652..f559e0f2f2c 100644 --- a/spec/unit/notifications.spec.ts +++ b/spec/unit/notifications.spec.ts @@ -106,7 +106,7 @@ describe("fixNotificationCountOnDecryption", () => { mockClient, ); - room.addLiveEvents([event]); + room.addLiveEvents([event], { addToState: false }); THREAD_ID = event.getId()!; threadEvent = mkEvent({ diff --git a/spec/unit/relations.spec.ts b/spec/unit/relations.spec.ts index 98525994008..a8e1c775b5c 100644 --- a/spec/unit/relations.spec.ts +++ b/spec/unit/relations.spec.ts @@ -198,8 +198,8 @@ describe("Relations", function () { }); const timelineSet = new EventTimelineSet(room); - timelineSet.addLiveEvent(targetEvent); - timelineSet.addLiveEvent(relationEvent); + timelineSet.addLiveEvent(targetEvent, { addToState: false }); + timelineSet.addLiveEvent(relationEvent, { addToState: false }); await relationsCreated; } @@ -212,8 +212,8 @@ describe("Relations", function () { }); const timelineSet = new EventTimelineSet(room); - timelineSet.addLiveEvent(relationEvent); - timelineSet.addLiveEvent(targetEvent); + timelineSet.addLiveEvent(relationEvent, { addToState: false }); + timelineSet.addLiveEvent(targetEvent, { addToState: false }); await relationsCreated; } diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 542bbcb2536..1f3efc0bf57 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -29,6 +29,9 @@ import { EventStatus, EventTimelineSet, EventType, + Filter, + FILTER_RELATED_BY_REL_TYPES, + FILTER_RELATED_BY_SENDERS, IContent, IEvent, IRelationsRequestOpts, @@ -175,7 +178,7 @@ describe("Room", function () { */ const mkMessageInRoom = async (room: Room, timestamp: number) => { const message = mkMessage({ ts: timestamp }); - await room.addLiveEvents([message]); + await room.addLiveEvents([message], { addToState: false }); return message; }; @@ -188,7 +191,7 @@ describe("Room", function () { */ const mkMessageInThread = (thread: Thread, timestamp: number) => { const message = mkThreadResponse(thread.rootEvent!, { ts: timestamp }); - thread.liveTimeline.addEvent(message, { toStartOfTimeline: false }); + thread.liveTimeline.addEvent(message, { toStartOfTimeline: false, addToState: false }); return message; }; @@ -202,14 +205,14 @@ describe("Room", function () { if (thread1EventTs !== null) { const { rootEvent: thread1RootEvent, thread: thread1 } = mkThread({ room }); const thread1Event = mkThreadResponse(thread1RootEvent, { ts: thread1EventTs }); - thread1.liveTimeline.addEvent(thread1Event, { toStartOfTimeline: true }); + thread1.liveTimeline.addEvent(thread1Event, { toStartOfTimeline: true, addToState: false }); result.thread1 = thread1; } if (thread2EventTs !== null) { const { rootEvent: thread2RootEvent, thread: thread2 } = mkThread({ room }); const thread2Event = mkThreadResponse(thread2RootEvent, { ts: thread2EventTs }); - thread2.liveTimeline.addEvent(thread2Event, { toStartOfTimeline: true }); + thread2.liveTimeline.addEvent(thread2Event, { toStartOfTimeline: true, addToState: false }); result.thread2 = thread2; } @@ -347,10 +350,11 @@ describe("Room", function () { event: true, }); dupe.event.event_id = events[0].getId(); - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); expect(room.timeline[0]).toEqual(events[0]); await room.addLiveEvents([dupe], { duplicateStrategy: DuplicateStrategy.Replace, + addToState: false, }); expect(room.timeline[0]).toEqual(dupe); }); @@ -364,7 +368,7 @@ describe("Room", function () { event: true, }); dupe.event.event_id = events[0].getId(); - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); expect(room.timeline[0]).toEqual(events[0]); // @ts-ignore await room.addLiveEvents([dupe], { @@ -382,7 +386,7 @@ describe("Room", function () { expect(emitRoom).toEqual(room); expect(toStart).toBeFalsy(); }); - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); expect(callCount).toEqual(2); }); @@ -405,7 +409,7 @@ describe("Room", function () { }, }), ]; - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: true }); expect(room.currentState.setStateEvents).toHaveBeenCalledWith([events[0]], { timelineWasEmpty: false }); expect(room.currentState.setStateEvents).toHaveBeenCalledWith([events[1]], { timelineWasEmpty: false }); expect(events[0].forwardLooking).toBe(true); @@ -425,7 +429,7 @@ describe("Room", function () { } return null; }); - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); expect(room.getEventReadUpTo(userA)).toEqual(events[1].getId()); }); @@ -460,7 +464,7 @@ describe("Room", function () { expect(stub.mock.calls[0][3]).toBeUndefined(); // then the remoteEvent - await room.addLiveEvents([remoteEvent]); + await room.addLiveEvents([remoteEvent], { addToState: false }); expect(room.timeline.length).toEqual(1); expect(stub).toHaveBeenCalledTimes(2); @@ -498,7 +502,7 @@ describe("Room", function () { // then /sync returns the remoteEvent, it should de-dupe based on the event ID. const remoteEvent = new MatrixEvent(Object.assign({ event_id: realEventId }, eventJson)); expect(remoteEvent.getTxnId()).toBeUndefined(); - await room.addLiveEvents([remoteEvent]); + await room.addLiveEvents([remoteEvent], { addToState: false }); // the duplicate strategy code should ensure we don't add a 2nd event to the live timeline expect(room.timeline.length).toEqual(1); // but without the event ID matching we will still have the local event in pending events @@ -528,7 +532,7 @@ describe("Room", function () { const realEventId = "$real-event-id"; const remoteEvent = new MatrixEvent(Object.assign({ event_id: realEventId }, eventJson)); expect(remoteEvent.getUnsigned().transaction_id).toBeUndefined(); - await room.addLiveEvents([remoteEvent]); + await room.addLiveEvents([remoteEvent], { addToState: false }); expect(room.timeline.length).toEqual(2); // impossible to de-dupe as no txn ID or matching event ID // then the /send request returns the real event ID. @@ -550,7 +554,7 @@ describe("Room", function () { remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; // add the remoteEvent - await room.addLiveEvents([remoteEvent]); + await room.addLiveEvents([remoteEvent], { addToState: false }); expect(room.timeline.length).toEqual(1); }); }); @@ -589,12 +593,12 @@ describe("Room", function () { it("should not be able to add events to the end", function () { expect(function () { - room.addEventsToTimeline(events, false, room.getLiveTimeline()); + room.addEventsToTimeline(events, false, false, room.getLiveTimeline()); }).toThrow(); }); it("should be able to add events to the start", function () { - room.addEventsToTimeline(events, true, room.getLiveTimeline()); + room.addEventsToTimeline(events, true, false, room.getLiveTimeline()); expect(room.timeline.length).toEqual(2); expect(room.timeline[0]).toEqual(events[1]); expect(room.timeline[1]).toEqual(events[0]); @@ -609,7 +613,7 @@ describe("Room", function () { expect(emitRoom).toEqual(room); expect(toStart).toBe(true); }); - room.addEventsToTimeline(events, true, room.getLiveTimeline()); + room.addEventsToTimeline(events, true, false, room.getLiveTimeline()); expect(callCount).toEqual(2); }); }); @@ -653,9 +657,9 @@ describe("Room", function () { event: true, content: { name: "Old Room Name" }, }); - await room.addLiveEvents([newEv]); + await room.addLiveEvents([newEv], { addToState: false }); expect(newEv.sender).toEqual(sentinel); - room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); + room.addEventsToTimeline([oldEv], true, false, room.getLiveTimeline()); expect(oldEv.sender).toEqual(oldSentinel); }); @@ -697,9 +701,9 @@ describe("Room", function () { skey: userA, event: true, }); - await room.addLiveEvents([newEv]); + await room.addLiveEvents([newEv], { addToState: false }); expect(newEv.target).toEqual(sentinel); - room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); + room.addEventsToTimeline([oldEv], true, false, room.getLiveTimeline()); expect(oldEv.target).toEqual(oldSentinel); }); @@ -725,7 +729,7 @@ describe("Room", function () { }), ]; - room.addEventsToTimeline(events, true, room.getLiveTimeline()); + room.addEventsToTimeline(events, true, 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); @@ -767,11 +771,11 @@ describe("Room", function () { }); it("should copy state from previous timeline", async function () { - await room.addLiveEvents([events[0], events[1]]); + await room.addLiveEvents([events[0], events[1]], { addToState: false }); expect(room.getLiveTimeline().getEvents().length).toEqual(2); room.resetLiveTimeline("sometoken", "someothertoken"); - await room.addLiveEvents([events[2]]); + await room.addLiveEvents([events[2]], { addToState: false }); const oldState = room.getLiveTimeline().getState(EventTimeline.BACKWARDS); const newState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); expect(room.getLiveTimeline().getEvents().length).toEqual(1); @@ -780,7 +784,7 @@ describe("Room", function () { }); it("should reset the legacy timeline fields", async function () { - await room.addLiveEvents([events[0], events[1]]); + await room.addLiveEvents([events[0], events[1]], { addToState: false }); expect(room.timeline.length).toEqual(2); const oldStateBeforeRunningReset = room.oldState; @@ -801,7 +805,7 @@ describe("Room", function () { room.resetLiveTimeline("sometoken", "someothertoken"); - await room.addLiveEvents([events[2]]); + await room.addLiveEvents([events[2]], { addToState: false }); const newLiveTimeline = room.getLiveTimeline(); expect(room.timeline).toEqual(newLiveTimeline.getEvents()); expect(room.oldState).toEqual(newLiveTimeline.getState(EventTimeline.BACKWARDS)); @@ -828,7 +832,7 @@ describe("Room", function () { }); it("should " + (timelineSupport ? "remember" : "forget") + " old timelines", async function () { - await room.addLiveEvents([events[0]]); + await room.addLiveEvents([events[0]], { addToState: false }); expect(room.timeline.length).toEqual(1); const firstLiveTimeline = room.getLiveTimeline(); room.resetLiveTimeline("sometoken", "someothertoken"); @@ -872,7 +876,7 @@ describe("Room", function () { ]; it("should handle events in the same timeline", async function () { - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); expect( room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!), @@ -890,8 +894,8 @@ describe("Room", function () { oldTimeline.setNeighbouringTimeline(room.getLiveTimeline(), Direction.Forward); room.getLiveTimeline().setNeighbouringTimeline(oldTimeline, Direction.Backward); - room.addEventsToTimeline([events[0]], false, oldTimeline); - await room.addLiveEvents([events[1]]); + room.addEventsToTimeline([events[0]], false, false, oldTimeline); + await room.addLiveEvents([events[1]], { addToState: false }); expect( room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!), @@ -904,8 +908,8 @@ describe("Room", function () { it("should return null for events in non-adjacent timelines", async function () { const oldTimeline = room.addTimeline(); - room.addEventsToTimeline([events[0]], false, oldTimeline); - await room.addLiveEvents([events[1]]); + room.addEventsToTimeline([events[0]], false, false, oldTimeline); + await room.addLiveEvents([events[1]], { addToState: false }); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!)).toBe( null, @@ -916,7 +920,7 @@ describe("Room", function () { }); it("should return null for unknown events", async function () { - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, "xxx")).toBe(null); expect(room.getUnfilteredTimelineSet().compareEventOrdering("xxx", events[0].getId()!)).toBe(null); @@ -994,54 +998,66 @@ describe("Room", function () { describe("recalculate", function () { const setJoinRule = async function (rule: JoinRule) { - await room.addLiveEvents([ - utils.mkEvent({ - type: EventType.RoomJoinRules, - room: roomId, - user: userA, - content: { - join_rule: rule, - }, - event: true, - }), - ]); + await room.addLiveEvents( + [ + utils.mkEvent({ + type: EventType.RoomJoinRules, + room: roomId, + user: userA, + content: { + join_rule: rule, + }, + event: true, + }), + ], + { addToState: true }, + ); }; const setAltAliases = async function (aliases: string[]) { - await room.addLiveEvents([ - utils.mkEvent({ - type: EventType.RoomCanonicalAlias, - room: roomId, - skey: "", - content: { - alt_aliases: aliases, - }, - event: true, - }), - ]); + await room.addLiveEvents( + [ + utils.mkEvent({ + type: EventType.RoomCanonicalAlias, + room: roomId, + skey: "", + content: { + alt_aliases: aliases, + }, + event: true, + }), + ], + { addToState: true }, + ); }; const setAlias = async function (alias: string) { - await room.addLiveEvents([ - utils.mkEvent({ - type: EventType.RoomCanonicalAlias, - room: roomId, - skey: "", - content: { alias }, - event: true, - }), - ]); + await room.addLiveEvents( + [ + utils.mkEvent({ + type: EventType.RoomCanonicalAlias, + room: roomId, + skey: "", + content: { alias }, + event: true, + }), + ], + { addToState: true }, + ); }; const setRoomName = async function (name: string) { - await room.addLiveEvents([ - utils.mkEvent({ - type: EventType.RoomName, - room: roomId, - user: userA, - content: { - name: name, - }, - event: true, - }), - ]); + await room.addLiveEvents( + [ + utils.mkEvent({ + type: EventType.RoomName, + room: roomId, + user: userA, + content: { + name: name, + }, + event: true, + }), + ], + { addToState: true }, + ); }; const addMember = async function (userId: string, state = KnownMembership.Join, opts: any = {}) { opts.room = roomId; @@ -1050,7 +1066,7 @@ describe("Room", function () { opts.skey = userId; opts.event = true; const event = utils.mkMembership(opts); - await room.addLiveEvents([event]); + await room.addLiveEvents([event], { addToState: true }); return event; }; @@ -1658,7 +1674,7 @@ describe("Room", function () { }), ]; - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); const ts = 13787898424; // check it initialises correctly @@ -1696,7 +1712,7 @@ describe("Room", function () { }), ]; - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); const ts = 13787898424; // check it initialises correctly @@ -1727,7 +1743,7 @@ describe("Room", function () { describe("hasUserReadUpTo", function () { it("returns true if there is a receipt for this event (main timeline)", function () { const ts = 13787898424; - room.addLiveEvents([eventToAck]); + room.addLiveEvents([eventToAck], { addToState: false }); room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)])); room.findEventById = jest.fn().mockReturnValue({ getThread: jest.fn() } as unknown as MatrixEvent); expect(room.hasUserReadEvent(userB, eventToAck.getId()!)).toEqual(true); @@ -1755,7 +1771,7 @@ describe("Room", function () { event: true, }), ]; - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); // When I add a receipt for the latest one room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)])); @@ -1778,7 +1794,7 @@ describe("Room", function () { // Given a thread exists in the room const { thread, events } = mkThread({ room, length: 3 }); thread.initialEventsFetched = true; - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); // When I add an unthreaded receipt for the latest thread message room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)])); @@ -1857,9 +1873,9 @@ describe("Room", function () { msg: "remote 2", event: true, }); - await room.addLiveEvents([eventA]); + await room.addLiveEvents([eventA], { addToState: false }); room.addPendingEvent(eventB, "TXN1"); - await room.addLiveEvents([eventC]); + await room.addLiveEvents([eventC], { addToState: false }); expect(room.timeline).toEqual([eventA, eventC]); expect(room.getPendingEvents()).toEqual([eventB]); }, @@ -1890,9 +1906,9 @@ describe("Room", function () { msg: "remote 2", event: true, }); - await room.addLiveEvents([eventA]); + await room.addLiveEvents([eventA], { addToState: false }); room.addPendingEvent(eventB, "TXN1"); - await room.addLiveEvents([eventC]); + await room.addLiveEvents([eventC], { addToState: false }); expect(room.timeline).toEqual([eventA, eventB, eventC]); }, ); @@ -2169,14 +2185,17 @@ describe("Room", function () { }); it("should return first member that isn't self", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + }), + ], + { addToState: true }, + ); expect(room.guessDMUserId()).toEqual(userB); }); it("should return self if only member present", function () { @@ -2307,148 +2326,166 @@ describe("Room", function () { it("should return a display name if one other member is in the room", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); it("should return a display name if one other member is banned", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Ban, - room: roomId, - event: true, - name: "User B", - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Ban, + room: roomId, + event: true, + name: "User B", + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); it("should return a display name if one other member is invited", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Invite, - room: roomId, - event: true, - name: "User B", - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Invite, + room: roomId, + event: true, + name: "User B", + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); it("should return 'Empty room (was User B)' if User B left the room", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Leave, - room: roomId, - event: true, - name: "User B", - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Leave, + room: roomId, + event: true, + name: "User B", + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); it("should return 'User B and User C' if in a room with two other users", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - utils.mkMembership({ - user: userC, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User C", - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + utils.mkMembership({ + user: userC, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User C", + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("User B and User C"); }); it("should return 'User B and 2 others' if in a room with three other users", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - utils.mkMembership({ - user: userC, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User C", - }), - utils.mkMembership({ - user: userD, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User D", - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + utils.mkMembership({ + user: userC, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User C", + }), + utils.mkMembership({ + user: userD, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User D", + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("User B and 2 others"); }); }); @@ -2456,228 +2493,249 @@ describe("Room", function () { describe("io.element.functional_users", function () { it("should return a display name (default behaviour) if no one is marked as a functional member", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, - skey: "", - room: roomId, - event: true, - content: { - service_members: [], - }, - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + content: { + service_members: [], + }, + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); it("should return a display name (default behaviour) if service members is a number (invalid)", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, - skey: "", - room: roomId, - event: true, - content: { - service_members: 1, - }, - }), - ]); - expect(room.getDefaultRoomName(userA)).toEqual("User B"); - }); - - it("should return a display name (default behaviour) if service members is a string (invalid)", async function () { - const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, - skey: "", - room: roomId, - event: true, - content: { - service_members: userB, - }, - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + content: { + service_members: 1, + }, + }), + ], + { addToState: true }, + ); + expect(room.getDefaultRoomName(userA)).toEqual("User B"); + }); + + it("should return a display name (default behaviour) if service members is a string (invalid)", async function () { + const room = new Room(roomId, new TestClient(userA).client, userA); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + content: { + service_members: userB, + }, + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); it("should return 'Empty room' if the only other member is a functional member", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, - skey: "", - room: roomId, - event: true, - content: { - service_members: [userB], - }, - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + content: { + service_members: [userB], + }, + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); it("should return 'User B' if User B is the only other member who isn't a functional member", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - utils.mkMembership({ - user: userC, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User C", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, - skey: "", - room: roomId, - event: true, - user: userA, - content: { - service_members: [userC], - }, - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + utils.mkMembership({ + user: userC, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User C", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + user: userA, + content: { + service_members: [userC], + }, + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); it("should return 'Empty room' if all other members are functional members", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - utils.mkMembership({ - user: userC, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User C", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, - skey: "", - room: roomId, - event: true, - user: userA, - content: { - service_members: [userB, userC], - }, - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + utils.mkMembership({ + user: userC, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User C", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + user: userA, + content: { + service_members: [userB, userC], + }, + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); it("should not break if an unjoined user is marked as a service user", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - await room.addLiveEvents([ - utils.mkMembership({ - user: userA, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User A", - }), - utils.mkMembership({ - user: userB, - mship: KnownMembership.Join, - room: roomId, - event: true, - name: "User B", - }), - utils.mkEvent({ - type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, - skey: "", - room: roomId, - event: true, - user: userA, - content: { - service_members: [userC], - }, - }), - ]); + await room.addLiveEvents( + [ + utils.mkMembership({ + user: userA, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User A", + }), + utils.mkMembership({ + user: userB, + mship: KnownMembership.Join, + room: roomId, + event: true, + name: "User B", + }), + utils.mkEvent({ + type: UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, + skey: "", + room: roomId, + event: true, + user: userA, + content: { + service_members: [userC], + }, + }), + ], + { addToState: true }, + ); expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); }); @@ -2783,7 +2841,7 @@ describe("Room", function () { }); const prom = emitPromise(room, ThreadEvent.New); - await room.addLiveEvents([randomMessage, threadRoot, threadResponse]); + await room.addLiveEvents([randomMessage, threadRoot, threadResponse], { addToState: false }); const thread: Thread = await prom; await emitPromise(room, ThreadEvent.Update); @@ -2812,7 +2870,10 @@ describe("Room", function () { // XXX: If we add the relation to the thread response before the thread finishes fetching via /relations // then the test will fail await emitPromise(room, ThreadEvent.Update); - await Promise.all([emitPromise(room, ThreadEvent.Update), room.addLiveEvents([threadResponseEdit])]); + await Promise.all([ + emitPromise(room, ThreadEvent.Update), + room.addLiveEvents([threadResponseEdit], { addToState: false }), + ]); expect(thread.replyToEvent!.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); }); @@ -2823,12 +2884,12 @@ describe("Room", function () { const threadRoot = mkMessage(); const threadResponse1 = mkThreadResponse(threadRoot); - await room.addLiveEvents([threadRoot]); + await room.addLiveEvents([threadRoot], { addToState: false }); const onEvent = jest.fn(); room.on(RoomEvent.Timeline, onEvent); - await room.addLiveEvents([threadResponse1]); + await room.addLiveEvents([threadResponse1], { addToState: false }); expect(onEvent).toHaveBeenCalled(); }); @@ -2841,7 +2902,7 @@ describe("Room", function () { const threadResponse1 = mkThreadResponse(threadRoot); const newThreadEventPromise = emitPromise(room, ThreadEvent.New); - await room.addLiveEvents([threadRoot, threadResponse1]); + await room.addLiveEvents([threadRoot, threadResponse1], { addToState: false }); const thread = await newThreadEventPromise; expect(thread.timeline).toContain(threadResponse1); @@ -2873,7 +2934,7 @@ describe("Room", function () { }); let prom = emitPromise(room, ThreadEvent.New); - await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); + await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2], { addToState: false }); const thread = await prom; expect(thread).toHaveLength(2); @@ -2907,7 +2968,7 @@ describe("Room", function () { prom = emitPromise(thread, ThreadEvent.Update); const threadResponse1Redaction = mkRedaction(threadResponse1); - await room.addLiveEvents([threadResponse1Redaction]); + await room.addLiveEvents([threadResponse1Redaction], { addToState: false }); await prom; expect(thread).toHaveLength(1); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); @@ -2944,7 +3005,9 @@ describe("Room", function () { }); const prom = emitPromise(room, ThreadEvent.New); - await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); + await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction], { + addToState: false, + }); const thread = await prom; await emitPromise(room, ThreadEvent.Update); @@ -2952,7 +3015,7 @@ describe("Room", function () { expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); const threadResponse2ReactionRedaction = mkRedaction(threadResponse2Reaction); - await room.addLiveEvents([threadResponse2ReactionRedaction]); + await room.addLiveEvents([threadResponse2ReactionRedaction], { addToState: false }); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); }); @@ -2984,14 +3047,16 @@ describe("Room", function () { }); const prom = emitPromise(room, ThreadEvent.New); - await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); + await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction], { + addToState: false, + }); const thread = await prom; expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); const threadRootRedaction = mkRedaction(threadRoot); - await room.addLiveEvents([threadRootRedaction]); + await room.addLiveEvents([threadRootRedaction], { addToState: false }); // We can't wait for a thread update here because there shouldn't be one (which is // what we're asserting). Flush any promises to try to get more certainty that an @@ -3048,12 +3113,12 @@ describe("Room", function () { }); let prom = emitPromise(room, ThreadEvent.New); - await room.addLiveEvents([threadRoot, threadResponse1]); + await room.addLiveEvents([threadRoot, threadResponse1], { addToState: false }); const thread: Thread = await prom; await emitPromise(room, ThreadEvent.Update); expect(thread.initialEventsFetched).toBeTruthy(); - await room.addLiveEvents([threadResponse2]); + await room.addLiveEvents([threadResponse2], { addToState: false }); expect(thread).toHaveLength(2); expect(thread.replyToEvent!.getId()).toBe(threadResponse2.getId()); @@ -3074,7 +3139,7 @@ describe("Room", function () { await emitPromise(room, ThreadEvent.Update); const threadResponse2Redaction = mkRedaction(threadResponse2); - await room.addLiveEvents([threadResponse2Redaction]); + await room.addLiveEvents([threadResponse2Redaction], { addToState: false }); expect(thread).toHaveLength(1); expect(thread.replyToEvent!.getId()).toBe(threadResponse1.getId()); @@ -3096,20 +3161,43 @@ describe("Room", function () { prom = emitPromise(room, ThreadEvent.Delete); const prom2 = emitPromise(room, RoomEvent.Timeline); const threadResponse1Redaction = mkRedaction(threadResponse1); - await room.addLiveEvents([threadResponse1Redaction]); + await room.addLiveEvents([threadResponse1Redaction], { addToState: false }); await prom; await prom2; expect(thread).toHaveLength(0); expect(thread.replyToEvent!.getId()).toBe(threadRoot.getId()); }); + + it("should add event to thread without server side support", async () => { + room.client.supportsThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.None); + + const threadRoot = mkMessage(); + const threadResponse1 = mkThreadResponse(threadRoot); + threadResponse1.getContent()["m.relates_to"]!.rel_type = "io.element.thread"; + + const thread = room.createThread(threadRoot.getId()!, threadRoot, [threadResponse1], false)!; + + expect(thread.events).toContain(threadResponse1); + }); + + afterAll(() => { + // Clear the latch created by `Thread.setServerSideSupport(FeatureSupport.None);` + FILTER_RELATED_BY_SENDERS.setPreferUnstable(false); + FILTER_RELATED_BY_REL_TYPES.setPreferUnstable(false); + THREAD_RELATION_TYPE.setPreferUnstable(false); + }); }); describe("eventShouldLiveIn", () => { const client = new TestClient(userA).client; - client.supportsThreads = () => true; - Thread.setServerSideSupport(FeatureSupport.Stable); const room = new Room(roomId, client, userA); + beforeEach(() => { + client.supportsThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); + }); + it("thread root and its relations&redactions should be in main timeline", () => { const randomMessage = mkMessage(); const threadRoot = mkMessage(); @@ -3258,7 +3346,7 @@ describe("Room", function () { const events = [threadRoot, rootReaction, threadResponse, threadReaction]; const prom = emitPromise(room, ThreadEvent.New); - await room.addLiveEvents(events); + await room.addLiveEvents(events, { addToState: false }); const thread = await prom; expect(thread).toBe(threadRoot.getThread()); expect(thread.rootEvent).toBe(threadRoot); @@ -3893,22 +3981,22 @@ describe("Room", function () { it("Returns null if the create event has no predecessor", async () => { const room = new Room("roomid", client!, "@u:example.com"); - await room.addLiveEvents([roomCreateEvent("roomid", null)]); + await room.addLiveEvents([roomCreateEvent("roomid", null)], { addToState: true }); expect(room.findPredecessor()).toBeNull(); }); it("Returns the predecessor ID if one is provided via create event", async () => { const room = new Room("roomid", client!, "@u:example.com"); - await room.addLiveEvents([roomCreateEvent("roomid", "replacedroomid")]); + await room.addLiveEvents([roomCreateEvent("roomid", "replacedroomid")], { addToState: true }); expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" }); }); it("Prefers the m.predecessor event if one exists", async () => { const room = new Room("roomid", client!, "@u:example.com"); - await room.addLiveEvents([ - roomCreateEvent("roomid", "replacedroomid"), - predecessorEvent("roomid", "otherreplacedroomid"), - ]); + await room.addLiveEvents( + [roomCreateEvent("roomid", "replacedroomid"), predecessorEvent("roomid", "otherreplacedroomid")], + { addToState: true }, + ); const useMsc3946 = true; expect(room.findPredecessor(useMsc3946)).toEqual({ roomId: "otherreplacedroomid", @@ -3919,10 +4007,16 @@ describe("Room", function () { it("uses the m.predecessor event ID if provided", async () => { const room = new Room("roomid", client!, "@u:example.com"); - await room.addLiveEvents([ - roomCreateEvent("roomid", "replacedroomid"), - predecessorEvent("roomid", "otherreplacedroomid", "lstevtid", ["one.example.com", "two.example.com"]), - ]); + await room.addLiveEvents( + [ + roomCreateEvent("roomid", "replacedroomid"), + predecessorEvent("roomid", "otherreplacedroomid", "lstevtid", [ + "one.example.com", + "two.example.com", + ]), + ], + { addToState: true }, + ); const useMsc3946 = true; expect(room.findPredecessor(useMsc3946)).toEqual({ roomId: "otherreplacedroomid", @@ -3933,10 +4027,10 @@ describe("Room", function () { it("Ignores the m.predecessor event if we don't ask to use it", async () => { const room = new Room("roomid", client!, "@u:example.com"); - await room.addLiveEvents([ - roomCreateEvent("roomid", "replacedroomid"), - predecessorEvent("roomid", "otherreplacedroomid"), - ]); + await room.addLiveEvents( + [roomCreateEvent("roomid", "replacedroomid"), predecessorEvent("roomid", "otherreplacedroomid")], + { addToState: true }, + ); // Don't provide an argument for msc3946ProcessDynamicPredecessor - // we should ignore the predecessor event. expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" }); @@ -3944,10 +4038,13 @@ describe("Room", function () { it("Ignores the m.predecessor event and returns null if we don't ask to use it", async () => { const room = new Room("roomid", client!, "@u:example.com"); - await room.addLiveEvents([ - roomCreateEvent("roomid", null), // Create event has no predecessor - predecessorEvent("roomid", "otherreplacedroomid", "lastevtid"), - ]); + await room.addLiveEvents( + [ + roomCreateEvent("roomid", null), // Create event has no predecessor + predecessorEvent("roomid", "otherreplacedroomid", "lastevtid"), + ], + { addToState: true }, + ); // Don't provide an argument for msc3946ProcessDynamicPredecessor - // we should ignore the predecessor event. expect(room.findPredecessor()).toBeNull(); @@ -4077,4 +4174,64 @@ describe("Room", function () { expect(client.fetchCapabilities).toHaveBeenCalled(); }); }); + + describe("getOrCreateFilteredTimelineSet", () => { + it("should locally filter events if prepopulateTimeline=true", () => { + room.addLiveEvents( + [ + utils.mkEvent( + { + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + body: "ev1", + }, + }, + room.client, + ), + utils.mkEvent( + { + event: true, + type: "custom.event.type", + user: userA, + room: roomId, + content: { + body: "ev2", + }, + }, + room.client, + ), + utils.mkEvent( + { + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + body: "ev3", + }, + }, + room.client, + ), + ], + { + addToState: false, + }, + ); + + const filter = Filter.fromJson(room.client.getUserId(), "filterId", { + room: { + timeline: { + types: ["custom.event.type"], + }, + }, + }); + const timelineSet = room.getOrCreateFilteredTimelineSet(filter, { prepopulateTimeline: true }); + const filteredEvents = timelineSet.getLiveTimeline().getEvents(); + expect(filteredEvents).toHaveLength(1); + expect(filteredEvents[0].getContent().body).toEqual("ev2"); + }); + }); }); diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 5769b50fb8a..8ece656e85a 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -17,8 +17,10 @@ limitations under the License. import { ReceiptType } from "../../src/@types/read_receipts"; import { - IJoinedRoom, + Category, IInvitedRoom, + IInviteState, + IJoinedRoom, IKnockedRoom, IKnockState, ILeftRoom, @@ -27,7 +29,6 @@ import { IStrippedState, ISyncResponse, SyncAccumulator, - IInviteState, } from "../../src/sync-accumulator"; import { IRoomSummary } from "../../src"; import * as utils from "../test-utils/test-utils"; @@ -85,6 +86,7 @@ describe("SyncAccumulator", function () { // technically cheating since we also cheekily pre-populate keys we // know that the sync accumulator will pre-populate. // It isn't 100% transitive. + const events = [member("alice", KnownMembership.Join), member("bob", KnownMembership.Join)]; const res = { next_batch: "abc", rooms: { @@ -92,18 +94,17 @@ describe("SyncAccumulator", function () { leave: {}, join: { "!foo:bar": { - account_data: { events: [] }, - ephemeral: { events: [] }, - unread_notifications: {}, - state: { - events: [member("alice", KnownMembership.Join), member("bob", KnownMembership.Join)], - }, - summary: { + "account_data": { events: [] }, + "ephemeral": { events: [] }, + "unread_notifications": {}, + "org.matrix.msc4222.state_after": { events }, + "state": { events }, + "summary": { "m.heroes": undefined, "m.joined_member_count": undefined, "m.invited_member_count": undefined, }, - timeline: { + "timeline": { events: [msg("alice", "hi")], prev_batch: "something", }, @@ -882,6 +883,147 @@ describe("SyncAccumulator", function () { ).not.toBeUndefined(); }); }); + + describe("msc4222", () => { + it("should accumulate state_after events", () => { + const initState = { + events: [member("alice", KnownMembership.Knock)], + }; + sa.accumulate( + syncSkeleton({ + "org.matrix.msc4222.state_after": initState, + }), + ); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].state).toEqual(initState); + + sa.accumulate( + syncSkeleton({ + "org.matrix.msc4222.state_after": { + events: [ + utils.mkEvent({ + user: "alice", + room: "!knock:bar", + type: "m.room.name", + content: { + name: "Room 1", + }, + skey: "", + }) as IStateEvent, + ], + }, + }), + ); + + expect( + sa.getJSON().roomsData[Category.Join]["!foo:bar"].state?.events.find((e) => e.type === "m.room.name") + ?.content.name, + ).toEqual("Room 1"); + + sa.accumulate( + syncSkeleton({ + "org.matrix.msc4222.state_after": { + events: [ + utils.mkEvent({ + user: "alice", + room: "!knock:bar", + type: "m.room.name", + content: { + name: "Room 2", + }, + skey: "", + }) as IStateEvent, + ], + }, + }), + ); + + expect( + sa.getJSON().roomsData[Category.Join]["!foo:bar"].state?.events.find((e) => e.type === "m.room.name") + ?.content.name, + ).toEqual("Room 2"); + }); + + it("should ignore state events in timeline", () => { + const initState = { + events: [member("alice", KnownMembership.Knock)], + }; + sa.accumulate( + syncSkeleton({ + "org.matrix.msc4222.state_after": initState, + }), + ); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].state).toEqual(initState); + + sa.accumulate( + syncSkeleton({ + "org.matrix.msc4222.state_after": { + events: [], + }, + "timeline": { + events: [ + utils.mkEvent({ + user: "alice", + room: "!knock:bar", + type: "m.room.name", + content: { + name: "Room 1", + }, + skey: "", + }) as IStateEvent, + ], + prev_batch: "something", + }, + }), + ); + + expect( + sa.getJSON().roomsData[Category.Join]["!foo:bar"].state?.events.find((e) => e.type === "m.room.name") + ?.content.name, + ).not.toEqual("Room 1"); + }); + + it("should not rewind state_after to start of timeline in toJSON", () => { + const initState = { + events: [member("alice", KnownMembership.Knock)], + }; + sa.accumulate( + syncSkeleton({ + "org.matrix.msc4222.state_after": initState, + "timeline": { + events: initState.events, + prev_batch: null, + }, + }), + ); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].state).toEqual(initState); + + const joinEvent = member("alice", KnownMembership.Join); + joinEvent.unsigned = { prev_content: initState.events[0].content, prev_sender: initState.events[0].sender }; + sa.accumulate( + syncSkeleton({ + "org.matrix.msc4222.state_after": { + events: [joinEvent], + }, + "timeline": { + events: [joinEvent], + prev_batch: "something", + }, + }), + ); + + const roomData = sa.getJSON().roomsData[Category.Join]["!foo:bar"]; + expect(roomData.state?.events.find((e) => e.type === "m.room.member")?.content.membership).toEqual( + KnownMembership.Knock, + ); + expect( + roomData["org.matrix.msc4222.state_after"]?.events.find((e) => e.type === "m.room.member")?.content + .membership, + ).toEqual(KnownMembership.Join); + expect(roomData.timeline?.events.find((e) => e.type === "m.room.member")?.content.membership).toEqual( + KnownMembership.Join, + ); + }); + }); }); function syncSkeleton( @@ -961,5 +1103,6 @@ function member(localpart: string, membership: Membership) { state_key: "@" + localpart + ":localhost", sender: "@" + localpart + ":localhost", type: "m.room.member", + unsigned: {}, }; } diff --git a/spec/unit/timeline-window.spec.ts b/spec/unit/timeline-window.spec.ts index a428e905a2f..be52abe86be 100644 --- a/spec/unit/timeline-window.spec.ts +++ b/spec/unit/timeline-window.spec.ts @@ -62,7 +62,7 @@ function addEventsToTimeline(timeline: EventTimeline, numEvents: number, toStart user: USER_ID, event: true, }), - { toStartOfTimeline }, + { toStartOfTimeline, addToState: false }, ); } } @@ -451,8 +451,8 @@ describe("TimelineWindow", function () { const liveEvents = createEvents(5); const [, , e3, e4, e5] = oldEvents; const [, e7, e8, e9, e10] = liveEvents; - room.addLiveEvents(liveEvents); - room.addEventsToTimeline(oldEvents, true, oldTimeline); + room.addLiveEvents(liveEvents, { addToState: false }); + room.addEventsToTimeline(oldEvents, true, false, oldTimeline); // And 2 windows over the timelines in this room const oldWindow = new TimelineWindow(mockClient, timelineSet); diff --git a/spec/unit/webrtc/groupCall.spec.ts b/spec/unit/webrtc/groupCall.spec.ts index 286f5d1011e..dcdd8600830 100644 --- a/spec/unit/webrtc/groupCall.spec.ts +++ b/spec/unit/webrtc/groupCall.spec.ts @@ -1566,16 +1566,19 @@ describe("Group Call", function () { async (roomId, eventType, content, stateKey) => { const eventId = `$${Math.random()}`; if (roomId === room.roomId) { - room.addLiveEvents([ - new MatrixEvent({ - event_id: eventId, - type: eventType, - room_id: roomId, - sender: FAKE_USER_ID_2, - content, - state_key: stateKey, - }), - ]); + room.addLiveEvents( + [ + new MatrixEvent({ + event_id: eventId, + type: eventType, + room_id: roomId, + sender: FAKE_USER_ID_2, + content, + state_key: stateKey, + }), + ], + { addToState: true }, + ); } return { event_id: eventId }; }, diff --git a/src/client.ts b/src/client.ts index 1bd3fb3f3a3..2e72dcf4353 100644 --- a/src/client.ts +++ b/src/client.ts @@ -6136,7 +6136,7 @@ export class MatrixClient extends TypedEventEmitter room.relations.aggregateChildEvent(event)); @@ -6248,7 +6248,7 @@ export class MatrixClient extends TypedEventEmitter new MatrixEvent(rawEvent as Partial)); - await this.syncApi!.injectRoomEvents(this.room!, [], events); + if (this.syncApi instanceof SyncApi) { + // Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode + // -> state events in `timelineEventList` will update the state. + await this.syncApi.injectRoomEvents(this.room!, undefined, events); + } else { + await this.syncApi!.injectRoomEvents(this.room!, events); // Sliding Sync + } events.forEach((event) => { this.emit(ClientEvent.Event, event); logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); @@ -567,7 +573,34 @@ export class RoomWidgetClient extends MatrixClient { // Only inject once we have update the txId await this.updateTxId(event); - await this.syncApi!.injectRoomEvents(this.room!, [], [event]); + + // The widget API does not tell us whether a state event came from `state_after` or not so we assume legacy behaviour for now. + if (this.syncApi instanceof SyncApi) { + // The code will want to be something like: + // ``` + // if (!params.addToTimeline && !params.addToState) { + // // Passing undefined for `stateAfterEventList` makes `injectRoomEvents` run in "legacy mode" + // // -> state events part of the `timelineEventList` parameter will update the state. + // this.injectRoomEvents(this.room!, [], undefined, [event]); + // } else { + // this.injectRoomEvents(this.room!, undefined, params.addToState ? [event] : [], params.addToTimeline ? [event] : []); + // } + // ``` + + // Passing undefined for `stateAfterEventList` allows will make `injectRoomEvents` run in legacy mode + // -> state events in `timelineEventList` will update the state. + await this.syncApi.injectRoomEvents(this.room!, [], undefined, [event]); + } else { + // The code will want to be something like: + // ``` + // if (!params.addToTimeline && !params.addToState) { + // this.injectRoomEvents(this.room!, [], [event]); + // } else { + // this.injectRoomEvents(this.room!, params.addToState ? [event] : [], params.addToTimeline ? [event] : []); + // } + // ``` + await this.syncApi!.injectRoomEvents(this.room!, [], [event]); // Sliding Sync + } this.emit(ClientEvent.Event, event); this.setSyncState(SyncState.Syncing); logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`); diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index da8e6383373..9f02a71a649 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -58,13 +58,13 @@ export interface IRoomTimelineData { } export interface IAddEventToTimelineOptions - extends Pick { + extends Pick { /** Whether the sync response came from cache */ fromCache?: boolean; } export interface IAddLiveEventOptions - extends Pick { + 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 @@ -391,6 +391,7 @@ export class EventTimelineSet extends TypedEventEmitter { public addEventsToTimeline( events: MatrixEvent[], toStartOfTimeline: boolean, + addToState: boolean, timeline: EventTimeline, paginationToken?: string, ): void { - timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken); + timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, addToState, timeline, paginationToken); } /** @@ -1907,7 +1908,7 @@ export class Room extends ReadReceipt { // see https://github.com/vector-im/vector-web/issues/2109 unfilteredLiveTimeline.getEvents().forEach(function (event) { - timelineSet.addLiveEvent(event); + timelineSet.addLiveEvent(event, { addToState: false }); // Filtered timeline sets should not track state }); // find the earliest unfiltered timeline @@ -1994,6 +1995,7 @@ export class Room extends ReadReceipt { if (filterType !== ThreadFilterType.My || currentUserParticipated) { timelineSet.getLiveTimeline().addEvent(thread.rootEvent!, { toStartOfTimeline: false, + addToState: false, }); } }); @@ -2068,6 +2070,7 @@ export class Room extends ReadReceipt { const opts = { duplicateStrategy: DuplicateStrategy.Ignore, fromCache: false, + addToState: false, roomState, }; this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, opts); @@ -2190,6 +2193,7 @@ export class Room extends ReadReceipt { duplicateStrategy: DuplicateStrategy.Replace, fromCache: false, roomState, + addToState: false, }); } } @@ -2381,9 +2385,13 @@ export class Room extends ReadReceipt { duplicateStrategy: DuplicateStrategy.Replace, fromCache: false, roomState: this.currentState, + addToState: false, }); } else { - timelineSet.addEventToTimeline(thread.rootEvent, timelineSet.getLiveTimeline(), { toStartOfTimeline }); + timelineSet.addEventToTimeline(thread.rootEvent, timelineSet.getLiveTimeline(), { + toStartOfTimeline, + addToState: false, + }); } } }; @@ -2540,7 +2548,7 @@ export class Room extends ReadReceipt { * Fires {@link RoomEvent.Timeline} */ private addLiveEvent(event: MatrixEvent, addLiveEventOptions: IAddLiveEventOptions): void { - const { duplicateStrategy, timelineWasEmpty, fromCache } = addLiveEventOptions; + const { duplicateStrategy, timelineWasEmpty, fromCache, addToState } = addLiveEventOptions; // add to our timeline sets for (const timelineSet of this.timelineSets) { @@ -2548,6 +2556,7 @@ export class Room extends ReadReceipt { duplicateStrategy, fromCache, timelineWasEmpty, + addToState, }); } @@ -2631,11 +2640,13 @@ export class Room extends ReadReceipt { if (timelineSet.getFilter()!.filterRoomTimeline([event]).length) { timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), { toStartOfTimeline: false, + addToState: false, // We don't support localEcho of state events yet }); } } else { timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), { toStartOfTimeline: false, + addToState: false, // We don't support localEcho of state events yet }); } } @@ -2886,8 +2897,8 @@ export class Room extends ReadReceipt { * @param addLiveEventOptions - addLiveEvent options * @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'. */ - public async addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): Promise { - const { duplicateStrategy, fromCache, timelineWasEmpty = false } = addLiveEventOptions ?? {}; + public async addLiveEvents(events: MatrixEvent[], addLiveEventOptions: IAddLiveEventOptions): Promise { + const { duplicateStrategy, fromCache, timelineWasEmpty = false, addToState } = addLiveEventOptions; if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); } @@ -2902,6 +2913,7 @@ export class Room extends ReadReceipt { duplicateStrategy, fromCache, timelineWasEmpty, + addToState, }; // List of extra events to check for being parents of any relations encountered diff --git a/src/models/thread.ts b/src/models/thread.ts index 905c4db3c1f..79ef5af222c 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -208,6 +208,7 @@ export class Thread extends ReadReceipt 0) { // old events are scrollback, insert them now - room.addEventsToTimeline(oldEvents, true, room.getLiveTimeline(), roomData.prev_batch); + room.addEventsToTimeline(oldEvents, true, false, room.getLiveTimeline(), roomData.prev_batch); } } @@ -754,7 +754,7 @@ export class SlidingSyncSdk { /** * Injects events into a room's model. * @param stateEventList - A list of state events. This is the state - * at the *START* of the timeline list if it is supplied. + * at the *END* of the timeline list if it is supplied. * @param timelineEventList - A list of timeline events. Lower index * is earlier in time. Higher index is later. * @param numLive - the number of events in timelineEventList which just happened, @@ -763,13 +763,9 @@ export class SlidingSyncSdk { public async injectRoomEvents( room: Room, stateEventList: MatrixEvent[], - timelineEventList?: MatrixEvent[], - numLive?: number, + timelineEventList: MatrixEvent[] = [], + numLive: number = 0, ): Promise { - timelineEventList = timelineEventList || []; - stateEventList = stateEventList || []; - numLive = numLive || 0; - // If there are no events in the timeline yet, initialise it with // the given state events const liveTimeline = room.getLiveTimeline(); @@ -820,16 +816,17 @@ export class SlidingSyncSdk { timelineEventList = timelineEventList.slice(0, -1 * liveTimelineEvents.length); } - // execute the timeline events. This will continue to diverge the current state - // if the timeline has any state events in it. + // Execute the timeline events. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. await room.addLiveEvents(timelineEventList, { fromCache: true, + addToState: false, }); if (liveTimelineEvents.length > 0) { await room.addLiveEvents(liveTimelineEvents, { fromCache: false, + addToState: false, }); } @@ -966,7 +963,7 @@ export class SlidingSyncSdk { return a.getTs() - b.getTs(); }); this.notifEvents.forEach((event) => { - this.client.getNotifTimelineSet()?.addLiveEvent(event); + this.client.getNotifTimelineSet()?.addLiveEvent(event, { addToState: false }); }); this.notifEvents = []; } diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index d66a497f822..d8a46bbb899 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -77,7 +77,9 @@ export interface ITimeline { export interface IJoinedRoom { "summary": IRoomSummary; - "state": IState; + // One of `state` or `state_after` is required. + "state"?: IState; + "org.matrix.msc4222.state_after"?: IState; // https://github.com/matrix-org/matrix-spec-proposals/pull/4222 "timeline": ITimeline; "ephemeral": IEphemeral; "account_data": IAccountData; @@ -106,9 +108,11 @@ export interface IInvitedRoom { } export interface ILeftRoom { - state: IState; - timeline: ITimeline; - account_data: IAccountData; + // One of `state` or `state_after` is required. + "state"?: IState; + "org.matrix.msc4222.state_after"?: IState; + "timeline": ITimeline; + "account_data": IAccountData; } export interface IKnockedRoom { @@ -481,13 +485,18 @@ export class SyncAccumulator { // Work out the current state. The deltas need to be applied in the order: // - existing state which didn't come down /sync. // - State events under the 'state' key. - // - State events in the 'timeline'. + // - State events under the 'state_after' key OR state events in the 'timeline' if 'state_after' is not present. data.state?.events?.forEach((e) => { setState(currentData._currentState, e); }); - data.timeline?.events?.forEach((e, index) => { - // this nops if 'e' isn't a state event + data["org.matrix.msc4222.state_after"]?.events?.forEach((e) => { setState(currentData._currentState, e); + }); + data.timeline?.events?.forEach((e, index) => { + if (!data["org.matrix.msc4222.state_after"]) { + // this nops if 'e' isn't a state event + setState(currentData._currentState, e); + } // append the event to the timeline. The back-pagination token // corresponds to the first event in the timeline let transformedEvent: TaggedEvent; @@ -563,17 +572,22 @@ export class SyncAccumulator { }); Object.keys(this.joinRooms).forEach((roomId) => { const roomData = this.joinRooms[roomId]; - const roomJson: IJoinedRoom = { - ephemeral: { events: [] }, - account_data: { events: [] }, - state: { events: [] }, - timeline: { + const roomJson: IJoinedRoom & { + // We track both `state` and `state_after` for downgrade compatibility + "state": IState; + "org.matrix.msc4222.state_after": IState; + } = { + "ephemeral": { events: [] }, + "account_data": { events: [] }, + "state": { events: [] }, + "org.matrix.msc4222.state_after": { events: [] }, + "timeline": { events: [], prev_batch: null, }, - unread_notifications: roomData._unreadNotifications, - unread_thread_notifications: roomData._unreadThreadNotifications, - summary: roomData._summary as IRoomSummary, + "unread_notifications": roomData._unreadNotifications, + "unread_thread_notifications": roomData._unreadThreadNotifications, + "summary": roomData._summary as IRoomSummary, }; // Add account data Object.keys(roomData._accountData).forEach((evType) => { @@ -650,8 +664,11 @@ export class SyncAccumulator { Object.keys(roomData._currentState).forEach((evType) => { Object.keys(roomData._currentState[evType]).forEach((stateKey) => { let ev = roomData._currentState[evType][stateKey]; + // Push to both fields to provide downgrade compatibility in the sync accumulator db + // the code will prefer `state_after` if it is present + roomJson["org.matrix.msc4222.state_after"].events.push(ev); + // Roll the state back to the value at the start of the timeline if it was changed if (rollBackState[evType] && rollBackState[evType][stateKey]) { - // use the reverse clobbered event instead. ev = rollBackState[evType][stateKey]; } roomJson.state.events.push(ev); diff --git a/src/sync.ts b/src/sync.ts index 2652a89a517..37ebc9139fb 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -175,14 +175,15 @@ export enum SetPresence { } interface ISyncParams { - filter?: string; - timeout: number; - since?: string; + "filter"?: string; + "timeout": number; + "since"?: string; // eslint-disable-next-line camelcase - full_state?: boolean; + "full_state"?: boolean; // eslint-disable-next-line camelcase - set_presence?: SetPresence; - _cacheBuster?: string | number; // not part of the API itself + "set_presence"?: SetPresence; + "_cacheBuster"?: string | number; // not part of the API itself + "org.matrix.msc4222.use_state_after"?: boolean; // https://github.com/matrix-org/matrix-spec-proposals/pull/4222 } type WrappedRoom = T & { @@ -344,8 +345,9 @@ export class SyncApi { ); const qps: ISyncParams = { - timeout: 0, // don't want to block since this is a single isolated req - filter: filterId, + "timeout": 0, // don't want to block since this is a single isolated req + "filter": filterId, + "org.matrix.msc4222.use_state_after": true, }; const data = await client.http.authedRequest(Method.Get, "/sync", qps as any, undefined, { @@ -375,21 +377,18 @@ export class SyncApi { prev_batch: null, events: [], }; - const events = this.mapSyncEventsFormat(leaveObj.timeline, room); - - const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); // set the back-pagination token. Do this *before* adding any // events so that clients can start back-paginating. room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS); - await this.injectRoomEvents(room, stateEvents, events); + const { timelineEvents } = await this.mapAndInjectRoomEvents(leaveObj); room.recalculate(); client.store.storeRoom(room); client.emit(ClientEvent.Room, room); - this.processEventsForNotifs(room, events); + this.processEventsForNotifs(room, timelineEvents); return room; }), ); @@ -464,6 +463,7 @@ export class SyncApi { this._peekRoom.addEventsToTimeline( messages.reverse(), true, + true, this._peekRoom.getLiveTimeline(), response.messages.start, ); @@ -551,7 +551,7 @@ export class SyncApi { }) .map(this.client.getEventMapper()); - await peekRoom.addLiveEvents(events); + await peekRoom.addLiveEvents(events, { addToState: true }); this.peekPoll(peekRoom, res.end); }, (err) => { @@ -976,7 +976,11 @@ export class SyncApi { filter = this.getGuestFilter(); } - const qps: ISyncParams = { filter, timeout }; + const qps: ISyncParams = { + filter, + timeout, + "org.matrix.msc4222.use_state_after": true, + }; if (this.opts.disablePresence) { qps.set_presence = SetPresence.Offline; @@ -1242,7 +1246,7 @@ export class SyncApi { const room = inviteObj.room; const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room); - await this.injectRoomEvents(room, stateEvents); + await this.injectRoomEvents(room, stateEvents, undefined); const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId()!)?.getSender(); @@ -1282,15 +1286,24 @@ export class SyncApi { await promiseMapSeries(joinRooms, async (joinObj) => { const room = joinObj.room; const stateEvents = this.mapSyncEventsFormat(joinObj.state, room); + const stateAfterEvents = this.mapSyncEventsFormat(joinObj["org.matrix.msc4222.state_after"], room); // Prevent events from being decrypted ahead of time // this helps large account to speed up faster // room::decryptCriticalEvent is in charge of decrypting all the events // required for a client to function properly - const events = this.mapSyncEventsFormat(joinObj.timeline, room, false); + const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false); const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral); const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data); - const encrypted = this.isRoomEncrypted(room, stateEvents, events); + // If state_after is present, this is the events that form the state at the end of the timeline block and + // regular timeline events do *not* count towards state. If it's not present, then the state is formed by + // the state events plus the timeline events. Note mapSyncEventsFormat returns an empty array if the field + // is absent so we explicitly check the field on the original object. + const eventsFormingFinalState = joinObj["org.matrix.msc4222.state_after"] + ? stateAfterEvents + : stateEvents.concat(timelineEvents); + + const encrypted = this.isRoomEncrypted(room, eventsFormingFinalState); // We store the server-provided value first so it's correct when any of the events fire. if (joinObj.unread_notifications) { /** @@ -1378,8 +1391,8 @@ export class SyncApi { // which we'll try to paginate but not get any new events (which // will stop us linking the empty timeline into the chain). // - for (let i = events.length - 1; i >= 0; i--) { - const eventId = events[i].getId()!; + for (let i = timelineEvents.length - 1; i >= 0; i--) { + const eventId = timelineEvents[i].getId()!; if (room.getTimelineForEvent(eventId)) { debuglog(`Already have event ${eventId} in limited sync - not resetting`); limited = false; @@ -1387,7 +1400,7 @@ export class SyncApi { // we might still be missing some of the events before i; // we don't want to be adding them to the end of the // timeline because that would put them out of order. - events.splice(0, i); + timelineEvents.splice(0, i); // XXX: there's a problem here if the skipped part of the // timeline modifies the state set in stateEvents, because @@ -1419,8 +1432,9 @@ export class SyncApi { // avoids a race condition if the application tries to send a message after the // state event is processed, but before crypto is enabled, which then causes the // crypto layer to complain. + if (this.syncOpts.cryptoCallbacks) { - for (const e of stateEvents.concat(events)) { + for (const e of eventsFormingFinalState) { if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") { await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e); } @@ -1428,7 +1442,17 @@ export class SyncApi { } try { - await this.injectRoomEvents(room, stateEvents, events, syncEventData.fromCache); + if ("org.matrix.msc4222.state_after" in joinObj) { + await this.injectRoomEvents( + room, + undefined, + stateAfterEvents, + timelineEvents, + syncEventData.fromCache, + ); + } else { + await this.injectRoomEvents(room, stateEvents, undefined, timelineEvents, syncEventData.fromCache); + } } catch (e) { logger.error(`Failed to process events on room ${room.roomId}:`, e); } @@ -1452,11 +1476,11 @@ export class SyncApi { client.emit(ClientEvent.Room, room); } - this.processEventsForNotifs(room, events); + this.processEventsForNotifs(room, timelineEvents); const emitEvent = (e: MatrixEvent): boolean => client.emit(ClientEvent.Event, e); stateEvents.forEach(emitEvent); - events.forEach(emitEvent); + timelineEvents.forEach(emitEvent); ephemeralEvents.forEach(emitEvent); accountDataEvents.forEach(emitEvent); @@ -1469,11 +1493,9 @@ export class SyncApi { // Handle leaves (e.g. kicked rooms) await promiseMapSeries(leaveRooms, async (leaveObj) => { const room = leaveObj.room; - const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); - const events = this.mapSyncEventsFormat(leaveObj.timeline, room); + const { timelineEvents, stateEvents, stateAfterEvents } = await this.mapAndInjectRoomEvents(leaveObj); const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data); - await this.injectRoomEvents(room, stateEvents, events); room.addAccountData(accountDataEvents); room.recalculate(); @@ -1482,12 +1504,15 @@ export class SyncApi { client.emit(ClientEvent.Room, room); } - this.processEventsForNotifs(room, events); + this.processEventsForNotifs(room, timelineEvents); - stateEvents.forEach(function (e) { + stateEvents?.forEach(function (e) { client.emit(ClientEvent.Event, e); }); - events.forEach(function (e) { + stateAfterEvents?.forEach(function (e) { + client.emit(ClientEvent.Event, e); + }); + timelineEvents.forEach(function (e) { client.emit(ClientEvent.Event, e); }); accountDataEvents.forEach(function (e) { @@ -1500,7 +1525,7 @@ export class SyncApi { const room = knockObj.room; const stateEvents = this.mapSyncEventsFormat(knockObj.knock_state, room); - await this.injectRoomEvents(room, stateEvents); + await this.injectRoomEvents(room, stateEvents, undefined); if (knockObj.isBrandNewRoom) { room.recalculate(); @@ -1525,7 +1550,7 @@ export class SyncApi { return a.getTs() - b.getTs(); }); this.notifEvents.forEach(function (event) { - client.getNotifTimelineSet()?.addLiveEvent(event); + client.getNotifTimelineSet()?.addLiveEvent(event, { addToState: true }); }); } @@ -1669,7 +1694,7 @@ export class SyncApi { } private mapSyncEventsFormat( - obj: IInviteState | ITimeline | IEphemeral, + obj: IInviteState | ITimeline | IEphemeral | undefined, room?: Room, decrypt = true, ): MatrixEvent[] { @@ -1737,28 +1762,69 @@ export class SyncApi { // When processing the sync response we cannot rely on Room.hasEncryptionStateEvent we actually // inject the events into the room object, so we have to inspect the events themselves. - private isRoomEncrypted(room: Room, stateEventList: MatrixEvent[], timelineEventList?: MatrixEvent[]): boolean { - return ( - room.hasEncryptionStateEvent() || - !!this.findEncryptionEvent(stateEventList) || - !!this.findEncryptionEvent(timelineEventList) + private isRoomEncrypted(room: Room, eventsFormingFinalState: MatrixEvent[]): boolean { + return room.hasEncryptionStateEvent() || !!this.findEncryptionEvent(eventsFormingFinalState); + } + + private async mapAndInjectRoomEvents(wrappedRoom: WrappedRoom): Promise<{ + timelineEvents: MatrixEvent[]; + stateEvents?: MatrixEvent[]; + stateAfterEvents?: MatrixEvent[]; + }> { + const stateEvents = this.mapSyncEventsFormat(wrappedRoom.state, wrappedRoom.room); + const stateAfterEvents = this.mapSyncEventsFormat( + wrappedRoom["org.matrix.msc4222.state_after"], + wrappedRoom.room, ); + const timelineEvents = this.mapSyncEventsFormat(wrappedRoom.timeline, wrappedRoom.room); + + if ("org.matrix.msc4222.state_after" in wrappedRoom) { + await this.injectRoomEvents(wrappedRoom.room, undefined, stateAfterEvents, timelineEvents); + } else { + await this.injectRoomEvents(wrappedRoom.room, stateEvents, undefined, timelineEvents); + } + + return { timelineEvents, stateEvents, stateAfterEvents }; } /** * Injects events into a room's model. * @param stateEventList - A list of state events. This is the state * at the *START* of the timeline list if it is supplied. + * @param stateAfterEventList - A list of state events. This is the state + * at the *END* of the timeline list if it is supplied. * @param timelineEventList - A list of timeline events, including threaded. Lower index * is earlier in time. Higher index is later. * @param fromCache - whether the sync response came from cache + * + * No more than one of stateEventList and stateAfterEventList must be supplied. If + * stateEventList is supplied, the events in timelineEventList are added to the state + * after stateEventList. If stateAfterEventList is supplied, the events in timelineEventList + * are not added to the state. */ public async injectRoomEvents( room: Room, stateEventList: MatrixEvent[], + stateAfterEventList: undefined, + timelineEventList?: MatrixEvent[], + fromCache?: boolean, + ): Promise; + public async injectRoomEvents( + room: Room, + stateEventList: undefined, + stateAfterEventList: MatrixEvent[], + timelineEventList?: MatrixEvent[], + fromCache?: boolean, + ): Promise; + public async injectRoomEvents( + room: Room, + stateEventList: MatrixEvent[] | undefined, + stateAfterEventList: MatrixEvent[] | undefined, timelineEventList?: MatrixEvent[], fromCache = false, ): Promise { + const eitherStateEventList = stateAfterEventList ?? stateEventList!; + // If there are no events in the timeline yet, initialise it with // the given state events const liveTimeline = room.getLiveTimeline(); @@ -1772,10 +1838,11 @@ export class SyncApi { // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise // find some solution where MatrixEvents are immutable but allow for a cache // field. - for (const ev of stateEventList) { + + for (const ev of eitherStateEventList) { this.client.getPushActionsForEvent(ev); } - liveTimeline.initialiseState(stateEventList, { + liveTimeline.initialiseState(eitherStateEventList, { timelineWasEmpty, }); } @@ -1807,17 +1874,18 @@ export class SyncApi { // XXX: As above, don't do this... //room.addLiveEvents(stateEventList || []); // Do this instead... - room.oldState.setStateEvents(stateEventList || []); - room.currentState.setStateEvents(stateEventList || []); + room.oldState.setStateEvents(eitherStateEventList); + room.currentState.setStateEvents(eitherStateEventList); } - // Execute the timeline events. This will continue to diverge the current state - // if the timeline has any state events in it. + // Execute the timeline events. If addToState is true the timeline has any state + // events in it, this will continue to diverge the current state. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. await room.addLiveEvents(timelineEventList || [], { fromCache, timelineWasEmpty, + addToState: stateAfterEventList === undefined, }); this.client.processBeaconEvents(room, timelineEventList); }