diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index fe7393bfd73..429ce6416ef 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -910,6 +910,200 @@ describe("MatrixClient event timelines", function() { }); }); + describe("fetchLatestLiveTimeline", function() { + beforeEach(() => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + }); + + it("timeline support must be enabled to work", async function() { + await client.stopClient(); + + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: false }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + await startClient(httpBackend, client); + + const room = client.getRoom(roomId)!; + const timelineSet = room.getTimelineSets()[0]!; + await expect(client.fetchLatestLiveTimeline(timelineSet)).rejects.toBeTruthy(); + }); + + it("timeline support works when enabled", async function() { + await client.stopClient(); + + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: true }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + + return startClient(httpBackend, client).then(() => { + const room = client.getRoom(roomId)!; + const timelineSet = room.getTimelineSets()[0]; + expect(client.fetchLatestLiveTimeline(timelineSet)).rejects.toBeFalsy(); + }); + }); + + it("only works with room timelines", async function() { + await client.stopClient(); + + const testClient = new TestClient( + userId, + "DEVICE", + accessToken, + undefined, + { timelineSupport: true }, + ); + client = testClient.client; + httpBackend = testClient.httpBackend; + await startClient(httpBackend, client); + + const timelineSet = new EventTimelineSet(undefined); + await expect(client.fetchLatestLiveTimeline(timelineSet)).rejects.toBeTruthy(); + }); + + it("should create a new timeline for new events", function() { + const room = client.getRoom(roomId)!; + const timelineSet = room.getTimelineSets()[0]; + + const latestMessageId = EVENTS[2].event_id!; + + 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: [EVENTS[1], EVENTS[0]], + event: EVENTS[2], + events_after: [EVENTS[3]], + state: [ + ROOM_NAME_EVENT, + USER_MEMBERSHIP_EVENT, + ], + end: "end_token", + }; + }); + + return Promise.all([ + client.fetchLatestLiveTimeline(timelineSet).then(function(tl) { + // Instead of this assertion logic, we could just add a spy + // for `getEventTimeline` and make sure it's called with the + // correct parameters. This doesn't feel too bad to make sure + // `fetchLatestLiveTimeline` is doing the right thing though. + expect(tl!.getEvents().length).toEqual(4); + for (let i = 0; i < 4; i++) { + expect(tl!.getEvents()[i].event).toEqual(EVENTS[i]); + expect(tl!.getEvents()[i]?.sender?.name).toEqual(userName); + } + expect(tl!.getPaginationToken(EventTimeline.BACKWARDS)) + .toEqual("start_token"); + expect(tl!.getPaginationToken(EventTimeline.FORWARDS)) + .toEqual("end_token"); + }), + httpBackend.flushAllExpected(), + ]); + }); + + 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 = THREAD_REPLY.event_id; + if (!latestMessageId) { + throw new Error('Expected THREAD_REPLY to have an event_id to reference in the test'); + } + + 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.fetchLatestLiveTimeline(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]; + + httpBackend.when("GET", "/rooms/!foo%3Abar/messages") + .respond(200, () => { + return { + chunk: [ + // No messages to return + ], + }; + }); + + return Promise.all([ + expect(client.fetchLatestLiveTimeline(timelineSet)).rejects.toThrow(), + httpBackend.flushAllExpected(), + ]); + }); + }); + describe("paginateEventTimeline", function() { it("should allow you to paginate backwards", function() { const room = client.getRoom(roomId)!; diff --git a/spec/integ/matrix-client-room-timeline.spec.ts b/spec/integ/matrix-client-room-timeline.spec.ts index f89ab04e2b8..0d49fa2cafd 100644 --- a/spec/integ/matrix-client-room-timeline.spec.ts +++ b/spec/integ/matrix-client-room-timeline.spec.ts @@ -829,7 +829,7 @@ describe("MatrixClient room timelines", function() { expect(room.timeline.length).toEqual(0); // `/messages` request for `refreshLiveTimeline()` -> - // `getLatestTimeline()` to construct a new timeline from. + // `fetchLatestLiveTimeline()` to construct a new timeline from. httpBackend!.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`) .respond(200, function() { return { @@ -840,7 +840,7 @@ describe("MatrixClient room timelines", function() { }; }); // `/context` request for `refreshLiveTimeline()` -> - // `getLatestTimeline()` -> `getEventTimeline()` to construct a new + // `fetchLatestLiveTimeline()` -> `getEventTimeline()` to construct a new // timeline from. httpBackend!.when("GET", contextUrl) .respond(200, function() { diff --git a/src/client.ts b/src/client.ts index b091a31ec45..446905d6aa9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -5569,6 +5569,140 @@ export class MatrixClient extends TypedEventEmitter { + // don't allow any timeline support unless it's been enabled. + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable it."); + } + + if (!timelineSet.room) { + throw new Error("fetchLatestLiveTimeline only supports room timelines"); + } + + if (timelineSet.threadListType !== null || timelineSet.thread && Thread.hasServerSideSupport) { + throw new Error("fetchLatestLiveTimeline only supports live timelines"); + } + + const messagesPath = utils.encodeUri( + "/rooms/$roomId/messages", { + $roomId: timelineSet.room.roomId, + }, + ); + const messageRequestParams: Record = { + dir: 'b', + // Since we only use the latest message in the response, we only need to + // fetch the one message here. + limit: "1", + }; + if (this.clientOpts?.lazyLoadMembers) { + messageRequestParams.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + } + const messagesRes = await this.http.authedRequest( + Method.Get, + messagesPath, + messageRequestParams, + ); + const latestEventInTimeline = messagesRes.chunk?.[0]; + const latestEventIdInTimeline = latestEventInTimeline?.event_id; + if (!latestEventIdInTimeline) { + throw new Error("No message returned when trying to construct fetchLatestLiveTimeline"); + } + + const contextPath = utils.encodeUri( + "/rooms/$roomId/context/$eventId", { + $roomId: timelineSet.room.roomId, + $eventId: latestEventIdInTimeline, + }, + ); + let contextRequestParams: Record | undefined = undefined; + if (this.clientOpts?.lazyLoadMembers) { + contextRequestParams = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) }; + } + const contextRes = await this.http.authedRequest( + Method.Get, + contextPath, + contextRequestParams, + ); + if (!contextRes.event || contextRes.event.event_id !== latestEventIdInTimeline) { + throw new Error( + `fetchLatestLiveTimeline: \`/context\` response did not include latestEventIdInTimeline=` + + `${latestEventIdInTimeline} which we were asking about. This is probably a bug in the ` + + `homeserver since we just saw the event with the other request above and now the server ` + + `claims it does not exist.`, + ); + } + + // By the time the request completes, the event might have ended up in the timeline. + const shortcutTimelineForEvent = timelineSet.getTimelineForEvent(latestEventIdInTimeline); + if (shortcutTimelineForEvent) { + return shortcutTimelineForEvent; + } + + const mapper = this.getEventMapper(); + const latestMatrixEventInTimeline = mapper(contextRes.event); + const events = [ + // Order events from most recent to oldest (reverse-chronological). + // We start with the last event, since that's the point at which we have known state. + // events_after is already backwards; events_before is forwards. + ...contextRes.events_after.reverse().map(mapper), + latestMatrixEventInTimeline, + ...contextRes.events_before.map(mapper), + ]; + + // This function handles non-thread timelines only, but we still process any + // thread events to populate thread summaries. + let timeline = timelineSet.getTimelineForEvent(events[0].getId()!); + if (timeline) { + const backwardsState = timeline.getState(EventTimeline.BACKWARDS); + if (backwardsState) { + backwardsState.setUnknownStateEvents(contextRes.state.map(mapper)); + } else { + throw new Error( + `fetchLatestLiveTimeline: While updating backwards state of the existing ` + + ` timeline=${timeline.toString()}, it unexpectedly did not have any backwards ` + + `state. Something probably broke with the timeline earlier for this to happen.`, + ); + } + } else { + // If the `latestEventIdInTimeline` does not belong to this `timelineSet` + // then it will be ignored and not added to the `timelineSet`. We'll instead + // just create a new blank timeline in the `timelineSet` with the proper + // pagination tokens setup to continue paginating. + timeline = timelineSet.addTimeline(); + timeline.initialiseState(contextRes.state.map(mapper)); + const forwardsState = timeline.getState(EventTimeline.FORWARDS); + if (forwardsState) { + forwardsState.paginationToken = contextRes.end; + } else { + throw new Error( + `fetchLatestLiveTimeline: While updating forwards state of the new ` + + ` timeline=${timeline.toString()}, it unexpectedly did not have any forwards ` + + `state. Something probably broke while creating the timeline for this to happen.`, + ); + } + } + + const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(events); + timelineSet.addEventsToTimeline(timelineEvents, true, timeline, contextRes.start); + // The target event is not in a thread but process the contextual events, so we can show any threads around it. + this.processThreadEvents(timelineSet.room, threadedEvents, true); + this.processBeaconEvents(timelineSet.room, timelineEvents); + + return timelineSet.getTimelineForEvent(latestEventIdInTimeline) ?? timeline; + } + /** * Makes a request to /messages with the appropriate lazy loading filter set. * XXX: if we do get rid of scrollback (as it's not used at the moment), diff --git a/src/models/room.ts b/src/models/room.ts index 0d16940273e..18d61a37475 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -910,9 +910,10 @@ export class Room extends ReadReceipt { const backwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.BACKWARDS); const eventsBefore = liveTimelineBefore.getEvents(); const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1]; + const mostRecentEventIdInTimeline = mostRecentEventInTimeline?.getId(); logger.log( `[refreshLiveTimeline for ${this.roomId}] at ` + - `mostRecentEventInTimeline=${mostRecentEventInTimeline && mostRecentEventInTimeline.getId()} ` + + `mostRecentEventIdInTimeline=${mostRecentEventIdInTimeline} ` + `liveTimelineBefore=${liveTimelineBefore.toString()} ` + `forwardPaginationToken=${forwardPaginationToken} ` + `backwardPaginationToken=${backwardPaginationToken}`, @@ -921,15 +922,15 @@ export class Room extends ReadReceipt { // Get the main TimelineSet const timelineSet = this.getUnfilteredTimelineSet(); - let newTimeline: Optional; + let newTimeline: EventTimeline; // If there isn't any event in the timeline, let's go fetch the latest // event and construct a timeline from it. // // This should only really happen if the user ran into an error // with refreshing the timeline before which left them in a blank // timeline from `resetLiveTimeline`. - if (!mostRecentEventInTimeline) { - newTimeline = await this.client.getLatestTimeline(timelineSet); + if (!mostRecentEventIdInTimeline) { + newTimeline = await this.client.fetchLatestLiveTimeline(timelineSet); } else { // Empty out all of `this.timelineSets`. But we also need to keep the // same `timelineSet` references around so the React code updates @@ -952,7 +953,22 @@ export class Room extends ReadReceipt { // we reset everything. The `timelineSet` we pass in needs to be empty // in order for this function to call `/context` and generate a new // timeline. - newTimeline = await this.client.getEventTimeline(timelineSet, mostRecentEventInTimeline.getId()!); + const timelineForMostRecentEvent = await this.client.getEventTimeline( + timelineSet, + mostRecentEventIdInTimeline, + ); + + if (!timelineForMostRecentEvent) { + throw new Error( + `refreshLiveTimeline: No new timeline was returned by \`getEventTimeline(...)\`. ` + + `This probably means that mostRecentEventIdInTimeline=${mostRecentEventIdInTimeline}` + + `which was in the live timeline before, wasn't supposed to be there in the first place ` + + `(maybe a problem with threads leaking into the main live timeline). ` + + `This is a problem with Element, please report this error.`, + ); + } + + newTimeline = timelineForMostRecentEvent; } // If a racing `/sync` beat us to creating a new timeline, use that @@ -969,11 +985,11 @@ export class Room extends ReadReceipt { // of using the `/context` historical token (ex. `t12-13_0_0_0_0_0_0_0_0`) // so that it matches the next response from `/sync` and we can properly // continue the timeline. - newTimeline!.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS); + newTimeline.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS); // Set our new fresh timeline as the live timeline to continue syncing // forwards and back paginating from. - timelineSet.setLiveTimeline(newTimeline!); + timelineSet.setLiveTimeline(newTimeline); // Fixup `this.oldstate` so that `scrollback` has the pagination tokens // available this.fixUpLegacyTimelineFields(); diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 8b4882c960a..05d4d49ddc7 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -411,10 +411,11 @@ export class MatrixCall extends TypedEventEmitter