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

Thread store and event parsing to create threads #1828

Merged
merged 12 commits into from
Aug 31, 2021
105 changes: 105 additions & 0 deletions spec/unit/models/thread.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { MatrixEvent } from "../../../src/models/event";
germain-gg marked this conversation as resolved.
Show resolved Hide resolved
import { Thread } from "../../../src/models/thread";

const mockMessage = (id: string, body: string, sender: string, replyId?: string) => {
const opts = {
content: {
body,
},
event_id: id,
origin_server_ts: 1628082832517,
sender: sender,
type: "m.room.message",
room_id: "!room123:hs1",
};

if (replyId) {
opts.content["m.relates_to"] = {
"m.in_reply_to": {
"event_id": replyId,
},
};
}

return new MatrixEvent(opts);
};

const mockAnnotation = (id: string, body: string, sender: string, replyId: string) => {
return new MatrixEvent({
"content": {
"m.relates_to": {
"event_id": replyId,
"key": body,
"rel_type": "m.annotation",
},
},
"origin_server_ts": 1628084947352,
"sender": sender,
"type": "m.reaction",
"event_id": id,
"room_id": "!room123:hs1",
});
};

const mockThread = () => {
const events = [
mockMessage("event1", "Hello", "alice"),
mockMessage("event2", "Bonjour", "bob", "event1"),
mockMessage("event3", "How are you?", "bob", "event2"),
];

return new Thread(events);
};

