From 54731b3f1647e4c37713e7b98dd22eedfc510f45 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 21 Dec 2022 18:59:45 +0100 Subject: [PATCH] Apply edits discovered from sync after thread is initialised --- spec/unit/event-timeline-set.spec.ts | 68 +++++++++++++++++++++++++++- src/models/thread.ts | 33 ++++++++++++-- 2 files changed, 95 insertions(+), 6 deletions(-) diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index 2831b6ca608..3e1a4b5115b 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -24,9 +24,11 @@ import { MatrixClient, MatrixEvent, MatrixEventEvent, + RelationType, Room, + RoomEvent, } from "../../src"; -import { Thread } from "../../src/models/thread"; +import { FeatureSupport, Thread } from "../../src/models/thread"; import { ReEmitter } from "../../src/ReEmitter"; describe("EventTimelineSet", () => { @@ -202,6 +204,70 @@ describe("EventTimelineSet", () => { expect(liveTimeline.getEvents().length).toStrictEqual(0); }); + it("should allow edits to be added to thread timeline", async () => { + jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true); + Thread.hasServerSideSupport = FeatureSupport.Stable; + + const sender = "@alice:matrix.org"; + + const root = utils.mkEvent({ + event: true, + content: { + body: "Thread root", + }, + type: EventType.RoomMessage, + sender, + }); + room.addLiveEvents([root]); + + const threadReply = utils.mkEvent({ + event: true, + content: { + "body": "Thread reply", + "m.relates_to": { + event_id: root.getId()!, + rel_type: RelationType.Thread, + }, + }, + type: EventType.RoomMessage, + sender, + }); + + const editToThreadReply = utils.mkEvent({ + event: true, + content: { + "body": " * edit", + "m.new_content": { + "body": "edit", + "msgtype": "m.text", + "org.matrix.msc1767.text": "edit", + }, + "m.relates_to": { + event_id: threadReply.getId()!, + rel_type: RelationType.Replace, + }, + }, + type: EventType.RoomMessage, + sender, + }); + + const thread = room.createThread(root.getId()!, root, [threadReply, editToThreadReply], false); + + jest.spyOn(thread, "processEvent").mockResolvedValue(); + jest.spyOn(client, "paginateEventTimeline").mockImplementation(async () => { + thread.timelineSet.getLiveTimeline().addEvent(threadReply, { toStartOfTimeline: true }); + return true; + }); + jest.spyOn(client, "relations").mockResolvedValue({ + events: [], + }); + + thread.once(RoomEvent.TimelineReset, () => { + const lastEvent = thread.timeline.at(-1)!; + expect(lastEvent.getContent().body).toBe(" * edit"); + }); + }); + describe("non-room timeline", () => { it("Adds event to timeline", () => { const nonRoomEventTimelineSet = new EventTimelineSet( diff --git a/src/models/thread.ts b/src/models/thread.ts index bca2d92b427..7ff3610e11a 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -97,6 +97,11 @@ export class Thread extends ReadReceipt { private readonly pendingEventOrdering: PendingEventOrdering; public initialEventsFetched = !Thread.hasServerSideSupport; + /** + * An array of events to add to the timeline once the thread has been initialised + * with server suppport. + */ + public replayEvents: MatrixEvent[] | null = []; public constructor(public readonly id: string, public rootEvent: MatrixEvent | undefined, opts: IThreadOpts) { super(); @@ -136,7 +141,7 @@ export class Thread extends ReadReceipt { this.setEventMetadata(this.rootEvent); } - private async fetchRootEvent(): Promise { + public async fetchRootEvent(): Promise { this.rootEvent = this.room.findEventById(this.id); // If the rootEvent does not exist in the local stores, then fetch it from the server. try { @@ -266,9 +271,23 @@ export class Thread extends ReadReceipt { this.addEventToTimeline(event, false); this.fetchEditsWhereNeeded(event); } else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) { - // Apply annotations and replace relations to the relations of the timeline only - this.timelineSet.relations?.aggregateParentEvent(event); - this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet); + if (!this.initialEventsFetched) { + /** + * A thread can be fully discovered via a single sync response + * And when that's the case we still ask the server to do an initialisation + * as it's the safest to ensure we have everything. + * However when we are in that scenario we might loose annotation or edits + * + * This fix keeps a reference to those events and replay them once the thread + * has been initialised properly. + */ + this.replayEvents?.push(event); + } else { + this.addEventToTimeline(event, toStartOfTimeline); + // Apply annotations and replace relations to the relations of the timeline only + this.timelineSet.relations?.aggregateParentEvent(event); + this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet); + } return; } @@ -316,7 +335,7 @@ export class Thread extends ReadReceipt { return rootEvent?.getServerAggregatedRelation(THREAD_RELATION_TYPE.name); } - private async processRootEvent(): Promise { + public async processRootEvent(): Promise { const bundledRelationship = this.getRootEventBundledRelationship(); if (Thread.hasServerSideSupport && bundledRelationship) { this.replyCount = bundledRelationship.count; @@ -375,6 +394,10 @@ export class Thread extends ReadReceipt { limit: Math.max(1, this.length), }); } + for (const event of this.replayEvents!) { + this.addEvent(event, false); + } + this.replayEvents = null; // just to make sure that, if we've created a timeline window for this thread before the thread itself // existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly. this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);