diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index dbcb48acda7..bb4e9563431 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -21,11 +21,13 @@ import { EventStatus, EventTimeline, EventTimelineSet, + EventType, Filter, IEvent, MatrixClient, MatrixEvent, PendingEventOrdering, + RelationType, Room, } from "../../src/matrix"; import { logger } from "../../src/logger"; @@ -33,6 +35,7 @@ import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/util import { TestClient } from "../TestClient"; import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread"; import { emitPromise } from "../test-utils/test-utils"; +import { Feature, ServerSupport } from "../../src/feature"; const userId = "@alice:localhost"; const userName = "Alice"; @@ -1164,6 +1167,117 @@ describe("MatrixClient event timelines", function () { ]); }); + it("should ensure thread events don't get reordered with recursive relations", async () => { + // Test data for a second reply to the first thread + const THREAD_REPLY2 = utils.mkEvent({ + room: roomId, + user: userId, + type: "m.room.message", + content: { + "body": "thread reply 2", + "msgtype": "m.text", + "m.relates_to": { + // We can't use the const here because we change server support mode for test + rel_type: "io.element.thread", + event_id: THREAD_ROOT.event_id, + }, + }, + event: true, + }); + THREAD_REPLY2.localTimestamp += 1000; + const THREAD_ROOT_REACTION = utils.mkEvent({ + event: true, + type: EventType.Reaction, + user: userId, + room: roomId, + content: { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: THREAD_ROOT.event_id!, + key: Math.random().toString(), + }, + }, + }); + THREAD_ROOT_REACTION.localTimestamp += 2000; + + // Test data for a second reply to the first thread + const THREAD_REPLY3 = utils.mkEvent({ + room: roomId, + user: userId, + type: "m.room.message", + content: { + "body": "thread reply 3", + "msgtype": "m.text", + "m.relates_to": { + // We can't use the const here because we change server support mode for test + rel_type: "io.element.thread", + event_id: THREAD_ROOT.event_id, + }, + }, + event: true, + }); + THREAD_REPLY3.localTimestamp += 3000; + + // Test data for the first thread, with the second reply + const THREAD_ROOT_UPDATED = { + ...THREAD_ROOT, + unsigned: { + ...THREAD_ROOT.unsigned, + "m.relations": { + ...THREAD_ROOT.unsigned!["m.relations"], + "io.element.thread": { + ...THREAD_ROOT.unsigned!["m.relations"]!["io.element.thread"], + count: 3, + latest_event: THREAD_REPLY3.event, + }, + }, + }, + }; + + // @ts-ignore + client.clientOpts.threadSupport = true; + client.canSupport.set(Feature.RelationsRecursion, ServerSupport.Stable); + Thread.setServerSideSupport(FeatureSupport.Stable); + Thread.setServerSideListSupport(FeatureSupport.Stable); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable); + + client.fetchRoomEvent = () => Promise.resolve(THREAD_ROOT_UPDATED); + + await client.stopClient(); // we don't need the client to be syncing at this time + const room = client.getRoom(roomId)!; + + const prom = emitPromise(room, ThreadEvent.Update); + // Assume we're seeing the reply while loading backlog + room.addLiveEvents([THREAD_REPLY2]); + httpBackend + .when( + "GET", + "/_matrix/client/v1/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) + + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + + buildRelationPaginationQuery({ + dir: Direction.Backward, + limit: 3, + recurse: true, + }), + ) + .respond(200, { + chunk: [THREAD_REPLY3.event, THREAD_ROOT_REACTION, THREAD_REPLY2.event, THREAD_REPLY], + }); + await flushHttp(prom); + // but while loading the metadata, a new reply has arrived + 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([ + THREAD_ROOT.event_id, + THREAD_REPLY.event_id, + THREAD_REPLY2.getId(), + THREAD_REPLY3.getId(), + ]); + }); + describe("paginateEventTimeline for thread list timeline", function () { const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c"; @@ -1847,7 +1961,10 @@ describe("MatrixClient event timelines", function () { encodeURIComponent(THREAD_ROOT.event_id!) + "/" + encodeURIComponent(THREAD_RELATION_TYPE.name) + - buildRelationPaginationQuery({ dir: Direction.Backward, from: "start_token" }), + buildRelationPaginationQuery({ + dir: Direction.Backward, + from: "start_token", + }), ) .respond(200, function () { return { diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index 4a624268c8a..a099aba0370 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -82,6 +82,7 @@ describe("EventTimelineSet", () => { beforeEach(() => { client = utils.mock(MatrixClient, "MatrixClient"); client.reEmitter = utils.mock(ReEmitter, "ReEmitter"); + client.canSupport = new Map(); room = new Room(roomId, client, userA); eventTimelineSet = new EventTimelineSet(room); eventTimeline = new EventTimeline(eventTimelineSet); diff --git a/src/@types/requests.ts b/src/@types/requests.ts index 12f4d8eb00a..364b9f12c2a 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -186,6 +186,7 @@ export interface IRelationsRequestOpts { to?: string; limit?: number; dir?: Direction; + recurse?: boolean; // MSC3981 Relations Recursion /~https://github.com/matrix-org/matrix-spec-proposals/pull/3981 } export interface IRelationsResponse { diff --git a/src/client.ts b/src/client.ts index ea53b62f28c..c545c2bbd49 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5736,6 +5736,7 @@ export class MatrixClient extends TypedEventEmitter = res.end; @@ -5813,7 +5814,7 @@ export class MatrixClient extends TypedEventEmitter { const mapper = this.getEventMapper(); @@ -7956,6 +7960,9 @@ export class MatrixClient extends TypedEventEmitter = { [Feature.AccountDataDeletion]: { unstablePrefixes: ["org.matrix.msc3391"], }, + [Feature.RelationsRecursion]: { + unstablePrefixes: ["org.matrix.msc3981"], + }, }; export async function buildFeatureSupportMap(versions: IServerVersions): Promise> { diff --git a/src/models/thread.ts b/src/models/thread.ts index 9a4ead33bc9..c31499b2b34 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -28,6 +28,7 @@ import { ServerControlledNamespacedValue } from "../NamespacedValue"; import { logger } from "../logger"; import { ReadReceipt } from "./read-receipt"; import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts"; +import { Feature, ServerSupport } from "../feature"; export enum ThreadEvent { New = "Thread.new", @@ -458,25 +459,28 @@ export class Thread extends ReadReceipt { // XXX: Workaround for /~https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084 private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise { - return Promise.all( - events - .filter((e) => e.isEncrypted()) - .map((event: MatrixEvent) => { - if (event.isRelation()) return; // skip - relations don't get edits - return this.client - .relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), { - limit: 1, - }) - .then((relations) => { - if (relations.events.length) { - event.makeReplaced(relations.events[0]); - } - }) - .catch((e) => { - logger.error("Failed to load edits for encrypted thread event", e); - }); - }), - ); + const recursionSupport = this.client.canSupport.get(Feature.RelationsRecursion) ?? ServerSupport.Unsupported; + if (recursionSupport !== ServerSupport.Unsupported) { + return Promise.all( + events + .filter((e) => e.isEncrypted()) + .map((event: MatrixEvent) => { + if (event.isRelation()) return; // skip - relations don't get edits + return this.client + .relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), { + limit: 1, + }) + .then((relations) => { + if (relations.events.length) { + event.makeReplaced(relations.events[0]); + } + }) + .catch((e) => { + logger.error("Failed to load edits for encrypted thread event", e); + }); + }), + ); + } } public setEventMetadata(event: Optional): void {