describe('Thread', () => {
it('should count participants', () => {
const thread = mockThread();

expect(thread.participants.size).toBe(2);

thread.addEvent(mockMessage("event4", "Ça va?", "bob", "event2"));
thread.addEvent(mockMessage("event5", "Cześć", "charlie", "event2"));

expect(thread.participants.size).toBe(3);
});

it('should store reference to root and tails', () => {
const thread = mockThread();
expect(thread.id).toBe("event1");
expect(thread.tail.size).toBe(1);

thread.addEvent(mockMessage("event4", "Ça va?", "bob", "event2"));
expect(thread.tail.size).toBe(2);

thread.addEvent(mockMessage("event5", "Ça va?", "bob", "event4"));
expect(thread.tail.size).toBe(2);

thread.addEvent(mockMessage("event6", "Ça va?", "bob", "event1"));
expect(thread.tail.size).toBe(3);
});

it('should only count message events', () => {
const thread = mockThread();
expect(thread.length).toBe(3);

thread.addEvent(mockMessage("event4", "Ça va?", "bob", "event2"));
expect(thread.length).toBe(4);

const reaction = mockAnnotation("event6", "✅", "bob", "event2");

thread.addEvent(reaction);

expect(thread.length).toBe(4);
expect(thread.eventTimeline.length).toBe(5);
});

it('tails event can only be m.room.message', () => {
const thread = mockThread();
expect(thread.length).toBe(3);

const reaction = mockAnnotation("event10", "✅", "bob", "event2");
thread.addEvent(reaction);

expect(Array.from(thread.tail)[0]).toBe("event3");
});
});
20 changes: 20 additions & 0 deletions src/models/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ export class MatrixEvent extends EventEmitter {
*/
private txnId: string = null;

/** A reference to the event ID making the root of the thread
*/
germain-gg marked this conversation as resolved.
Show resolved Hide resolved
private threadRoot: string = null;

/* Set an approximate timestamp for the event relative the local clock.
* This will inherently be approximate because it doesn't take into account
* the time between the server putting the 'age' field on the event as it sent
Expand Down Expand Up @@ -380,6 +384,14 @@ export class MatrixEvent extends EventEmitter {
return this.event.content || {};
}

/**
* Get the event ID of the replied event
*/
public get replyEventId(): string {
const relations = this.getWireContent()["m.relates_to"];
return relations?.["m.in_reply_to"]?.["event_id"];
}

/**
* Get the previous event content JSON. This will only return something for
* state events which exist in the timeline.
Expand Down Expand Up @@ -1265,6 +1277,14 @@ export class MatrixEvent extends EventEmitter {
public getTxnId(): string | undefined {
return this.txnId;
}

public setThreadRoot(threadRoot: string): void {
this.threadRoot = threadRoot;
}

public getThreadRoot(): string {
return this.threadRoot;
}
}

/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted
Expand Down
71 changes: 71 additions & 0 deletions src/models/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersio
import { ResizeMethod } from "../@types/partials";
import { Filter } from "../filter";
import { RoomState } from "./room-state";
import { Thread } from "./thread";

// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
Expand Down Expand Up @@ -145,6 +146,8 @@ export class Room extends EventEmitter {
public oldState: RoomState;
public currentState: RoomState;

private threads = new Map<string, Thread>();

/**
* Construct a new Room.
*
Expand Down Expand Up @@ -1047,6 +1050,74 @@ export class Room extends EventEmitter {
events, toStartOfTimeline,
timeline, paginationToken,
);
this.parseEventsForThread(events, timeline);
}

public async parseEventsForThread(
events: MatrixEvent[],
timeline: EventTimeline,
): Promise<void> {
for (let i = events.length - 1; i >= 0; i--) {
if (events[i].replyEventId) {
const thread = this.findThreadByTailEvent(events[i].replyEventId);
if (thread) {
thread.addEvent(events[i]);
} else {
const replyChain = await this.getReplyChain(timeline.getTimelineSet(), [events[i]]);

const rootId = replyChain[0].getId();
const thread = this.threads.get(rootId);
if (thread) {
replyChain.forEach(event => thread.addEvent(event));
} else {
this.threads.set(
rootId,
new Thread(replyChain),
);
}
}
}
}
}

public findThreadByTailEvent(eventId: string): Thread {
return Array.from(this.threads.values()).find(thread => {
return thread.tail.has(eventId);
});
}

/**
* Build the reply chain starting from the bottom up
*/
public async getReplyChain(
timelinetSet: EventTimelineSet,
events: MatrixEvent[] = [],
): Promise<MatrixEvent[]> {
const parentEvent = await this.getEvent(events[0].replyEventId);
if (parentEvent.replyEventId) {
return this.getReplyChain(timelinetSet, [parentEvent, ...events]);
} else {
return [parentEvent, ...events];
}
}

/**
* Retrieve an event with the option to get it from the already
* loaded event timeline set
*/
public async getEvent(eventId: string, timelineSet?: EventTimelineSet): Promise<MatrixEvent> {
const parentEvent = timelineSet?.findEventById(eventId);
if (parentEvent) {
return parentEvent;
}

const response = await this.client.http.authedRequest(
undefined,
"GET",
`/rooms/${this.roomId}/event/${eventId}`,
);

return new MatrixEvent(response);
}

/**
Expand Down
101 changes: 101 additions & 0 deletions src/models/thread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { EventType } from "../@types/event";
import { MatrixEvent } from "./event";

export class Thread {
private root: string;
public tail = new Set<string>();
private events = new Map<string, MatrixEvent>();
private _messageCount = 0;

constructor(events: MatrixEvent[] = []) {
events.forEach(event => this.addEvent(event));
}

/**
* Add an event to the thread and updates
* the tail/root references if needed
* @param event The event to add
*/
public addEvent(event: MatrixEvent): void {
if (this.events.has(event.getId())) {
return;
}

const isRoomMessage = event.getType() === EventType.RoomMessage;

if (this.tail.has(event.replyEventId)) {
this.tail.delete(event.replyEventId);
}
this.tail.add(event.getId());

if (!event.replyEventId && isRoomMessage) {
this.root = event.getId();
this.events.forEach(event => event.setThreadRoot(this.root));
}

if (isRoomMessage) {
this._messageCount++;
}

this.events.set(event.getId(), event);

if (this.root) {
event.setThreadRoot(this.root);
}
}

/**
* A sorted list of events to display
*/
public get eventTimeline(): MatrixEvent[] {
return Array.from(this.events.values())
.sort((a, b) => a.getTs() - b.getTs());
}

/**
* The thread ID, which is the same as the root event ID
*/
public get id(): string {
return this.root;
}

public get rootEvent(): MatrixEvent {
return this.events.get(this.root);
}

/**
* The number of messages in the thread
*/
public get length(): number {
return this._messageCount;
}

/**
* A set of mxid participating to the thread
*/
public get participants(): Set<string> {
const participants = new Set<string>();
this.events.forEach(event => {
if (event.getType() === EventType.RoomMessage) {
participants.add(event.getSender());
}
});
return participants;
}
}