From 8ecc696c1b1698b6068e3dc953d98696d0ce8efe Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 11:28:35 +0100 Subject: [PATCH 01/19] Do not assume that a relation lives in main timeline if we do not know its parent --- src/models/room.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index f56d30629e4..0ba38ec8740 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2096,7 +2096,7 @@ export class Room extends ReadReceipt { shouldLiveInThread: boolean; threadId?: string; } { - if (!this.client?.supportsThreads()) { + if (!this.client?.supportsThreads() || !event.isRelation()) { return { shouldLiveInRoom: true, shouldLiveInThread: false, @@ -2141,9 +2141,10 @@ export class Room extends ReadReceipt { }; } - // We've exhausted all scenarios, can safely assume that this event should live in the room timeline only + // We've exhausted all scenarios, + // we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread return { - shouldLiveInRoom: true, + shouldLiveInRoom: false, shouldLiveInThread: false, }; } From 062ab5fed5424ae8d12b9ce9d98d8c9ef126ef76 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 11:30:37 +0100 Subject: [PATCH 02/19] For pagination, partition relations with unknown parents into a separate bucket And only add them to relation map, no timelines --- src/client.ts | 10 +++++++--- src/models/room.ts | 13 +++++++++---- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/client.ts b/src/client.ts index f35fb94c201..4937494b435 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5573,11 +5573,13 @@ export class MatrixClient extends TypedEventEmitter room.relations.aggregateChildEvent(event)); room.oldState.paginationToken = res.end ?? null; if (res.chunk.length === 0) { @@ -5686,11 +5688,12 @@ export class MatrixClient extends TypedEventEmitter timelineSet.relations.aggregateChildEvent(event)); // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up @@ -6230,7 +6233,7 @@ export class MatrixClient extends TypedEventEmitter it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)), false, ); + unknownRelations.forEach((event) => room.relations.aggregateChildEvent(event)); const atEnd = res.end === undefined || res.end === res.start; diff --git a/src/models/room.ts b/src/models/room.ts index 0ba38ec8740..b024c4f4bea 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2797,13 +2797,14 @@ export class Room extends ReadReceipt { public partitionThreadedEvents( events: MatrixEvent[], - ): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[]] { + ): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[], unknownRelations: MatrixEvent[]] { // Indices to the events array, for readability const ROOM = 0; const THREAD = 1; + const UNKNOWN_RELATION = 2; if (this.client.supportsThreads()) { const threadRoots = this.findThreadRoots(events); - return events.reduce( + return events.reduce<[MatrixEvent[], MatrixEvent[], MatrixEvent[]]>( (memo, event: MatrixEvent) => { const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( event, @@ -2820,13 +2821,17 @@ export class Room extends ReadReceipt { memo[THREAD].push(event); } + if (!shouldLiveInThread && !shouldLiveInRoom) { + memo[UNKNOWN_RELATION].push(event); + } + return memo; }, - [[] as MatrixEvent[], [] as MatrixEvent[]], + [[], [], []], ); } else { // When `experimentalThreadSupport` is disabled treat all events as timelineEvents - return [events as MatrixEvent[], [] as MatrixEvent[]]; + return [events as MatrixEvent[], [] as MatrixEvent[], [] as MatrixEvent[]]; } } From 81500402a81265b0befa42d770b19de2660a54d3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 11:30:58 +0100 Subject: [PATCH 03/19] Make addLiveEvents async and have it fetch parent events of unknown relations to not insert into the wrong timeline --- src/models/room.ts | 36 ++++++++++++++++++++++++++++++------ src/sliding-sync-sdk.ts | 12 ++++++------ src/sync.ts | 6 +++--- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index b024c4f4bea..7b406f91895 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2701,16 +2701,20 @@ export class Room extends ReadReceipt { * @param addLiveEventOptions - addLiveEvent options * @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'. */ - public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void; + public async addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): Promise; /** * @deprecated In favor of the overload with `IAddLiveEventOptions` */ - public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void; - public addLiveEvents( + public async addLiveEvents( + events: MatrixEvent[], + duplicateStrategy?: DuplicateStrategy, + fromCache?: boolean, + ): Promise; + public async addLiveEvents( events: MatrixEvent[], duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions, fromCache = false, - ): void { + ): Promise { let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy; let timelineWasEmpty: boolean | undefined = false; if (typeof duplicateStrategyOrOpts === "object") { @@ -2761,6 +2765,9 @@ export class Room extends ReadReceipt { timelineWasEmpty, }; + // List of extra events to check for being parents of any relations encountered + const neighbouringEvents = [...events]; + for (const event of events) { // TODO: We should have a filter to say "only add state event types X Y Z to the timeline". this.processLiveEvent(event); @@ -2774,12 +2781,27 @@ export class Room extends ReadReceipt { } } - const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( + let { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( event, - events, + neighbouringEvents, threadRoots, ); + if (!shouldLiveInThread && !shouldLiveInRoom && event.isRelation()) { + try { + const parentEvent = await this.client.fetchRoomEvent(this.roomId, event.relationEventId!); + neighbouringEvents.push(new MatrixEvent(parentEvent)); + + ({ shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( + event, + neighbouringEvents, + threadRoots, + )); + } catch (e) { + logger.error("Failed to load parent event of unhandled relation", e); + } + } + if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) { eventsByThread[threadId ?? ""] = []; } @@ -2787,6 +2809,8 @@ export class Room extends ReadReceipt { if (shouldLiveInRoom) { this.addLiveEvent(event, options); + } else if (!shouldLiveInThread && event.isRelation()) { + this.relations.aggregateChildEvent(event); } } diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index b7d2223ffa9..27eae2d94b9 100644 --- a/src/sliding-sync-sdk.ts +++ b/src/sliding-sync-sdk.ts @@ -628,7 +628,7 @@ export class SlidingSyncSdk { if (roomData.invite_state) { const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state); - this.injectRoomEvents(room, inviteStateEvents); + await this.injectRoomEvents(room, inviteStateEvents); if (roomData.initial) { room.recalculate(); this.client.store.storeRoom(room); @@ -700,7 +700,7 @@ export class SlidingSyncSdk { } } */ - this.injectRoomEvents(room, stateEvents, timelineEvents, roomData.num_live); + await this.injectRoomEvents(room, stateEvents, timelineEvents, roomData.num_live); // we deliberately don't add ephemeral events to the timeline room.addEphemeralEvents(ephemeralEvents); @@ -747,12 +747,12 @@ export class SlidingSyncSdk { * @param numLive - the number of events in timelineEventList which just happened, * supplied from the server. */ - public injectRoomEvents( + public async injectRoomEvents( room: Room, stateEventList: MatrixEvent[], timelineEventList?: MatrixEvent[], numLive?: number, - ): void { + ): Promise { timelineEventList = timelineEventList || []; stateEventList = stateEventList || []; numLive = numLive || 0; @@ -811,11 +811,11 @@ export class SlidingSyncSdk { // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. - room.addLiveEvents(timelineEventList, { + await room.addLiveEvents(timelineEventList, { fromCache: true, }); if (liveTimelineEvents.length > 0) { - room.addLiveEvents(liveTimelineEvents, { + await room.addLiveEvents(liveTimelineEvents, { fromCache: false, }); } diff --git a/src/sync.ts b/src/sync.ts index 4800880bc4a..0620432c70a 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -501,7 +501,7 @@ export class SyncApi { }, ) .then( - (res) => { + async (res) => { if (this._peekRoom !== peekRoom) { debuglog("Stopped peeking in room %s", peekRoom.roomId); return; @@ -541,7 +541,7 @@ export class SyncApi { }) .map(this.client.getEventMapper()); - peekRoom.addLiveEvents(events); + await peekRoom.addLiveEvents(events); this.peekPoll(peekRoom, res.end); }, (err) => { @@ -1773,7 +1773,7 @@ export class SyncApi { // if the timeline has any state events in it. // This also needs to be done before running push rules on the events as they need // to be decorated with sender etc. - room.addLiveEvents(timelineEventList || [], { + await room.addLiveEvents(timelineEventList || [], { fromCache, timelineWasEmpty, }); From 61eb1d5d1ca8cfcd892f06dc72ea2832e479c62b Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 12:01:42 +0100 Subject: [PATCH 04/19] Fix tests not awaiting addLIveEvents --- .../matrix-client-event-timeline.spec.ts | 10 +- ...matrix-client-unread-notifications.spec.ts | 2 +- spec/unit/models/thread.spec.ts | 2 +- spec/unit/room.spec.ts | 388 +++++++++--------- 4 files changed, 205 insertions(+), 197 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index bb4e9563431..26909850df4 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -1142,7 +1142,7 @@ describe("MatrixClient event timelines", function () { const prom = emitPromise(room, ThreadEvent.Update); // Assume we're seeing the reply while loading backlog - room.addLiveEvents([THREAD_REPLY2]); + await room.addLiveEvents([THREAD_REPLY2]); httpBackend .when( "GET", @@ -1156,7 +1156,7 @@ describe("MatrixClient event timelines", function () { }); await flushHttp(prom); // but while loading the metadata, a new reply has arrived - room.addLiveEvents([THREAD_REPLY3]); + await room.addLiveEvents([THREAD_REPLY3]); 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([ @@ -1248,7 +1248,7 @@ describe("MatrixClient event timelines", function () { const prom = emitPromise(room, ThreadEvent.Update); // Assume we're seeing the reply while loading backlog - room.addLiveEvents([THREAD_REPLY2]); + await room.addLiveEvents([THREAD_REPLY2]); httpBackend .when( "GET", @@ -1267,7 +1267,7 @@ describe("MatrixClient event timelines", function () { }); await flushHttp(prom); // but while loading the metadata, a new reply has arrived - room.addLiveEvents([THREAD_REPLY3]); + await room.addLiveEvents([THREAD_REPLY3]); 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([ @@ -1572,7 +1572,7 @@ describe("MatrixClient event timelines", function () { respondToEvent(THREAD_ROOT_UPDATED); respondToEvent(THREAD_ROOT_UPDATED); respondToEvent(THREAD2_ROOT); - room.addLiveEvents([THREAD_REPLY2]); + await room.addLiveEvents([THREAD_REPLY2]); await httpBackend.flushAllExpected(); await prom; expect(thread.length).toBe(2); diff --git a/spec/integ/matrix-client-unread-notifications.spec.ts b/spec/integ/matrix-client-unread-notifications.spec.ts index 8274d7afaba..a4d3c2b8991 100644 --- a/spec/integ/matrix-client-unread-notifications.spec.ts +++ b/spec/integ/matrix-client-unread-notifications.spec.ts @@ -89,7 +89,7 @@ describe("MatrixClient syncing", () => { const thread = mkThread({ room, client: client!, authorId: selfUserId, participantUserIds: [selfUserId] }); const threadReply = thread.events.at(-1)!; - room.addLiveEvents([thread.rootEvent]); + await room.addLiveEvents([thread.rootEvent]); // Initialize read receipt datastructure before testing the reaction room.addReceiptToStructure(thread.rootEvent.getId()!, ReceiptType.Read, selfUserId, { ts: 1 }, false); diff --git a/spec/unit/models/thread.spec.ts b/spec/unit/models/thread.spec.ts index 6aa1d5c862e..d8dd88809b4 100644 --- a/spec/unit/models/thread.spec.ts +++ b/spec/unit/models/thread.spec.ts @@ -697,7 +697,7 @@ async function createThread(client: MatrixClient, user: string, roomId: string): // Ensure the root is in the room timeline root.setThreadId(root.getId()); - room.addLiveEvents([root]); + await room.addLiveEvents([root]); // Create the thread and wait for it to be initialised const thread = room.createThread(root.getId()!, root, [], false); diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index e168a5bf628..0c7a51309b3 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -172,9 +172,9 @@ describe("Room", function () { * @param timestamp - Timestamp of the message * @return The message event */ - const mkMessageInRoom = (room: Room, timestamp: number) => { + const mkMessageInRoom = async (room: Room, timestamp: number) => { const message = mkMessage({ ts: timestamp }); - room.addLiveEvents([message]); + await room.addLiveEvents([message]); return message; }; @@ -319,23 +319,25 @@ describe("Room", function () { }), ]; - it("Make sure legacy overload passing options directly as parameters still works", () => { - expect(() => room.addLiveEvents(events, DuplicateStrategy.Replace, false)).not.toThrow(); - expect(() => room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).not.toThrow(); + it("Make sure legacy overload passing options directly as parameters still works", async () => { + await expect(room.addLiveEvents(events, DuplicateStrategy.Replace, false)).resolves.not.toThrow(); + await expect(room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).resolves.not.toThrow(); // @ts-ignore - expect(() => room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false)).toThrow(); + await expect( + room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false), + ).rejects.toThrow(); }); - it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", function () { - expect(function () { + it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", async function () { + return expect( // @ts-ignore room.addLiveEvents(events, { duplicateStrategy: "foo", - }); - }).toThrow(); + }), + ).rejects.toThrow(); }); - it("should replace a timeline event if dupe strategy is 'replace'", function () { + it("should replace a timeline event if dupe strategy is 'replace'", async function () { // make a duplicate const dupe = utils.mkMessage({ room: roomId, @@ -344,15 +346,15 @@ describe("Room", function () { event: true, }); dupe.event.event_id = events[0].getId(); - room.addLiveEvents(events); + await room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); - room.addLiveEvents([dupe], { + await room.addLiveEvents([dupe], { duplicateStrategy: DuplicateStrategy.Replace, }); expect(room.timeline[0]).toEqual(dupe); }); - it("should ignore a given dupe event if dupe strategy is 'ignore'", function () { + it("should ignore a given dupe event if dupe strategy is 'ignore'", async function () { // make a duplicate const dupe = utils.mkMessage({ room: roomId, @@ -361,16 +363,16 @@ describe("Room", function () { event: true, }); dupe.event.event_id = events[0].getId(); - room.addLiveEvents(events); + await room.addLiveEvents(events); expect(room.timeline[0]).toEqual(events[0]); // @ts-ignore - room.addLiveEvents([dupe], { + await room.addLiveEvents([dupe], { duplicateStrategy: "ignore", }); expect(room.timeline[0]).toEqual(events[0]); }); - it("should emit 'Room.timeline' events", function () { + it("should emit 'Room.timeline' events", async function () { let callCount = 0; room.on(RoomEvent.Timeline, function (event, emitRoom, toStart) { callCount += 1; @@ -379,11 +381,11 @@ describe("Room", function () { expect(emitRoom).toEqual(room); expect(toStart).toBeFalsy(); }); - room.addLiveEvents(events); + await room.addLiveEvents(events); expect(callCount).toEqual(2); }); - it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", function () { + it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", async function () { const events: MatrixEvent[] = [ utils.mkMembership({ room: roomId, @@ -402,7 +404,7 @@ describe("Room", function () { }, }), ]; - room.addLiveEvents(events); + await room.addLiveEvents(events); expect(room.currentState.setStateEvents).toHaveBeenCalledWith([events[0]], { timelineWasEmpty: false }); expect(room.currentState.setStateEvents).toHaveBeenCalledWith([events[1]], { timelineWasEmpty: false }); expect(events[0].forwardLooking).toBe(true); @@ -410,7 +412,7 @@ describe("Room", function () { expect(room.oldState.setStateEvents).not.toHaveBeenCalled(); }); - it("should synthesize read receipts for the senders of events", function () { + it("should synthesize read receipts for the senders of events", async function () { const sentinel = { userId: userA, membership: "join", @@ -422,11 +424,11 @@ describe("Room", function () { } return null; }); - room.addLiveEvents(events); + await room.addLiveEvents(events); expect(room.getEventReadUpTo(userA)).toEqual(events[1].getId()); }); - it("should emit Room.localEchoUpdated when a local echo is updated", function () { + it("should emit Room.localEchoUpdated when a local echo is updated", async function () { const localEvent = utils.mkMessage({ room: roomId, user: userA, @@ -457,7 +459,7 @@ describe("Room", function () { expect(stub.mock.calls[0][3]).toBeUndefined(); // then the remoteEvent - room.addLiveEvents([remoteEvent]); + await room.addLiveEvents([remoteEvent]); expect(room.timeline.length).toEqual(1); expect(stub).toHaveBeenCalledTimes(2); @@ -469,7 +471,7 @@ describe("Room", function () { expect(stub.mock.calls[1][3]).toBe(EventStatus.SENDING); }); - it("should be able to update local echo without a txn ID (/send then /sync)", function () { + it("should be able to update local echo without a txn ID (/send then /sync)", async function () { const eventJson = utils.mkMessage({ room: roomId, user: userA, @@ -495,14 +497,14 @@ 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(); - room.addLiveEvents([remoteEvent]); + await room.addLiveEvents([remoteEvent]); // 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 expect(room.getEventForTxnId(txnId)).toBeUndefined(); }); - it("should be able to update local echo without a txn ID (/sync then /send)", function () { + it("should be able to update local echo without a txn ID (/sync then /send)", async function () { const eventJson = utils.mkMessage({ room: roomId, user: userA, @@ -525,7 +527,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(); - room.addLiveEvents([remoteEvent]); + await room.addLiveEvents([remoteEvent]); 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. @@ -538,7 +540,7 @@ describe("Room", function () { expect(room.getEventForTxnId(txnId)).toBeUndefined(); }); - it("should correctly handle remote echoes from other devices", () => { + it("should correctly handle remote echoes from other devices", async () => { const remoteEvent = utils.mkMessage({ room: roomId, user: userA, @@ -547,7 +549,7 @@ describe("Room", function () { remoteEvent.event.unsigned = { transaction_id: "TXN_ID" }; // add the remoteEvent - room.addLiveEvents([remoteEvent]); + await room.addLiveEvents([remoteEvent]); expect(room.timeline.length).toEqual(1); }); }); @@ -612,7 +614,7 @@ describe("Room", function () { }); describe("event metadata handling", function () { - it("should set event.sender for new and old events", function () { + it("should set event.sender for new and old events", async function () { const sentinel = { userId: userA, membership: "join", @@ -650,13 +652,13 @@ describe("Room", function () { event: true, content: { name: "Old Room Name" }, }); - room.addLiveEvents([newEv]); + await room.addLiveEvents([newEv]); expect(newEv.sender).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); expect(oldEv.sender).toEqual(oldSentinel); }); - it("should set event.target for new and old m.room.member events", function () { + it("should set event.target for new and old m.room.member events", async function () { const sentinel = { userId: userA, membership: "join", @@ -694,7 +696,7 @@ describe("Room", function () { skey: userA, event: true, }); - room.addLiveEvents([newEv]); + await room.addLiveEvents([newEv]); expect(newEv.target).toEqual(sentinel); room.addEventsToTimeline([oldEv], true, room.getLiveTimeline()); expect(oldEv.target).toEqual(oldSentinel); @@ -763,12 +765,12 @@ describe("Room", function () { ]; }); - it("should copy state from previous timeline", function () { - room.addLiveEvents([events[0], events[1]]); + it("should copy state from previous timeline", async function () { + await room.addLiveEvents([events[0], events[1]]); expect(room.getLiveTimeline().getEvents().length).toEqual(2); room.resetLiveTimeline("sometoken", "someothertoken"); - room.addLiveEvents([events[2]]); + await room.addLiveEvents([events[2]]); const oldState = room.getLiveTimeline().getState(EventTimeline.BACKWARDS); const newState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); expect(room.getLiveTimeline().getEvents().length).toEqual(1); @@ -776,8 +778,8 @@ describe("Room", function () { expect(newState?.getStateEvents(EventType.RoomName, "")).toEqual(events[2]); }); - it("should reset the legacy timeline fields", function () { - room.addLiveEvents([events[0], events[1]]); + it("should reset the legacy timeline fields", async function () { + await room.addLiveEvents([events[0], events[1]]); expect(room.timeline.length).toEqual(2); const oldStateBeforeRunningReset = room.oldState; @@ -798,7 +800,7 @@ describe("Room", function () { room.resetLiveTimeline("sometoken", "someothertoken"); - room.addLiveEvents([events[2]]); + await room.addLiveEvents([events[2]]); const newLiveTimeline = room.getLiveTimeline(); expect(room.timeline).toEqual(newLiveTimeline.getEvents()); expect(room.oldState).toEqual(newLiveTimeline.getState(EventTimeline.BACKWARDS)); @@ -824,8 +826,8 @@ describe("Room", function () { expect(callCount).toEqual(1); }); - it("should " + (timelineSupport ? "remember" : "forget") + " old timelines", function () { - room.addLiveEvents([events[0]]); + it("should " + (timelineSupport ? "remember" : "forget") + " old timelines", async function () { + await room.addLiveEvents([events[0]]); expect(room.timeline.length).toEqual(1); const firstLiveTimeline = room.getLiveTimeline(); room.resetLiveTimeline("sometoken", "someothertoken"); @@ -868,8 +870,8 @@ describe("Room", function () { }), ]; - it("should handle events in the same timeline", function () { - room.addLiveEvents(events); + it("should handle events in the same timeline", async function () { + await room.addLiveEvents(events); expect( room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!), @@ -882,13 +884,13 @@ describe("Room", function () { ).toEqual(0); }); - it("should handle events in adjacent timelines", function () { + it("should handle events in adjacent timelines", async function () { const oldTimeline = room.addTimeline(); oldTimeline.setNeighbouringTimeline(room.getLiveTimeline(), Direction.Forward); room.getLiveTimeline().setNeighbouringTimeline(oldTimeline, Direction.Backward); room.addEventsToTimeline([events[0]], false, oldTimeline); - room.addLiveEvents([events[1]]); + await room.addLiveEvents([events[1]]); expect( room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!), @@ -898,11 +900,11 @@ describe("Room", function () { ).toBeGreaterThan(0); }); - it("should return null for events in non-adjacent timelines", function () { + it("should return null for events in non-adjacent timelines", async function () { const oldTimeline = room.addTimeline(); room.addEventsToTimeline([events[0]], false, oldTimeline); - room.addLiveEvents([events[1]]); + await room.addLiveEvents([events[1]]); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, events[1].getId()!)).toBe( null, @@ -912,8 +914,8 @@ describe("Room", function () { ); }); - it("should return null for unknown events", function () { - room.addLiveEvents(events); + it("should return null for unknown events", async function () { + await room.addLiveEvents(events); expect(room.getUnfilteredTimelineSet().compareEventOrdering(events[0].getId()!, "xxx")).toBe(null); expect(room.getUnfilteredTimelineSet().compareEventOrdering("xxx", events[0].getId()!)).toBe(null); @@ -990,8 +992,8 @@ describe("Room", function () { }); describe("recalculate", function () { - const setJoinRule = function (rule: JoinRule) { - room.addLiveEvents([ + const setJoinRule = async function (rule: JoinRule) { + await room.addLiveEvents([ utils.mkEvent({ type: EventType.RoomJoinRules, room: roomId, @@ -1003,8 +1005,8 @@ describe("Room", function () { }), ]); }; - const setAltAliases = function (aliases: string[]) { - room.addLiveEvents([ + const setAltAliases = async function (aliases: string[]) { + await room.addLiveEvents([ utils.mkEvent({ type: EventType.RoomCanonicalAlias, room: roomId, @@ -1016,8 +1018,8 @@ describe("Room", function () { }), ]); }; - const setAlias = function (alias: string) { - room.addLiveEvents([ + const setAlias = async function (alias: string) { + await room.addLiveEvents([ utils.mkEvent({ type: EventType.RoomCanonicalAlias, room: roomId, @@ -1027,8 +1029,8 @@ describe("Room", function () { }), ]); }; - const setRoomName = function (name: string) { - room.addLiveEvents([ + const setRoomName = async function (name: string) { + await room.addLiveEvents([ utils.mkEvent({ type: EventType.RoomName, room: roomId, @@ -1040,14 +1042,14 @@ describe("Room", function () { }), ]); }; - const addMember = function (userId: string, state = "join", opts: any = {}) { + const addMember = async function (userId: string, state = "join", opts: any = {}) { opts.room = roomId; opts.mship = state; opts.user = opts.user || userId; opts.skey = userId; opts.event = true; const event = utils.mkMembership(opts); - room.addLiveEvents([event]); + await room.addLiveEvents([event]); return event; }; @@ -1059,10 +1061,10 @@ describe("Room", function () { describe("Room.recalculate => Stripped State Events", function () { it( "should set stripped state events as actual state events if the " + "room is an invite room", - function () { + async function () { const roomName = "flibble"; - const event = addMember(userA, "invite"); + const event = await addMember(userA, "invite"); event.event.unsigned = {}; event.event.unsigned.invite_room_state = [ { @@ -1080,8 +1082,8 @@ describe("Room", function () { }, ); - it("should not clobber state events if it isn't an invite room", function () { - const event = addMember(userA, "join"); + it("should not clobber state events if it isn't an invite room", async function () { + const event = await addMember(userA, "join"); const roomName = "flibble"; setRoomName(roomName); const roomNameToIgnore = "ignoreme"; @@ -1537,7 +1539,7 @@ describe("Room", function () { ]); }); - it("should prioritise the most recent event", function () { + it("should prioritise the most recent event", async function () { const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, @@ -1559,7 +1561,7 @@ describe("Room", function () { }), ]; - room.addLiveEvents(events); + await room.addLiveEvents(events); const ts = 13787898424; // check it initialises correctly @@ -1575,7 +1577,7 @@ describe("Room", function () { expect(room.getEventReadUpTo(userB)).toEqual(events[2].getId()); }); - it("should prioritise the most recent event even if it is synthetic", () => { + it("should prioritise the most recent event even if it is synthetic", async () => { const events: MatrixEvent[] = [ utils.mkMessage({ room: roomId, @@ -1597,7 +1599,7 @@ describe("Room", function () { }), ]; - room.addLiveEvents(events); + await room.addLiveEvents(events); const ts = 13787898424; // check it initialises correctly @@ -1673,66 +1675,72 @@ describe("Room", function () { }); describe("addPendingEvent", function () { - it("should add pending events to the pendingEventList if " + "pendingEventOrdering == 'detached'", function () { - const client = new TestClient("@alice:example.com", "alicedevice").client; - client.supportsThreads = () => true; - const room = new Room(roomId, client, userA, { - pendingEventOrdering: PendingEventOrdering.Detached, - }); - const eventA = utils.mkMessage({ - room: roomId, - user: userA, - msg: "remote 1", - event: true, - }); - const eventB = utils.mkMessage({ - room: roomId, - user: userA, - msg: "local 1", - event: true, - }); - eventB.status = EventStatus.SENDING; - const eventC = utils.mkMessage({ - room: roomId, - user: userA, - msg: "remote 2", - event: true, - }); - room.addLiveEvents([eventA]); - room.addPendingEvent(eventB, "TXN1"); - room.addLiveEvents([eventC]); - expect(room.timeline).toEqual([eventA, eventC]); - expect(room.getPendingEvents()).toEqual([eventB]); - }); + it( + "should add pending events to the pendingEventList if " + "pendingEventOrdering == 'detached'", + async function () { + const client = new TestClient("@alice:example.com", "alicedevice").client; + client.supportsThreads = () => true; + const room = new Room(roomId, client, userA, { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + const eventA = utils.mkMessage({ + room: roomId, + user: userA, + msg: "remote 1", + event: true, + }); + const eventB = utils.mkMessage({ + room: roomId, + user: userA, + msg: "local 1", + event: true, + }); + eventB.status = EventStatus.SENDING; + const eventC = utils.mkMessage({ + room: roomId, + user: userA, + msg: "remote 2", + event: true, + }); + await room.addLiveEvents([eventA]); + room.addPendingEvent(eventB, "TXN1"); + await room.addLiveEvents([eventC]); + expect(room.timeline).toEqual([eventA, eventC]); + expect(room.getPendingEvents()).toEqual([eventB]); + }, + ); - it("should add pending events to the timeline if " + "pendingEventOrdering == 'chronological'", function () { - const room = new Room(roomId, new TestClient(userA).client, userA, { - pendingEventOrdering: PendingEventOrdering.Chronological, - }); - const eventA = utils.mkMessage({ - room: roomId, - user: userA, - msg: "remote 1", - event: true, - }); - const eventB = utils.mkMessage({ - room: roomId, - user: userA, - msg: "local 1", - event: true, - }); - eventB.status = EventStatus.SENDING; - const eventC = utils.mkMessage({ - room: roomId, - user: userA, - msg: "remote 2", - event: true, - }); - room.addLiveEvents([eventA]); - room.addPendingEvent(eventB, "TXN1"); - room.addLiveEvents([eventC]); - expect(room.timeline).toEqual([eventA, eventB, eventC]); - }); + it( + "should add pending events to the timeline if " + "pendingEventOrdering == 'chronological'", + async function () { + const room = new Room(roomId, new TestClient(userA).client, userA, { + pendingEventOrdering: PendingEventOrdering.Chronological, + }); + const eventA = utils.mkMessage({ + room: roomId, + user: userA, + msg: "remote 1", + event: true, + }); + const eventB = utils.mkMessage({ + room: roomId, + user: userA, + msg: "local 1", + event: true, + }); + eventB.status = EventStatus.SENDING; + const eventC = utils.mkMessage({ + room: roomId, + user: userA, + msg: "remote 2", + event: true, + }); + await room.addLiveEvents([eventA]); + room.addPendingEvent(eventB, "TXN1"); + await room.addLiveEvents([eventC]); + expect(room.timeline).toEqual([eventA, eventB, eventC]); + }, + ); it("should apply redactions eagerly in the pending event list", () => { const client = new TestClient("@alice:example.com", "alicedevice").client; @@ -2004,9 +2012,9 @@ describe("Room", function () { }); expect(room.guessDMUserId()).toEqual(userB); }); - it("should return first member that isn't self", function () { + it("should return first member that isn't self", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userB, mship: "join", @@ -2070,9 +2078,9 @@ describe("Room", function () { expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); - it("should return a display name if one other member is in the 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2091,9 +2099,9 @@ describe("Room", function () { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return a display name if one other member is banned", function () { + it("should return a display name if one other member is banned", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2112,9 +2120,9 @@ describe("Room", function () { expect(room.getDefaultRoomName(userA)).toEqual("Empty room (was User B)"); }); - it("should return a display name if one other member is invited", function () { + it("should return a display name if one other member is invited", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2133,9 +2141,9 @@ describe("Room", function () { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return 'Empty room (was User B)' if User B left the room", function () { + 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2154,9 +2162,9 @@ describe("Room", function () { 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", function () { + 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2182,9 +2190,9 @@ describe("Room", function () { 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", function () { + 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2219,9 +2227,9 @@ 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", 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2249,9 +2257,9 @@ describe("Room", function () { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return a display name (default behaviour) if service members is a number (invalid)", function () { + 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2279,9 +2287,9 @@ describe("Room", function () { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return a display name (default behaviour) if service members is a string (invalid)", function () { + 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2309,9 +2317,9 @@ describe("Room", function () { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return 'Empty room' if the only other member is a functional member", function () { + 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2339,9 +2347,9 @@ describe("Room", function () { 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", function () { + 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2377,9 +2385,9 @@ describe("Room", function () { expect(room.getDefaultRoomName(userA)).toEqual("User B"); }); - it("should return 'Empty room' if all other members are functional members", function () { + it("should return 'Empty room' if all other members are functional members", async function () { const room = new Room(roomId, new TestClient(userA).client, userA); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2415,9 +2423,9 @@ describe("Room", function () { expect(room.getDefaultRoomName(userA)).toEqual("Empty room"); }); - it("should not break if an unjoined user is marked as a service user", function () { + 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); - room.addLiveEvents([ + await room.addLiveEvents([ utils.mkMembership({ user: userA, mship: "join", @@ -2548,7 +2556,7 @@ describe("Room", function () { }); let prom = emitPromise(room, ThreadEvent.New); - room.addLiveEvents([randomMessage, threadRoot, threadResponse]); + await room.addLiveEvents([randomMessage, threadRoot, threadResponse]); const thread: Thread = await prom; await emitPromise(room, ThreadEvent.Update); @@ -2575,7 +2583,7 @@ describe("Room", function () { }); prom = emitPromise(room, ThreadEvent.Update); - room.addLiveEvents([threadResponseEdit]); + await room.addLiveEvents([threadResponseEdit]); await prom; expect(thread.replyToEvent!.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); }); @@ -2606,7 +2614,7 @@ describe("Room", function () { }); let prom = emitPromise(room, ThreadEvent.New); - room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); + await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); const thread = await prom; await emitPromise(room, ThreadEvent.Update); @@ -2641,7 +2649,7 @@ describe("Room", function () { prom = emitPromise(thread, ThreadEvent.Update); const threadResponse1Redaction = mkRedaction(threadResponse1); - room.addLiveEvents([threadResponse1Redaction]); + await room.addLiveEvents([threadResponse1Redaction]); await prom; expect(thread).toHaveLength(1); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); @@ -2674,7 +2682,7 @@ describe("Room", function () { }); const prom = emitPromise(room, ThreadEvent.New); - room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); + await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); const thread = await prom; await emitPromise(room, ThreadEvent.Update); @@ -2682,7 +2690,7 @@ describe("Room", function () { expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); const threadResponse2ReactionRedaction = mkRedaction(threadResponse2Reaction); - room.addLiveEvents([threadResponse2ReactionRedaction]); + await room.addLiveEvents([threadResponse2ReactionRedaction]); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); }); @@ -2714,7 +2722,7 @@ describe("Room", function () { }); let prom = emitPromise(room, ThreadEvent.New); - room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); + await room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); const thread = await prom; await emitPromise(room, ThreadEvent.Update); @@ -2723,7 +2731,7 @@ describe("Room", function () { prom = emitPromise(room, ThreadEvent.Update); const threadRootRedaction = mkRedaction(threadRoot); - room.addLiveEvents([threadRootRedaction]); + await room.addLiveEvents([threadRootRedaction]); await prom; expect(thread).toHaveLength(2); }); @@ -2776,12 +2784,12 @@ describe("Room", function () { }); let prom = emitPromise(room, ThreadEvent.New); - room.addLiveEvents([threadRoot, threadResponse1]); + await room.addLiveEvents([threadRoot, threadResponse1]); const thread: Thread = await prom; await emitPromise(room, ThreadEvent.Update); expect(thread.initialEventsFetched).toBeTruthy(); - room.addLiveEvents([threadResponse2]); + await room.addLiveEvents([threadResponse2]); expect(thread).toHaveLength(2); expect(thread.replyToEvent!.getId()).toBe(threadResponse2.getId()); @@ -2802,7 +2810,7 @@ describe("Room", function () { prom = emitPromise(room, ThreadEvent.Update); const threadResponse2Redaction = mkRedaction(threadResponse2); - room.addLiveEvents([threadResponse2Redaction]); + await room.addLiveEvents([threadResponse2Redaction]); await prom; await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(1); @@ -2826,7 +2834,7 @@ describe("Room", function () { prom = emitPromise(room, ThreadEvent.Delete); const prom2 = emitPromise(room, RoomEvent.Timeline); const threadResponse1Redaction = mkRedaction(threadResponse1); - room.addLiveEvents([threadResponse1Redaction]); + await room.addLiveEvents([threadResponse1Redaction]); await prom; await prom2; expect(thread).toHaveLength(0); @@ -2946,7 +2954,7 @@ describe("Room", function () { const events = [threadRoot, rootReaction, threadResponse, threadReaction]; const prom = emitPromise(room, ThreadEvent.New); - room.addLiveEvents(events); + await room.addLiveEvents(events); const thread = await prom; expect(thread).toBe(threadRoot.getThread()); expect(thread.rootEvent).toBe(threadRoot); @@ -3452,21 +3460,21 @@ describe("Room", function () { expect(room.findPredecessor()).toBeNull(); }); - it("Returns null if the create event has no predecessor", () => { + it("Returns null if the create event has no predecessor", async () => { const room = new Room("roomid", client!, "@u:example.com"); - room.addLiveEvents([roomCreateEvent("roomid", null)]); + await room.addLiveEvents([roomCreateEvent("roomid", null)]); expect(room.findPredecessor()).toBeNull(); }); - it("Returns the predecessor ID if one is provided via create event", () => { + it("Returns the predecessor ID if one is provided via create event", async () => { const room = new Room("roomid", client!, "@u:example.com"); - room.addLiveEvents([roomCreateEvent("roomid", "replacedroomid")]); + await room.addLiveEvents([roomCreateEvent("roomid", "replacedroomid")]); expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" }); }); - it("Prefers the m.predecessor event if one exists", () => { + it("Prefers the m.predecessor event if one exists", async () => { const room = new Room("roomid", client!, "@u:example.com"); - room.addLiveEvents([ + await room.addLiveEvents([ roomCreateEvent("roomid", "replacedroomid"), predecessorEvent("roomid", "otherreplacedroomid"), ]); @@ -3478,9 +3486,9 @@ describe("Room", function () { }); }); - it("uses the m.predecessor event ID if provided", () => { + it("uses the m.predecessor event ID if provided", async () => { const room = new Room("roomid", client!, "@u:example.com"); - room.addLiveEvents([ + await room.addLiveEvents([ roomCreateEvent("roomid", "replacedroomid"), predecessorEvent("roomid", "otherreplacedroomid", "lstevtid", ["one.example.com", "two.example.com"]), ]); @@ -3492,9 +3500,9 @@ describe("Room", function () { }); }); - it("Ignores the m.predecessor event if we don't ask to use it", () => { + it("Ignores the m.predecessor event if we don't ask to use it", async () => { const room = new Room("roomid", client!, "@u:example.com"); - room.addLiveEvents([ + await room.addLiveEvents([ roomCreateEvent("roomid", "replacedroomid"), predecessorEvent("roomid", "otherreplacedroomid"), ]); @@ -3503,9 +3511,9 @@ describe("Room", function () { expect(room.findPredecessor()).toEqual({ roomId: "replacedroomid", eventId: "id_of_last_known_event" }); }); - it("Ignores the m.predecessor event and returns null if we don't ask to use it", () => { + 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"); - room.addLiveEvents([ + await room.addLiveEvents([ roomCreateEvent("roomid", null), // Create event has no predecessor predecessorEvent("roomid", "otherreplacedroomid", "lastevtid"), ]); @@ -3520,8 +3528,8 @@ describe("Room", function () { expect(room.getLastLiveEvent()).toBeUndefined(); }); - it("when there is only an event in the main timeline and there are no threads, it should return the last event from the main timeline", () => { - const lastEventInMainTimeline = mkMessageInRoom(room, 23); + it("when there is only an event in the main timeline and there are no threads, it should return the last event from the main timeline", async () => { + const lastEventInMainTimeline = await mkMessageInRoom(room, 23); expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline); }); @@ -3536,29 +3544,29 @@ describe("Room", function () { }); describe("when there are events in both, the main timeline and threads", () => { - it("and the last event is in a thread, it should return the last event from the thread", () => { - mkMessageInRoom(room, 23); + it("and the last event is in a thread, it should return the last event from the thread", async () => { + await mkMessageInRoom(room, 23); const { thread } = mkThread({ room, length: 0 }); const lastEventInThread = mkMessageInThread(thread, 42); expect(room.getLastLiveEvent()).toBe(lastEventInThread); }); - it("and the last event is in the main timeline, it should return the last event from the main timeline", () => { - const lastEventInMainTimeline = mkMessageInRoom(room, 42); + it("and the last event is in the main timeline, it should return the last event from the main timeline", async () => { + const lastEventInMainTimeline = await mkMessageInRoom(room, 42); const { thread } = mkThread({ room, length: 0 }); mkMessageInThread(thread, 23); expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline); }); - it("and both events have the same timestamp, it should return the last event from the thread", () => { - mkMessageInRoom(room, 23); + it("and both events have the same timestamp, it should return the last event from the thread", async () => { + await mkMessageInRoom(room, 23); const { thread } = mkThread({ room, length: 0 }); const lastEventInThread = mkMessageInThread(thread, 23); expect(room.getLastLiveEvent()).toBe(lastEventInThread); }); - it("and there is a thread without any messages, it should return the last event from the main timeline", () => { - const lastEventInMainTimeline = mkMessageInRoom(room, 23); + it("and there is a thread without any messages, it should return the last event from the main timeline", async () => { + const lastEventInMainTimeline = await mkMessageInRoom(room, 23); mkThread({ room, length: 0 }); expect(room.getLastLiveEvent()).toBe(lastEventInMainTimeline); }); From 4ae5820f26e41bc280694140bcf1e67dd8868534 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 12:17:43 +0100 Subject: [PATCH 05/19] Fix handling of thread roots in eventShouldLiveIn --- src/models/room.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/models/room.ts b/src/models/room.ts index 7b406f91895..147eb0f5fdb 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2096,7 +2096,7 @@ export class Room extends ReadReceipt { shouldLiveInThread: boolean; threadId?: string; } { - if (!this.client?.supportsThreads() || !event.isRelation()) { + if (!this.client?.supportsThreads()) { return { shouldLiveInRoom: true, shouldLiveInThread: false, @@ -2132,6 +2132,13 @@ export class Room extends ReadReceipt { return this.eventShouldLiveIn(parentEvent, events, roots); } + if (!event.isRelation()) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: false, + }; + } + // Edge case where we know the event is a relation but don't have the parentEvent if (roots?.has(event.relationEventId!)) { return { From 2afd31fd0bb8d37d02c7c47d1d794fdf73d3daae Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 12:17:50 +0100 Subject: [PATCH 06/19] Fix types --- spec/unit/room.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 0c7a51309b3..2ed44dffd7b 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -322,8 +322,8 @@ describe("Room", function () { it("Make sure legacy overload passing options directly as parameters still works", async () => { await expect(room.addLiveEvents(events, DuplicateStrategy.Replace, false)).resolves.not.toThrow(); await expect(room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).resolves.not.toThrow(); - // @ts-ignore await expect( + // @ts-ignore room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false), ).rejects.toThrow(); }); From bfe3a08df53e857df112c1deffba0533cb56afe1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 12:45:51 +0100 Subject: [PATCH 07/19] Fix tests --- spec/integ/sliding-sync-sdk.spec.ts | 152 +++++++++++++--------------- 1 file changed, 70 insertions(+), 82 deletions(-) diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index 6fdaeb119bd..39d4ac6016e 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -42,6 +42,7 @@ import { SyncApiOptions, SyncState } from "../../src/sync"; import { IStoredClientOpts } from "../../src/client"; import { logger } from "../../src/logger"; import { emitPromise } from "../test-utils/test-utils"; +import { defer } from "../../lib/utils"; describe("SlidingSyncSdk", () => { let client: MatrixClient | undefined; @@ -301,67 +302,57 @@ describe("SlidingSyncSdk", () => { }, }; - it("can be created with required_state and timeline", () => { + it("can be created with required_state and timeline", async () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomA, data[roomA]); + await emitPromise(client!, ClientEvent.Room); const gotRoom = client!.getRoom(roomA); - expect(gotRoom).toBeDefined(); - if (gotRoom == null) { - return; - } - expect(gotRoom.name).toEqual(data[roomA].name); - expect(gotRoom.getMyMembership()).toEqual("join"); - assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline); + expect(gotRoom).toBeTruthy(); + expect(gotRoom!.name).toEqual(data[roomA].name); + expect(gotRoom!.getMyMembership()).toEqual("join"); + assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-2), data[roomA].timeline); }); - it("can be created with timeline only", () => { + it("can be created with timeline only", async () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, data[roomB]); + await emitPromise(client!, ClientEvent.Room); const gotRoom = client!.getRoom(roomB); - expect(gotRoom).toBeDefined(); - if (gotRoom == null) { - return; - } - expect(gotRoom.name).toEqual(data[roomB].name); - expect(gotRoom.getMyMembership()).toEqual("join"); - assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline); + expect(gotRoom).toBeTruthy(); + expect(gotRoom!.name).toEqual(data[roomB].name); + expect(gotRoom!.getMyMembership()).toEqual("join"); + assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-5), data[roomB].timeline); }); - it("can be created with a highlight_count", () => { + it("can be created with a highlight_count", async () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomC, data[roomC]); + await emitPromise(client!, ClientEvent.Room); const gotRoom = client!.getRoom(roomC); - expect(gotRoom).toBeDefined(); - if (gotRoom == null) { - return; - } - expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual( + expect(gotRoom).toBeTruthy(); + expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual( data[roomC].highlight_count, ); }); - it("can be created with a notification_count", () => { + it("can be created with a notification_count", async () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomD, data[roomD]); + await emitPromise(client!, ClientEvent.Room); const gotRoom = client!.getRoom(roomD); - expect(gotRoom).toBeDefined(); - if (gotRoom == null) { - return; - } - expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual( + expect(gotRoom).toBeTruthy(); + expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual( data[roomD].notification_count, ); }); - it("can be created with an invited/joined_count", () => { + it("can be created with an invited/joined_count", async () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomG, data[roomG]); + await emitPromise(client!, ClientEvent.Room); const gotRoom = client!.getRoom(roomG); - expect(gotRoom).toBeDefined(); - if (gotRoom == null) { - return; - } - expect(gotRoom.getInvitedMemberCount()).toEqual(data[roomG].invited_count); - expect(gotRoom.getJoinedMemberCount()).toEqual(data[roomG].joined_count); + expect(gotRoom).toBeTruthy(); + expect(gotRoom!.getInvitedMemberCount()).toEqual(data[roomG].invited_count); + expect(gotRoom!.getJoinedMemberCount()).toEqual(data[roomG].joined_count); }); - it("can be created with live events", () => { - let seenLiveEvent = false; + it("can be created with live events", async () => { + const seenLiveEventDeferred = defer(); const listener = ( ev: MatrixEvent, room?: Room, @@ -371,43 +362,37 @@ describe("SlidingSyncSdk", () => { ) => { if (timelineData?.liveEvent) { assertTimelineEvents([ev], data[roomH].timeline.slice(-1)); - seenLiveEvent = true; + seenLiveEventDeferred.resolve(true); } }; client!.on(RoomEvent.Timeline, listener); mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomH, data[roomH]); + await emitPromise(client!, ClientEvent.Room); client!.off(RoomEvent.Timeline, listener); const gotRoom = client!.getRoom(roomH); - expect(gotRoom).toBeDefined(); - if (gotRoom == null) { - return; - } - expect(gotRoom.name).toEqual(data[roomH].name); - expect(gotRoom.getMyMembership()).toEqual("join"); + expect(gotRoom).toBeTruthy(); + expect(gotRoom!.name).toEqual(data[roomH].name); + expect(gotRoom!.getMyMembership()).toEqual("join"); // check the entire timeline is correct - assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), data[roomH].timeline); - expect(seenLiveEvent).toBe(true); + assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), data[roomH].timeline); + await expect(seenLiveEventDeferred.promise).resolves.toBeTruthy(); }); - it("can be created with invite_state", () => { + it("can be created with invite_state", async () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomE, data[roomE]); + await emitPromise(client!, ClientEvent.Room); const gotRoom = client!.getRoom(roomE); - expect(gotRoom).toBeDefined(); - if (gotRoom == null) { - return; - } - expect(gotRoom.getMyMembership()).toEqual("invite"); - expect(gotRoom.currentState.getJoinRule()).toEqual(JoinRule.Invite); + expect(gotRoom).toBeTruthy(); + expect(gotRoom!.getMyMembership()).toEqual("invite"); + expect(gotRoom!.currentState.getJoinRule()).toEqual(JoinRule.Invite); }); - it("uses the 'name' field to caluclate the room name", () => { + it("uses the 'name' field to caluclate the room name", async () => { mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomF, data[roomF]); + await emitPromise(client!, ClientEvent.Room); const gotRoom = client!.getRoom(roomF); - expect(gotRoom).toBeDefined(); - if (gotRoom == null) { - return; - } - expect(gotRoom.name).toEqual(data[roomF].name); + expect(gotRoom).toBeTruthy(); + expect(gotRoom!.name).toEqual(data[roomF].name); }); describe("updating", () => { @@ -419,33 +404,33 @@ describe("SlidingSyncSdk", () => { name: data[roomA].name, }); const gotRoom = client!.getRoom(roomA); - expect(gotRoom).toBeDefined(); + expect(gotRoom).toBeTruthy(); if (gotRoom == null) { return; } const newTimeline = data[roomA].timeline; newTimeline.push(newEvent); - assertTimelineEvents(gotRoom.getLiveTimeline().getEvents().slice(-3), newTimeline); + assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents().slice(-3), newTimeline); }); it("can update with a new required_state event", async () => { let gotRoom = client!.getRoom(roomB); - expect(gotRoom).toBeDefined(); + expect(gotRoom).toBeTruthy(); if (gotRoom == null) { return; } - expect(gotRoom.getJoinRule()).toEqual(JoinRule.Invite); // default + expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Invite); // default mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomB, { required_state: [mkOwnStateEvent("m.room.join_rules", { join_rule: "restricted" }, "")], timeline: [], name: data[roomB].name, }); gotRoom = client!.getRoom(roomB); - expect(gotRoom).toBeDefined(); + expect(gotRoom).toBeTruthy(); if (gotRoom == null) { return; } - expect(gotRoom.getJoinRule()).toEqual(JoinRule.Restricted); + expect(gotRoom!.getJoinRule()).toEqual(JoinRule.Restricted); }); it("can update with a new highlight_count", async () => { @@ -456,11 +441,11 @@ describe("SlidingSyncSdk", () => { highlight_count: 1, }); const gotRoom = client!.getRoom(roomC); - expect(gotRoom).toBeDefined(); + expect(gotRoom).toBeTruthy(); if (gotRoom == null) { return; } - expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1); + expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Highlight)).toEqual(1); }); it("can update with a new notification_count", async () => { @@ -471,11 +456,11 @@ describe("SlidingSyncSdk", () => { notification_count: 1, }); const gotRoom = client!.getRoom(roomD); - expect(gotRoom).toBeDefined(); + expect(gotRoom).toBeTruthy(); if (gotRoom == null) { return; } - expect(gotRoom.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1); + expect(gotRoom!.getUnreadNotificationCount(NotificationCountType.Total)).toEqual(1); }); it("can update with a new joined_count", () => { @@ -486,11 +471,11 @@ describe("SlidingSyncSdk", () => { joined_count: 1, }); const gotRoom = client!.getRoom(roomG); - expect(gotRoom).toBeDefined(); + expect(gotRoom).toBeTruthy(); if (gotRoom == null) { return; } - expect(gotRoom.getJoinedMemberCount()).toEqual(1); + expect(gotRoom!.getJoinedMemberCount()).toEqual(1); }); // Regression test for a bug which caused the timeline entries to be out-of-order @@ -512,7 +497,7 @@ describe("SlidingSyncSdk", () => { initial: true, // e.g requested via room subscription }); const gotRoom = client!.getRoom(roomA); - expect(gotRoom).toBeDefined(); + expect(gotRoom).toBeTruthy(); if (gotRoom == null) { return; } @@ -530,7 +515,7 @@ describe("SlidingSyncSdk", () => { ); // we expect the timeline now to be oldTimeline (so the old events are in fact old) - assertTimelineEvents(gotRoom.getLiveTimeline().getEvents(), oldTimeline); + assertTimelineEvents(gotRoom!.getLiveTimeline().getEvents(), oldTimeline); }); }); }); @@ -626,9 +611,9 @@ describe("SlidingSyncSdk", () => { await httpBackend!.flush("/profile", 1, 1000); await emitPromise(client!, RoomMemberEvent.Name); const room = client!.getRoom(roomId)!; - expect(room).toBeDefined(); + expect(room).toBeTruthy(); const inviteeMember = room.getMember(invitee)!; - expect(inviteeMember).toBeDefined(); + expect(inviteeMember).toBeTruthy(); expect(inviteeMember.getMxcAvatarUrl()).toEqual(inviteeProfile.avatar_url); expect(inviteeMember.name).toEqual(inviteeProfile.displayname); }); @@ -723,7 +708,7 @@ describe("SlidingSyncSdk", () => { ], }); globalData = client!.getAccountData(globalType)!; - expect(globalData).toBeDefined(); + expect(globalData).toBeTruthy(); expect(globalData.getContent()).toEqual(globalContent); }); @@ -744,6 +729,7 @@ describe("SlidingSyncSdk", () => { foo: "bar", }; const roomType = "test"; + await emitPromise(client!, ClientEvent.Room); ext.onResponse({ rooms: { [roomId]: [ @@ -755,9 +741,9 @@ describe("SlidingSyncSdk", () => { }, }); const room = client!.getRoom(roomId)!; - expect(room).toBeDefined(); + expect(room).toBeTruthy(); const event = room.getAccountData(roomType)!; - expect(event).toBeDefined(); + expect(event).toBeTruthy(); expect(event.getContent()).toEqual(roomContent); }); @@ -943,8 +929,9 @@ describe("SlidingSyncSdk", () => { ], initial: true, }); + await emitPromise(client!, ClientEvent.Room); const room = client!.getRoom(roomId)!; - expect(room).toBeDefined(); + expect(room).toBeTruthy(); expect(room.getMember(selfUserId)?.typing).toEqual(false); ext.onResponse({ rooms: { @@ -984,7 +971,7 @@ describe("SlidingSyncSdk", () => { initial: true, }); const room = client!.getRoom(roomId)!; - expect(room).toBeDefined(); + expect(room).toBeTruthy(); expect(room.getMember(selfUserId)?.typing).toEqual(false); ext.onResponse({ rooms: { @@ -1077,12 +1064,13 @@ describe("SlidingSyncSdk", () => { ], initial: true, }); + await emitPromise(client!, ClientEvent.Room); const room = client!.getRoom(roomId)!; - expect(room).toBeDefined(); + expect(room).toBeTruthy(); expect(room.getReadReceiptForUserId(alice, true)).toBeNull(); ext.onResponse(generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567)); const receipt = room.getReadReceiptForUserId(alice); - expect(receipt).toBeDefined(); + expect(receipt).toBeTruthy(); expect(receipt?.eventId).toEqual(lastEvent.event_id); expect(receipt?.data.ts).toEqual(1234567); expect(receipt?.data.thread_id).toBeFalsy(); From 2d6ee39869b569d3b0e28400e4d97f93b9e36862 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 12:49:46 +0100 Subject: [PATCH 08/19] Fix import --- spec/integ/sliding-sync-sdk.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index 39d4ac6016e..dfec79e1583 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -42,7 +42,7 @@ import { SyncApiOptions, SyncState } from "../../src/sync"; import { IStoredClientOpts } from "../../src/client"; import { logger } from "../../src/logger"; import { emitPromise } from "../test-utils/test-utils"; -import { defer } from "../../lib/utils"; +import { defer } from "../../src/utils"; describe("SlidingSyncSdk", () => { let client: MatrixClient | undefined; From bd8d7e54b96911a1a2f969bf058b8dcb77d35219 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 13:45:37 +0100 Subject: [PATCH 09/19] Stash thread ID of relations in unsigned to be stashed in sync accumulator --- src/models/event.ts | 1 + src/models/room.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 5c55449a3f5..69f5fc009f2 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -63,6 +63,7 @@ export interface IUnsigned { "transaction_id"?: string; "invite_room_state"?: StrippedState[]; "m.relations"?: Record; // No common pattern for aggregated relations + "io.element.relation_thread_id"?: string; } export interface IThreadBundledRelationship { diff --git a/src/models/room.ts b/src/models/room.ts index 147eb0f5fdb..8e4e35a3fa9 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -75,6 +75,8 @@ import { isPollEvent, Poll, PollEvent } from "./poll"; export const KNOWN_SAFE_ROOM_VERSION = "9"; const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; +const UNSIGNED_THREAD_ID_FIELD = "io.element.relation_thread_id"; + interface IOpts { /** * Controls where pending messages appear in a room's timeline. @@ -2148,6 +2150,15 @@ export class Room extends ReadReceipt { }; } + const unsigned = event.getUnsigned(); + if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD] === "string") { + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: unsigned[UNSIGNED_THREAD_ID_FIELD], + }; + } + // We've exhausted all scenarios, // we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread return { @@ -2796,8 +2807,16 @@ export class Room extends ReadReceipt { if (!shouldLiveInThread && !shouldLiveInRoom && event.isRelation()) { try { - const parentEvent = await this.client.fetchRoomEvent(this.roomId, event.relationEventId!); - neighbouringEvents.push(new MatrixEvent(parentEvent)); + const parentEvent = new MatrixEvent( + await this.client.fetchRoomEvent(this.roomId, event.relationEventId!), + ); + neighbouringEvents.push(parentEvent); + if (parentEvent.threadRootId) { + threadRoots.add(parentEvent.threadRootId); + const unsigned = event.getUnsigned(); + unsigned[UNSIGNED_THREAD_ID_FIELD] = parentEvent.threadRootId; + event.setUnsigned(unsigned); + } ({ shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn( event, @@ -2875,6 +2894,10 @@ export class Room extends ReadReceipt { if (event.isRelation(THREAD_RELATION_TYPE.name)) { threadRoots.add(event.relationEventId ?? ""); } + const unsigned = event.getUnsigned(); + if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD] === "string") { + threadRoots.add(unsigned[UNSIGNED_THREAD_ID_FIELD]); + } } return threadRoots; } From 05ed6409b35f5e9bea3b699d0abcaac3d02588c5 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 14:09:52 +0100 Subject: [PATCH 10/19] Persist after processing --- src/sync.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sync.ts b/src/sync.ts index 0620432c70a..5b6a13d50ed 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -899,8 +899,6 @@ export class SyncApi { // Reset after a successful sync this.failedSyncCount = 0; - await this.client.store.setSyncData(data); - const syncEventData = { oldSyncToken: syncToken ?? undefined, nextSyncToken: data.next_batch, @@ -924,6 +922,9 @@ export class SyncApi { this.client.emit(ClientEvent.SyncUnexpectedError, e); } + // Persist after processing as processing may mutate `unsigned` + await this.client.store.setSyncData(data); + // update this as it may have changed syncEventData.catchingUp = this.catchingUp; From 8e323ba10b46b8bfdc884ce8326d788c317cf930 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 14:39:19 +0100 Subject: [PATCH 11/19] Revert "Persist after processing" This reverts commit 05ed6409b35f5e9bea3b699d0abcaac3d02588c5. --- src/sync.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/sync.ts b/src/sync.ts index 5b6a13d50ed..0620432c70a 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -899,6 +899,8 @@ export class SyncApi { // Reset after a successful sync this.failedSyncCount = 0; + await this.client.store.setSyncData(data); + const syncEventData = { oldSyncToken: syncToken ?? undefined, nextSyncToken: data.next_batch, @@ -922,9 +924,6 @@ export class SyncApi { this.client.emit(ClientEvent.SyncUnexpectedError, e); } - // Persist after processing as processing may mutate `unsigned` - await this.client.store.setSyncData(data); - // update this as it may have changed syncEventData.catchingUp = this.catchingUp; From 5bdbb4d5d2c0118efc996e16023b9a41b5aa2d16 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 17:18:43 +0100 Subject: [PATCH 12/19] Update unsigned field name to match MSC4023 --- src/models/event.ts | 2 +- src/models/room.ts | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 69f5fc009f2..da412f40225 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -63,7 +63,7 @@ export interface IUnsigned { "transaction_id"?: string; "invite_room_state"?: StrippedState[]; "m.relations"?: Record; // No common pattern for aggregated relations - "io.element.relation_thread_id"?: string; + "org.matrix.msc4023.thread_id"?: string; } export interface IThreadBundledRelationship { diff --git a/src/models/room.ts b/src/models/room.ts index 8e4e35a3fa9..8d8c8a56183 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -75,8 +75,6 @@ import { isPollEvent, Poll, PollEvent } from "./poll"; export const KNOWN_SAFE_ROOM_VERSION = "9"; const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"]; -const UNSIGNED_THREAD_ID_FIELD = "io.element.relation_thread_id"; - interface IOpts { /** * Controls where pending messages appear in a room's timeline. @@ -2151,11 +2149,11 @@ export class Room extends ReadReceipt { } const unsigned = event.getUnsigned(); - if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD] === "string") { + if (typeof unsigned["org.matrix.msc4023.thread_id"] === "string") { return { shouldLiveInRoom: false, shouldLiveInThread: true, - threadId: unsigned[UNSIGNED_THREAD_ID_FIELD], + threadId: unsigned["org.matrix.msc4023.thread_id"], }; } @@ -2814,7 +2812,7 @@ export class Room extends ReadReceipt { if (parentEvent.threadRootId) { threadRoots.add(parentEvent.threadRootId); const unsigned = event.getUnsigned(); - unsigned[UNSIGNED_THREAD_ID_FIELD] = parentEvent.threadRootId; + unsigned["org.matrix.msc4023.thread_id"] = parentEvent.threadRootId; event.setUnsigned(unsigned); } @@ -2895,8 +2893,8 @@ export class Room extends ReadReceipt { threadRoots.add(event.relationEventId ?? ""); } const unsigned = event.getUnsigned(); - if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD] === "string") { - threadRoots.add(unsigned[UNSIGNED_THREAD_ID_FIELD]); + if (typeof unsigned["org.matrix.msc4023.thread_id"] === "string") { + threadRoots.add(unsigned["org.matrix.msc4023.thread_id"]); } } return threadRoots; From c0a2cef2ed7cf28f088664b3d0abe3798e66d71c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 31 May 2023 17:19:02 +0100 Subject: [PATCH 13/19] Persist after processing to store thread id in unsigned sync accumulator --- src/sync.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/sync.ts b/src/sync.ts index 0620432c70a..4c78aea89a0 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -899,8 +899,6 @@ export class SyncApi { // Reset after a successful sync this.failedSyncCount = 0; - await this.client.store.setSyncData(data); - const syncEventData = { oldSyncToken: syncToken ?? undefined, nextSyncToken: data.next_batch, @@ -924,6 +922,10 @@ export class SyncApi { this.client.emit(ClientEvent.SyncUnexpectedError, e); } + // Persist after processing as `unsigned` may get mutated + // with an `org.matrix.msc4023.thread_id` + await this.client.store.setSyncData(data); + // update this as it may have changed syncEventData.catchingUp = this.catchingUp; @@ -1627,16 +1629,17 @@ export class SyncApi { return Object.keys(obj) .filter((k) => !unsafeProp(k)) .map((roomId) => { - const arrObj = obj[roomId] as T & { room: Room; isBrandNewRoom: boolean }; let room = client.store.getRoom(roomId); let isBrandNewRoom = false; if (!room) { room = this.createRoom(roomId); isBrandNewRoom = true; } - arrObj.room = room; - arrObj.isBrandNewRoom = isBrandNewRoom; - return arrObj; + return { + ...obj[roomId], + room, + isBrandNewRoom, + }; }); } From 2dd21c7761fdc1fbe361c888f288dc2805b77c17 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jun 2023 10:34:54 +0100 Subject: [PATCH 14/19] Add test --- spec/integ/matrix-client-syncing.spec.ts | 124 +++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/spec/integ/matrix-client-syncing.spec.ts b/spec/integ/matrix-client-syncing.spec.ts index 86228037a46..baec3039ff4 100644 --- a/spec/integ/matrix-client-syncing.spec.ts +++ b/spec/integ/matrix-client-syncing.spec.ts @@ -36,11 +36,15 @@ import { NotificationCountType, IEphemeral, Room, + IndexedDBStore, + RelationType, } from "../../src"; import { ReceiptType } from "../../src/@types/read_receipts"; import { UNREAD_THREAD_NOTIFICATIONS } from "../../src/@types/sync"; import * as utils from "../test-utils/test-utils"; import { TestClient } from "../TestClient"; +import { emitPromise, mkEvent, mkMessage } from "../test-utils/test-utils"; +import { THREAD_RELATION_TYPE } from "../../src/models/thread"; describe("MatrixClient syncing", () => { const selfUserId = "@alice:localhost"; @@ -1867,4 +1871,124 @@ describe("MatrixClient syncing (IndexedDB version)", () => { idbClient.stopClient(); idbHttpBackend.stop(); }); + + it("should query server for which thread a 2nd order relation belongs to and stash in sync accumulator", async () => { + const roomId = "!room:example.org"; + + async function startClient(client: MatrixClient): Promise { + await Promise.all([ + idbClient.startClient({ + // Without this all events just go into the main timeline + threadSupport: true, + }), + idbHttpBackend.flushAllExpected(), + emitPromise(idbClient, ClientEvent.Room), + ]); + } + + function assertEventsExpected(client: MatrixClient): void { + const room = client.getRoom(roomId); + const mainTimelineEvents = room!.getLiveTimeline().getEvents(); + expect(mainTimelineEvents).toHaveLength(1); + expect(mainTimelineEvents[0].getContent().body).toEqual("Test"); + + const thread = room!.getThread("$someThreadId")!; + expect(thread.replayEvents).toHaveLength(1); + expect(thread.replayEvents![0].getRelation()!.key).toEqual("🪿"); + } + + let idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, { + store: new IndexedDBStore({ + indexedDB: global.indexedDB, + dbName: "test", + }), + }); + let idbHttpBackend = idbTestClient.httpBackend; + let idbClient = idbTestClient.client; + await idbClient.store.startup(); + + idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] }); + idbHttpBackend.when("GET", "/pushrules/").respond(200, {}); + idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + + const syncRoomSection = { + join: { + [roomId]: { + timeline: { + prev_batch: "foo", + events: [ + mkMessage({ + room: roomId, + user: selfUserId, + msg: "Test", + }), + mkEvent({ + room: roomId, + user: selfUserId, + content: { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: "$someUnknownEvent", + key: "🪿", + }, + }, + type: "m.reaction", + }), + ], + }, + }, + }, + }; + idbHttpBackend.when("GET", "/sync").respond(200, { + ...syncData, + rooms: syncRoomSection, + }); + idbHttpBackend.when("GET", `/rooms/${encodeURIComponent(roomId)}/event/%24someUnknownEvent`).respond( + 200, + mkEvent({ + room: roomId, + user: selfUserId, + content: { + "body": "Thread response", + "m.relates_to": { + rel_type: THREAD_RELATION_TYPE.name, + event_id: "$someThreadId", + }, + }, + type: "m.room.message", + }), + ); + + await startClient(idbClient); + assertEventsExpected(idbClient); + + idbHttpBackend.verifyNoOutstandingExpectation(); + // Force sync accumulator to persist, reset client, assert it doesn't re-fetch event on next start-up + await idbClient.store.save(true); + await idbClient.stopClient(); + await idbClient.store.destroy(); + await idbHttpBackend.stop(); + + idbTestClient = new TestClient(selfUserId, "DEVICE", selfAccessToken, undefined, { + store: new IndexedDBStore({ + indexedDB: global.indexedDB, + dbName: "test", + }), + }); + idbHttpBackend = idbTestClient.httpBackend; + idbClient = idbTestClient.client; + await idbClient.store.startup(); + + idbHttpBackend.when("GET", "/versions").respond(200, { versions: ["v1.4"] }); + idbHttpBackend.when("GET", "/pushrules/").respond(200, {}); + idbHttpBackend.when("POST", "/filter").respond(200, { filter_id: "a filter id" }); + idbHttpBackend.when("GET", "/sync").respond(200, syncData); + + await startClient(idbClient); + assertEventsExpected(idbClient); + + idbHttpBackend.verifyNoOutstandingExpectation(); + await idbClient.stopClient(); + await idbHttpBackend.stop(); + }); }); From bad9cc22a135aa4f0c8af254bc64f4673464f46d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jun 2023 10:35:21 +0100 Subject: [PATCH 15/19] Fix replayEvents getting doubled up due to Thread::addEvents being called in createThread and separately --- src/models/room.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/models/room.ts b/src/models/room.ts index 8d8c8a56183..43d6dbb56ac 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2173,14 +2173,13 @@ export class Room extends ReadReceipt { } private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void { - let thread = this.getThread(threadId); - - if (!thread) { + const thread = this.getThread(threadId); + if (thread) { + thread.addEvents(events, toStartOfTimeline); + } else { const rootEvent = this.findEventById(threadId) ?? events.find((e) => e.getId() === threadId); - thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline); + this.createThread(threadId, rootEvent, events, toStartOfTimeline); } - - thread.addEvents(events, toStartOfTimeline); } /** From a920630f7c47acc70361399a2076848f9b535c77 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jun 2023 10:40:02 +0100 Subject: [PATCH 16/19] Fix test --- spec/integ/matrix-client-event-timeline.spec.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index 26909850df4..680e408380b 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -1937,11 +1937,6 @@ describe("MatrixClient event timelines", function () { .respond(200, function () { return THREAD_ROOT; }); - httpBackend - .when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!)) - .respond(200, function () { - return THREAD_ROOT; - }); httpBackend .when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) .respond(200, function () { From 55566a732f45fcb02bd9850d1fa2d98a0a85cc3c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jun 2023 15:51:03 +0100 Subject: [PATCH 17/19] Switch to using UnstableValue --- src/@types/event.ts | 7 +++++++ src/models/event.ts | 22 ++++++++++++++++++---- src/models/room.ts | 23 ++++++----------------- 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index 17af8df0272..a0eca5cc011 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -235,6 +235,13 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue( "org.matrix.msc3890.local_notification_settings", ); +/** + * https://github.com/matrix-org/matrix-doc/pull/4023 + * + * @experimental + */ +export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id"); + export interface IEncryptedFile { url: string; mimetype?: string; diff --git a/src/models/event.ts b/src/models/event.ts index da412f40225..0c89fea407c 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -24,7 +24,13 @@ import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk"; import type { IEventDecryptionResult } from "../@types/crypto"; import { logger } from "../logger"; import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; -import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event"; +import { + EVENT_VISIBILITY_CHANGE_TYPE, + EventType, + MsgType, + RelationType, + UNSIGNED_THREAD_ID_FIELD, +} from "../@types/event"; import { Crypto } from "../crypto"; import { deepSortedObjectEntries, internaliseString } from "../utils"; import { RoomMember } from "./room-member"; @@ -63,7 +69,7 @@ export interface IUnsigned { "transaction_id"?: string; "invite_room_state"?: StrippedState[]; "m.relations"?: Record; // No common pattern for aggregated relations - "org.matrix.msc4023.thread_id"?: string; + [UNSIGNED_THREAD_ID_FIELD.name]?: string; } export interface IThreadBundledRelationship { @@ -573,8 +579,16 @@ export class MatrixEvent extends TypedEventEmitter { } // A thread relation is always only shown in a thread - if (event.isRelation(THREAD_RELATION_TYPE.name)) { + const threadRootId = event.threadRootId; + if (threadRootId != undefined) { return { shouldLiveInRoom: false, shouldLiveInThread: true, - threadId: event.threadRootId, + threadId: threadRootId, }; } @@ -2148,15 +2149,6 @@ export class Room extends ReadReceipt { }; } - const unsigned = event.getUnsigned(); - if (typeof unsigned["org.matrix.msc4023.thread_id"] === "string") { - return { - shouldLiveInRoom: false, - shouldLiveInThread: true, - threadId: unsigned["org.matrix.msc4023.thread_id"], - }; - } - // We've exhausted all scenarios, // we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread return { @@ -2888,12 +2880,9 @@ export class Room extends ReadReceipt { private findThreadRoots(events: MatrixEvent[]): Set { const threadRoots = new Set(); for (const event of events) { - if (event.isRelation(THREAD_RELATION_TYPE.name)) { - threadRoots.add(event.relationEventId ?? ""); - } - const unsigned = event.getUnsigned(); - if (typeof unsigned["org.matrix.msc4023.thread_id"] === "string") { - threadRoots.add(unsigned["org.matrix.msc4023.thread_id"]); + const threadRootId = event.threadRootId; + if (threadRootId != undefined) { + threadRoots.add(threadRootId); } } return threadRoots; From 660627fcef05f0bc841294ab7e903bb4ad8d9c38 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jun 2023 15:51:31 +0100 Subject: [PATCH 18/19] Add comment --- src/models/room.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/models/room.ts b/src/models/room.ts index 404be716ac8..71206eac10a 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -2151,6 +2151,7 @@ export class Room extends ReadReceipt { // We've exhausted all scenarios, // we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread + // adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts return { shouldLiveInRoom: false, shouldLiveInThread: false, From 156fcff941b910901253c1b0b7f1dc2d2d4829ea Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 1 Jun 2023 16:04:28 +0100 Subject: [PATCH 19/19] Iterate --- src/models/event.ts | 12 ++---------- src/models/room.ts | 26 +++++++++++++++++++------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 0c89fea407c..feb21fbba74 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -579,16 +579,8 @@ export class MatrixEvent extends TypedEventEmitter { } // A thread relation is always only shown in a thread - const threadRootId = event.threadRootId; - if (threadRootId != undefined) { + if (event.isRelation(THREAD_RELATION_TYPE.name)) { return { shouldLiveInRoom: false, shouldLiveInThread: true, - threadId: threadRootId, + threadId: event.threadRootId, }; } @@ -2149,6 +2149,15 @@ export class Room extends ReadReceipt { }; } + const unsigned = event.getUnsigned(); + if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD.name] === "string") { + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: unsigned[UNSIGNED_THREAD_ID_FIELD.name], + }; + } + // We've exhausted all scenarios, // we cannot assume that it lives in the main timeline as this may be a relation for an unknown thread // adding the event in the wrong timeline causes stuck notifications and can break ability to send read receipts @@ -2804,7 +2813,7 @@ export class Room extends ReadReceipt { if (parentEvent.threadRootId) { threadRoots.add(parentEvent.threadRootId); const unsigned = event.getUnsigned(); - unsigned["org.matrix.msc4023.thread_id"] = parentEvent.threadRootId; + unsigned[UNSIGNED_THREAD_ID_FIELD.name] = parentEvent.threadRootId; event.setUnsigned(unsigned); } @@ -2881,9 +2890,12 @@ export class Room extends ReadReceipt { private findThreadRoots(events: MatrixEvent[]): Set { const threadRoots = new Set(); for (const event of events) { - const threadRootId = event.threadRootId; - if (threadRootId != undefined) { - threadRoots.add(threadRootId); + if (event.isRelation(THREAD_RELATION_TYPE.name)) { + threadRoots.add(event.relationEventId ?? ""); + } + const unsigned = event.getUnsigned(); + if (typeof unsigned[UNSIGNED_THREAD_ID_FIELD.name] === "string") { + threadRoots.add(unsigned[UNSIGNED_THREAD_ID_FIELD.name]!); } } return threadRoots;