Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix refreshLiveTimeline(...) not working when the latest event in the room is a threaded message #2852

83 changes: 73 additions & 10 deletions spec/integ/matrix-client-event-timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,7 +780,12 @@ describe("MatrixClient event timelines", function() {
});
});

describe("getLatestTimeline", function() {
describe("getLatestLiveTimeline", function() {
beforeEach(() => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
});

it("timeline support must be enabled to work", async function() {
await client.stopClient();

Expand All @@ -797,7 +802,7 @@ describe("MatrixClient event timelines", function() {

const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0]!;
await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy();
await expect(client.getLatestLiveTimeline(timelineSet)).rejects.toBeTruthy();
});

it("timeline support works when enabled", async function() {
Expand All @@ -816,7 +821,7 @@ describe("MatrixClient event timelines", function() {
return startClient(httpBackend, client).then(() => {
const room = client.getRoom(roomId)!;
const timelineSet = room.getTimelineSets()[0];
expect(client.getLatestTimeline(timelineSet)).rejects.toBeFalsy();
expect(client.getLatestLiveTimeline(timelineSet)).rejects.toBeFalsy();
});
});

Expand All @@ -835,14 +840,14 @@ describe("MatrixClient event timelines", function() {
await startClient(httpBackend, client);

const timelineSet = new EventTimelineSet(undefined);
await expect(client.getLatestTimeline(timelineSet)).rejects.toBeTruthy();
await expect(client.getLatestLiveTimeline(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 = 'event1:bar';
const latestMessageId = EVENTS[2].event_id!;

httpBackend.when("GET", "/rooms/!foo%3Abar/messages")
.respond(200, function() {
Expand All @@ -869,11 +874,11 @@ describe("MatrixClient event timelines", function() {
});

return Promise.all([
client.getLatestTimeline(timelineSet).then(function(tl) {
client.getLatestLiveTimeline(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
// `getLatestTimeline` is doing the right thing though.
// `getLatestLiveTimeline` 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]);
Expand All @@ -888,6 +893,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 = THREAD_REPLY.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: [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.getLatestLiveTimeline(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];
Expand All @@ -902,7 +965,7 @@ describe("MatrixClient event timelines", function() {
});

return Promise.all([
expect(client.getLatestTimeline(timelineSet)).rejects.toThrow(),
expect(client.getLatestLiveTimeline(timelineSet)).rejects.toThrow(),
httpBackend.flushAllExpected(),
]);
});
Expand Down Expand Up @@ -1122,7 +1185,7 @@ describe("MatrixClient event timelines", function() {
respondToContext();
await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!));
respondToThreads();
const timeline = await flushHttp(client.getLatestTimeline(timelineSet));
const timeline = await flushHttp(client.getLatestLiveTimeline(timelineSet));
expect(timeline).not.toBeNull();

respondToThreads();
Expand Down Expand Up @@ -1178,7 +1241,7 @@ describe("MatrixClient event timelines", function() {
await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!));

respondToMessagesRequest();
const timeline = await flushHttp(client.getLatestTimeline(timelineSet));
Copy link
Contributor Author

@MadLittleMods MadLittleMods Nov 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like some non-refresh-timeline tests got infected with getLatestTimeline usage in the months while these PR's have sat.

But I don't really want to support getLatestTimeline for threads which these tests rely on.

Either we have to maintain the old getLatestTimeline(...) alongside getLatestLiveTimeline(...) forever or we can refactor these tests away from it.

Do we care about any external consumers that also may have started using it? Maybe should have marked it experimental to match the sole usage when it was introduced.

const timeline = await flushHttp(client.getLatestLiveTimeline(timelineSet));
expect(timeline).not.toBeNull();

respondToMessagesRequest();
Expand Down
4 changes: 2 additions & 2 deletions spec/integ/matrix-client-room-timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
// `getLatestLiveTimeline()` to construct a new timeline from.
httpBackend!.when("GET", `/rooms/${encodeURIComponent(roomId)}/messages`)
.respond(200, function() {
return {
Expand All @@ -840,7 +840,7 @@ describe("MatrixClient room timelines", function() {
};
});
// `/context` request for `refreshLiveTimeline()` ->
// `getLatestTimeline()` -> `getEventTimeline()` to construct a new
// `getLatestLiveTimeline()` -> `getEventTimeline()` to construct a new
// timeline from.
httpBackend!.when("GET", contextUrl)
.respond(200, function() {
Expand Down
129 changes: 90 additions & 39 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5400,65 +5400,116 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Get an EventTimeline for the latest events in the room. This will just
* call `/messages` to get the latest message in the room, then use
* `client.getEventTimeline(...)` to construct a new timeline from it.
* Always returns timeline in the given `timelineSet`.
*
* @param {EventTimelineSet} timelineSet The timelineSet to find or add the timeline to
*
* @return {Promise} Resolves:
* {@link module:models/event-timeline~EventTimeline} timeline with the latest events in the room
*/
public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<Optional<EventTimeline>> {
public async getLatestLiveTimeline(timelineSet: EventTimelineSet): Promise<EventTimeline> {
// 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("getLatestTimeline only supports room timelines");
throw new Error("getLatestLiveTimeline only supports room timelines");
}

let event;
if (timelineSet.threadListType !== null) {
const res = await this.createThreadListMessagesRequest(
timelineSet.room.roomId,
null,
1,
Direction.Backward,
timelineSet.threadListType,
timelineSet.getFilter(),
);
event = res.chunk?.[0];
} else if (timelineSet.thread && Thread.hasServerSideSupport) {
const res = await this.fetchRelations(
timelineSet.room.roomId,
timelineSet.thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Backward, limit: 1 },
);
event = res.chunk?.[0];
} else {
const messagesPath = utils.encodeUri(
"/rooms/$roomId/messages", {
$roomId: timelineSet.room.roomId,
},
);
if (timelineSet.threadListType !== null || timelineSet.thread && Thread.hasServerSideSupport) {
throw new Error("getLatestLiveTimeline only supports live timelines");
}

const params: Record<string, string | string[]> = {
dir: 'b',
};
if (this.clientOpts?.lazyLoadMembers) {
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
}
const messagesPath = utils.encodeUri(
"/rooms/$roomId/messages", {
$roomId: timelineSet.room.roomId,
},
);
const messageRequestParams: Record<string, string | string[]> = {
dir: 'b',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direction.Backward

// 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<IMessagesResponse>(
Method.Get,
messagesPath,
messageRequestParams,
);
Comment on lines +5598 to +5616
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this reuse createMessagesRequest instead?

const latestEventInTimeline = messagesRes.chunk?.[0];
const latestEventIdInTimeline = latestEventInTimeline?.event_id;
if (!latestEventIdInTimeline) {
throw new Error("No message returned when trying to construct getLatestLiveTimeline");
}

const res = await this.http.authedRequest<IMessagesResponse>(Method.Get, messagesPath, params);
event = res.chunk?.[0];
const contextPath = utils.encodeUri(
"/rooms/$roomId/context/$eventId", {
$roomId: timelineSet.room.roomId,
$eventId: latestEventIdInTimeline,
},
);
let contextRequestParams: Record<string, string | string[]> | undefined = undefined;
if (this.clientOpts?.lazyLoadMembers) {
contextRequestParams = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) };
}
if (!event) {
throw new Error("No message returned when trying to construct getLatestTimeline");
const contextRes = await this.http.authedRequest<IContextResponse>(
Method.Get,
contextPath,
contextRequestParams,
);
if (!contextRes.event || contextRes.event.event_id !== latestEventIdInTimeline) {
throw new Error(
`getLatestLiveTimeline: \`/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) {
timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(contextRes.state.map(mapper));
} 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));
timeline.getState(EventTimeline.FORWARDS)!.paginationToken = contextRes.end;
}

return this.getEventTimeline(timelineSet, event.event_id);
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;
Copy link
Contributor Author

@MadLittleMods MadLittleMods Nov 4, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opted for a new function as concluded in #2521 (comment)

This is very similar to the existing logic in getEventTimeline(...) but changed so that

// 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.

In getEventTimeline(...), it will return undefined which isn't useful when we want the new empty timeline. Any good way to de-dupe this logic or untangle the mess?

}

/**
Expand Down
30 changes: 23 additions & 7 deletions src/models/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -913,9 +913,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
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}`,
Expand All @@ -924,15 +925,15 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// Get the main TimelineSet
const timelineSet = this.getUnfilteredTimelineSet();

let newTimeline: Optional<EventTimeline>;
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.getLatestLiveTimeline(timelineSet);
} else {
// Empty out all of `this.timelineSets`. But we also need to keep the
// same `timelineSet` references around so the React code updates
Expand All @@ -955,7 +956,22 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// 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
Expand All @@ -972,11 +988,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// 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();
Expand Down