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 issues with getEventTimeline and thread roots #2444

Merged
merged 6 commits into from
Jun 8, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions spec/integ/matrix-client-event-timeline.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as utils from "../test-utils/test-utils";
Copy link
Contributor

Choose a reason for hiding this comment

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

The title and changelog of this PR is not every descriptive: "Fix issues with getEventTimeline and thread roots". It just describes the code and not what's being fixed.

Potential option:

Fix events from main timeline getting mixed in threads

Copy link
Member Author

Choose a reason for hiding this comment

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

But this does more than that, that's just one symptom it fixes, changelog entries should be relatively short even if that's vague

Copy link
Contributor

@MadLittleMods MadLittleMods Jun 13, 2022

Choose a reason for hiding this comment

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

What other symptoms does it fix? The other problems are all variations of that AFAICT.

The problem is that following the link to this PR from the changelog also doesn't have any of the extra details either.

Copy link
Member Author

Choose a reason for hiding this comment

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

The problem is that following the link to this PR from the changelog also doesn't have any of the extra details either.

Except it does, in the canonical source, the issue it links to

image

Copy link
Contributor

Choose a reason for hiding this comment

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

Why doesn't "Fix events from main timeline getting mixed in threads" work to describe what this does then?

Copy link
Member Author

Choose a reason for hiding this comment

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

Because it also stops other effects, like stops an unrelated timeline being returned if you call with a threaded-timelineset and an event id from a diff thread/main timeline

Copy link
Contributor

@MadLittleMods MadLittleMods Jun 13, 2022

Choose a reason for hiding this comment

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

The description and linked issue don't explain this. And my changelog example can be extended to something like:

Fix events from main timeline getting mixed in threads and generally events being mixed across timelines.

Which is way more useful than Fix issues with getEventTimeline and thread roots where thread roots are one of the edge cases that it actually doesn't fix anyway. And doesn't describe the main issue it fixes that everyone is running into in the first place.

I had to decode all of this information from the diff (which is painful) and when I did, I found bugs. The expectations and why were not spelled out in the code or description where we can easily compare how the code behaves to those expectations. One (maybe 2) symptoms are explain in the linked issue.

Copy link
Member Author

@t3chguy t3chguy Jun 13, 2022

Choose a reason for hiding this comment

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

The description and linked issue don't explain this.

Nor do they need to given threads in the js-sdk is marked @experimental

where thread roots are one of the edge cases that it actually doesn't fix anyway.

This isn't true, it fixed the issue for event timeline sets accessing thread roots which caused the issue you were hitting with main timeline events showing up in threads, and created a regression around accessing thread roots from main timeline event timeline sets

Copy link
Contributor

@MadLittleMods MadLittleMods Jun 13, 2022

Choose a reason for hiding this comment

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

Nor do they need to given threads in the js-sdk is marked @experimental

While compromises and known problems are probably part of an @experimental feature, seems like we shouldn't just drop the ball on all of it. I think this problem generally exists in most PR's though. And regardless, our current plan/goal for threads is to make this not experimental where this code is bound to live on.

We should so we can better maintain this. We're not going to remember this fresh context that I am driving for in 6 months when we find another regression. I understand that some of this code will change when we get backend changes to better support threads but that info will just make it easier to refactor this in the future as well to see that we're still maintaining all of the previous behavior plus the extra benefits.

This isn't true, it fixed the issue for event timeline sets accessing thread roots which caused the issue you were hitting with main timeline events showing up in threads, and created a regression around accessing thread roots from main timeline event timeline sets

I see what you mean. This is an example of what we should be explaining though.

import { EventTimeline } from "../../src/matrix";
import { EventTimeline, Filter, MatrixEvent } from "../../src/matrix";
import { logger } from "../../src/logger";
import { TestClient } from "../TestClient";
import { Thread, THREAD_RELATION_TYPE } from "../../src/models/thread";
Expand Down Expand Up @@ -500,7 +500,8 @@ describe("MatrixClient event timelines", function() {
Thread.setServerSideSupport(true);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];
const thread = room.createThread(THREAD_ROOT.event_id, undefined, [], false);
const timelineSet = thread.timelineSet;

httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id))
.respond(200, function() {
Expand Down Expand Up @@ -538,6 +539,92 @@ describe("MatrixClient event timelines", function() {
expect(timeline.getEvents().find(e => e.getId() === THREAD_ROOT.event_id)).toBeTruthy();
expect(timeline.getEvents().find(e => e.getId() === THREAD_REPLY.event_id)).toBeTruthy();
});

it("should return undefined when event is not within a thread but timelineSet is", async () => {
t3chguy marked this conversation as resolved.
Show resolved Hide resolved
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const threadRoot = new MatrixEvent(THREAD_ROOT);
const thread = room.createThread(THREAD_ROOT.event_id, threadRoot, [threadRoot], false);
const timelineSet = thread.timelineSet;

httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id))
.respond(200, function() {
return THREAD_ROOT;
});

httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});

