diff --git a/.gitignore b/.gitignore index 4fc51ab8595..2e74154ce4f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules /*.log package-lock.json .lock-wscript +.DS_Store build/Release coverage lib-cov diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index f2bfa5f6ad2..4b8f3e0745c 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -669,7 +669,7 @@ describe("MatrixClient event timelines", function() { expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); }); - it("should return undefined when event is not in the thread that the given timelineSet is representing", () => { + it("should not include main timeline event when timelineSet is representing a thread", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; Thread.setServerSideSupport(FeatureSupport.Experimental); @@ -691,13 +691,40 @@ describe("MatrixClient event timelines", function() { }; }); - return Promise.all([ - expect(client.getEventTimeline(timelineSet, EVENTS[0].event_id!)).resolves.toBeUndefined(), - httpBackend.flushAllExpected(), - ]); + // getEventTimeline -> thread.fetchInitialEvents + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20&dir=b") + .respond(200, function() { + return { + original_event: THREAD_ROOT, + chunk: [THREAD_REPLY], + // no next batch as this is the oldest end of the timeline + }; + }); + + // getEventTimeline -> thread.fetchEvents + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&limit=50") + .respond(200, function() { + return { + original_event: THREAD_ROOT, + chunk: [THREAD_REPLY], + // no next batch as this is the oldest end of the timeline + }; + }); + + const timelinePromise = client.getEventTimeline(timelineSet, EVENTS[0].event_id!); + await httpBackend.flushAllExpected(); + + const timeline = await timelinePromise; + + // The main timeline event should not be in the timelineSet representing a thread + expect(timeline.getEvents().find(e => e.getId() === EVENTS[0].event_id!)).toBeFalsy(); }); - it("should return undefined when event is within a thread but timelineSet is not", () => { + it("should not include threaded reply when timelineSet is representing the main room", async () => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; Thread.setServerSideSupport(FeatureSupport.Experimental); @@ -717,10 +744,13 @@ describe("MatrixClient event timelines", function() { }; }); - return Promise.all([ - expect(client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!)).resolves.toBeUndefined(), - httpBackend.flushAllExpected(), - ]); + const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); + await httpBackend.flushAllExpected(); + + const timeline = await timelinePromise; + + // The threaded reply should not be in a main room timeline + expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id!)).toBeFalsy(); }); it("should should add lazy loading filter when requested", async () => { @@ -753,6 +783,11 @@ describe("MatrixClient event timelines", function() { }); describe("getLatestTimeline", function() { + beforeEach(() => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + }); + it("timeline support must be enabled to work", async function() { await client.stopClient(); @@ -768,7 +803,7 @@ describe("MatrixClient event timelines", function() { await startClient(httpBackend, client); const room = client.getRoom(roomId); - const timelineSet = room.getTimelineSets()[0]; + const timelineSet = room!.getTimelineSets()[0]; await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy(); }); @@ -860,6 +895,64 @@ describe("MatrixClient event timelines", function() { ]); }); + it("should successfully create a new timeline even when the latest event is a threaded reply", function() { + const room = client.getRoom(roomId); + const timelineSet = room!.getTimelineSets()[0]; + expect(timelineSet.thread).toBeUndefined(); + + const latestMessageId = 'threadedEvent1:bar'; + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .respond(200, function() { + return { + chunk: [{ + event_id: latestMessageId, + }], + }; + }); + + httpBackend.when("GET", `/rooms/!foo%3Abar/context/${encodeURIComponent(latestMessageId)}`) + .respond(200, function() { + return { + start: "start_token", + events_before: [THREAD_ROOT, EVENTS[0]], + event: THREAD_REPLY, + events_after: [], + state: [ + ROOM_NAME_EVENT, + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + }); + + // Make it easy to debug when there is a mismatch of events. We care + // about the event ID for direct comparison and the content for a + // human readable description. + const eventPropertiesToCompare = (event) => { + return { + eventId: event.event_id || event.getId(), + contentBody: event.content?.body || event.getContent()?.body, + }; + }; + return Promise.all([ + client.getLatestTimeline(timelineSet).then(function(tl) { + const events = tl!.getEvents(); + const expectedEvents = [EVENTS[0], THREAD_ROOT]; + expect(events.map(event => eventPropertiesToCompare(event))) + .toEqual(expectedEvents.map(event => eventPropertiesToCompare(event))); + // Sanity check: The threaded reply should not be in the timeline + expect(events.find(e => e.getId() === THREAD_REPLY.event_id)).toBeFalsy(); + + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token"); + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) + .toEqual("end_token"); + }), + httpBackend.flushAllExpected(), + ]); + }); + it("should throw error when /messages does not return a message", () => { const room = client.getRoom(roomId)!; const timelineSet = room.getTimelineSets()[0]; diff --git a/spec/integ/matrix-client-room-timeline.spec.ts b/spec/integ/matrix-client-room-timeline.spec.ts index 42d90d91c73..ec84ab8a20d 100644 --- a/spec/integ/matrix-client-room-timeline.spec.ts +++ b/spec/integ/matrix-client-room-timeline.spec.ts @@ -696,7 +696,7 @@ describe("MatrixClient room timelines", function() { }); // Wait for the timeline to reset(when it goes blank) which means - // it's in the middle of the refrsh logic right before the + // it's in the middle of the refresh logic right before the // `getEventTimeline()` -> `/context`. Then simulate a racey `/sync` // to happen in the middle of all of this refresh timeline logic. We // want to make sure the sync pagination still works as expected diff --git a/spec/unit/event-timeline-set.spec.ts b/spec/unit/event-timeline-set.spec.ts index e6c45fbd460..bac38db5aa8 100644 --- a/spec/unit/event-timeline-set.spec.ts +++ b/spec/unit/event-timeline-set.spec.ts @@ -55,6 +55,23 @@ describe('EventTimelineSet', () => { }); }; + const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ + event: true, + type: EventType.RoomMessage, + user: userA, + room: roomId, + content: { + "body": "Thread response :: " + Math.random(), + "m.relates_to": { + "event_id": root.getId(), + "m.in_reply_to": { + "event_id": root.getId(), + }, + "rel_type": "m.thread", + }, + }, + }, room.client); + beforeEach(() => { client = utils.mock(MatrixClient, 'MatrixClient'); client.reEmitter = utils.mock(ReEmitter, 'ReEmitter'); @@ -117,6 +134,13 @@ describe('EventTimelineSet', () => { }); describe('addEventToTimeline', () => { + let thread: Thread; + + beforeEach(() => { + (client.supportsExperimentalThreads as jest.Mock).mockReturnValue(true); + thread = new Thread("!thread_id:server", messageEvent, { room, client }); + }); + it("Adds event to timeline", () => { const liveTimeline = eventTimelineSet.getLiveTimeline(); expect(liveTimeline.getEvents().length).toStrictEqual(0); @@ -144,6 +168,41 @@ describe('EventTimelineSet', () => { ); }).not.toThrow(); }); + + it("should not add an event to a timeline that does not belong to the timelineSet", () => { + const eventTimelineSet2 = new EventTimelineSet(room); + const liveTimeline2 = eventTimelineSet2.getLiveTimeline(); + expect(liveTimeline2.getEvents().length).toStrictEqual(0); + + expect(() => { + eventTimelineSet.addEventToTimeline(messageEvent, liveTimeline2, { + toStartOfTimeline: true, + }); + }).toThrowError(); + }); + + it("should not add a threaded reply to the main room timeline", () => { + const liveTimeline = eventTimelineSet.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + + const threadedReplyEvent = mkThreadResponse(messageEvent); + + eventTimelineSet.addEventToTimeline(threadedReplyEvent, liveTimeline, { + toStartOfTimeline: true, + }); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + }); + + it("should not add a normal message to the timelineSet representing a thread", () => { + const eventTimelineSetForThread = new EventTimelineSet(room, {}, client, thread); + const liveTimeline = eventTimelineSetForThread.getLiveTimeline(); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + + eventTimelineSetForThread.addEventToTimeline(messageEvent, liveTimeline, { + toStartOfTimeline: true, + }); + expect(liveTimeline.getEvents().length).toStrictEqual(0); + }); }); describe('aggregateRelations', () => { @@ -191,8 +250,8 @@ describe('EventTimelineSet', () => { }); it('should not return the related events', () => { - eventTimelineSet.relations.aggregateChildEvent(messageEvent); - const relations = eventTimelineSet.relations.getChildEventsForEvent( + eventTimelineSet!.relations.aggregateChildEvent(messageEvent); + const relations = eventTimelineSet!.relations.getChildEventsForEvent( messageEvent.getId(), "m.in_reply_to", EventType.RoomMessage, @@ -226,23 +285,6 @@ describe('EventTimelineSet', () => { }); describe("canContain", () => { - const mkThreadResponse = (root: MatrixEvent) => utils.mkEvent({ - event: true, - type: EventType.RoomMessage, - user: userA, - room: roomId, - content: { - "body": "Thread response :: " + Math.random(), - "m.relates_to": { - "event_id": root.getId(), - "m.in_reply_to": { - "event_id": root.getId(), - }, - "rel_type": "m.thread", - }, - }, - }, room.client); - let thread: Thread; beforeEach(() => { diff --git a/spec/unit/relations.spec.ts b/spec/unit/relations.spec.ts index 091d95ea914..1b67e6d9f4f 100644 --- a/spec/unit/relations.spec.ts +++ b/spec/unit/relations.spec.ts @@ -18,10 +18,19 @@ import { EventTimelineSet } from "../../src/models/event-timeline-set"; import { MatrixEvent, MatrixEventEvent } from "../../src/models/event"; import { Room } from "../../src/models/room"; import { Relations } from "../../src/models/relations"; +import { MatrixClient } from "../../src"; +import { TestClient } from "../TestClient"; describe("Relations", function() { + let client: MatrixClient; + beforeEach(() => { + client = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + }); + it("should deduplicate annotations", function() { - const room = new Room("room123", null, null); + const room = new Room("room123", client, null); const relations = new Relations("m.annotation", "m.reaction", room); // Create an instance of an annotation @@ -98,7 +107,7 @@ describe("Relations", function() { // Add the target event first, then the relation event { - const room = new Room("room123", null, null); + const room = new Room("room123", client, null); const relationsCreated = new Promise(resolve => { targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); @@ -112,7 +121,7 @@ describe("Relations", function() { // Add the relation event first, then the target event { - const room = new Room("room123", null, null); + const room = new Room("room123", client, null); const relationsCreated = new Promise(resolve => { targetEvent.once(MatrixEventEvent.RelationsCreated, resolve); }); @@ -126,7 +135,7 @@ describe("Relations", function() { }); it("should re-use Relations between all timeline sets in a room", async () => { - const room = new Room("room123", null, null); + const room = new Room("room123", client, null); const timelineSet1 = new EventTimelineSet(room); const timelineSet2 = new EventTimelineSet(room); expect(room.relations).toBe(timelineSet1.relations); @@ -135,7 +144,7 @@ describe("Relations", function() { it("should ignore m.replace for state events", async () => { const userId = "@bob:example.com"; - const room = new Room("room123", null, userId); + const room = new Room("room123", client, userId); const relations = new Relations("m.replace", "m.room.topic", room); // Create an instance of a state event with rel_type m.replace diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 902437e0be3..83f1e69e675 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -1577,7 +1577,10 @@ describe("Room", function() { }); it("should remove cancelled events from the timeline", function() { - const room = new Room(roomId, null, userA); + const client = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + const room = new Room(roomId, client, userA); const eventA = utils.mkMessage({ room: roomId, user: userA, event: true, }); diff --git a/src/client.ts b/src/client.ts index 572ae1d37e1..b9dc32f615b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5152,7 +5152,7 @@ export class MatrixClient extends TypedEventEmitterIf the EventTimelineSet object already has the given event in its store, the * corresponding timeline will be returned. Otherwise, a /context request is * made, and used to construct an EventTimeline. - * If the event does not belong to this EventTimelineSet then undefined will be returned. + * If the event does not belong to this EventTimelineSet then it will ignored. * * @param {EventTimelineSet} timelineSet The timelineSet to look for the event in, must be bound to a room * @param {string} eventId The ID of the event to look for @@ -5160,7 +5160,7 @@ export class MatrixClient extends TypedEventEmitter> { + public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + @@ -5210,10 +5210,6 @@ export class MatrixClient extends TypedEventEmitter { + public async load(initialEventId?: string, initialWindowSize = 20): Promise { // given an EventTimeline, find the event we were looking for, and initialise our // fields so that the event in question is in the middle of the window. const initFields = (timeline: EventTimeline) => { @@ -135,11 +135,12 @@ export class TimelineWindow { return Promise.resolve(); } - return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields); + await this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields); + return; } else { const tl = this.timelineSet.getLiveTimeline(); initFields(tl); - return Promise.resolve(); + return; } }