1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-08-06 12:02:40 +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(); httpBackend.verifyNoOutstandingExpectation();
client.stopClient(); client.stopClient();
Thread.setServerSideSupport(FeatureSupport.None); 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() { describe("getEventTimeline", function() {
it("should create a new timeline for new events", function() { it("should create a new timeline for new events", function() {
const room = client.getRoom(roomId)!; const room = client.getRoom(roomId)!;
@@ -595,22 +601,8 @@ describe("MatrixClient event timelines", function() {
// @ts-ignore // @ts-ignore
client.clientOpts.experimentalThreadSupport = true; client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental); 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 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!)) httpBackend.when("GET", "/rooms/!foo%3Abar/event/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, function() { .respond(200, function() {
@@ -619,7 +611,7 @@ describe("MatrixClient event timelines", function() {
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) + "/" + encodeURIComponent(THREAD_ROOT.event_id!) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&limit=1")
.respond(200, function() { .respond(200, function() {
return { return {
original_event: THREAD_ROOT, 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(); 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; const timeline = await timelinePromise;
expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); 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() { 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"; const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c";
function respondToFilter(): ExpectedHttpRequest { function respondToFilter(): ExpectedHttpRequest {
@@ -1050,7 +1074,7 @@ describe("MatrixClient event timelines", function() {
next_batch: RANDOM_TOKEN as string | null, next_batch: RANDOM_TOKEN as string | null,
}, },
): ExpectedHttpRequest { ): 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, $roomId: roomId,
})); }));
request.respond(200, response); request.respond(200, response);
@@ -1089,8 +1113,9 @@ describe("MatrixClient event timelines", function() {
beforeEach(() => { beforeEach(() => {
// @ts-ignore // @ts-ignore
client.clientOpts.experimentalThreadSupport = true; client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental); Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable); Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
}); });
async function testPagination(timelineSet: EventTimelineSet, direction: Direction) { 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() { it("should allow you to paginate all threads backwards", async function() {
const room = client.getRoom(roomId); const room = client.getRoom(roomId);
const timelineSets = await (room?.createThreadsTimelineSets()); const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull(); expect(timelineSets).not.toBeNull();
const [allThreads, myThreads] = timelineSets!; const [allThreads, myThreads] = timelineSets!;
await testPagination(allThreads, Direction.Backward); 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() { it("should allow you to paginate all threads forwards", async function() {
const room = client.getRoom(roomId); const room = client.getRoom(roomId);
const timelineSets = await (room?.createThreadsTimelineSets()); const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull(); expect(timelineSets).not.toBeNull();
const [allThreads, myThreads] = timelineSets!; const [allThreads, myThreads] = timelineSets!;
@@ -1130,7 +1155,7 @@ describe("MatrixClient event timelines", function() {
it("should allow fetching all threads", async function() { it("should allow fetching all threads", async function() {
const room = client.getRoom(roomId)!; const room = client.getRoom(roomId)!;
const timelineSets = await room.createThreadsTimelineSets(); const timelineSets = await room!.createThreadsTimelineSets();
expect(timelineSets).not.toBeNull(); expect(timelineSets).not.toBeNull();
respondToThreads(); respondToThreads();
respondToThreads(); respondToThreads();
@@ -1418,11 +1443,8 @@ describe("MatrixClient event timelines", function() {
}); });
}); });
it("should re-insert room IDs for bundled thread relation events", async () => { describe("should re-insert room IDs for bundled thread relation events", () => {
// @ts-ignore async function doTest() {
client.clientOpts.experimentalThreadSupport = true;
Thread.setServerSideSupport(FeatureSupport.Experimental);
httpBackend.when("GET", "/sync").respond(200, { httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_4", next_batch: "s_5_4",
rooms: { rooms: {
@@ -1444,6 +1466,14 @@ describe("MatrixClient event timelines", function() {
const thread = room.getThread(THREAD_ROOT.event_id!)!; const thread = room.getThread(THREAD_ROOT.event_id!)!;
const timelineSet = thread.timelineSet; 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!)) httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!))
.respond(200, { .respond(200, {
start: "start_token", start: "start_token",
@@ -1455,18 +1485,23 @@ describe("MatrixClient event timelines", function() {
}); });
httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) + "/" + encodeURIComponent(THREAD_ROOT.event_id!) + "/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") 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() { .respond(200, function() {
return { return {
original_event: THREAD_ROOT, original_event: THREAD_ROOT,
chunk: [THREAD_REPLY], chunk: [THREAD_REPLY],
// no next batch as this is the oldest end of the timeline
}; };
}); });
await Promise.all([ const timeline = await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!));
client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!),
httpBackend.flushAllExpected(),
]);
httpBackend.when("GET", "/sync").respond(200, { httpBackend.when("GET", "/sync").respond(200, {
next_batch: "s_5_5", next_batch: "s_5_5",
@@ -1486,6 +1521,37 @@ describe("MatrixClient event timelines", function() {
await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]);
expect(thread.liveTimeline.getEvents()[1].event).toEqual(THREAD_REPLY); 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();
});
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);
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(); 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 () => { it("should read related events with relation type", async () => {
@@ -72,7 +72,7 @@ describe("MatrixClient relations", () => {
await httpBackend!.flushAllExpected(); 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 () => { it("should read related events with relation type and event type", async () => {
@@ -87,7 +87,7 @@ describe("MatrixClient relations", () => {
await httpBackend!.flushAllExpected(); 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 () => { it("should read related events with custom options", async () => {
@@ -107,7 +107,7 @@ describe("MatrixClient relations", () => {
await httpBackend!.flushAllExpected(); 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 () => { it('should use default direction in the fetchRelations endpoint', async () => {

View File

@@ -231,6 +231,130 @@ describe("MatrixClient", function() {
client.stopClient(); 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 () => { it("should create (unstable) file trees", async () => {
const userId = "@test:example.org"; const userId = "@test:example.org";
const roomId = "!room: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", () => { describe("redactEvent", () => {
const roomId = "!room:example.org"; const roomId = "!room:example.org";
const mockRoom = { const mockRoom = {

View File

@@ -39,7 +39,7 @@ import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event";
import { TestClient } from "../TestClient"; import { TestClient } from "../TestClient";
import { emitPromise } from "../test-utils/test-utils"; import { emitPromise } from "../test-utils/test-utils";
import { ReceiptType } from "../../src/@types/read_receipts"; 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 { WrappedReceipt } from "../../src/models/read-receipt";
import { Crypto } from "../../src/crypto"; import { Crypto } from "../../src/crypto";
@@ -2203,6 +2203,7 @@ describe("Room", function() {
it("Edits update the lastReply event", async () => { it("Edits update the lastReply event", async () => {
room.client.supportsExperimentalThreads = () => true; room.client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const randomMessage = mkMessage(); const randomMessage = mkMessage();
const threadRoot = mkMessage(); const threadRoot = mkMessage();
@@ -2216,7 +2217,7 @@ describe("Room", function() {
unsigned: { unsigned: {
"age": 123, "age": 123,
"m.relations": { "m.relations": {
"m.thread": { [THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse.event, latest_event: threadResponse.event,
count: 2, count: 2,
current_user_participated: true, current_user_participated: true,
@@ -2228,11 +2229,29 @@ describe("Room", function() {
let prom = emitPromise(room, ThreadEvent.New); let prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([randomMessage, threadRoot, threadResponse]); room.addLiveEvents([randomMessage, threadRoot, threadResponse]);
const thread = await prom; 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); 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]); room.addLiveEvents([threadResponseEdit]);
await prom; await prom;
expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); 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 () => { it("Redactions to thread responses decrement the length", async () => {
room.client.supportsExperimentalThreads = () => true; room.client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage(); const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot); const threadResponse1 = mkThreadResponse(threadRoot);
@@ -2252,7 +2272,7 @@ describe("Room", function() {
unsigned: { unsigned: {
"age": 123, "age": 123,
"m.relations": { "m.relations": {
"m.thread": { [THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event, latest_event: threadResponse2.event,
count: 2, count: 2,
current_user_participated: true, current_user_participated: true,
@@ -2264,10 +2284,36 @@ describe("Room", function() {
let prom = emitPromise(room, ThreadEvent.New); let prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]);
const thread = await prom; const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2); expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); 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); prom = emitPromise(thread, ThreadEvent.Update);
const threadResponse1Redaction = mkRedaction(threadResponse1); const threadResponse1Redaction = mkRedaction(threadResponse1);
room.addLiveEvents([threadResponse1Redaction]); room.addLiveEvents([threadResponse1Redaction]);
@@ -2278,6 +2324,7 @@ describe("Room", function() {
it("Redactions to reactions in threads do not decrement the length", async () => { it("Redactions to reactions in threads do not decrement the length", async () => {
room.client.supportsExperimentalThreads = () => true; room.client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage(); const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot); const threadResponse1 = mkThreadResponse(threadRoot);
@@ -2291,7 +2338,7 @@ describe("Room", function() {
unsigned: { unsigned: {
"age": 123, "age": 123,
"m.relations": { "m.relations": {
"m.thread": { [THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event, latest_event: threadResponse2.event,
count: 2, count: 2,
current_user_participated: true, current_user_participated: true,
@@ -2303,6 +2350,7 @@ describe("Room", function() {
const prom = emitPromise(room, ThreadEvent.New); const prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]);
const thread = await prom; const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2); expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); 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 () => { it("should not decrement the length when the thread root is redacted", async () => {
room.client.supportsExperimentalThreads = () => true; room.client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const threadRoot = mkMessage(); const threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot); const threadResponse1 = mkThreadResponse(threadRoot);
@@ -2328,7 +2377,7 @@ describe("Room", function() {
unsigned: { unsigned: {
"age": 123, "age": 123,
"m.relations": { "m.relations": {
"m.thread": { [THREAD_RELATION_TYPE.name]: {
latest_event: threadResponse2.event, latest_event: threadResponse2.event,
count: 2, count: 2,
current_user_participated: true, current_user_participated: true,
@@ -2340,6 +2389,7 @@ describe("Room", function() {
let prom = emitPromise(room, ThreadEvent.New); let prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]);
const thread = await prom; const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2); expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId());
@@ -2353,6 +2403,18 @@ describe("Room", function() {
it("Redacting the lastEvent finds a new lastEvent", async () => { it("Redacting the lastEvent finds a new lastEvent", async () => {
room.client.supportsExperimentalThreads = () => true; 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 threadRoot = mkMessage();
const threadResponse1 = mkThreadResponse(threadRoot); const threadResponse1 = mkThreadResponse(threadRoot);
@@ -2377,21 +2439,53 @@ describe("Room", function() {
let prom = emitPromise(room, ThreadEvent.New); let prom = emitPromise(room, ThreadEvent.New);
room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]);
const thread = await prom; const thread = await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(2); expect(thread).toHaveLength(2);
expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); 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); prom = emitPromise(room, ThreadEvent.Update);
const threadResponse2Redaction = mkRedaction(threadResponse2); const threadResponse2Redaction = mkRedaction(threadResponse2);
room.addLiveEvents([threadResponse2Redaction]); room.addLiveEvents([threadResponse2Redaction]);
await prom; await prom;
await emitPromise(room, ThreadEvent.Update);
expect(thread).toHaveLength(1); expect(thread).toHaveLength(1);
expect(thread.replyToEvent.getId()).toBe(threadResponse1.getId()); 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); const threadResponse1Redaction = mkRedaction(threadResponse1);
room.addLiveEvents([threadResponse1Redaction]); room.addLiveEvents([threadResponse1Redaction]);
await prom; await prom;
await prom2;
expect(thread).toHaveLength(0); expect(thread).toHaveLength(0);
expect(thread.replyToEvent.getId()).toBe(threadRoot.getId()); expect(thread.replyToEvent.getId()).toBe(threadRoot.getId());
}); });
@@ -2400,6 +2494,7 @@ describe("Room", function() {
describe("eventShouldLiveIn", () => { describe("eventShouldLiveIn", () => {
const client = new TestClient(userA).client; const client = new TestClient(userA).client;
client.supportsExperimentalThreads = () => true; client.supportsExperimentalThreads = () => true;
Thread.setServerSideSupport(FeatureSupport.Stable);
const room = new Room(roomId, client, userA); const room = new Room(roomId, client, userA);
it("thread root and its relations&redactions should be in both", () => { 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 { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler';
import * as utils from './utils'; import * as utils from './utils';
import { QueryDict, sleep } from './utils'; import { replaceParam, QueryDict, sleep } from './utils';
import { Direction, EventTimeline } from "./models/event-timeline"; import { Direction, EventTimeline } from "./models/event-timeline";
import { IActionsObject, PushProcessor } from "./pushprocessor"; import { IActionsObject, PushProcessor } from "./pushprocessor";
import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery"; import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
@@ -193,7 +193,14 @@ import { TypedEventEmitter } from "./models/typed-event-emitter";
import { ReceiptType } from "./@types/read_receipts"; import { ReceiptType } from "./@types/read_receipts";
import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync"; import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync";
import { SlidingSyncSdk } from "./sliding-sync-sdk"; 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 { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
import { UnstableValue } from "./NamespacedValue"; import { UnstableValue } from "./NamespacedValue";
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue"; import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
@@ -1192,9 +1199,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const support = this.canSupport.get(Feature.ThreadUnreadNotifications); const support = this.canSupport.get(Feature.ThreadUnreadNotifications);
UNREAD_THREAD_NOTIFICATIONS.setPreferUnstable(support === ServerSupport.Unstable); UNREAD_THREAD_NOTIFICATIONS.setPreferUnstable(support === ServerSupport.Unstable);
const { threads, list } = await this.doesServerSupportThread(); const { threads, list, fwdPagination } = await this.doesServerSupportThread();
Thread.setServerSideSupport(threads); Thread.setServerSideSupport(threads);
Thread.setServerSideListSupport(list); Thread.setServerSideListSupport(list);
Thread.setServerSideFwdPaginationSupport(fwdPagination);
// shallow-copy the opts dict before modifying and storing it // shallow-copy the opts dict before modifying and storing it
this.clientOpts = Object.assign({}, opts) as IStoredClientOpts; this.clientOpts = Object.assign({}, opts) as IStoredClientOpts;
@@ -5171,6 +5179,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return timelineSet.getTimelineForEvent(eventId); return timelineSet.getTimelineForEvent(eventId);
} }
if (timelineSet.thread && this.supportsExperimentalThreads()) {
return this.getThreadTimeline(timelineSet, eventId);
}
const path = utils.encodeUri( const path = utils.encodeUri(
"/rooms/$roomId/context/$eventId", { "/rooms/$roomId/context/$eventId", {
$roomId: timelineSet.room.roomId, $roomId: timelineSet.room.roomId,
@@ -5196,6 +5208,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const mapper = this.getEventMapper(); const mapper = this.getEventMapper();
const event = mapper(res.event); 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 = [ const events = [
// Order events from most recent to oldest (reverse-chronological). // 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. // 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), ...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. // Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
let timeline = timelineSet.getTimelineForEvent(events[0].getId()!); let timeline = timelineSet.getTimelineForEvent(events[0].getId()!);
if (timeline) { if (timeline) {
@@ -5261,6 +5245,154 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
?? timeline; ?? 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 * 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 * 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"); throw new Error("getLatestTimeline only supports room timelines");
} }
let res: IMessagesResponse; let event;
const roomId = timelineSet.room.roomId; if (timelineSet.threadListType !== null) {
if (timelineSet.isThreadTimeline) { const res = await this.createThreadListMessagesRequest(
res = await this.createThreadListMessagesRequest( timelineSet.room.roomId,
roomId,
null, null,
1, 1,
Direction.Backward, Direction.Backward,
timelineSet.threadListType,
timelineSet.getFilter(), 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 { } else {
res = await this.createMessagesRequest( const messagesPath = utils.encodeUri(
roomId, "/rooms/$roomId/messages", {
null, $roomId: timelineSet.room.roomId,
1, },
Direction.Backward,
timelineSet.getFilter(),
); );
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) { 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); return this.getEventTimeline(timelineSet, event.event_id);
@@ -5376,6 +5525,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
fromToken: string | null, fromToken: string | null,
limit = 30, limit = 30,
dir = Direction.Backward, dir = Direction.Backward,
threadListType: ThreadFilterType | null = ThreadFilterType.All,
timelineFilter?: Filter, timelineFilter?: Filter,
): Promise<IMessagesResponse> { ): Promise<IMessagesResponse> {
const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId }); 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> = { const params: Record<string, string> = {
limit: limit.toString(), limit: limit.toString(),
dir: dir, dir: dir,
include: 'all', include: threadFilterTypeToFilter(threadListType),
}; };
if (fromToken) { if (fromToken) {
@@ -5395,7 +5545,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// create a shallow copy of LAZY_LOADING_MESSAGES_FILTER, // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER,
// so the timelineFilter doesn't get written into it below // so the timelineFilter doesn't get written into it below
filter = { filter = {
...filter,
...Filter.LAZY_LOADING_MESSAGES_FILTER, ...Filter.LAZY_LOADING_MESSAGES_FILTER,
}; };
} }
@@ -5411,14 +5560,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
params.filter = JSON.stringify(filter); params.filter = JSON.stringify(filter);
} }
const opts: { prefix?: string } = {}; const opts = {
if (Thread.hasServerSideListSupport === FeatureSupport.Experimental) { prefix: Thread.hasServerSideListSupport === FeatureSupport.Stable
opts.prefix = "/_matrix/client/unstable/org.matrix.msc3856"; ? "/_matrix/client/v1"
} : "/_matrix/client/unstable/org.matrix.msc3856",
};
return this.http.authedRequest<IThreadedMessagesResponse>(Method.Get, path, params, undefined, opts) return this.http.authedRequest<IThreadedMessagesResponse>(Method.Get, path, params, undefined, opts)
.then(res => ({ .then(res => ({
...res, ...res,
chunk: res.chunk?.reverse(),
start: res.prev_batch, start: res.prev_batch,
end: res.next_batch, end: res.next_batch,
})); }));
@@ -5440,7 +5591,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public paginateEventTimeline(eventTimeline: EventTimeline, opts: IPaginateOpts): Promise<boolean> { public paginateEventTimeline(eventTimeline: EventTimeline, opts: IPaginateOpts): Promise<boolean> {
const isNotifTimeline = (eventTimeline.getTimelineSet() === this.notifTimelineSet); const isNotifTimeline = (eventTimeline.getTimelineSet() === this.notifTimelineSet);
const room = this.getRoom(eventTimeline.getRoomId()!); 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 // TODO: we should implement a backoff (as per scrollback()) to deal more
// nicely with HTTP errors. // nicely with HTTP errors.
@@ -5511,16 +5663,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventTimeline.paginationRequests[dir] = null; eventTimeline.paginationRequests[dir] = null;
}); });
eventTimeline.paginationRequests[dir] = promise; eventTimeline.paginationRequests[dir] = promise;
} else if (isThreadTimeline) { } else if (threadListType !== null) {
if (!room) { if (!room) {
throw new Error("Unknown room " + eventTimeline.getRoomId()); 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( promise = this.createThreadListMessagesRequest(
eventTimeline.getRoomId()!, eventTimeline.getRoomId()!,
token, token,
opts.limit, opts.limit,
dir, dir,
threadListType,
eventTimeline.getFilter(), eventTimeline.getFilter(),
).then((res) => { ).then((res) => {
if (res.state) { if (res.state) {
@@ -5547,6 +5704,45 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventTimeline.paginationRequests[dir] = null; eventTimeline.paginationRequests[dir] = null;
}); });
eventTimeline.paginationRequests[dir] = promise; 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 { } else {
if (!room) { if (!room) {
throw new Error("Unknown room " + eventTimeline.getRoomId()); 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 matrixEvents = res.chunk.map(this.getEventMapper());
const timelineSet = eventTimeline.getTimelineSet(); const timelineSet = eventTimeline.getTimelineSet();
const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents); const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token); timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
this.processBeaconEvents(room, timelineEvents); 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; const atEnd = res.end === undefined || res.end === res.start;
@@ -6654,25 +6852,40 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async doesServerSupportThread(): Promise<{ public async doesServerSupportThread(): Promise<{
threads: FeatureSupport; threads: FeatureSupport;
list: FeatureSupport; list: FeatureSupport;
fwdPagination: FeatureSupport;
}> { }> {
if (await this.isVersionSupported("v1.4")) {
return {
threads: FeatureSupport.Stable,
list: FeatureSupport.Stable,
fwdPagination: FeatureSupport.Stable,
};
}
try { 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"),
this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"), this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"),
this.doesServerSupportUnstableFeature("org.matrix.msc3856"), this.doesServerSupportUnstableFeature("org.matrix.msc3856"),
this.doesServerSupportUnstableFeature("org.matrix.msc3856.stable"), 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 { return {
threads: determineFeatureSupport(threadStable, threadUnstable), threads: determineFeatureSupport(threadStable, threadUnstable),
list: determineFeatureSupport(listStable, listUnstable), list: determineFeatureSupport(listStable, listUnstable),
fwdPagination: determineFeatureSupport(fwdPaginationStable, fwdPaginationUnstable),
}; };
} catch (e) { } catch (e) {
return { return {
threads: FeatureSupport.None, threads: FeatureSupport.None,
list: FeatureSupport.None, list: FeatureSupport.None,
fwdPagination: FeatureSupport.None,
}; };
} }
} }
@@ -6732,12 +6945,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventType?: EventType | string | null, eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward }, opts: IRelationsRequestOpts = { dir: Direction.Backward },
): Promise<{ ): Promise<{
originalEvent?: MatrixEvent; originalEvent?: MatrixEvent | null;
events: MatrixEvent[]; events: MatrixEvent[];
nextBatch?: string; nextBatch?: string | null;
prevBatch?: string; prevBatch?: string | null;
}> { }> {
const fetchedEventType = this.getEncryptedIfNeededEventType(roomId, eventType); const fetchedEventType = eventType ? this.getEncryptedIfNeededEventType(roomId, eventType) : null;
const result = await this.fetchRelations( const result = await this.fetchRelations(
roomId, roomId,
eventId, eventId,
@@ -6761,10 +6974,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
events = events.filter(e => e.getSender() === originalEvent.getSender()); events = events.filter(e => e.getSender() === originalEvent.getSender());
} }
return { return {
originalEvent, originalEvent: originalEvent ?? null,
events, events,
nextBatch: result.next_batch, nextBatch: result.next_batch ?? null,
prevBatch: result.prev_batch, prevBatch: result.prev_batch ?? null,
}; };
} }
@@ -7281,7 +7494,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventType?: EventType | string | null, eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward }, opts: IRelationsRequestOpts = { dir: Direction.Backward },
): Promise<IRelationsResponse> { ): 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"; let templatedUrl = "/rooms/$roomId/relations/$eventId";
if (relationType !== null) { if (relationType !== null) {
@@ -7327,7 +7544,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @return {Promise} Resolves to an object containing the event. * @return {Promise} Resolves to an object containing the event.
* @return {module:http-api.MatrixError} Rejects: with an error response. * @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( const path = utils.encodeUri(
"/rooms/$roomId/event/$eventId", { "/rooms/$roomId/event/$eventId", {
$roomId: roomId, $roomId: roomId,

View File

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

View File

@@ -1542,11 +1542,16 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
/** /**
* @experimental * @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.thread = thread;
this.setThreadId(thread.id); this.setThreadId(thread?.id);
if (thread) {
this.reEmitter.reEmit(thread, [ThreadEvent.Update]); this.reEmitter.reEmit(thread, [ThreadEvent.Update]);
} }
}
/** /**
* @experimental * @experimental
@@ -1555,7 +1560,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
return this.thread; return this.thread;
} }
public setThreadId(threadId: string): void { public setThreadId(threadId?: string): void {
this.threadId = threadId; this.threadId = threadId;
} }
} }

View File

@@ -146,6 +146,7 @@ export type RoomEmittedEvents = RoomEvent
| ThreadEvent.New | ThreadEvent.New
| ThreadEvent.Update | ThreadEvent.Update
| ThreadEvent.NewReply | ThreadEvent.NewReply
| ThreadEvent.Delete
| MatrixEventEvent.BeforeRedaction | MatrixEventEvent.BeforeRedaction
| BeaconEvent.New | BeaconEvent.New
| BeaconEvent.Update | BeaconEvent.Update
@@ -180,7 +181,7 @@ export type RoomEventHandlerMap = {
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
} & Pick< } & Pick<
ThreadHandlerMap, ThreadHandlerMap,
ThreadEvent.Update | ThreadEvent.NewReply ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete
> >
& EventTimelineSetHandlerMap & EventTimelineSetHandlerMap
& Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> & 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). * 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. * 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++) { for (let i = 0; i < this.timelineSets.length; i++) {
this.timelineSets[i].resetLiveTimeline( this.timelineSets[i].resetLiveTimeline(
backPaginationToken ?? undefined, backPaginationToken ?? undefined,
@@ -1651,7 +1652,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
let timelineSet: EventTimelineSet; let timelineSet: EventTimelineSet;
if (Thread.hasServerSideListSupport) { if (Thread.hasServerSideListSupport) {
timelineSet = 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, [ this.reEmitter.reEmit(timelineSet, [
RoomEvent.Timeline, RoomEvent.Timeline,
RoomEvent.TimelineReset, RoomEvent.TimelineReset,
@@ -1758,7 +1759,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
let latestMyThreadsRootEvent: MatrixEvent | undefined; let latestMyThreadsRootEvent: MatrixEvent | undefined;
const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
for (const rootEvent of threadRoots) { for (const rootEvent of threadRoots) {
this.threadsTimelineSets[0].addLiveEvent(rootEvent, { this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, {
duplicateStrategy: DuplicateStrategy.Ignore, duplicateStrategy: DuplicateStrategy.Ignore,
fromCache: false, fromCache: false,
roomState, roomState,
@@ -1767,7 +1768,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
const threadRelationship = rootEvent const threadRelationship = rootEvent
.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name); .getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
if (threadRelationship?.current_user_participated) { if (threadRelationship?.current_user_participated) {
this.threadsTimelineSets[1].addLiveEvent(rootEvent, { this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, {
duplicateStrategy: DuplicateStrategy.Ignore, duplicateStrategy: DuplicateStrategy.Ignore,
fromCache: false, fromCache: false,
roomState, roomState,
@@ -1785,6 +1786,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
} }
this.on(ThreadEvent.NewReply, this.onThreadNewReply); this.on(ThreadEvent.NewReply, this.onThreadNewReply);
this.on(ThreadEvent.Delete, this.onThreadDelete);
this.threadsReady = true; this.threadsReady = true;
} }
@@ -1803,6 +1805,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
null, null,
undefined, undefined,
Direction.Backward, Direction.Backward,
timelineSet.threadListType,
timelineSet.getFilter(), timelineSet.getFilter(),
); );
@@ -1823,14 +1826,21 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
} }
private onThreadNewReply(thread: Thread): void { 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) { for (const timelineSet of this.threadsTimelineSets) {
timelineSet.removeEvent(thread.id); 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 { private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void {
let thread = this.getThread(threadId); let thread = this.getThread(threadId);
if (thread) { if (!thread) {
thread.addEvents(events, toStartOfTimeline);
} else {
const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId); const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId);
thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline); 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( public createThread(
threadId: string, threadId: string,
rootEvent: MatrixEvent | undefined, rootEvent: MatrixEvent | undefined,
@@ -1958,38 +1998,37 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
} }
const thread = new Thread(threadId, rootEvent, { const thread = new Thread(threadId, rootEvent, {
initialEvents: events,
room: this, room: this,
client: this.client, 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 // If we managed to create a thread and figure out its `id` then we can use it
this.threads.set(thread.id, thread); this.threads.set(thread.id, thread);
this.reEmitter.reEmit(thread, [ this.reEmitter.reEmit(thread, [
ThreadEvent.Delete,
ThreadEvent.Update, ThreadEvent.Update,
ThreadEvent.NewReply, ThreadEvent.NewReply,
RoomEvent.Timeline, RoomEvent.Timeline,
RoomEvent.TimelineReset, 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; this.lastThread = thread;
} }
if (this.threadsReady) { if (this.threadsReady) {
this.threadsTimelineSets.forEach(timelineSet => { this.updateThreadRootEvents(thread, toStartOfTimeline);
if (thread.rootEvent) {
if (Thread.hasServerSideSupport) {
timelineSet.addLiveEvent(thread.rootEvent);
} else {
timelineSet.addEventToTimeline(
thread.rootEvent,
timelineSet.getLiveTimeline(),
toStartOfTimeline,
);
}
}
});
} }
this.emit(ThreadEvent.New, 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 { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix";
import { TypedReEmitter } from "../ReEmitter"; import { TypedReEmitter } from "../ReEmitter";
import { IRelationsRequestOpts } from "../@types/requests";
import { IThreadBundledRelationship, MatrixEvent } from "./event"; import { IThreadBundledRelationship, MatrixEvent } from "./event";
import { Direction, EventTimeline } from "./event-timeline"; import { EventTimeline } from "./event-timeline";
import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set'; import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set';
import { Room } from './room'; import { Room } from './room';
import { RoomState } from "./room-state"; import { RoomState } from "./room-state";
@@ -33,6 +32,7 @@ export enum ThreadEvent {
Update = "Thread.update", Update = "Thread.update",
NewReply = "Thread.newReply", NewReply = "Thread.newReply",
ViewThread = "Thread.viewThread", ViewThread = "Thread.viewThread",
Delete = "Thread.delete"
} }
type EmittedEvents = Exclude<ThreadEvent, ThreadEvent.New> type EmittedEvents = Exclude<ThreadEvent, ThreadEvent.New>
@@ -43,10 +43,10 @@ export type EventHandlerMap = {
[ThreadEvent.Update]: (thread: Thread) => void; [ThreadEvent.Update]: (thread: Thread) => void;
[ThreadEvent.NewReply]: (thread: Thread, event: MatrixEvent) => void; [ThreadEvent.NewReply]: (thread: Thread, event: MatrixEvent) => void;
[ThreadEvent.ViewThread]: () => void; [ThreadEvent.ViewThread]: () => void;
[ThreadEvent.Delete]: (thread: Thread) => void;
} & EventTimelineSetHandlerMap; } & EventTimelineSetHandlerMap;
interface IThreadOpts { interface IThreadOpts {
initialEvents?: MatrixEvent[];
room: Room; room: Room;
client: MatrixClient; client: MatrixClient;
} }
@@ -73,6 +73,7 @@ export function determineFeatureSupport(stable: boolean, unstable: boolean): Fea
export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> { export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
public static hasServerSideSupport = FeatureSupport.None; public static hasServerSideSupport = FeatureSupport.None;
public static hasServerSideListSupport = 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 * 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 reEmitter: TypedReEmitter<EmittedEvents, EventHandlerMap>;
private lastEvent!: MatrixEvent; private lastEvent: MatrixEvent | undefined;
private replyCount = 0; private replyCount = 0;
public readonly room: Room; public readonly room: Room;
@@ -122,14 +123,10 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho);
this.timelineSet.on(RoomEvent.Timeline, 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 // 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. // gappy sync and a thread around this event may already exist.
this.initialiseThread(); this.initialiseThread();
this.setEventMetadata(this.rootEvent);
this.rootEvent?.setThread(this);
} }
private async fetchRootEvent(): Promise<void> { private async fetchRootEvent(): Promise<void> {
@@ -142,13 +139,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
} catch (e) { } catch (e) {
logger.error("Failed to fetch thread root to construct thread with", e); logger.error("Failed to fetch thread root to construct thread with", e);
} }
await this.processEvent(this.rootEvent);
// 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);
} }
public static setServerSideSupport( public static setServerSideSupport(
@@ -168,6 +159,12 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
Thread.hasServerSideListSupport = status; Thread.hasServerSideListSupport = status;
} }
public static setServerSideFwdPaginationSupport(
status: FeatureSupport,
): void {
Thread.hasServerSideFwdPaginationSupport = status;
}
private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => { private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => {
if (event?.isRelation(THREAD_RELATION_TYPE.name) && if (event?.isRelation(THREAD_RELATION_TYPE.name) &&
this.room.eventShouldLiveIn(event).threadId === this.id && 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 if (event.threadRootId !== this.id) return; // ignore redactions for other timelines
const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse(); if (this.replyCount <= 0) {
this.lastEvent = events.find(e => ( for (const threadEvent of this.events) {
!e.isRedacted() && this.clearEventMetadata(threadEvent);
e.isRelation(THREAD_RELATION_TYPE.name) }
)) ?? this.rootEvent!; this.lastEvent = this.rootEvent;
this.emit(ThreadEvent.Update, this); 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 (event.threadRootId !== this.id) return; // ignore echoes for other timelines
if (this.lastEvent === event) return; if (this.lastEvent === event) return;
if (!event.isRelation(THREAD_RELATION_TYPE.name)) return; if (!event.isRelation(THREAD_RELATION_TYPE.name)) return;
// There is a risk that the `localTimestamp` approximation will not be accurate await this.initialiseThread();
// 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.NewReply, this, event);
}
}
this.emit(ThreadEvent.Update, this);
}; };
public get roomState(): RoomState { public get roomState(): RoomState {
@@ -237,7 +219,7 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void { public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(ev => this.addEvent(ev, toStartOfTimeline, false)); 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. * 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. * @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 { public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> {
event.setThread(this); this.setEventMetadata(event);
if (!this._currentUserParticipated && event.getSender() === this.client.getUserId()) { const lastReply = this.lastReply();
this._currentUserParticipated = true; const isNewestReply = !lastReply || event.localTimestamp > lastReply!.localTimestamp;
}
// Add all incoming events to the thread's timeline set when there's no server support // Add all incoming events to the thread's timeline set when there's no server support
if (!Thread.hasServerSideSupport) { if (!Thread.hasServerSideSupport) {
@@ -265,16 +246,13 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.addEventToTimeline(event, toStartOfTimeline); this.addEventToTimeline(event, toStartOfTimeline);
this.client.decryptEventIfNeeded(event, {}); this.client.decryptEventIfNeeded(event, {});
} else if (!toStartOfTimeline && } else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) {
this.initialEventsFetched && await this.fetchEditsWhereNeeded(event);
event.localTimestamp > this.lastReply()!.localTimestamp
) {
this.fetchEditsWhereNeeded(event);
this.addEventToTimeline(event, false); this.addEventToTimeline(event, false);
} else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) { } else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
// Apply annotations and replace relations to the relations of the timeline only // Apply annotations and replace relations to the relations of the timeline only
this.timelineSet.relations.aggregateParentEvent(event); this.timelineSet.relations?.aggregateParentEvent(event);
this.timelineSet.relations.aggregateChildEvent(event, this.timelineSet); this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
return; return;
} }
@@ -285,7 +263,15 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
} }
if (emit) { 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); return rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
} }
private async initialiseThread(): Promise<void> { public async initialiseThread(): Promise<void> {
let bundledRelationship = this.getRootEventBundledRelationship(); let bundledRelationship = this.getRootEventBundledRelationship();
if (Thread.hasServerSideSupport && !bundledRelationship) { if (Thread.hasServerSideSupport) {
await this.fetchRootEvent(); await this.fetchRootEvent();
bundledRelationship = this.getRootEventBundledRelationship(); bundledRelationship = this.getRootEventBundledRelationship();
} }
@@ -304,15 +290,25 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
this.replyCount = bundledRelationship.count; this.replyCount = bundledRelationship.count;
this._currentUserParticipated = !!bundledRelationship.current_user_participated; this._currentUserParticipated = !!bundledRelationship.current_user_participated;
const event = new MatrixEvent({ const mapper = this.client.getEventMapper();
room_id: this.room.roomId, this.lastEvent = mapper(bundledRelationship.latest_event);
...bundledRelationship.latest_event, await this.processEvent(this.lastEvent);
}); }
this.setEventMetadata(event);
event.setThread(this);
this.lastEvent = event;
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); this.emit(ThreadEvent.Update, this);
@@ -334,16 +330,19 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
})); }));
} }
public async fetchInitialEvents(): Promise<void> { public setEventMetadata(event: Optional<MatrixEvent>): void {
if (this.initialEventsFetched) return; if (event) {
await this.fetchEvents();
this.initialEventsFetched = true;
}
private setEventMetadata(event: MatrixEvent): void {
EventTimeline.setEventMetadata(event, this.roomState, false); EventTimeline.setEventMetadata(event, this.roomState, false);
event.setThread(this); event.setThread(this);
} }
}
public clearEventMetadata(event: Optional<MatrixEvent>): void {
if (event) {
event.setThread(undefined);
delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name];
}
}
/** /**
* Finds an event by ID in the current thread * Finds an event by ID in the current thread
@@ -406,55 +405,6 @@ export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
return this.timelineSet.getLiveTimeline(); 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 { public getUnfilteredTimelineSet(): EventTimelineSet {
return this.timelineSet; return this.timelineSet;
} }
@@ -485,3 +435,12 @@ export enum ThreadFilterType {
"My", "My",
"All" "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, // 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. // which is important to keep room-switching feeling snappy.
if (initialEventId) { if (this.timelineSet.getTimelineForEvent(initialEventId)) {
const timeline = this.timelineSet.getTimelineForEvent(initialEventId); initFields(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 Promise.resolve();
} } else if (initialEventId) {
return this.client.getEventTimeline(this.timelineSet, initialEventId)
return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields); .then(initFields);
} else { } else {
const tl = this.timelineSet.getLiveTimeline(); initFields(this.timelineSet.getLiveTimeline());
initFields(tl);
return Promise.resolve(); return Promise.resolve();
} }
} }
@@ -236,8 +232,9 @@ export class TimelineWindow {
} }
} }
return Boolean(tl.timeline.getNeighbouringTimeline(direction) || const hasNeighbouringTimeline = tl.timeline.getNeighbouringTimeline(direction);
tl.timeline.getPaginationToken(direction) !== null); 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 * @return {Promise} Resolves to a boolean which is true if more events
* were successfully retrieved. * were successfully retrieved.
*/ */
public paginate( public async paginate(
direction: Direction, direction: Direction,
size: number, size: number,
makeRequest = true, makeRequest = true,
@@ -274,7 +271,7 @@ export class TimelineWindow {
if (!tl) { if (!tl) {
debuglog("TimelineWindow: no timeline yet"); debuglog("TimelineWindow: no timeline yet");
return Promise.resolve(false); return false;
} }
if (tl.pendingPaginate) { if (tl.pendingPaginate) {
@@ -283,20 +280,20 @@ export class TimelineWindow {
// try moving the cap // try moving the cap
if (this.extend(direction, size)) { if (this.extend(direction, size)) {
return Promise.resolve(true); return true;
} }
if (!makeRequest || requestLimit === 0) { if (!makeRequest || requestLimit === 0) {
// todo: should we return something different to indicate that there // todo: should we return something different to indicate that there
// might be more events out there, but we haven't found them yet? // might be more events out there, but we haven't found them yet?
return Promise.resolve(false); return false;
} }
// try making a pagination request // try making a pagination request
const token = tl.timeline.getPaginationToken(direction); const token = tl.timeline.getPaginationToken(direction);
if (token === null) { if (!token) {
debuglog("TimelineWindow: no token"); debuglog("TimelineWindow: no token");
return Promise.resolve(false); return false;
} }
debuglog("TimelineWindow: starting request"); debuglog("TimelineWindow: starting request");
@@ -309,8 +306,7 @@ export class TimelineWindow {
}).then((r) => { }).then((r) => {
debuglog("TimelineWindow: request completed with result " + r); debuglog("TimelineWindow: request completed with result " + r);
if (!r) { if (!r) {
// end of timeline return this.paginate(direction, size, false, 0);
return false;
} }
// recurse to advance the index into the results. // recurse to advance the index into the results.

View File

@@ -22,6 +22,7 @@ limitations under the License.
import unhomoglyph from "unhomoglyph"; import unhomoglyph from "unhomoglyph";
import promiseRetry from "p-retry"; import promiseRetry from "p-retry";
import { Optional } from "matrix-events-sdk";
import { MatrixEvent } from "./models/event"; import { MatrixEvent } from "./models/event";
import { M_TIMESTAMP } from "./@types/location"; 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>; 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. * Decode a query string in `application/x-www-form-urlencoded` format.
* @param {string} query A query string to decode e.g. * @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" }. * variables with. E.g. { "$bar": "baz" }.
* @return {string} The result of replacing all template variables e.g. '/foo/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) { for (const key in variables) {
if (!variables.hasOwnProperty(key)) { if (!variables.hasOwnProperty(key)) {
continue; continue;
} }
const value = variables[key];
if (value === undefined || value === null) {
continue;
}
pathTemplate = pathTemplate.replace( pathTemplate = pathTemplate.replace(
key, encodeURIComponent(variables[key]), key, encodeURIComponent(value),
); );
} }
return pathTemplate; return pathTemplate;