From 6b822ccd61f83362814b3da6a45acbe61b5f3bb0 Mon Sep 17 00:00:00 2001 From: Germain Date: Thu, 10 Feb 2022 15:09:46 +0000 Subject: [PATCH] Improve thread partitioning for 2nd degree relations (#2165) --- spec/integ/matrix-client-methods.spec.js | 169 ++++++----------------- src/@types/event.ts | 1 + src/client.ts | 95 +++++++++---- src/models/event-timeline-set.ts | 23 +-- src/models/event.ts | 12 +- src/models/relations.ts | 8 +- src/models/room.ts | 65 ++++----- src/models/thread.ts | 43 +++--- src/sync.ts | 2 +- 9 files changed, 190 insertions(+), 228 deletions(-) diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.js index 3f793241d..3c99e2862 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.js @@ -3,7 +3,6 @@ import { CRYPTO_ENABLED } from "../../src/client"; import { MatrixEvent } from "../../src/models/event"; import { Filter, MemoryStore, Room } from "../../src/matrix"; import { TestClient } from "../TestClient"; -import { Thread } from "../../src/models/thread"; describe("MatrixClient", function() { let client = null; @@ -405,6 +404,11 @@ describe("MatrixClient", function() { it("copies pre-thread in-timeline vote events onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; + + const eventMessageInThread = buildEventMessageInThread(); + const eventPollResponseReference = buildEventPollResponseReference(); + const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const events = [ eventMessageInThread, eventPollResponseReference, @@ -435,6 +439,11 @@ describe("MatrixClient", function() { it("copies pre-thread in-timeline reactions onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; + + const eventMessageInThread = buildEventMessageInThread(); + const eventReaction = buildEventReaction(); + const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const events = [ eventMessageInThread, eventReaction, @@ -456,6 +465,11 @@ describe("MatrixClient", function() { it("copies post-thread in-timeline vote events onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; + + const eventPollResponseReference = buildEventPollResponseReference(); + const eventMessageInThread = buildEventMessageInThread(); + const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const events = [ eventPollResponseReference, eventMessageInThread, @@ -475,119 +489,13 @@ describe("MatrixClient", function() { ]); }); - it("copies post-thread in-thread vote events onto both timelines", function() { - client.clientOpts = { experimentalThreadSupport: true }; - - // Events for this test only, because we hack around with them - const eventMessageInThread2 = new MatrixEvent({ - "age": 80098509, - "content": { - "algorithm": "m.megolm.v1.aes-sha2", - "ciphertext": "ENCRYPTEDSTUFF", - "device_id": "XISFUZSKHH", - "m.relates_to": { - "event_id": "$AAA2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", - "m.in_reply_to": { - "event_id": "$AAA2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", - }, - "rel_type": "io.element.thread", - }, - "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", - "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", - }, - "event_id": "$AAAhKIGYowtBblVLkRimeIg8TcdjETnxhDPGfi6NpDg", - "origin_server_ts": 1643815466378, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "type": "m.room.encrypted", - "unsigned": { "age": 80098509 }, - "user_id": "@andybalaam-test1:matrix.org", - }); - - const eventPollStartThreadRoot2 = new MatrixEvent({ - "age": 80108647, - "content": { - "algorithm": "m.megolm.v1.aes-sha2", - "ciphertext": "ENCRYPTEDSTUFF", - "device_id": "XISFUZSKHH", - "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", - "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", - }, - "event_id": "$AAA2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", - "origin_server_ts": 1643815456240, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "type": "m.room.encrypted", - "unsigned": { "age": 80108647 }, - "user_id": "@andybalaam-test1:matrix.org", - }); - - const eventPollResponseReference2 = new MatrixEvent({ - "age": 80098509, - "content": { - "algorithm": "m.megolm.v1.aes-sha2", - "ciphertext": "ENCRYPTEDSTUFF", - "device_id": "XISFUZSKHH", - "m.relates_to": { - "event_id": "$AAA2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", - "rel_type": "m.reference", - }, - "sender_key": "i3N3CtG/CD2bGB8rA9fW6adLYSDvlUhf2iuU73L65Vg", - "session_id": "Ja11R/KG6ua0wdk8zAzognrxjio1Gm/RK2Gn6lFL804", - }, - "event_id": "$AAAvpezvsF0cKgav3g8W-uEVS4WkDHgxbJZvL3uMR1g", - "origin_server_ts": 1643815458650, - "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", - "sender": "@andybalaam-test1:matrix.org", - "type": "m.room.encrypted", - "unsigned": { "age": 80106237 }, - "user_id": "@andybalaam-test1:matrix.org", - }); - - // When we react within a thread, sometimes the thread root - // has isThreadRelation === true, because thread is set on it, - // but threadId is not. - eventPollStartThreadRoot2.setThread( - new Thread( - eventPollStartThreadRoot2, - { - client, - room: new Room(), - }, - ), - ); - - const events = [ - eventPollResponseReference2, - eventMessageInThread2, - eventPollStartThreadRoot2, - ]; - - const [timeline, threaded] = client.partitionThreadedEvents(events); - - expect(timeline).toEqual([ - eventPollResponseReference2, - // eventPollStartThreadRoot2, - // This is weird: by hacking the thread root to have an inconsistency - // between thread and threadId (which is what I have observed in the - // wild), we have persuaded the code that the thread root is actually - // within the thread, so it is not provided to the main timeline. - // - // This should go away when we fix this inconsistency. When that - // happens, we should probably delete this test. - ]); - - expect(threaded).toEqual([ - withThreadId( - eventPollResponseReference2, eventPollStartThreadRoot2.getId(), - ), - eventMessageInThread2, - eventPollStartThreadRoot2, // See note above for why this appears here. - ]); - }); - it("copies post-thread in-timeline reactions onto both timelines", function() { client.clientOpts = { experimentalThreadSupport: true }; + + const eventReaction = buildEventReaction(); + const eventMessageInThread = buildEventMessageInThread(); + const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const events = [ eventReaction, eventMessageInThread, @@ -610,6 +518,19 @@ describe("MatrixClient", function() { it("sends room state events to the main timeline only", function() { client.clientOpts = { experimentalThreadSupport: true }; // This is based on recording the events in a real room: + + const eventMessageInThread = buildEventMessageInThread(); + const eventPollResponseReference = buildEventPollResponseReference(); + const eventPollStartThreadRoot = buildEventPollStartThreadRoot(); + const eventRoomName = buildEventRoomName(); + const eventEncryption = buildEventEncryption(); + const eventGuestAccess = buildEventGuestAccess(); + const eventHistoryVisibility = buildEventHistoryVisibility(); + const eventJoinRules = buildEventJoinRules(); + const eventPowerLevels = buildEventPowerLevels(); + const eventMember = buildEventMember(); + const eventCreate = buildEventCreate(); + const events = [ eventMessageInThread, eventPollResponseReference, @@ -655,7 +576,7 @@ function withThreadId(event, newThreadId) { return ret; } -const eventMessageInThread = new MatrixEvent({ +const buildEventMessageInThread = () => new MatrixEvent({ "age": 80098509, "content": { "algorithm": "m.megolm.v1.aes-sha2", @@ -680,7 +601,7 @@ const eventMessageInThread = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventPollResponseReference = new MatrixEvent({ +const buildEventPollResponseReference = () => new MatrixEvent({ "age": 80098509, "content": { "algorithm": "m.megolm.v1.aes-sha2", @@ -702,7 +623,7 @@ const eventPollResponseReference = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventReaction = new MatrixEvent({ +const buildEventReaction = () => new MatrixEvent({ "content": { "m.relates_to": { "event_id": "$VLS2ojbPmxb6x8ECetn45hmND6cRDcjgv-j-to9m7Vo", @@ -721,7 +642,7 @@ const eventReaction = new MatrixEvent({ "room_id": "!STrMRsukXHtqQdSeHa:matrix.org", }); -const eventPollStartThreadRoot = new MatrixEvent({ +const buildEventPollStartThreadRoot = () => new MatrixEvent({ "age": 80108647, "content": { "algorithm": "m.megolm.v1.aes-sha2", @@ -739,7 +660,7 @@ const eventPollStartThreadRoot = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventRoomName = new MatrixEvent({ +const buildEventRoomName = () => new MatrixEvent({ "age": 80123249, "content": { "name": "1 poll, 1 vote, 1 thread", @@ -754,7 +675,7 @@ const eventRoomName = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventEncryption = new MatrixEvent({ +const buildEventEncryption = () => new MatrixEvent({ "age": 80123383, "content": { "algorithm": "m.megolm.v1.aes-sha2", @@ -769,7 +690,7 @@ const eventEncryption = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventGuestAccess = new MatrixEvent({ +const buildEventGuestAccess = () => new MatrixEvent({ "age": 80123473, "content": { "guest_access": "can_join", @@ -784,7 +705,7 @@ const eventGuestAccess = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventHistoryVisibility = new MatrixEvent({ +const buildEventHistoryVisibility = () => new MatrixEvent({ "age": 80123556, "content": { "history_visibility": "shared", @@ -799,7 +720,7 @@ const eventHistoryVisibility = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventJoinRules = new MatrixEvent({ +const buildEventJoinRules = () => new MatrixEvent({ "age": 80123696, "content": { "join_rule": "invite", @@ -814,7 +735,7 @@ const eventJoinRules = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventPowerLevels = new MatrixEvent({ +const buildEventPowerLevels = () => new MatrixEvent({ "age": 80124105, "content": { "ban": 50, @@ -849,7 +770,7 @@ const eventPowerLevels = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventMember = new MatrixEvent({ +const buildEventMember = () => new MatrixEvent({ "age": 80125279, "content": { "avatar_url": "mxc://matrix.org/aNtbVcFfwotudypZcHsIcPOc", @@ -866,7 +787,7 @@ const eventMember = new MatrixEvent({ "user_id": "@andybalaam-test1:matrix.org", }); -const eventCreate = new MatrixEvent({ +const buildEventCreate = () => new MatrixEvent({ "age": 80126105, "content": { "creator": "@andybalaam-test1:matrix.org", diff --git a/src/@types/event.ts b/src/@types/event.ts index 1d03e23c4..98c7de32f 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -92,6 +92,7 @@ export enum EventType { export enum RelationType { Annotation = "m.annotation", Replace = "m.replace", + Reference = "m.reference", /** * Note, "io.element.thread" is hardcoded * Should be replaced with "m.thread" once MSC3440 lands diff --git a/src/client.ts b/src/client.ts index 3ebc0740f..64d669b3d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3594,17 +3594,20 @@ export class MatrixClient extends EventEmitter { threadId = null; } - if (threadId && content["m.relates_to"]?.rel_type !== RelationType.Thread) { + // If we expect that an event is part of a thread but is missing the relation + // we need to add it manually, as well as the reply fallback + if (threadId && !content["m.relates_to"]?.rel_type) { content["m.relates_to"] = { ...content["m.relates_to"], "rel_type": RelationType.Thread, "event_id": threadId, }; - const thread = this.getRoom(roomId)?.threads.get(threadId); if (thread) { content["m.relates_to"]["m.in_reply_to"] = { - "event_id": thread.replyToEvent.getId(), + "event_id": thread.lastReply((ev: MatrixEvent) => { + return ev.isThreadRelation && !ev.status; + }), }; } } @@ -3652,6 +3655,7 @@ export class MatrixClient extends EventEmitter { const thread = room?.threads.get(threadId); if (thread) { localEvent.setThread(thread); + localEvent.setThreadId(thread.id); } // if this is a relation or redaction of an event @@ -9089,6 +9093,52 @@ export class MatrixClient extends EventEmitter { return threadRoots; } + private eventShouldLiveIn(event: MatrixEvent, room: Room, events: MatrixEvent[], roots: Set): { + shouldLiveInRoom: boolean; + shouldLiveInThread: boolean; + threadId?: string; + } { + // A thread relation is always only shown in a thread + if (event.isThreadRelation) { + return { + shouldLiveInRoom: false, + shouldLiveInThread: true, + threadId: event.relationEventId, + }; + } + + const parentEventId = event.getAssociatedId(); + const parentEvent = room?.findEventById(parentEventId) || events.find((mxEv: MatrixEvent) => ( + mxEv.getId() === parentEventId + )); + + // A reaction targetting the thread root needs to be routed to both the + // the main timeline and the associated thread + const targetingThreadRoot = parentEvent?.isThreadRoot || roots.has(event.relationEventId); + if (targetingThreadRoot) { + return { + shouldLiveInRoom: true, + shouldLiveInThread: true, + threadId: event.relationEventId, + }; + } + + // If the parent event also has an associated ID we want to re-run the + // computation for that parent event. + // In the case of the redaction of a reaction that targets a root event + // we want that redaction to be pushed to both timeline + if (parentEvent?.getAssociatedId()) { + return this.eventShouldLiveIn(parentEvent, room, events, roots); + } else { + // We've exhausted all scenarios, can safely assume that this event + // should live in the room timeline + return { + shouldLiveInRoom: true, + shouldLiveInThread: false, + }; + } + } + public partitionThreadedEvents(events: MatrixEvent[]): [MatrixEvent[], MatrixEvent[]] { // Indices to the events array, for readibility const ROOM = 0; @@ -9097,35 +9147,22 @@ export class MatrixClient extends EventEmitter { const threadRoots = this.findThreadRoots(events); return events.reduce((memo, event: MatrixEvent) => { const room = this.getRoom(event.getRoomId()); - // An event should live in the thread timeline if - // - It's a reply in thread event - // - It's related to a reply in thread event - let shouldLiveInThreadTimeline = event.isThreadRelation; - if (!shouldLiveInThreadTimeline) { - const parentEventId = event.parentEventId; - const parentEvent = room?.findEventById(parentEventId) || events.find((mxEv: MatrixEvent) => { - return mxEv.getId() === parentEventId; - }); - const targetingThreadRoot = parentEvent?.isThreadRoot || threadRoots.has(event.relationEventId); - if (targetingThreadRoot && !event.isThreadRelation && event.relationEventId) { - // If we refer to the thread root, we should be copied - // into the thread as well as the main timeline. - // This happens for reactions, annotations, poll votes etc. - const copiedEvent = event.toSnapshot(); + const { + shouldLiveInRoom, + shouldLiveInThread, + threadId, + } = this.eventShouldLiveIn(event, room, events, threadRoots); - // The copied event is in this thread: - copiedEvent.setThreadId(parentEventId); - memo[THREAD].push(copiedEvent); - } else if (parentEvent?.isThreadRelation) { - // If our parent is in a thread, we are in that - // same thread too. (E.g. if I reply within a thread.) - shouldLiveInThreadTimeline = true; - event.setThreadId(parentEvent.threadRootId); - } + if (shouldLiveInRoom) { + memo[ROOM].push(event); } - const targetTimeline = shouldLiveInThreadTimeline ? THREAD : ROOM; - memo[targetTimeline].push(event); + + if (shouldLiveInThread) { + event.setThreadId(threadId); + memo[THREAD].push(event); + } + return memo; }, [[], []]); } else { diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index b39cb80b2..03408c08b 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -28,7 +28,6 @@ import { Room } from "./room"; import { Filter } from "../filter"; import { EventType, RelationType } from "../@types/event"; import { RoomState } from "./room-state"; -import { Thread } from "./thread"; // var DEBUG = false; const DEBUG = true; @@ -159,17 +158,12 @@ export class EventTimelineSet extends EventEmitter { * * @throws If opts.pendingEventOrdering was not 'detached' */ - public getPendingEvents(thread?: Thread): MatrixEvent[] { + public getPendingEvents(): MatrixEvent[] { if (!this.room || !this.displayPendingEvents) { return []; } - const pendingEvents = this.room.getPendingEvents(thread); - if (this.filter) { - return this.filter.filterRoomTimeline(pendingEvents); - } else { - return pendingEvents; - } + return this.room.getPendingEvents(); } /** * Get the live timeline for this room. @@ -756,7 +750,7 @@ export class EventTimelineSet extends EventEmitter { */ public getRelationsForEvent( eventId: string, - relationType: RelationType, + relationType: RelationType | string, eventType: EventType | string, ): Relations | undefined { if (!this.unstableClientRelationAggregation) { @@ -774,6 +768,17 @@ export class EventTimelineSet extends EventEmitter { return relationsWithRelType[eventType]; } + public getAllRelationsEventForEvent(eventId: string): MatrixEvent[] { + const relationsForEvent = this.relations[eventId] || {}; + const events = []; + for (const relationsRecord of Object.values(relationsForEvent)) { + for (const relations of Object.values(relationsRecord)) { + events.push(...relations.getRelations()); + } + } + return events; + } + /** * Set an event as the target event if any Relations exist for it already * diff --git a/src/models/event.ts b/src/models/event.ts index 147758056..9b04ae099 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -512,8 +512,7 @@ export class MatrixEvent extends EventEmitter { if (relatesTo?.rel_type === RelationType.Thread) { return relatesTo.event_id; } else { - return this.threadId - || this.getThread()?.id; + return this.getThread()?.id || this.threadId; } } @@ -537,10 +536,6 @@ export class MatrixEvent extends EventEmitter { return !!threadDetails || (this.getThread()?.id === this.getId()); } - public get parentEventId(): string { - return this.replyEventId || this.relationEventId; - } - public get replyEventId(): string { // We're prefer ev.getContent() over ev.getWireContent() to make sure // we grab the latest edit with potentially new relations. But we also @@ -1427,7 +1422,9 @@ export class MatrixEvent extends EventEmitter { */ public getAssociatedId(): string | undefined { const relation = this.getRelation(); - if (relation) { + if (this.replyEventId) { + return this.replyEventId; + } else if (relation) { return relation.event_id; } else if (this.isRedaction()) { return this.event.redacts; @@ -1561,6 +1558,7 @@ export class MatrixEvent extends EventEmitter { */ public setThread(thread: Thread): void { this.thread = thread; + this.setThreadId(thread.id); this.reEmitter.reEmit(thread, [ThreadEvent.Ready, ThreadEvent.Update]); } diff --git a/src/models/relations.ts b/src/models/relations.ts index 2021b15a4..29adaab66 100644 --- a/src/models/relations.ts +++ b/src/models/relations.ts @@ -171,11 +171,11 @@ export class Relations extends EventEmitter { * @return {Array} * Relation events in insertion order. */ - public getRelations() { + public getRelations(): MatrixEvent[] { return [...this.relations]; } - private addAnnotationToAggregation(event: MatrixEvent) { + private addAnnotationToAggregation(event: MatrixEvent): void { const { key } = event.getRelation(); if (!key) { return; @@ -204,7 +204,7 @@ export class Relations extends EventEmitter { eventsFromSender.add(event); } - private removeAnnotationFromAggregation(event: MatrixEvent) { + private removeAnnotationFromAggregation(event: MatrixEvent): void { const { key } = event.getRelation(); if (!key) { return; @@ -240,7 +240,7 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} redactedEvent * The original relation event that is about to be redacted. */ - private onBeforeRedaction = async (redactedEvent: MatrixEvent) => { + private onBeforeRedaction = async (redactedEvent: MatrixEvent): Promise => { if (!this.relations.has(redactedEvent)) { return; } diff --git a/src/models/room.ts b/src/models/room.ts index 9c59ae88b..8132cc10e 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -512,16 +512,14 @@ export class Room extends EventEmitter { * * @throws If opts.pendingEventOrdering was not 'detached' */ - public getPendingEvents(thread?: Thread): MatrixEvent[] { + public getPendingEvents(): MatrixEvent[] { if (this.opts.pendingEventOrdering !== PendingEventOrdering.Detached) { throw new Error( "Cannot call getPendingEvents with pendingEventOrdering == " + this.opts.pendingEventOrdering); } - return this.pendingEventList.filter(event => { - return !thread || thread.id === event.threadRootId; - }); + return this.pendingEventList; } /** @@ -1358,7 +1356,7 @@ export class Room extends EventEmitter { } else if (event.isThreadRoot) { return this.threads.get(event.getId()); } else { - const parentEvent = this.findEventById(event.parentEventId); + const parentEvent = this.findEventById(event.getAssociatedId()); return this.findThreadForEvent(parentEvent); } } @@ -1396,21 +1394,15 @@ export class Room extends EventEmitter { } } - if (event.getUnsigned().transaction_id) { - const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id]; - if (existingEvent) { - // remote echo of an event we sent earlier - this.handleRemoteEcho(event, existingEvent); - return; - } - } - this.emit(ThreadEvent.Update, thread); } - public createThread(rootEvent: MatrixEvent, events?: MatrixEvent[]): Thread | undefined { + public createThread(rootEvent: MatrixEvent, events: MatrixEvent[] = []): Thread | undefined { + const tl = this.getTimelineForEvent(rootEvent.getId()); + const relatedEvents = tl?.getTimelineSet().getAllRelationsEventForEvent(rootEvent.getId()) ?? []; + const thread = new Thread(rootEvent, { - initialEvents: events, + initialEvents: events.concat(relatedEvents), room: this, client: this.client, }); @@ -1564,8 +1556,7 @@ export class Room extends EventEmitter { EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS), false); this.txnToEvent[txnId] = event; - const thread = this.findThreadForEvent(event); - if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached && !thread) { + if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) { if (this.pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) { logger.warn("Setting event as NOT_SENT due to messages in the same state"); event.setStatus(EventStatus.NOT_SENT); @@ -1581,8 +1572,7 @@ export class Room extends EventEmitter { if (event.isRedaction()) { const redactId = event.event.redacts; - let redactedEvent = this.pendingEventList && - this.pendingEventList.find(e => e.getId() === redactId); + let redactedEvent = this.pendingEventList?.find(e => e.getId() === redactId); if (!redactedEvent) { redactedEvent = this.findEventById(redactId); } @@ -1592,20 +1582,16 @@ export class Room extends EventEmitter { } } } else { - if (thread) { - thread.addEvent(event, false); - } else { - for (let i = 0; i < this.timelineSets.length; i++) { - const timelineSet = this.timelineSets[i]; - if (timelineSet.getFilter()) { - if (timelineSet.getFilter().filterRoomTimeline([event]).length) { - timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); - } - } else { + for (let i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + if (timelineSet.getFilter()) { + if (timelineSet.getFilter().filterRoomTimeline([event]).length) { timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), false); } + } else { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); } } } @@ -1666,7 +1652,9 @@ export class Room extends EventEmitter { const thread = this.findThreadForEvent(event); if (thread) { thread.timelineSet.aggregateRelations(event); - } else { + } + + if (thread?.id === event.getAssociatedId() || !thread) { // TODO: We should consider whether this means it would be a better // design to lift the relations handling up to the room instead. for (let i = 0; i < this.timelineSets.length; i++) { @@ -1682,6 +1670,10 @@ export class Room extends EventEmitter { } } + public getEventForTxnId(txnId: string): MatrixEvent { + return this.txnToEvent[txnId]; + } + /** * Deal with the echo of a message we sent. * @@ -1696,7 +1688,7 @@ export class Room extends EventEmitter { * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" * @private */ - private handleRemoteEcho(remoteEvent: MatrixEvent, localEvent: MatrixEvent): void { + public handleRemoteEcho(remoteEvent: MatrixEvent, localEvent: MatrixEvent): void { const oldEventId = localEvent.getId(); const newEventId = remoteEvent.getId(); const oldStatus = localEvent.status; @@ -1721,7 +1713,9 @@ export class Room extends EventEmitter { const thread = this.findThreadForEvent(remoteEvent); if (thread) { thread.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); - } else { + } + + if (thread?.id === remoteEvent.getAssociatedId() || !thread) { for (let i = 0; i < this.timelineSets.length; i++) { const timelineSet = this.timelineSets[i]; @@ -1791,7 +1785,8 @@ export class Room extends EventEmitter { const thread = this.findThreadForEvent(event); if (thread) { thread.timelineSet.replaceEventId(oldEventId, newEventId); - } else { + } + if (thread?.id === event.getAssociatedId() || !thread) { // if the event was already in the timeline (which will be the case if // opts.pendingEventOrdering==chronological), we need to update the // timeline map. diff --git a/src/models/thread.ts b/src/models/thread.ts index 8dbbc53f3..9465cc6a9 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -113,6 +113,27 @@ export class Thread extends TypedEventEmitter { return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS); } + private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void { + if (event.getUnsigned().transaction_id) { + const existingEvent = this.room.getEventForTxnId(event.getUnsigned().transaction_id); + if (existingEvent) { + // remote echo of an event we sent earlier + this.room.handleRemoteEcho(event, existingEvent); + return; + } + } + + if (!this.findEventById(event.getId())) { + this.timelineSet.addEventToTimeline( + event, + this.liveTimeline, + toStartOfTimeline, + false, + this.roomState, + ); + } + } + /** * Add an event to the thread and updates * the tail/root references if needed @@ -123,36 +144,20 @@ export class Thread extends TypedEventEmitter { // Add all incoming events to the thread's timeline set when there's // no server support if (!this.hasServerSideSupport) { - if (this.timelineSet.findEventById(event.getId())) { - return; - } - // all the relevant membership info to hydrate events with a sender // is held in the main room timeline // We want to fetch the room state from there and pass it down to this thread // timeline set to let it reconcile an event with its relevant RoomMember event.setThread(this); - this.timelineSet.addEventToTimeline( - event, - this.liveTimeline, - toStartOfTimeline, - false, - this.roomState, - ); + this.addEventToTimeline(event, toStartOfTimeline); await this.client.decryptEventIfNeeded(event, {}); } if (this.hasServerSideSupport && this.initialEventsFetched) { - if (event.localTimestamp > this.lastReply().localTimestamp && !this.findEventById(event.getId())) { - this.timelineSet.addEventToTimeline( - event, - this.liveTimeline, - false, - false, - this.roomState, - ); + if (event.localTimestamp > this.lastReply().localTimestamp) { + this.addEventToTimeline(event, false); } } diff --git a/src/sync.ts b/src/sync.ts index 69ce74869..c0da84c44 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1727,7 +1727,7 @@ export class SyncApi { // extractRelatedEvents(event: MatrixEvent, events: MatrixEvent[], relatedEvents: MatrixEvent[] = []): MatrixEvent[] { // relatedEvents.push(event); - // const parentEventId = event.parentEventId; + // const parentEventId = event.getAssociatedId(); // const parentEventIndex = events.findIndex(event => event.getId() === parentEventId); // if (parentEventIndex > -1) {