1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-30 04:23:07 +03:00

Loading threads with server-side assistance (#2735)

* Fix bug where undefined vs null in pagination tokens wasn't correctly handled
* Fix bug where thread list results were sorted incorrectly
* Allow removing the relationship of an event to a thread
* Implement feature detection for new threads MSCs and specs
* Prefix dir parameter for threads pagination if necessary
* Make threads conform to the same timeline APIs as any other timeline
* Extract thread timeline loading out of thread class
* fix thread roots not being updated correctly
* fix jumping to events by link
* implement new thread timeline loading
* Fix fetchRoomEvent incorrect return type

Co-authored-by: Germain <germains@element.io>
Co-authored-by: Germain <germain@souquet.com>
This commit is contained in:
Janne Mareike Koschinski
2022-10-28 13:48:14 +02:00
committed by GitHub
parent b44787192d
commit 068fbb7660
11 changed files with 878 additions and 477 deletions

View File

@ -342,8 +342,14 @@ describe("MatrixClient event timelines", function() {
httpBackend.verifyNoOutstandingExpectation();
client.stopClient();
Thread.setServerSideSupport(FeatureSupport.None);
Thread.setServerSideListSupport(FeatureSupport.None);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.None);
});
async function flushHttp<T>(promise: Promise<T>): Promise<T> {
return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result);
}
describe("getEventTimeline", function() {
it("should create a new timeline for new events", function() {
const room = client.getRoom(roomId)!;
@ -595,22 +601,8 @@ describe("MatrixClient event timelines", function() {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
client.stopClient(); // we don't need the client to be syncing at this time
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
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() {
return {
start: "start_token0",
events_before: [],
event: THREAD_REPLY,
events_after: [],
end: "end_token0",
state: [],
};
});
httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function() {
@ -619,7 +611,7 @@ describe("MatrixClient event timelines", function() {
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20")
encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&limit=1")
.respond(200, function() {
return {
original_event: THREAD_ROOT,
@ -628,9 +620,45 @@ describe("MatrixClient event timelines", function() {
};
});
const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!);
const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false);
await httpBackend.flushAllExpected();
const timelineSet = thread.timelineSet;
const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!);
const timeline = await timelinePromise;
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 handle thread replies with server support by fetching a contiguous thread timeline", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
await client.stopClient(); // we don't need the client to be syncing at this time
const room = client.getRoom(roomId)!;
httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function() {
return THREAD_ROOT;
});
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&limit=1")
.respond(200, function() {
return {
original_event: THREAD_ROOT,
chunk: [THREAD_REPLY],
// no next batch as this is the oldest end of the timeline
};
});
const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false);
await httpBackend.flushAllExpected();
const timelineSet = thread.timelineSet;
const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!);
const timeline = await timelinePromise;
expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy();
@ -1025,10 +1053,6 @@ describe("MatrixClient event timelines", function() {
});
describe("paginateEventTimeline for thread list timeline", function() {
async function flushHttp<T>(promise: Promise<T>): Promise<T> {
return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result);
}
const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c";
function respondToFilter(): ExpectedHttpRequest {
@ -1050,7 +1074,7 @@ describe("MatrixClient event timelines", function() {
next_batch: RANDOM_TOKEN as string | null,
},
): ExpectedHttpRequest {
const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/threads", {
const request = httpBackend.when("GET", encodeUri("/_matrix/client/v1/rooms/$roomId/threads", {
$roomId: roomId,
}));
request.respond(200, response);
@ -1089,8 +1113,9 @@ describe("MatrixClient event timelines", function() {
beforeEach(() => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
});
async function testPagination(timelineSet: EventTimelineSet, direction: Direction) {
@ -1111,7 +1136,7 @@ describe("MatrixClient event timelines", function() {
it("should allow you to paginate all threads backwards", async function() {
const room = client.getRoom(roomId);
const timelineSets = await (room?.createThreadsTimelineSets());
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
const [allThreads, myThreads] = timelineSets!;
await testPagination(allThreads, Direction.Backward);
@ -1120,7 +1145,7 @@ describe("MatrixClient event timelines", function() {
it("should allow you to paginate all threads forwards", async function() {
const room = client.getRoom(roomId);
const timelineSets = await (room?.createThreadsTimelineSets());
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
const [allThreads, myThreads] = timelineSets!;
@ -1130,7 +1155,7 @@ describe("MatrixClient event timelines", function() {
it("should allow fetching all threads", async function() {
const room = client.getRoom(roomId)!;
const timelineSets = await room.createThreadsTimelineSets();
const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull();
respondToThreads();
respondToThreads();
@ -1418,74 +1443,115 @@ describe("MatrixClient event timelines", function() {
});
});
it("should re-insert room IDs for bundled thread relation events", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_4",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
SYNC_THREAD_ROOT,
],
prev_batch: "f_1_1",
describe("should re-insert room IDs for bundled thread relation events", () => {
async function doTest() {
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_4",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
SYNC_THREAD_ROOT,
],
prev_batch: "f_1_1",
},
},
},
},
},
});
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
const room = client.getRoom(roomId)!;
const thread = room.getThread(THREAD_ROOT.event_id!)!;
const timelineSet = thread.timelineSet;
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, {
start: "start_token",
events_before: [],
event: THREAD_ROOT,
events_after: [],
state: [],
end: "end_token",
});
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20")
.respond(200, function() {
return {
original_event: THREAD_ROOT,
chunk: [THREAD_REPLY],
// no next batch as this is the oldest end of the timeline
};
});
await Promise.all([
client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!),
httpBackend.flushAllExpected(),
]);
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_5",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
SYNC_THREAD_REPLY,
],
prev_batch: "f_1_2",
const room = client.getRoom(roomId)!;
const thread = room.getThread(THREAD_ROOT.event_id!)!;
const timelineSet = thread.timelineSet;
const buildParams = (direction: Direction, token: string): string => {
if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) {
return `?from=${token}&org.matrix.msc3715.dir=${direction}`;
} else {
return `?dir=${direction}&from=${token}`;
}
};
httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, {
start: "start_token",
events_before: [],
event: THREAD_ROOT,
events_after: [],
state: [],
end: "end_token",
});
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + buildParams(Direction.Backward, "start_token"))
.respond(200, function() {
return {
original_event: THREAD_ROOT,
chunk: [],
};
});
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + buildParams(Direction.Forward, "end_token"))
.respond(200, function() {
return {
original_event: THREAD_ROOT,
chunk: [THREAD_REPLY],
};
});
const timeline = await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!));
httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_5",
rooms: {
join: {
[roomId]: {
timeline: {
events: [
SYNC_THREAD_REPLY,
],
prev_batch: "f_1_2",
},
},
},
},
},
});
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
expect(timeline!.getEvents()[1]!.event).toEqual(THREAD_REPLY);
}
it("in stable mode", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
return doTest();
});
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
it("in backwards compatible unstable mode", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.Experimental);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Experimental);
expect(thread.liveTimeline.getEvents()[1].event).toEqual(THREAD_REPLY);
return doTest();
});
it("in backwards compatible mode", async () => {
// @ts-ignore
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
Thread.setServerSideListSupport(FeatureSupport.None);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.None);
return doTest();
});
});
});