const timelinePromise = client.getEventTimeline(timelineSet, EVENTS[0].event_id);
await httpBackend.flushAllExpected();

const timeline = await timelinePromise;
expect(timeline).toBeUndefined();
});

it("should return undefined when event is within a thread but timelineSet is not", async () => {
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(true);
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];

httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id))
.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: THREAD_REPLY,
events_after: [],
end: "end_token0",
state: [],
};
});

const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id);
await httpBackend.flushAllExpected();

const timeline = await timelinePromise;
expect(timeline).toBeUndefined();
});

it("should should add lazy loading filter when requested", async () => {
client.clientOpts.lazyLoadMembers = true;
client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId);
const timelineSet = room.getTimelineSets()[0];

const req = httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(EVENTS[0].event_id));
req.respond(200, function() {
return {
start: "start_token0",
events_before: [],
event: EVENTS[0],
events_after: [],
end: "end_token0",
state: [],
};
});
req.check((request) => {
expect(request.opts.qs.filter).toEqual(JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER));
});

await Promise.all([
client.getEventTimeline(timelineSet, EVENTS[0].event_id),
httpBackend.flushAllExpected(),
]);
});
});

describe("getLatestTimeline", function() {
Expand Down
61 changes: 34 additions & 27 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5245,14 +5245,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* <p>If 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.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* If the event does not belong to this EventTimelineSet then undefined will be returned.
* If the event does not belong to this EventTimelineSet then undefined will be returned
* (like an `eventId` that doesn't belong in the thread represented by the given `EventTimelineSet`).

*
* @param {EventTimelineSet} timelineSet The timelineSet to look for the event in
* @param {EventTimelineSet} timelineSet The timelineSet to look for the event in, must be bound to a room
t3chguy marked this conversation as resolved.
Show resolved Hide resolved
* @param {string} eventId The ID of the event to look for
*
* @return {Promise} Resolves:
* {@link module:models/event-timeline~EventTimeline} including the given event
*/
public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline> {
public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | undefined> {
// don't allow any timeline support unless it's been enabled.
if (!this.timelineSupport) {
throw new Error("timeline support is disabled. Set the 'timelineSupport'" +
Expand Down Expand Up @@ -5297,38 +5298,44 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
...res.events_before.map(mapper),
];

// Where the event is a thread reply (not a root) and running in MSC-enabled mode the Thread timeline only
// functions contiguously, so we have to jump through some hoops to get our target event in it.
// XXX: workaround for https://github.com/vector-im/element-meta/issues/150
if (Thread.hasServerSideSupport &&
this.supportsExperimentalThreads() &&
event.isRelation(THREAD_RELATION_TYPE.name)
) {
const [, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
let thread = timelineSet.room.getThread(event.threadRootId);
if (!thread) {
thread = timelineSet.room.createThread(event.threadRootId, undefined, threadedEvents, true);
if (this.supportsExperimentalThreads()) {
const { threadId, shouldLiveInRoom } = timelineSet.room.eventShouldLiveIn(event);

if (!timelineSet.thread && !shouldLiveInRoom) {
// Thread response does not belong in this timelineSet
return undefined;
}

const opts: IRelationsRequestOpts = {
direction: Direction.Backward,
limit: 50,
};
if (timelineSet.thread?.id !== threadId) {
t3chguy marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Contributor

@MadLittleMods MadLittleMods Jun 9, 2022

Choose a reason for hiding this comment

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

A threadRoot returns:

{
    shouldLiveInRoom: true,
    shouldLiveInThread: true,
    threadId: event.getId(),
}

Won't this logic prevent a threadRoot from showing in the main room timeline? undefined !== $abc

Copy link
Member Author

Choose a reason for hiding this comment

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

Indeed, I think this is the cause of element-hq/element-web#22539

The if check should be timelineSet.thread && timelineSet.thread.id !== threadId

t3chguy marked this conversation as resolved.
Show resolved Hide resolved
// Event does not belong in this timelineSet
return undefined;
}
t3chguy marked this conversation as resolved.
Show resolved Hide resolved

await thread.fetchInitialEvents();
let nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward);
// Where the event is a thread reply (not a root) and running in MSC-enabled mode the Thread timeline only
// functions contiguously, so we have to jump through some hoops to get our target event in it.
// XXX: workaround for https://github.com/vector-im/element-meta/issues/150
if (Thread.hasServerSideSupport && timelineSet.thread) {
const thread = timelineSet.thread;
const opts: IRelationsRequestOpts = {
direction: Direction.Backward,
limit: 50,
};

// Fetch events until we find the one we were asked for, or we run out of pages
while (!thread.findEventById(eventId)) {
if (nextBatch) {
opts.from = nextBatch;
await thread.fetchInitialEvents();
let nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward);

// Fetch events until we find the one we were asked for, or we run out of pages
while (!thread.findEventById(eventId)) {
if (nextBatch) {
opts.from = nextBatch;
}

({ nextBatch } = await thread.fetchEvents(opts));
if (!nextBatch) break;
}

({ nextBatch } = await thread.fetchEvents(opts));
if (!nextBatch) break;
return thread.liveTimeline;
}

return thread.liveTimeline;
}

// Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
Expand Down
7 changes: 5 additions & 2 deletions src/models/event-timeline-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { RoomState } from "./room-state";
import { TypedEventEmitter } from "./typed-event-emitter";
import { RelationsContainer } from "./relations-container";
import { MatrixClient } from "../client";
import { Thread } from "./thread";

const DEBUG = true;

Expand Down Expand Up @@ -110,7 +111,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* map from event_id to timeline and index.
*
* @constructor
* @param {?Room} room
* @param {Room=} room
* Room for this timelineSet. May be null for non-room cases, such as the
* notification timeline.
* @param {Object} opts Options inherited from Room.
Expand All @@ -119,13 +120,15 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* Set to true to enable improved timeline support.
* @param {Object} [opts.filter = null]
* The filter object, if any, for this timelineSet.
* @param {MatrixClient} client the Matrix client which owns this EventTimelineSet,
* @param {MatrixClient=} client the Matrix client which owns this EventTimelineSet,
Copy link
Contributor

@MadLittleMods MadLittleMods Jun 9, 2022

Choose a reason for hiding this comment

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

Is this only necessary for threads? That's the only place we use this functionality. We should add a comment with the reasoning for when this is necessary and the thread use case.

Copy link
Member Author

Choose a reason for hiding this comment

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

Its used for the notification timeline (or any without a room) where a client is still necessary but cannot be accessed via room.client

Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add that statement to the JSDoc here? It's great context for why we use it.

* can be omitted if room is specified.
* @param {Thread=} thread the thread to which this timeline set relates.
*/
constructor(
public readonly room: Room | undefined,
opts: IOpts = {},
client?: MatrixClient,
public readonly thread?: Thread,
Comment on lines 130 to +131
Copy link
Contributor

@MadLittleMods MadLittleMods Jun 9, 2022

Choose a reason for hiding this comment

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

Seems like we should put this stuff in opts

We can still assign this.thread = opts.thread in the constructor.

Copy link
Member Author

Choose a reason for hiding this comment

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

Seems like we should put this stuff in opts

Why? https://github.com/vector-im/element-meta/pull/216/files doesn't favour it in any way shape or form

Copy link
Contributor

Choose a reason for hiding this comment

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

Feel free to defer in favor of the style-guide.

The function signature here is getting pretty complex and we already have opts where this seems like it would fit.

) {
super();

Expand Down
2 changes: 1 addition & 1 deletion src/models/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,7 +537,7 @@ export class MatrixEvent extends TypedEventEmitter<EmittedEvents, MatrixEventHan
return mRelatesTo?.['m.in_reply_to']?.event_id;
}

public get relationEventId(): string {
public get relationEventId(): string | undefined {
return this.getWireContent()
?.["m.relates_to"]
?.event_id;
Expand Down
2 changes: 1 addition & 1 deletion src/models/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class Thread extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
this.timelineSet = new EventTimelineSet(this.room, {
timelineSupport: true,
pendingEvents: true,
});
}, this.client, this);
this.reEmitter = new TypedReEmitter(this);

this.reEmitter.reEmit(this.timelineSet, [
Expand Down