View File

@ -60,7 +60,7 @@ describe("MatrixClient relations", () => {
await httpBackend!.flushAllExpected();
expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" });
expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null });
});
it("should read related events with relation type", async () => {
@ -72,7 +72,7 @@ describe("MatrixClient relations", () => {
await httpBackend!.flushAllExpected();
expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" });
expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null });
});
it("should read related events with relation type and event type", async () => {
@ -87,7 +87,7 @@ describe("MatrixClient relations", () => {
await httpBackend!.flushAllExpected();
expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" });
expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null });
});
it("should read related events with custom options", async () => {
@ -107,7 +107,7 @@ describe("MatrixClient relations", () => {
await httpBackend!.flushAllExpected();
expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" });
expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null });
});
it('should use default direction in the fetchRelations endpoint', async () => {

View File

@ -231,6 +231,130 @@ describe("MatrixClient", function() {
client.stopClient();
});
describe("sendEvent", () => {
const roomId = "!room:example.org";
const body = "This is the body";
const content = { body };
it("overload without threadId works", async () => {
const eventId = "$eventId:example.org";
const txnId = client.makeTxnId();
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: content,
}];
await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId);
});
it("overload with null threadId works", async () => {
const eventId = "$eventId:example.org";
const txnId = client.makeTxnId();
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: content,
}];
await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId);
});
it("overload with threadId works", async () => {
const eventId = "$eventId:example.org";
const txnId = client.makeTxnId();
const threadId = "$threadId:server";
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"event_id": threadId,
"is_falling_back": true,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
it("should add thread relation if threadId is passed and the relation is missing", async () => {
const eventId = "$eventId:example.org";
const threadId = "$threadId:server";
const txnId = client.makeTxnId();
const room = new Room(roomId, client, userId);
store.getRoom.mockReturnValue(room);
const rootEvent = new MatrixEvent({ event_id: threadId });
room.createThread(threadId, rootEvent, [rootEvent], false);
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"m.in_reply_to": {
event_id: threadId,
},
"event_id": threadId,
"is_falling_back": true,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
it("should add thread relation if threadId is passed and the relation is missing with reply", async () => {
const eventId = "$eventId:example.org";
const threadId = "$threadId:server";
const txnId = client.makeTxnId();
const content = {
body,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$other:event",
},
},
};
const room = new Room(roomId, client, userId);
store.getRoom.mockReturnValue(room);
const rootEvent = new MatrixEvent({ event_id: threadId });
room.createThread(threadId, rootEvent, [rootEvent], false);
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$other:event",
},
"event_id": threadId,
"is_falling_back": false,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
});
it("should create (unstable) file trees", async () => {
const userId = "@test:example.org";
const roomId = "!room:example.org";
@ -777,130 +901,6 @@ describe("MatrixClient", function() {
});
});
describe("sendEvent", () => {
const roomId = "!room:example.org";
const body = "This is the body";
const content = { body };
it("overload without threadId works", async () => {
const eventId = "$eventId:example.org";
const txnId = client.makeTxnId();
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: content,
}];
await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId);
});
it("overload with null threadId works", async () => {
const eventId = "$eventId:example.org";
const txnId = client.makeTxnId();
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: content,
}];
await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId);
});
it("overload with threadId works", async () => {
const eventId = "$eventId:example.org";
const txnId = client.makeTxnId();
const threadId = "$threadId:server";
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"event_id": threadId,
"is_falling_back": true,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
it("should add thread relation if threadId is passed and the relation is missing", async () => {
const eventId = "$eventId:example.org";
const threadId = "$threadId:server";
const txnId = client.makeTxnId();
const room = new Room(roomId, client, userId);
store.getRoom.mockReturnValue(room);
const rootEvent = new MatrixEvent({ event_id: threadId });
room.createThread(threadId, rootEvent, [rootEvent], false);
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"m.in_reply_to": {
event_id: threadId,
},
"event_id": threadId,
"is_falling_back": true,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
it("should add thread relation if threadId is passed and the relation is missing with reply", async () => {
const eventId = "$eventId:example.org";
const threadId = "$threadId:server";
const txnId = client.makeTxnId();
const content = {
body,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$other:event",
},
},
};
const room = new Room(roomId, client, userId);
store.getRoom.mockReturnValue(room);
const rootEvent = new MatrixEvent({ event_id: threadId });
room.createThread(threadId, rootEvent, [rootEvent], false);
httpLookups = [{
method: "PUT",
path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`,
data: { event_id: eventId },
expectBody: {
...content,
"m.relates_to": {
"m.in_reply_to": {
event_id: "$other:event",
},
"event_id": threadId,
"is_falling_back": false,
"rel_type": "m.thread",
},
},
}];
await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId);
});
});
describe("redactEvent", () => {
const roomId = "!room:example.org";
const mockRoom = {

View File

@ -39,7 +39,7 @@ import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
import { TestClient } from "../TestClient";
import { emitPromise } from "../test-utils/test-utils";
import { ReceiptType } from "../../src/@types/read_receipts";
import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread";
import { FeatureSupport, Thread, ThreadEvent, THREAD_RELATION_TYPE } from "../../src/models/thread";
import { WrappedReceipt } from "../../src/models/read-receipt";
import { Crypto } from "../../src/crypto";
@ -2203,6 +2203,7 @@ describe("Room", function() {
it("Edits update the lastReply event", async () => {
room.client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const randomMessage = mkMessage();
const threadRoot = mkMessage();
@ -2216,7 +2217,7 @@ describe("Room", function() {
unsigned: {
"age": 123,
"m.relations": {
"m.thread": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse.event,
count: 2,
current_user_participated: true,
@ -2228,11 +2229,29 @@ describe("Room", function() {
let prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([randomMessage, threadRoot, threadResponse]);
const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread.replyToEvent).toBe(threadResponse);
expect(thread.replyToEvent.event).toEqual(threadResponse.event);
expect(thread.replyToEvent.getContent().body).toBe(threadResponse.getContent().body);
prom = emitPromise(thread, ThreadEvent.Update);
room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
[THREAD_RELATION_TYPE.name]: {
latest_event: {
...threadResponse.event,
content: threadResponseEdit.event.content,
},
count: 2,
current_user_participated: true,
},
},
},
});
prom = emitPromise(room, ThreadEvent.Update);
room.addLiveEvents([threadResponseEdit]);
await prom;
expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body);
@ -2240,6 +2259,7 @@ describe("Room", function() {
it("Redactions to thread responses decrement the length", async () => {
room.client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
@ -2252,7 +2272,7 @@ describe("Room", function() {
unsigned: {
"age": 123,
"m.relations": {
"m.thread": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event,
count: 2,
current_user_participated: true,
@ -2264,10 +2284,36 @@ describe("Room", function() {
let prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]);
const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
thread.timelineSet.addEventToTimeline(
threadResponse1,
thread.liveTimeline,
{ toStartOfTimeline: true, fromCache: false, roomState: thread.roomState },
);
thread.timelineSet.addEventToTimeline(
threadResponse2,
thread.liveTimeline,
{ toStartOfTimeline: true, fromCache: false, roomState: thread.roomState },
);
room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event,
count: 1,
current_user_participated: true,
},
},
},
});
prom = emitPromise(thread, ThreadEvent.Update);
const threadResponse1Redaction = mkRedaction(threadResponse1);
room.addLiveEvents([threadResponse1Redaction]);
@ -2278,6 +2324,7 @@ describe("Room", function() {
it("Redactions to reactions in threads do not decrement the length", async () => {
room.client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
@ -2291,7 +2338,7 @@ describe("Room", function() {
unsigned: {
"age": 123,
"m.relations": {
"m.thread": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event,
count: 2,
current_user_participated: true,
@ -2303,6 +2350,7 @@ describe("Room", function() {
const prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]);
const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
@ -2315,6 +2363,7 @@ describe("Room", function() {
it("should not decrement the length when the thread root is redacted", async () => {
room.client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
@ -2328,7 +2377,7 @@ describe("Room", function() {
unsigned: {
"age": 123,
"m.relations": {
"m.thread": {
[THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event,
count: 2,
current_user_participated: true,
@ -2340,6 +2389,7 @@ describe("Room", function() {
let prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]);
const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
@ -2353,6 +2403,18 @@ describe("Room", function() {
it("Redacting the lastEvent finds a new lastEvent", async () => {
room.client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
room.client.createThreadListMessagesRequest = () => Promise.resolve({
start: null,
end: null,
chunk: [],
state: [],
});
await room.createThreadsTimelineSets();
await room.fetchRoomThreads();
const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot);
@ -2377,21 +2439,53 @@ describe("Room", function() {
let prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]);
const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
"m.thread": {
latest_event: threadResponse1.event,
count: 1,
current_user_participated: true,
},
},
},
});
prom = emitPromise(room, ThreadEvent.Update);
const threadResponse2Redaction = mkRedaction(threadResponse2);
room.addLiveEvents([threadResponse2Redaction]);
await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(1);
expect(thread.replyToEvent.getId()).toBe(threadResponse1.getId());
prom = emitPromise(room, ThreadEvent.Update);
room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({
...threadRoot.event,
unsigned: {
"age": 123,
"m.relations": {
"m.thread": {
latest_event: threadRoot.event,
count: 0,
current_user_participated: true,
},
},
},
});
prom = emitPromise(room, ThreadEvent.Delete);
const prom2 = emitPromise(room, RoomEvent.Timeline);
const threadResponse1Redaction = mkRedaction(threadResponse1);
room.addLiveEvents([threadResponse1Redaction]);
await prom;
await prom2;
expect(thread).toHaveLength(0);
expect(thread.replyToEvent.getId()).toBe(threadRoot.getId());
});
@ -2400,6 +2494,7 @@ describe("Room", function() {
describe("eventShouldLiveIn", () => {
const client = new TestClient(userA).client;
client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const room = new Room(roomId, client, userA);
it("thread root and its relations&redactions should be in both", () => {

View File

@ -36,7 +36,7 @@ import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, suppor
import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler';
import * as utils from './utils';
import { QueryDict, sleep } from './utils';
import { replaceParam, QueryDict, sleep } from './utils';
import { Direction, EventTimeline } from "./models/event-timeline";
import { IActionsObject, PushProcessor } from "./pushprocessor";
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
@ -193,7 +193,14 @@ import { TypedEventEmitter } from "./models/typed-event-emitter";
import { ReceiptType } from "./@types/read_receipts";
import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync";
import { SlidingSyncSdk } from "./sliding-sync-sdk";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, determineFeatureSupport } from "./models/thread";
import {
FeatureSupport,
Thread,
THREAD_RELATION_TYPE,
determineFeatureSupport,
ThreadFilterType,
threadFilterTypeToFilter,
} from "./models/thread";
import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { UnstableValue } from "./NamespacedValue";
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
@ -1192,9 +1199,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const support = this.canSupport.get(Feature.ThreadUnreadNotifications);
UNREAD_THREAD_NOTIFICATIONS.setPreferUnstable(support === ServerSupport.Unstable);
const { threads, list } = await this.doesServerSupportThread();
const { threads, list, fwdPagination } = await this.doesServerSupportThread();
Thread.setServerSideSupport(threads);
Thread.setServerSideListSupport(list);
Thread.setServerSideFwdPaginationSupport(fwdPagination);
// shallow-copy the opts dict before modifying and storing it
this.clientOpts = Object.assign({}, opts) as IStoredClientOpts;
@ -5171,6 +5179,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return timelineSet.getTimelineForEvent(eventId);
}
if (timelineSet.thread && this.supportsExperimentalThreads()) {
return this.getThreadTimeline(timelineSet, eventId);
}
const path = utils.encodeUri(
"/rooms/$roomId/context/$eventId", {
$roomId: timelineSet.room.roomId,
@ -5196,6 +5208,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const mapper = this.getEventMapper();
const event = mapper(res.event);
if (event.isRelation(THREAD_RELATION_TYPE.name)) {
logger.warn("Tried loading a regular timeline at the position of a thread event");
return undefined;
}
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.
@ -5205,38 +5221,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
...res.events_before.map(mapper),
];
if (this.supportsExperimentalThreads()) {
if (!timelineSet.canContain(event)) {
return undefined;
}
// 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 = {
dir: Direction.Backward,
limit: 50,
};
await thread.fetchInitialEvents();
let nextBatch: string | null | undefined = 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;
}
return thread.liveTimeline;
}
}
// Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
let timeline = timelineSet.getTimelineForEvent(events[0].getId()!);
if (timeline) {
@ -5261,6 +5245,154 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
?? timeline;
}
public async getThreadTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | undefined> {
if (!this.supportsExperimentalThreads()) {
throw new Error("could not get thread timeline: no client support");
}
if (!timelineSet.room) {
throw new Error("could not get thread timeline: not a room timeline");
}
if (!timelineSet.thread) {
throw new Error("could not get thread timeline: not a thread timeline");
}
const path = utils.encodeUri(
"/rooms/$roomId/context/$eventId", {
$roomId: timelineSet.room.roomId,
$eventId: eventId,
},
);
const params: Record<string, string | string[]> = {
limit: "0",
};
if (this.clientOpts?.lazyLoadMembers) {
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
}
// TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
const mapper = this.getEventMapper();
const event = mapper(res.event);
if (!timelineSet.canContain(event)) {
return undefined;
}
if (Thread.hasServerSideSupport) {
if (Thread.hasServerSideFwdPaginationSupport) {
if (!timelineSet.thread) {
throw new Error("could not get thread timeline: not a thread timeline");
}
const thread = timelineSet.thread;
const resOlder: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Backward, from: res.start },
);
const resNewer: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Forward, from: res.end },
);
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.
...resNewer.chunk.reverse().map(mapper),
event,
...resOlder.chunk.map(mapper),
];
for (const event of events) {
await timelineSet.thread?.processEvent(event);
}
// Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
let timeline = timelineSet.getTimelineForEvent(event.getId());
if (timeline) {
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper));
} else {
timeline = timelineSet.addTimeline();
timeline.initialiseState(res.state.map(mapper));
}
timelineSet.addEventsToTimeline(events, true, timeline, resNewer.next_batch);
if (!resOlder.next_batch) {
timelineSet.addEventsToTimeline([mapper(resOlder.original_event)], true, timeline, null);
}
timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
timeline.setPaginationToken(resNewer.next_batch ?? null, Direction.Forward);
this.processBeaconEvents(timelineSet.room, events);
// There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
// timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
// anywhere, if it was later redacted, so we just return the timeline we first thought of.
return timelineSet.getTimelineForEvent(eventId)
?? timeline;
} else {
// 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
const thread = timelineSet.thread;
const resOlder = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Backward, from: res.start },
);
const eventsNewer: IEvent[] = [];
let nextBatch: Optional<string> = res.end;
while (nextBatch) {
const resNewer: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir: Direction.Forward, from: nextBatch },
);
nextBatch = resNewer.next_batch ?? null;
eventsNewer.push(...resNewer.chunk);
}
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.
...eventsNewer.reverse().map(mapper),
event,
...resOlder.chunk.map(mapper),
];
for (const event of events) {
await timelineSet.thread?.processEvent(event);
}
// Here we handle non-thread timelines only, but still process any thread events to populate thread
// summaries.
const timeline = timelineSet.getLiveTimeline();
timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper));
timelineSet.addEventsToTimeline(events, true, timeline, null);
if (!resOlder.next_batch) {
timelineSet.addEventsToTimeline([mapper(resOlder.original_event)], true, timeline, null);
}
timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
timeline.setPaginationToken(null, Direction.Forward);
this.processBeaconEvents(timelineSet.room, events);
return timeline;
}
}
}
/**
* 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
@ -5282,28 +5414,45 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("getLatestTimeline only supports room timelines");
}
let res: IMessagesResponse;
const roomId = timelineSet.room.roomId;
if (timelineSet.isThreadTimeline) {
res = await this.createThreadListMessagesRequest(
roomId,
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 {
res = await this.createMessagesRequest(
roomId,
null,
1,
Direction.Backward,
timelineSet.getFilter(),
const messagesPath = utils.encodeUri(
"/rooms/$roomId/messages", {
$roomId: timelineSet.room.roomId,
},
);
const params: Record<string, string | string[]> = {
dir: 'b',
};
if (this.clientOpts?.lazyLoadMembers) {
params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
}
const res = await this.http.authedRequest<IMessagesResponse>(Method.Get, messagesPath, params);
event = res.chunk?.[0];
}
const event = res.chunk?.[0];
if (!event) {
throw new Error("No message returned from /messages when trying to construct getLatestTimeline");
throw new Error("No message returned when trying to construct getLatestTimeline");
}
return this.getEventTimeline(timelineSet, event.event_id);
@ -5376,6 +5525,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
fromToken: string | null,
limit = 30,
dir = Direction.Backward,
threadListType: ThreadFilterType | null = ThreadFilterType.All,
timelineFilter?: Filter,
): Promise<IMessagesResponse> {
const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId });
@ -5383,7 +5533,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const params: Record<string, string> = {
limit: limit.toString(),
dir: dir,
include: 'all',
include: threadFilterTypeToFilter(threadListType),
};
if (fromToken) {
@ -5395,7 +5545,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// create a shallow copy of LAZY_LOADING_MESSAGES_FILTER,
// so the timelineFilter doesn't get written into it below
filter = {
...filter,
...Filter.LAZY_LOADING_MESSAGES_FILTER,
};
}
@ -5411,14 +5560,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
params.filter = JSON.stringify(filter);
}
const opts: { prefix?: string } = {};
if (Thread.hasServerSideListSupport === FeatureSupport.Experimental) {
opts.prefix = "/_matrix/client/unstable/org.matrix.msc3856";
}
const opts = {
prefix: Thread.hasServerSideListSupport === FeatureSupport.Stable
? "/_matrix/client/v1"
: "/_matrix/client/unstable/org.matrix.msc3856",
};
return this.http.authedRequest<IThreadedMessagesResponse>(Method.Get, path, params, undefined, opts)
.then(res => ({
...res,
chunk: res.chunk?.reverse(),
start: res.prev_batch,
end: res.next_batch,
}));
@ -5440,7 +5591,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public paginateEventTimeline(eventTimeline: EventTimeline, opts: IPaginateOpts): Promise<boolean> {
const isNotifTimeline = (eventTimeline.getTimelineSet() === this.notifTimelineSet);
const room = this.getRoom(eventTimeline.getRoomId()!);
const isThreadTimeline = eventTimeline.getTimelineSet().isThreadTimeline;
const threadListType = eventTimeline.getTimelineSet().threadListType;
const thread = eventTimeline.getTimelineSet().thread;
// TODO: we should implement a backoff (as per scrollback()) to deal more
// nicely with HTTP errors.
@ -5511,16 +5663,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventTimeline.paginationRequests[dir] = null;
});
eventTimeline.paginationRequests[dir] = promise;
} else if (isThreadTimeline) {
} else if (threadListType !== null) {
if (!room) {
throw new Error("Unknown room " + eventTimeline.getRoomId());
}
if (!Thread.hasServerSideFwdPaginationSupport && dir === Direction.Forward) {
throw new Error("Cannot paginate threads forwards without server-side support for MSC 3715");
}
promise = this.createThreadListMessagesRequest(
eventTimeline.getRoomId()!,
token,
opts.limit,
dir,
threadListType,
eventTimeline.getFilter(),
).then((res) => {
if (res.state) {
@ -5547,6 +5704,45 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventTimeline.paginationRequests[dir] = null;
});
eventTimeline.paginationRequests[dir] = promise;
} else if (thread) {
const room = this.getRoom(eventTimeline.getRoomId() ?? undefined);
if (!room) {
throw new Error("Unknown room " + eventTimeline.getRoomId());
}
promise = this.fetchRelations(
eventTimeline.getRoomId() ?? "",
thread.id,
THREAD_RELATION_TYPE.name,
null,
{ dir, limit: opts.limit, from: token ?? undefined },
).then(async (res) => {
const mapper = this.getEventMapper();
const matrixEvents = res.chunk.map(mapper);
for (const event of matrixEvents) {
await eventTimeline.getTimelineSet()?.thread?.processEvent(event);
}
const newToken = res.next_batch;
const timelineSet = eventTimeline.getTimelineSet();
timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, newToken ?? null);
if (!newToken && backwards) {
timelineSet.addEventsToTimeline([mapper(res.original_event)], true, eventTimeline, null);
}
this.processBeaconEvents(timelineSet.room, matrixEvents);
// if we've hit the end of the timeline, we need to stop trying to
// paginate. We need to keep the 'forwards' token though, to make sure
// we can recover from gappy syncs.
if (backwards && !newToken) {
eventTimeline.setPaginationToken(null, dir);
}
return Boolean(newToken);
}).finally(() => {
eventTimeline.paginationRequests[dir] = null;
});
eventTimeline.paginationRequests[dir] = promise;
} else {
if (!room) {
throw new Error("Unknown room " + eventTimeline.getRoomId());
@ -5568,10 +5764,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const matrixEvents = res.chunk.map(this.getEventMapper());
const timelineSet = eventTimeline.getTimelineSet();
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
this.processBeaconEvents(room, timelineEvents);
this.processThreadEvents(room, threadedEvents, backwards);
this.processThreadRoots(room,
timelineEvents.filter(it => it.isRelation(THREAD_RELATION_TYPE.name)),
false);
const atEnd = res.end === undefined || res.end === res.start;
@ -6654,25 +6852,40 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async doesServerSupportThread(): Promise<{
threads: FeatureSupport;
list: FeatureSupport;
fwdPagination: FeatureSupport;
}> {
if (await this.isVersionSupported("v1.4")) {
return {
threads: FeatureSupport.Stable,
list: FeatureSupport.Stable,
fwdPagination: FeatureSupport.Stable,
};
}
try {
const [threadUnstable, threadStable, listUnstable, listStable] = await Promise.all([
const [
threadUnstable, threadStable,
listUnstable, listStable,
fwdPaginationUnstable, fwdPaginationStable,
] = await Promise.all([
this.doesServerSupportUnstableFeature("org.matrix.msc3440"),
this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"),
this.doesServerSupportUnstableFeature("org.matrix.msc3856"),
this.doesServerSupportUnstableFeature("org.matrix.msc3856.stable"),
this.doesServerSupportUnstableFeature("org.matrix.msc3715"),
this.doesServerSupportUnstableFeature("org.matrix.msc3715.stable"),
]);
// TODO: Use `this.isVersionSupported("v1.3")` for whatever spec version includes MSC3440 formally.
return {
threads: determineFeatureSupport(threadStable, threadUnstable),
list: determineFeatureSupport(listStable, listUnstable),
fwdPagination: determineFeatureSupport(fwdPaginationStable, fwdPaginationUnstable),
};
} catch (e) {
return {
threads: FeatureSupport.None,
list: FeatureSupport.None,
fwdPagination: FeatureSupport.None,
};
}
}
@ -6732,12 +6945,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward },
): Promise<{
originalEvent?: MatrixEvent;
originalEvent?: MatrixEvent | null;
events: MatrixEvent[];
nextBatch?: string;
prevBatch?: string;
nextBatch?: string | null;
prevBatch?: string | null;
}> {
const fetchedEventType = this.getEncryptedIfNeededEventType(roomId, eventType);
const fetchedEventType = eventType ? this.getEncryptedIfNeededEventType(roomId, eventType) : null;
const result = await this.fetchRelations(
roomId,
eventId,
@ -6761,10 +6974,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
events = events.filter(e => e.getSender() === originalEvent.getSender());
}
return {
originalEvent,
originalEvent: originalEvent ?? null,
events,
nextBatch: result.next_batch,
prevBatch: result.prev_batch,
nextBatch: result.next_batch ?? null,
prevBatch: result.prev_batch ?? null,
};
}
@ -7281,7 +7494,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward },
): Promise<IRelationsResponse> {
const queryString = utils.encodeParams(opts as Record<string, string | number>);
let params = opts as QueryDict;
if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) {
params = replaceParam("dir", "org.matrix.msc3715.dir", params);
}
const queryString = utils.encodeParams(params);
let templatedUrl = "/rooms/$roomId/relations/$eventId";
if (relationType !== null) {
@ -7327,7 +7544,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves to an object containing the event.
* @return {module:http-api.MatrixError} Rejects: with an error response.
*/
public fetchRoomEvent(roomId: string, eventId: string): Promise<IMinimalEvent> {
public fetchRoomEvent(roomId: string, eventId: string): Promise<Partial<IEvent>> {
const path = utils.encodeUri(
"/rooms/$roomId/event/$eventId", {
$roomId: roomId,

View File

@ -27,7 +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";
import { Thread, ThreadFilterType } from "./thread";
const DEBUG = true;
@ -140,7 +140,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
opts: IOpts = {},
client?: MatrixClient,
public readonly thread?: Thread,
public readonly isThreadTimeline: boolean = false,
public readonly threadListType: ThreadFilterType | null = null,
) {
super();
@ -297,8 +297,8 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
* @return {?module:models/event-timeline~EventTimeline} timeline containing
* the given event, or null if unknown
*/
public getTimelineForEvent(eventId: string | null): EventTimeline | null {
if (eventId === null) { return null; }
public getTimelineForEvent(eventId?: string): EventTimeline | null {
if (eventId === null || eventId === undefined) { return null; }
const res = this._eventIdToTimeline.get(eventId);
return (res === undefined) ? null : res;
}
@ -359,7 +359,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
events: MatrixEvent[],
toStartOfTimeline: boolean,
timeline: EventTimeline,
paginationToken?: string,
paginationToken?: string | null,
): void {
if (!timeline) {
throw new Error(

View File

@ -1542,10 +1542,15 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
/**
* @experimental
*/
public setThread(thread: Thread): void {
public setThread(thread?: Thread): void {
if (this.thread) {
this.reEmitter.stopReEmitting(this.thread, [ThreadEvent.Update]);
}
this.thread = thread;
this.setThreadId(thread.id);
this.reEmitter.reEmit(thread, [ThreadEvent.Update]);
this.setThreadId(thread?.id);
if (thread) {
this.reEmitter.reEmit(thread, [ThreadEvent.Update]);
}
}
/**
@ -1555,7 +1560,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
return this.thread;
}
public setThreadId(threadId: string): void {
public setThreadId(threadId?: string): void {
this.threadId = threadId;
}
}

View File

@ -146,6 +146,7 @@ export type RoomEmittedEvents = RoomEvent
| ThreadEvent.New
| ThreadEvent.Update
| ThreadEvent.NewReply
| ThreadEvent.Delete
| MatrixEventEvent.BeforeRedaction
| BeaconEvent.New
| BeaconEvent.Update
@ -180,7 +181,7 @@ export type RoomEventHandlerMap = {
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
} & Pick<
ThreadHandlerMap,
ThreadEvent.Update | ThreadEvent.NewReply
ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete
>
& EventTimelineSetHandlerMap
& Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction>
@ -1006,7 +1007,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* timeline which would otherwise be unable to paginate forwards without this token).
* Removing just the old live timeline whilst preserving previous ones is not supported.
*/
public resetLiveTimeline(backPaginationToken: string | null, forwardPaginationToken: string | null): void {
public resetLiveTimeline(backPaginationToken?: string | null, forwardPaginationToken?: string | null): void {
for (let i = 0; i < this.timelineSets.length; i++) {
this.timelineSets[i].resetLiveTimeline(
backPaginationToken ?? undefined,
@ -1651,7 +1652,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
let timelineSet: EventTimelineSet;
if (Thread.hasServerSideListSupport) {
timelineSet =
new EventTimelineSet(this, this.opts, undefined, undefined, Boolean(Thread.hasServerSideListSupport));
new EventTimelineSet(this, this.opts, undefined, undefined, filterType ?? ThreadFilterType.All);
this.reEmitter.reEmit(timelineSet, [
RoomEvent.Timeline,
RoomEvent.TimelineReset,
@ -1758,7 +1759,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
let latestMyThreadsRootEvent: MatrixEvent | undefined;
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
for (const rootEvent of threadRoots) {
this.threadsTimelineSets[0].addLiveEvent(rootEvent, {
this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, {
duplicateStrategy: DuplicateStrategy.Ignore,
fromCache: false,
roomState,
@ -1767,7 +1768,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const threadRelationship = rootEvent
.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
if (threadRelationship?.current_user_participated) {
this.threadsTimelineSets[1].addLiveEvent(rootEvent, {
this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, {
duplicateStrategy: DuplicateStrategy.Ignore,
fromCache: false,
roomState,
@ -1785,6 +1786,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
this.on(ThreadEvent.NewReply, this.onThreadNewReply);
this.on(ThreadEvent.Delete, this.onThreadDelete);
this.threadsReady = true;
}
@ -1803,6 +1805,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
null,
undefined,
Direction.Backward,
timelineSet.threadListType,
timelineSet.getFilter(),
);
@ -1823,14 +1826,21 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
private onThreadNewReply(thread: Thread): void {
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
this.updateThreadRootEvents(thread, false);
}
private onThreadDelete(thread: Thread): void {
this.threads.delete(thread.id);
const timeline = this.getTimelineForEvent(thread.id);
const roomEvent = timeline?.getEvents()?.find(it => it.getId() === thread.id);
if (roomEvent) {
thread.clearEventMetadata(roomEvent);
} else {
logger.debug("onThreadDelete: Could not find root event in room timeline");
}
for (const timelineSet of this.threadsTimelineSets) {
timelineSet.removeEvent(thread.id);
timelineSet.addLiveEvent(thread.rootEvent, {
duplicateStrategy: DuplicateStrategy.Replace,
fromCache: false,
roomState,
});
}
}
@ -1912,13 +1922,12 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void {
let thread = this.getThread(threadId);
if (thread) {
thread.addEvents(events, toStartOfTimeline);
} else {
if (!thread) {
const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId);
thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline);
this.emit(ThreadEvent.Update, thread);
}
thread.addEvents(events, toStartOfTimeline);
}
/**
@ -1942,6 +1951,37 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
));
}
private updateThreadRootEvents = (thread: Thread, toStartOfTimeline: boolean) => {
if (thread.length) {
this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline);
if (thread.hasCurrentUserParticipated) {
this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline);
}
}
};
private updateThreadRootEvent = (
timelineSet: Optional<EventTimelineSet>,
thread: Thread,
toStartOfTimeline: boolean,
) => {
if (timelineSet && thread.rootEvent) {
if (Thread.hasServerSideSupport) {
timelineSet.addLiveEvent(thread.rootEvent, {
duplicateStrategy: DuplicateStrategy.Replace,
fromCache: false,
roomState: this.currentState,
});
} else {
timelineSet.addEventToTimeline(
thread.rootEvent,
timelineSet.getLiveTimeline(),
{ toStartOfTimeline },
);
}
}
};
public createThread(
threadId: string,
rootEvent: MatrixEvent | undefined,
@ -1958,38 +1998,37 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
const thread = new Thread(threadId, rootEvent, {
initialEvents: events,
room: this,
client: this.client,
});
// This is necessary to be able to jump to events in threads:
// If we jump to an event in a thread where neither the event, nor the root,
// nor any thread event are loaded yet, we'll load the event as well as the thread root, create the thread,
// and pass the event through this.
for (const event of events) {
thread.setEventMetadata(event);
}
// If we managed to create a thread and figure out its `id` then we can use it
this.threads.set(thread.id, thread);
this.reEmitter.reEmit(thread, [
ThreadEvent.Delete,
ThreadEvent.Update,
ThreadEvent.NewReply,
RoomEvent.Timeline,
RoomEvent.TimelineReset,
]);
const isNewer = this.lastThread?.rootEvent
&& rootEvent?.localTimestamp
&& this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp;
if (!this.lastThread || this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp) {
if (!this.lastThread || isNewer) {
this.lastThread = thread;
}
if (this.threadsReady) {
this.threadsTimelineSets.forEach(timelineSet => {
if (thread.rootEvent) {
if (Thread.hasServerSideSupport) {
timelineSet.addLiveEvent(thread.rootEvent);
} else {
timelineSet.addEventToTimeline(
thread.rootEvent,
timelineSet.getLiveTimeline(),
toStartOfTimeline,
);
}
}
});
this.updateThreadRootEvents(thread, toStartOfTimeline);
}
this.emit(ThreadEvent.New, thread, toStartOfTimeline);

View File

@ -18,9 +18,8 @@ import { Optional } from "matrix-events-sdk";
import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix";
import { TypedReEmitter } from "../ReEmitter";
import { IRelationsRequestOpts } from "../@types/requests";
import { IThreadBundledRelationship, MatrixEvent } from "./event";
import { Direction, EventTimeline } from "./event-timeline";
import { EventTimeline } from "./event-timeline";
import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set';
import { Room } from './room';
import { RoomState } from "./room-state";
@ -33,6 +32,7 @@ export enum ThreadEvent {
Update = "Thread.update",
NewReply = "Thread.newReply",
ViewThread = "Thread.viewThread",
Delete = "Thread.delete"
}
type EmittedEvents = Exclude<ThreadEvent, ThreadEvent.New>
@ -43,10 +43,10 @@ export type EventHandlerMap = {
[ThreadEvent.Update]: (thread: Thread) => void;
[ThreadEvent.NewReply]: (thread: Thread, event: MatrixEvent) => void;
[ThreadEvent.ViewThread]: () => void;
[ThreadEvent.Delete]: (thread: Thread) => void;
} & EventTimelineSetHandlerMap;
interface IThreadOpts {
initialEvents?: MatrixEvent[];
room: Room;
client: MatrixClient;
}
@ -73,6 +73,7 @@ export function determineFeatureSupport(stable: boolean, unstable: boolean): Fea
export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
public static hasServerSideSupport = FeatureSupport.None;
public static hasServerSideListSupport = FeatureSupport.None;
public static hasServerSideFwdPaginationSupport = FeatureSupport.None;
/**
* A reference to all the events ID at the bottom of the threads
@ -83,7 +84,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
private reEmitter: TypedReEmitter<EmittedEvents, EventHandlerMap>;
private lastEvent!: MatrixEvent;
private lastEvent: MatrixEvent | undefined;
private replyCount = 0;
public readonly room: Room;
@ -122,14 +123,10 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho);
this.timelineSet.on(RoomEvent.Timeline, this.onEcho);
if (opts.initialEvents) {
this.addEvents(opts.initialEvents, false);
}
// even if this thread is thought to be originating from this client, we initialise it as we may be in a
// gappy sync and a thread around this event may already exist.
this.initialiseThread();
this.rootEvent?.setThread(this);
this.setEventMetadata(this.rootEvent);
}
private async fetchRootEvent(): Promise<void> {
@ -142,13 +139,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
} catch (e) {
logger.error("Failed to fetch thread root to construct thread with", e);
}
// The root event might be not be visible to the person requesting it.
// If it wasn't fetched successfully the thread will work in "limited" mode and won't
// benefit from all the APIs a homeserver can provide to enhance the thread experience
this.rootEvent?.setThread(this);
this.emit(ThreadEvent.Update, this);
await this.processEvent(this.rootEvent);
}
public static setServerSideSupport(
@ -168,6 +159,12 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
Thread.hasServerSideListSupport = status;
}
public static setServerSideFwdPaginationSupport(
status: FeatureSupport,
): void {
Thread.hasServerSideFwdPaginationSupport = status;
}
private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => {
if (event?.isRelation(THREAD_RELATION_TYPE.name) &&
this.room.eventShouldLiveIn(event).threadId === this.id &&
@ -179,42 +176,27 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
}
};
private onRedaction = (event: MatrixEvent) => {
private onRedaction = async (event: MatrixEvent) => {
if (event.threadRootId !== this.id) return; // ignore redactions for other timelines
const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse();
this.lastEvent = events.find(e => (
!e.isRedacted() &&
e.isRelation(THREAD_RELATION_TYPE.name)
)) ?? this.rootEvent!;
this.emit(ThreadEvent.Update, this);
if (this.replyCount <= 0) {
for (const threadEvent of this.events) {
this.clearEventMetadata(threadEvent);
}
this.lastEvent = this.rootEvent;
this._currentUserParticipated = false;
this.emit(ThreadEvent.Delete, this);
} else {
await this.initialiseThread();
}
};
private onEcho = (event: MatrixEvent) => {
private onEcho = async (event: MatrixEvent) => {
if (event.threadRootId !== this.id) return; // ignore echoes for other timelines
if (this.lastEvent === event) return;
if (!event.isRelation(THREAD_RELATION_TYPE.name)) return;
// There is a risk that the `localTimestamp` approximation will not be accurate
// when threads are used over federation. That could result in the reply
// count value drifting away from the value returned by the server
const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name);
if (!this.lastEvent || this.lastEvent.isRedacted() || (isThreadReply
&& (event.getId() !== this.lastEvent.getId())
&& (event.localTimestamp > this.lastEvent.localTimestamp))
) {
this.lastEvent = event;
if (this.lastEvent.getId() !== this.id) {
// This counting only works when server side support is enabled as we started the counting
// from the value returned within the bundled relationship
if (Thread.hasServerSideSupport) {
this.replyCount++;
}
this.emit(ThreadEvent.NewReply, this, event);
}
}
this.emit(ThreadEvent.Update, this);
await this.initialiseThread();
this.emit(ThreadEvent.NewReply, this, event);
};
public get roomState(): RoomState {
@ -237,7 +219,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(ev => this.addEvent(ev, toStartOfTimeline, false));
this.emit(ThreadEvent.Update, this);
this.initialiseThread();
}
/**
@ -249,12 +231,11 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
* to the start (and not the end) of the timeline.
* @param {boolean} emit whether to emit the Update event if the thread was updated or not.
*/
public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): void {
event.setThread(this);
public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> {
this.setEventMetadata(event);
if (!this._currentUserParticipated && event.getSender() === this.client.getUserId()) {
this._currentUserParticipated = true;
}
const lastReply = this.lastReply();
const isNewestReply = !lastReply || event.localTimestamp > lastReply!.localTimestamp;
// Add all incoming events to the thread's timeline set when there's no server support
if (!Thread.hasServerSideSupport) {
@ -265,16 +246,13 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.addEventToTimeline(event, toStartOfTimeline);
this.client.decryptEventIfNeeded(event, {});
} else if (!toStartOfTimeline &&
this.initialEventsFetched &&
event.localTimestamp > this.lastReply()!.localTimestamp
) {
this.fetchEditsWhereNeeded(event);
} else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) {
await this.fetchEditsWhereNeeded(event);
this.addEventToTimeline(event, false);
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
// Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.relations.aggregateParentEvent(event);
this.timelineSet.relations.aggregateChildEvent(event, this.timelineSet);
this.timelineSet.relations?.aggregateParentEvent(event);
this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
return;
}
@ -285,7 +263,15 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
}
if (emit) {
this.emit(ThreadEvent.Update, this);
this.emit(ThreadEvent.NewReply, this, event);
this.initialiseThread();
}
}
public async processEvent(event: Optional<MatrixEvent>): Promise<void> {
if (event) {
this.setEventMetadata(event);
await this.fetchEditsWhereNeeded(event);
}
}
@ -293,9 +279,9 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
return rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
}
private async initialiseThread(): Promise<void> {
public async initialiseThread(): Promise<void> {
let bundledRelationship = this.getRootEventBundledRelationship();
if (Thread.hasServerSideSupport && !bundledRelationship) {
if (Thread.hasServerSideSupport) {
await this.fetchRootEvent();
bundledRelationship = this.getRootEventBundledRelationship();
}
@ -304,15 +290,25 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.replyCount = bundledRelationship.count;
this._currentUserParticipated = !!bundledRelationship.current_user_participated;
const event = new MatrixEvent({
room_id: this.room.roomId,
...bundledRelationship.latest_event,
});
this.setEventMetadata(event);
event.setThread(this);
this.lastEvent = event;
const mapper = this.client.getEventMapper();
this.lastEvent = mapper(bundledRelationship.latest_event);
await this.processEvent(this.lastEvent);
}
this.fetchEditsWhereNeeded(event);
if (!this.initialEventsFetched) {
this.initialEventsFetched = true;
// fetch initial event to allow proper pagination
try {
// if the thread has regular events, this will just load the last reply.
// if the thread is newly created, this will load the root event.
await this.client.paginateEventTimeline(this.liveTimeline, { backwards: true, limit: 1 });
// just to make sure that, if we've created a timeline window for this thread before the thread itself
// existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);
} catch (e) {
logger.error("Failed to load start of newly created thread: ", e);
this.initialEventsFetched = false;
}
}
this.emit(ThreadEvent.Update, this);
@ -334,15 +330,18 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
}));
}
public async fetchInitialEvents(): Promise<void> {
if (this.initialEventsFetched) return;
await this.fetchEvents();
this.initialEventsFetched = true;
public setEventMetadata(event: Optional<MatrixEvent>): void {
if (event) {
EventTimeline.setEventMetadata(event, this.roomState, false);
event.setThread(this);
}
}
private setEventMetadata(event: MatrixEvent): void {
EventTimeline.setEventMetadata(event, this.roomState, false);
event.setThread(this);
public clearEventMetadata(event: Optional<MatrixEvent>): void {
if (event) {
event.setThread(undefined);
delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name];
}
}
/**
@ -406,55 +405,6 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
return this.timelineSet.getLiveTimeline();
}
public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, dir: Direction.Backward }): Promise<{
originalEvent?: MatrixEvent;
events: MatrixEvent[];
nextBatch?: string | null;
prevBatch?: string;
}> {
let {
originalEvent,
events,
prevBatch,
nextBatch,
} = await this.client.relations(
this.room.roomId,
this.id,
THREAD_RELATION_TYPE.name,
null,
opts,
);
// When there's no nextBatch returned with a `from` request we have reached
// the end of the thread, and therefore want to return an empty one
if (!opts.to && !nextBatch && originalEvent) {
events = [...events, originalEvent];
}
await this.fetchEditsWhereNeeded(...events);
await Promise.all(events.map(event => {
this.setEventMetadata(event);
return this.client.decryptEventIfNeeded(event);
}));
const prependEvents = (opts.dir ?? Direction.Backward) === Direction.Backward;
this.timelineSet.addEventsToTimeline(
events,
prependEvents,
this.liveTimeline,
prependEvents ? nextBatch : prevBatch,
);
return {
originalEvent,
events,
prevBatch,
nextBatch,
};
}
public getUnfilteredTimelineSet(): EventTimelineSet {
return this.timelineSet;
}
@ -485,3 +435,12 @@ export enum ThreadFilterType {
"My",
"All"
}
export function threadFilterTypeToFilter(type: ThreadFilterType | null): 'all' | 'participated' {
switch (type) {
case ThreadFilterType.My:
return 'participated';
default:
return 'all';
}
}

View File

@ -133,18 +133,14 @@ export class TimelineWindow {
// We avoid delaying the resolution of the promise by a reactor tick if we already have the data we need,
// which is important to keep room-switching feeling snappy.
if (initialEventId) {
const timeline = this.timelineSet.getTimelineForEvent(initialEventId);
if (timeline) {
// hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does.
initFields(timeline);
return Promise.resolve();
}
return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields);
if (this.timelineSet.getTimelineForEvent(initialEventId)) {
initFields(this.timelineSet.getTimelineForEvent(initialEventId));
return Promise.resolve();
} else if (initialEventId) {
return this.client.getEventTimeline(this.timelineSet, initialEventId)
.then(initFields);
} else {
const tl = this.timelineSet.getLiveTimeline();
initFields(tl);
initFields(this.timelineSet.getLiveTimeline());
return Promise.resolve();
}
}
@ -236,8 +232,9 @@ export class TimelineWindow {
}
}
return Boolean(tl.timeline.getNeighbouringTimeline(direction) ||
tl.timeline.getPaginationToken(direction) !== null);
const hasNeighbouringTimeline = tl.timeline.getNeighbouringTimeline(direction);
const paginationToken = tl.timeline.getPaginationToken(direction);
return Boolean(hasNeighbouringTimeline) || Boolean(paginationToken);
}
/**
@ -262,7 +259,7 @@ export class TimelineWindow {
* @return {Promise} Resolves to a boolean which is true if more events
* were successfully retrieved.
*/
public paginate(
public async paginate(
direction: Direction,
size: number,
makeRequest = true,
@ -274,7 +271,7 @@ export class TimelineWindow {
if (!tl) {
debuglog("TimelineWindow: no timeline yet");
return Promise.resolve(false);
return false;
}
if (tl.pendingPaginate) {
@ -283,20 +280,20 @@ export class TimelineWindow {
// try moving the cap
if (this.extend(direction, size)) {
return Promise.resolve(true);
return true;
}
if (!makeRequest || requestLimit === 0) {
// todo: should we return something different to indicate that there
// might be more events out there, but we haven't found them yet?
return Promise.resolve(false);
return false;
}
// try making a pagination request
const token = tl.timeline.getPaginationToken(direction);
if (token === null) {
if (!token) {
debuglog("TimelineWindow: no token");
return Promise.resolve(false);
return false;
}
debuglog("TimelineWindow: starting request");
@ -309,8 +306,7 @@ export class TimelineWindow {
}).then((r) => {
debuglog("TimelineWindow: request completed with result " + r);
if (!r) {
// end of timeline
return false;
return this.paginate(direction, size, false, 0);
}
// recurse to advance the index into the results.

View File

@ -22,6 +22,7 @@ limitations under the License.
import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry";
import { Optional } from "matrix-events-sdk";
import { MatrixEvent } from "./models/event";
import { M_TIMESTAMP } from "./@types/location";
@ -76,6 +77,25 @@ export function encodeParams(params: QueryDict, urlSearchParams?: URLSearchParam
export type QueryDict = Record<string, string[] | string | number | boolean | undefined>;
/**
* Replace a stable parameter with the unstable naming for params
* @param stable
* @param unstable
* @param dict
*/
export function replaceParam(
stable: string,
unstable: string,
dict: QueryDict,
): QueryDict {
const result = {
...dict,
[unstable]: dict[stable],
};
delete result[stable];
return result;
}
/**
* Decode a query string in `application/x-www-form-urlencoded` format.
* @param {string} query A query string to decode e.g.
@ -103,13 +123,17 @@ export function decodeParams(query: string): Record<string, string | string[]> {
* variables with. E.g. { "$bar": "baz" }.
* @return {string} The result of replacing all template variables e.g. '/foo/baz'.
*/
export function encodeUri(pathTemplate: string, variables: Record<string, string>): string {
export function encodeUri(pathTemplate: string, variables: Record<string, Optional<string>>): string {
for (const key in variables) {
if (!variables.hasOwnProperty(key)) {
continue;
}
const value = variables[key];
if (value === undefined || value === null) {
continue;
}
pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]),
key, encodeURIComponent(value),
);
}
return pathTemplate;