diff --git a/spec/integ/matrix-client-event-timeline.spec.ts b/spec/integ/matrix-client-event-timeline.spec.ts index bc31b4d4e67..4e732184749 100644 --- a/spec/integ/matrix-client-event-timeline.spec.ts +++ b/spec/integ/matrix-client-event-timeline.spec.ts @@ -342,8 +342,14 @@ describe("MatrixClient event timelines", function() { httpBackend.verifyNoOutstandingExpectation(); client.stopClient(); Thread.setServerSideSupport(FeatureSupport.None); + Thread.setServerSideListSupport(FeatureSupport.None); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.None); }); + async function flushHttp(promise: Promise): Promise { + return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result); + } + describe("getEventTimeline", function() { it("should create a new timeline for new events", function() { const room = client.getRoom(roomId)!; @@ -595,23 +601,43 @@ describe("MatrixClient event timelines", function() { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; Thread.setServerSideSupport(FeatureSupport.Experimental); - client.stopClient(); // we don't need the client to be syncing at this time + await client.stopClient(); // we don't need the client to be syncing at this time const room = client.getRoom(roomId)!; - const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); - const timelineSet = thread.timelineSet; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_REPLY.event_id!)) + 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 { - start: "start_token0", - events_before: [], - event: THREAD_REPLY, - events_after: [], - end: "end_token0", - state: [], + original_event: THREAD_ROOT, + chunk: [THREAD_REPLY], + // no next batch as this is the oldest end of the timeline }; }); + const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); + await httpBackend.flushAllExpected(); + const timelineSet = thread.timelineSet; + + const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); + const timeline = await timelinePromise; + + expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); + 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; @@ -619,7 +645,7 @@ describe("MatrixClient event timelines", function() { httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + - encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") + encodeURIComponent(THREAD_RELATION_TYPE.name) + "?dir=b&limit=1") .respond(200, function() { return { original_event: THREAD_ROOT, @@ -628,9 +654,11 @@ describe("MatrixClient event timelines", function() { }; }); - const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); + const thread = room.createThread(THREAD_ROOT.event_id!, undefined, [], false); await httpBackend.flushAllExpected(); + const timelineSet = thread.timelineSet; + const timelinePromise = client.getEventTimeline(timelineSet, THREAD_REPLY.event_id!); const timeline = await timelinePromise; expect(timeline!.getEvents().find(e => e.getId() === THREAD_ROOT.event_id!)).toBeTruthy(); @@ -1025,10 +1053,6 @@ describe("MatrixClient event timelines", function() { }); describe("paginateEventTimeline for thread list timeline", function() { - async function flushHttp(promise: Promise): Promise { - return Promise.all([promise, httpBackend.flushAllExpected()]).then(([result]) => result); - } - const RANDOM_TOKEN = "7280349c7bee430f91defe2a38a0a08c"; function respondToFilter(): ExpectedHttpRequest { @@ -1050,7 +1074,7 @@ describe("MatrixClient event timelines", function() { next_batch: RANDOM_TOKEN as string | null, }, ): ExpectedHttpRequest { - const request = httpBackend.when("GET", encodeUri("/_matrix/client/r0/rooms/$roomId/threads", { + const request = httpBackend.when("GET", encodeUri("/_matrix/client/v1/rooms/$roomId/threads", { $roomId: roomId, })); request.respond(200, response); @@ -1089,8 +1113,9 @@ describe("MatrixClient event timelines", function() { beforeEach(() => { // @ts-ignore client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideSupport(FeatureSupport.Stable); Thread.setServerSideListSupport(FeatureSupport.Stable); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable); }); async function testPagination(timelineSet: EventTimelineSet, direction: Direction) { @@ -1111,7 +1136,7 @@ describe("MatrixClient event timelines", function() { it("should allow you to paginate all threads backwards", async function() { const room = client.getRoom(roomId); - const timelineSets = await (room?.createThreadsTimelineSets()); + const timelineSets = await room!.createThreadsTimelineSets(); expect(timelineSets).not.toBeNull(); const [allThreads, myThreads] = timelineSets!; await testPagination(allThreads, Direction.Backward); @@ -1120,7 +1145,7 @@ describe("MatrixClient event timelines", function() { it("should allow you to paginate all threads forwards", async function() { const room = client.getRoom(roomId); - const timelineSets = await (room?.createThreadsTimelineSets()); + const timelineSets = await room!.createThreadsTimelineSets(); expect(timelineSets).not.toBeNull(); const [allThreads, myThreads] = timelineSets!; @@ -1130,7 +1155,7 @@ describe("MatrixClient event timelines", function() { it("should allow fetching all threads", async function() { const room = client.getRoom(roomId)!; - const timelineSets = await room.createThreadsTimelineSets(); + const timelineSets = await room!.createThreadsTimelineSets(); expect(timelineSets).not.toBeNull(); respondToThreads(); respondToThreads(); @@ -1418,74 +1443,115 @@ describe("MatrixClient event timelines", function() { }); }); - it("should re-insert room IDs for bundled thread relation events", async () => { - // @ts-ignore - client.clientOpts.experimentalThreadSupport = true; - Thread.setServerSideSupport(FeatureSupport.Experimental); - - httpBackend.when("GET", "/sync").respond(200, { - next_batch: "s_5_4", - rooms: { - join: { - [roomId]: { - timeline: { - events: [ - SYNC_THREAD_ROOT, - ], - prev_batch: "f_1_1", + describe("should re-insert room IDs for bundled thread relation events", () => { + async function doTest() { + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_4", + rooms: { + join: { + [roomId]: { + timeline: { + events: [ + SYNC_THREAD_ROOT, + ], + prev_batch: "f_1_1", + }, }, }, }, - }, - }); - await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); + }); + await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); - const room = client.getRoom(roomId)!; - const thread = room.getThread(THREAD_ROOT.event_id!)!; - const timelineSet = thread.timelineSet; + const room = client.getRoom(roomId)!; + const thread = room.getThread(THREAD_ROOT.event_id!)!; + const timelineSet = thread.timelineSet; - httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) - .respond(200, { - start: "start_token", - events_before: [], - event: THREAD_ROOT, - events_after: [], - state: [], - end: "end_token", - }); - httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + - encodeURIComponent(THREAD_ROOT.event_id!) + "/" + - encodeURIComponent(THREAD_RELATION_TYPE.name) + "?limit=20") - .respond(200, function() { - return { - original_event: THREAD_ROOT, - chunk: [THREAD_REPLY], - // no next batch as this is the oldest end of the timeline - }; - }); - await Promise.all([ - client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!), - httpBackend.flushAllExpected(), - ]); + 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", "/sync").respond(200, { - next_batch: "s_5_5", - rooms: { - join: { - [roomId]: { - timeline: { - events: [ - SYNC_THREAD_REPLY, - ], - prev_batch: "f_1_2", + httpBackend.when("GET", "/rooms/!foo%3Abar/context/" + encodeURIComponent(THREAD_ROOT.event_id!)) + .respond(200, { + start: "start_token", + events_before: [], + event: THREAD_ROOT, + events_after: [], + state: [], + end: "end_token", + }); + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + buildParams(Direction.Backward, "start_token")) + .respond(200, function() { + return { + original_event: THREAD_ROOT, + chunk: [], + }; + }); + httpBackend.when("GET", "/rooms/!foo%3Abar/relations/" + + encodeURIComponent(THREAD_ROOT.event_id!) + "/" + + encodeURIComponent(THREAD_RELATION_TYPE.name) + buildParams(Direction.Forward, "end_token")) + .respond(200, function() { + return { + original_event: THREAD_ROOT, + chunk: [THREAD_REPLY], + }; + }); + const timeline = await flushHttp(client.getEventTimeline(timelineSet, THREAD_ROOT.event_id!)); + + httpBackend.when("GET", "/sync").respond(200, { + next_batch: "s_5_5", + rooms: { + join: { + [roomId]: { + timeline: { + events: [ + SYNC_THREAD_REPLY, + ], + prev_batch: "f_1_2", + }, }, }, }, - }, + }); + + await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); + + expect(timeline!.getEvents()[1]!.event).toEqual(THREAD_REPLY); + } + + it("in stable mode", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Stable); + Thread.setServerSideListSupport(FeatureSupport.Stable); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable); + + return doTest(); }); - await Promise.all([httpBackend.flushAllExpected(), utils.syncPromise(client)]); + it("in backwards compatible unstable mode", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.Experimental); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.Experimental); - expect(thread.liveTimeline.getEvents()[1].event).toEqual(THREAD_REPLY); + return doTest(); + }); + + it("in backwards compatible mode", async () => { + // @ts-ignore + client.clientOpts.experimentalThreadSupport = true; + Thread.setServerSideSupport(FeatureSupport.Experimental); + Thread.setServerSideListSupport(FeatureSupport.None); + Thread.setServerSideFwdPaginationSupport(FeatureSupport.None); + + return doTest(); + }); }); }); diff --git a/spec/integ/matrix-client-relations.spec.ts b/spec/integ/matrix-client-relations.spec.ts index 3a8a99fbff3..456db2efb07 100644 --- a/spec/integ/matrix-client-relations.spec.ts +++ b/spec/integ/matrix-client-relations.spec.ts @@ -60,7 +60,7 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null }); }); it("should read related events with relation type", async () => { @@ -72,7 +72,7 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null }); }); it("should read related events with relation type and event type", async () => { @@ -87,7 +87,7 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null }); }); it("should read related events with custom options", async () => { @@ -107,7 +107,7 @@ describe("MatrixClient relations", () => { await httpBackend!.flushAllExpected(); - expect(await response).toEqual({ "events": [], "nextBatch": "NEXT" }); + expect(await response).toEqual({ "events": [], "nextBatch": "NEXT", "originalEvent": null, "prevBatch": null }); }); it('should use default direction in the fetchRelations endpoint', async () => { diff --git a/spec/unit/matrix-client.spec.ts b/spec/unit/matrix-client.spec.ts index 91687e8ad2b..8b2cb51f8e7 100644 --- a/spec/unit/matrix-client.spec.ts +++ b/spec/unit/matrix-client.spec.ts @@ -231,6 +231,130 @@ describe("MatrixClient", function() { client.stopClient(); }); + describe("sendEvent", () => { + const roomId = "!room:example.org"; + const body = "This is the body"; + const content = { body }; + + it("overload without threadId works", async () => { + const eventId = "$eventId:example.org"; + const txnId = client.makeTxnId(); + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: content, + }]; + + await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("overload with null threadId works", async () => { + const eventId = "$eventId:example.org"; + const txnId = client.makeTxnId(); + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: content, + }]; + + await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId); + }); + + it("overload with threadId works", async () => { + const eventId = "$eventId:example.org"; + const txnId = client.makeTxnId(); + const threadId = "$threadId:server"; + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "event_id": threadId, + "is_falling_back": true, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("should add thread relation if threadId is passed and the relation is missing", async () => { + const eventId = "$eventId:example.org"; + const threadId = "$threadId:server"; + const txnId = client.makeTxnId(); + + const room = new Room(roomId, client, userId); + store.getRoom.mockReturnValue(room); + + const rootEvent = new MatrixEvent({ event_id: threadId }); + room.createThread(threadId, rootEvent, [rootEvent], false); + + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: threadId, + }, + "event_id": threadId, + "is_falling_back": true, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + + it("should add thread relation if threadId is passed and the relation is missing with reply", async () => { + const eventId = "$eventId:example.org"; + const threadId = "$threadId:server"; + const txnId = client.makeTxnId(); + + const content = { + body, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$other:event", + }, + }, + }; + + const room = new Room(roomId, client, userId); + store.getRoom.mockReturnValue(room); + + const rootEvent = new MatrixEvent({ event_id: threadId }); + room.createThread(threadId, rootEvent, [rootEvent], false); + + httpLookups = [{ + method: "PUT", + path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + data: { event_id: eventId }, + expectBody: { + ...content, + "m.relates_to": { + "m.in_reply_to": { + event_id: "$other:event", + }, + "event_id": threadId, + "is_falling_back": false, + "rel_type": "m.thread", + }, + }, + }]; + + await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); + }); + }); + it("should create (unstable) file trees", async () => { const userId = "@test:example.org"; const roomId = "!room:example.org"; @@ -777,130 +901,6 @@ describe("MatrixClient", function() { }); }); - describe("sendEvent", () => { - const roomId = "!room:example.org"; - const body = "This is the body"; - const content = { body }; - - it("overload without threadId works", async () => { - const eventId = "$eventId:example.org"; - const txnId = client.makeTxnId(); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: content, - }]; - - await client.sendEvent(roomId, EventType.RoomMessage, { ...content }, txnId); - }); - - it("overload with null threadId works", async () => { - const eventId = "$eventId:example.org"; - const txnId = client.makeTxnId(); - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: content, - }]; - - await client.sendEvent(roomId, null, EventType.RoomMessage, { ...content }, txnId); - }); - - it("overload with threadId works", async () => { - const eventId = "$eventId:example.org"; - const txnId = client.makeTxnId(); - const threadId = "$threadId:server"; - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: { - ...content, - "m.relates_to": { - "event_id": threadId, - "is_falling_back": true, - "rel_type": "m.thread", - }, - }, - }]; - - await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); - }); - - it("should add thread relation if threadId is passed and the relation is missing", async () => { - const eventId = "$eventId:example.org"; - const threadId = "$threadId:server"; - const txnId = client.makeTxnId(); - - const room = new Room(roomId, client, userId); - store.getRoom.mockReturnValue(room); - - const rootEvent = new MatrixEvent({ event_id: threadId }); - room.createThread(threadId, rootEvent, [rootEvent], false); - - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: { - ...content, - "m.relates_to": { - "m.in_reply_to": { - event_id: threadId, - }, - "event_id": threadId, - "is_falling_back": true, - "rel_type": "m.thread", - }, - }, - }]; - - await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); - }); - - it("should add thread relation if threadId is passed and the relation is missing with reply", async () => { - const eventId = "$eventId:example.org"; - const threadId = "$threadId:server"; - const txnId = client.makeTxnId(); - - const content = { - body, - "m.relates_to": { - "m.in_reply_to": { - event_id: "$other:event", - }, - }, - }; - - const room = new Room(roomId, client, userId); - store.getRoom.mockReturnValue(room); - - const rootEvent = new MatrixEvent({ event_id: threadId }); - room.createThread(threadId, rootEvent, [rootEvent], false); - - httpLookups = [{ - method: "PUT", - path: `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, - data: { event_id: eventId }, - expectBody: { - ...content, - "m.relates_to": { - "m.in_reply_to": { - event_id: "$other:event", - }, - "event_id": threadId, - "is_falling_back": false, - "rel_type": "m.thread", - }, - }, - }]; - - await client.sendEvent(roomId, threadId, EventType.RoomMessage, { ...content }, txnId); - }); - }); - describe("redactEvent", () => { const roomId = "!room:example.org"; const mockRoom = { diff --git a/spec/unit/room.spec.ts b/spec/unit/room.spec.ts index 29a96eab6a1..ed15b61fee0 100644 --- a/spec/unit/room.spec.ts +++ b/spec/unit/room.spec.ts @@ -39,7 +39,7 @@ import { UNSTABLE_ELEMENT_FUNCTIONAL_USERS } from "../../src/@types/event"; import { TestClient } from "../TestClient"; import { emitPromise } from "../test-utils/test-utils"; import { ReceiptType } from "../../src/@types/read_receipts"; -import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread"; +import { FeatureSupport, Thread, ThreadEvent, THREAD_RELATION_TYPE } from "../../src/models/thread"; import { WrappedReceipt } from "../../src/models/read-receipt"; import { Crypto } from "../../src/crypto"; @@ -2203,6 +2203,7 @@ describe("Room", function() { it("Edits update the lastReply event", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const randomMessage = mkMessage(); const threadRoot = mkMessage(); @@ -2216,7 +2217,7 @@ describe("Room", function() { unsigned: { "age": 123, "m.relations": { - "m.thread": { + [THREAD_RELATION_TYPE.name]: { latest_event: threadResponse.event, count: 2, current_user_participated: true, @@ -2228,11 +2229,29 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([randomMessage, threadRoot, threadResponse]); const thread = await prom; + await emitPromise(room, ThreadEvent.Update); - expect(thread.replyToEvent).toBe(threadResponse); + expect(thread.replyToEvent.event).toEqual(threadResponse.event); expect(thread.replyToEvent.getContent().body).toBe(threadResponse.getContent().body); - prom = emitPromise(thread, ThreadEvent.Update); + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: { + ...threadResponse.event, + content: threadResponseEdit.event.content, + }, + count: 2, + current_user_participated: true, + }, + }, + }, + }); + + prom = emitPromise(room, ThreadEvent.Update); room.addLiveEvents([threadResponseEdit]); await prom; expect(thread.replyToEvent.getContent().body).toBe(threadResponseEdit.getContent()["m.new_content"].body); @@ -2240,6 +2259,7 @@ describe("Room", function() { it("Redactions to thread responses decrement the length", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const threadRoot = mkMessage(); const threadResponse1 = mkThreadResponse(threadRoot); @@ -2252,7 +2272,7 @@ describe("Room", function() { unsigned: { "age": 123, "m.relations": { - "m.thread": { + [THREAD_RELATION_TYPE.name]: { latest_event: threadResponse2.event, count: 2, current_user_participated: true, @@ -2264,10 +2284,36 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); const thread = await prom; + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + thread.timelineSet.addEventToTimeline( + threadResponse1, + thread.liveTimeline, + { toStartOfTimeline: true, fromCache: false, roomState: thread.roomState }, + ); + thread.timelineSet.addEventToTimeline( + threadResponse2, + thread.liveTimeline, + { toStartOfTimeline: true, fromCache: false, roomState: thread.roomState }, + ); + + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + [THREAD_RELATION_TYPE.name]: { + latest_event: threadResponse2.event, + count: 1, + current_user_participated: true, + }, + }, + }, + }); + prom = emitPromise(thread, ThreadEvent.Update); const threadResponse1Redaction = mkRedaction(threadResponse1); room.addLiveEvents([threadResponse1Redaction]); @@ -2278,6 +2324,7 @@ describe("Room", function() { it("Redactions to reactions in threads do not decrement the length", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const threadRoot = mkMessage(); const threadResponse1 = mkThreadResponse(threadRoot); @@ -2291,7 +2338,7 @@ describe("Room", function() { unsigned: { "age": 123, "m.relations": { - "m.thread": { + [THREAD_RELATION_TYPE.name]: { latest_event: threadResponse2.event, count: 2, current_user_participated: true, @@ -2303,6 +2350,7 @@ describe("Room", function() { const prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); const thread = await prom; + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); @@ -2315,6 +2363,7 @@ describe("Room", function() { it("should not decrement the length when the thread root is redacted", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const threadRoot = mkMessage(); const threadResponse1 = mkThreadResponse(threadRoot); @@ -2328,7 +2377,7 @@ describe("Room", function() { unsigned: { "age": 123, "m.relations": { - "m.thread": { + [THREAD_RELATION_TYPE.name]: { latest_event: threadResponse2.event, count: 2, current_user_participated: true, @@ -2340,6 +2389,7 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2, threadResponse2Reaction]); const thread = await prom; + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); @@ -2353,6 +2403,18 @@ describe("Room", function() { it("Redacting the lastEvent finds a new lastEvent", async () => { room.client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); + Thread.setServerSideListSupport(FeatureSupport.Stable); + + room.client.createThreadListMessagesRequest = () => Promise.resolve({ + start: null, + end: null, + chunk: [], + state: [], + }); + + await room.createThreadsTimelineSets(); + await room.fetchRoomThreads(); const threadRoot = mkMessage(); const threadResponse1 = mkThreadResponse(threadRoot); @@ -2377,21 +2439,53 @@ describe("Room", function() { let prom = emitPromise(room, ThreadEvent.New); room.addLiveEvents([threadRoot, threadResponse1, threadResponse2]); const thread = await prom; + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(2); expect(thread.replyToEvent.getId()).toBe(threadResponse2.getId()); + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadResponse1.event, + count: 1, + current_user_participated: true, + }, + }, + }, + }); + prom = emitPromise(room, ThreadEvent.Update); const threadResponse2Redaction = mkRedaction(threadResponse2); room.addLiveEvents([threadResponse2Redaction]); await prom; + await emitPromise(room, ThreadEvent.Update); expect(thread).toHaveLength(1); expect(thread.replyToEvent.getId()).toBe(threadResponse1.getId()); - prom = emitPromise(room, ThreadEvent.Update); + room.client.fetchRoomEvent = (eventId: string) => Promise.resolve({ + ...threadRoot.event, + unsigned: { + "age": 123, + "m.relations": { + "m.thread": { + latest_event: threadRoot.event, + count: 0, + current_user_participated: true, + }, + }, + }, + }); + + prom = emitPromise(room, ThreadEvent.Delete); + const prom2 = emitPromise(room, RoomEvent.Timeline); const threadResponse1Redaction = mkRedaction(threadResponse1); room.addLiveEvents([threadResponse1Redaction]); await prom; + await prom2; expect(thread).toHaveLength(0); expect(thread.replyToEvent.getId()).toBe(threadRoot.getId()); }); @@ -2400,6 +2494,7 @@ describe("Room", function() { describe("eventShouldLiveIn", () => { const client = new TestClient(userA).client; client.supportsExperimentalThreads = () => true; + Thread.setServerSideSupport(FeatureSupport.Stable); const room = new Room(roomId, client, userA); it("thread root and its relations&redactions should be in both", () => { diff --git a/src/client.ts b/src/client.ts index 0bc763e363e..67d56f7b942 100644 --- a/src/client.ts +++ b/src/client.ts @@ -36,7 +36,7 @@ import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, suppor import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter"; import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from './webrtc/callEventHandler'; import * as utils from './utils'; -import { QueryDict, sleep } from './utils'; +import { replaceParam, QueryDict, sleep } from './utils'; import { Direction, EventTimeline } from "./models/event-timeline"; import { IActionsObject, PushProcessor } from "./pushprocessor"; import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery"; @@ -193,7 +193,14 @@ import { TypedEventEmitter } from "./models/typed-event-emitter"; import { ReceiptType } from "./@types/read_receipts"; import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync"; import { SlidingSyncSdk } from "./sliding-sync-sdk"; -import { FeatureSupport, Thread, THREAD_RELATION_TYPE, determineFeatureSupport } from "./models/thread"; +import { + FeatureSupport, + Thread, + THREAD_RELATION_TYPE, + determineFeatureSupport, + ThreadFilterType, + threadFilterTypeToFilter, +} from "./models/thread"; import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon"; import { UnstableValue } from "./NamespacedValue"; import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue"; @@ -1192,9 +1199,10 @@ export class MatrixClient extends TypedEventEmitter { + 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 = { + 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(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 = res.end; + while (nextBatch) { + const resNewer: IRelationsResponse = await this.fetchRelations( + timelineSet.room.roomId, + thread.id, + THREAD_RELATION_TYPE.name, + null, + { dir: Direction.Forward, from: nextBatch }, + ); + nextBatch = resNewer.next_batch ?? null; + eventsNewer.push(...resNewer.chunk); + } + const events = [ + // Order events from most recent to oldest (reverse-chronological). + // We start with the last event, since that's the point at which we have known state. + // events_after is already backwards; events_before is forwards. + ...eventsNewer.reverse().map(mapper), + event, + ...resOlder.chunk.map(mapper), + ]; + for (const event of events) { + await timelineSet.thread?.processEvent(event); + } + + // Here we handle non-thread timelines only, but still process any thread events to populate thread + // summaries. + const timeline = timelineSet.getLiveTimeline(); + timeline.getState(EventTimeline.BACKWARDS).setUnknownStateEvents(res.state.map(mapper)); + + timelineSet.addEventsToTimeline(events, true, timeline, null); + if (!resOlder.next_batch) { + timelineSet.addEventsToTimeline([mapper(resOlder.original_event)], true, timeline, null); + } + timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward); + timeline.setPaginationToken(null, Direction.Forward); + this.processBeaconEvents(timelineSet.room, events); + + return timeline; + } + } + } + /** * Get an EventTimeline for the latest events in the room. This will just * call `/messages` to get the latest message in the room, then use @@ -5282,28 +5414,45 @@ export class MatrixClient extends TypedEventEmitter = { + dir: 'b', + }; + if (this.clientOpts?.lazyLoadMembers) { + params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER); + } + + const res = await this.http.authedRequest(Method.Get, messagesPath, params); + event = res.chunk?.[0]; } - const event = res.chunk?.[0]; if (!event) { - throw new Error("No message returned from /messages when trying to construct getLatestTimeline"); + throw new Error("No message returned when trying to construct getLatestTimeline"); } return this.getEventTimeline(timelineSet, event.event_id); @@ -5376,6 +5525,7 @@ export class MatrixClient extends TypedEventEmitter { const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId }); @@ -5383,7 +5533,7 @@ export class MatrixClient extends TypedEventEmitter = { limit: limit.toString(), dir: dir, - include: 'all', + include: threadFilterTypeToFilter(threadListType), }; if (fromToken) { @@ -5395,7 +5545,6 @@ export class MatrixClient extends TypedEventEmitter(Method.Get, path, params, undefined, opts) .then(res => ({ ...res, + chunk: res.chunk?.reverse(), start: res.prev_batch, end: res.next_batch, })); @@ -5440,7 +5591,8 @@ export class MatrixClient extends TypedEventEmitter { const isNotifTimeline = (eventTimeline.getTimelineSet() === this.notifTimelineSet); const room = this.getRoom(eventTimeline.getRoomId()!); - const isThreadTimeline = eventTimeline.getTimelineSet().isThreadTimeline; + const threadListType = eventTimeline.getTimelineSet().threadListType; + const thread = eventTimeline.getTimelineSet().thread; // TODO: we should implement a backoff (as per scrollback()) to deal more // nicely with HTTP errors. @@ -5511,16 +5663,21 @@ export class MatrixClient extends TypedEventEmitter { if (res.state) { @@ -5547,6 +5704,45 @@ export class MatrixClient extends TypedEventEmitter { + const mapper = this.getEventMapper(); + const matrixEvents = res.chunk.map(mapper); + for (const event of matrixEvents) { + await eventTimeline.getTimelineSet()?.thread?.processEvent(event); + } + + const newToken = res.next_batch; + + const timelineSet = eventTimeline.getTimelineSet(); + timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, newToken ?? null); + if (!newToken && backwards) { + timelineSet.addEventsToTimeline([mapper(res.original_event)], true, eventTimeline, null); + } + this.processBeaconEvents(timelineSet.room, matrixEvents); + + // if we've hit the end of the timeline, we need to stop trying to + // paginate. We need to keep the 'forwards' token though, to make sure + // we can recover from gappy syncs. + if (backwards && !newToken) { + eventTimeline.setPaginationToken(null, dir); + } + return Boolean(newToken); + }).finally(() => { + eventTimeline.paginationRequests[dir] = null; + }); + eventTimeline.paginationRequests[dir] = promise; } else { if (!room) { throw new Error("Unknown room " + eventTimeline.getRoomId()); @@ -5568,10 +5764,12 @@ export class MatrixClient extends TypedEventEmitter it.isRelation(THREAD_RELATION_TYPE.name)), + false); const atEnd = res.end === undefined || res.end === res.start; @@ -6654,25 +6852,40 @@ export class MatrixClient extends TypedEventEmitter { + if (await this.isVersionSupported("v1.4")) { + return { + threads: FeatureSupport.Stable, + list: FeatureSupport.Stable, + fwdPagination: FeatureSupport.Stable, + }; + } + try { - const [threadUnstable, threadStable, listUnstable, listStable] = await Promise.all([ + const [ + threadUnstable, threadStable, + listUnstable, listStable, + fwdPaginationUnstable, fwdPaginationStable, + ] = await Promise.all([ this.doesServerSupportUnstableFeature("org.matrix.msc3440"), this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"), this.doesServerSupportUnstableFeature("org.matrix.msc3856"), this.doesServerSupportUnstableFeature("org.matrix.msc3856.stable"), + this.doesServerSupportUnstableFeature("org.matrix.msc3715"), + this.doesServerSupportUnstableFeature("org.matrix.msc3715.stable"), ]); - // TODO: Use `this.isVersionSupported("v1.3")` for whatever spec version includes MSC3440 formally. - return { threads: determineFeatureSupport(threadStable, threadUnstable), list: determineFeatureSupport(listStable, listUnstable), + fwdPagination: determineFeatureSupport(fwdPaginationStable, fwdPaginationUnstable), }; } catch (e) { return { threads: FeatureSupport.None, list: FeatureSupport.None, + fwdPagination: FeatureSupport.None, }; } } @@ -6732,12 +6945,12 @@ export class MatrixClient extends TypedEventEmitter { - const fetchedEventType = this.getEncryptedIfNeededEventType(roomId, eventType); + const fetchedEventType = eventType ? this.getEncryptedIfNeededEventType(roomId, eventType) : null; const result = await this.fetchRelations( roomId, eventId, @@ -6761,10 +6974,10 @@ export class MatrixClient extends TypedEventEmitter e.getSender() === originalEvent.getSender()); } return { - originalEvent, + originalEvent: originalEvent ?? null, events, - nextBatch: result.next_batch, - prevBatch: result.prev_batch, + nextBatch: result.next_batch ?? null, + prevBatch: result.prev_batch ?? null, }; } @@ -7281,7 +7494,11 @@ export class MatrixClient extends TypedEventEmitter { - const queryString = utils.encodeParams(opts as Record); + let params = opts as QueryDict; + if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) { + params = replaceParam("dir", "org.matrix.msc3715.dir", params); + } + const queryString = utils.encodeParams(params); let templatedUrl = "/rooms/$roomId/relations/$eventId"; if (relationType !== null) { @@ -7327,7 +7544,7 @@ export class MatrixClient extends TypedEventEmitter { + public fetchRoomEvent(roomId: string, eventId: string): Promise> { const path = utils.encodeUri( "/rooms/$roomId/event/$eventId", { $roomId: roomId, diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts index 252edf34118..abf20ebf8bf 100644 --- a/src/models/event-timeline-set.ts +++ b/src/models/event-timeline-set.ts @@ -27,7 +27,7 @@ import { RoomState } from "./room-state"; import { TypedEventEmitter } from "./typed-event-emitter"; import { RelationsContainer } from "./relations-container"; import { MatrixClient } from "../client"; -import { Thread } from "./thread"; +import { Thread, ThreadFilterType } from "./thread"; const DEBUG = true; @@ -140,7 +140,7 @@ export class EventTimelineSet extends TypedEventEmitter void; } & Pick< ThreadHandlerMap, - ThreadEvent.Update | ThreadEvent.NewReply + ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete > & EventTimelineSetHandlerMap & Pick @@ -1006,7 +1007,7 @@ export class Room extends ReadReceipt { * timeline which would otherwise be unable to paginate forwards without this token). * Removing just the old live timeline whilst preserving previous ones is not supported. */ - public resetLiveTimeline(backPaginationToken: string | null, forwardPaginationToken: string | null): void { + public resetLiveTimeline(backPaginationToken?: string | null, forwardPaginationToken?: string | null): void { for (let i = 0; i < this.timelineSets.length; i++) { this.timelineSets[i].resetLiveTimeline( backPaginationToken ?? undefined, @@ -1651,7 +1652,7 @@ export class Room extends ReadReceipt { let timelineSet: EventTimelineSet; if (Thread.hasServerSideListSupport) { timelineSet = - new EventTimelineSet(this, this.opts, undefined, undefined, Boolean(Thread.hasServerSideListSupport)); + new EventTimelineSet(this, this.opts, undefined, undefined, filterType ?? ThreadFilterType.All); this.reEmitter.reEmit(timelineSet, [ RoomEvent.Timeline, RoomEvent.TimelineReset, @@ -1758,7 +1759,7 @@ export class Room extends ReadReceipt { let latestMyThreadsRootEvent: MatrixEvent | undefined; const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); for (const rootEvent of threadRoots) { - this.threadsTimelineSets[0].addLiveEvent(rootEvent, { + this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, { duplicateStrategy: DuplicateStrategy.Ignore, fromCache: false, roomState, @@ -1767,7 +1768,7 @@ export class Room extends ReadReceipt { const threadRelationship = rootEvent .getServerAggregatedRelation(THREAD_RELATION_TYPE.name); if (threadRelationship?.current_user_participated) { - this.threadsTimelineSets[1].addLiveEvent(rootEvent, { + this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, { duplicateStrategy: DuplicateStrategy.Ignore, fromCache: false, roomState, @@ -1785,6 +1786,7 @@ export class Room extends ReadReceipt { } this.on(ThreadEvent.NewReply, this.onThreadNewReply); + this.on(ThreadEvent.Delete, this.onThreadDelete); this.threadsReady = true; } @@ -1803,6 +1805,7 @@ export class Room extends ReadReceipt { null, undefined, Direction.Backward, + timelineSet.threadListType, timelineSet.getFilter(), ); @@ -1823,14 +1826,21 @@ export class Room extends ReadReceipt { } private onThreadNewReply(thread: Thread): void { - const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS); + this.updateThreadRootEvents(thread, false); + } + + private onThreadDelete(thread: Thread): void { + this.threads.delete(thread.id); + + const timeline = this.getTimelineForEvent(thread.id); + const roomEvent = timeline?.getEvents()?.find(it => it.getId() === thread.id); + if (roomEvent) { + thread.clearEventMetadata(roomEvent); + } else { + logger.debug("onThreadDelete: Could not find root event in room timeline"); + } for (const timelineSet of this.threadsTimelineSets) { timelineSet.removeEvent(thread.id); - timelineSet.addLiveEvent(thread.rootEvent, { - duplicateStrategy: DuplicateStrategy.Replace, - fromCache: false, - roomState, - }); } } @@ -1912,13 +1922,12 @@ export class Room extends ReadReceipt { private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void { let thread = this.getThread(threadId); - if (thread) { - thread.addEvents(events, toStartOfTimeline); - } else { + if (!thread) { const rootEvent = this.findEventById(threadId) ?? events.find(e => e.getId() === threadId); thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline); - this.emit(ThreadEvent.Update, thread); } + + thread.addEvents(events, toStartOfTimeline); } /** @@ -1942,6 +1951,37 @@ export class Room extends ReadReceipt { )); } + 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, + thread: Thread, + toStartOfTimeline: boolean, + ) => { + if (timelineSet && thread.rootEvent) { + if (Thread.hasServerSideSupport) { + timelineSet.addLiveEvent(thread.rootEvent, { + duplicateStrategy: DuplicateStrategy.Replace, + fromCache: false, + roomState: this.currentState, + }); + } else { + timelineSet.addEventToTimeline( + thread.rootEvent, + timelineSet.getLiveTimeline(), + { toStartOfTimeline }, + ); + } + } + }; + public createThread( threadId: string, rootEvent: MatrixEvent | undefined, @@ -1958,38 +1998,37 @@ export class Room extends ReadReceipt { } const thread = new Thread(threadId, rootEvent, { - initialEvents: events, room: this, client: this.client, }); + // This is necessary to be able to jump to events in threads: + // If we jump to an event in a thread where neither the event, nor the root, + // nor any thread event are loaded yet, we'll load the event as well as the thread root, create the thread, + // and pass the event through this. + for (const event of events) { + thread.setEventMetadata(event); + } + // If we managed to create a thread and figure out its `id` then we can use it this.threads.set(thread.id, thread); this.reEmitter.reEmit(thread, [ + ThreadEvent.Delete, ThreadEvent.Update, ThreadEvent.NewReply, RoomEvent.Timeline, RoomEvent.TimelineReset, ]); + const isNewer = this.lastThread?.rootEvent + && rootEvent?.localTimestamp + && this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp; - if (!this.lastThread || this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp) { + if (!this.lastThread || isNewer) { this.lastThread = thread; } if (this.threadsReady) { - this.threadsTimelineSets.forEach(timelineSet => { - if (thread.rootEvent) { - if (Thread.hasServerSideSupport) { - timelineSet.addLiveEvent(thread.rootEvent); - } else { - timelineSet.addEventToTimeline( - thread.rootEvent, - timelineSet.getLiveTimeline(), - toStartOfTimeline, - ); - } - } - }); + this.updateThreadRootEvents(thread, toStartOfTimeline); } this.emit(ThreadEvent.New, thread, toStartOfTimeline); diff --git a/src/models/thread.ts b/src/models/thread.ts index 69b47004026..117100d6923 100644 --- a/src/models/thread.ts +++ b/src/models/thread.ts @@ -18,9 +18,8 @@ import { Optional } from "matrix-events-sdk"; import { MatrixClient, MatrixEventEvent, RelationType, RoomEvent } from "../matrix"; import { TypedReEmitter } from "../ReEmitter"; -import { IRelationsRequestOpts } from "../@types/requests"; import { IThreadBundledRelationship, MatrixEvent } from "./event"; -import { Direction, EventTimeline } from "./event-timeline"; +import { EventTimeline } from "./event-timeline"; import { EventTimelineSet, EventTimelineSetHandlerMap } from './event-timeline-set'; import { Room } from './room'; import { RoomState } from "./room-state"; @@ -33,6 +32,7 @@ export enum ThreadEvent { Update = "Thread.update", NewReply = "Thread.newReply", ViewThread = "Thread.viewThread", + Delete = "Thread.delete" } type EmittedEvents = Exclude @@ -43,10 +43,10 @@ export type EventHandlerMap = { [ThreadEvent.Update]: (thread: Thread) => void; [ThreadEvent.NewReply]: (thread: Thread, event: MatrixEvent) => void; [ThreadEvent.ViewThread]: () => void; + [ThreadEvent.Delete]: (thread: Thread) => void; } & EventTimelineSetHandlerMap; interface IThreadOpts { - initialEvents?: MatrixEvent[]; room: Room; client: MatrixClient; } @@ -73,6 +73,7 @@ export function determineFeatureSupport(stable: boolean, unstable: boolean): Fea export class Thread extends ReadReceipt { public static hasServerSideSupport = FeatureSupport.None; public static hasServerSideListSupport = FeatureSupport.None; + public static hasServerSideFwdPaginationSupport = FeatureSupport.None; /** * A reference to all the events ID at the bottom of the threads @@ -83,7 +84,7 @@ export class Thread extends ReadReceipt { private reEmitter: TypedReEmitter; - private lastEvent!: MatrixEvent; + private lastEvent: MatrixEvent | undefined; private replyCount = 0; public readonly room: Room; @@ -122,14 +123,10 @@ export class Thread extends ReadReceipt { this.room.on(RoomEvent.LocalEchoUpdated, this.onEcho); this.timelineSet.on(RoomEvent.Timeline, this.onEcho); - if (opts.initialEvents) { - this.addEvents(opts.initialEvents, false); - } // even if this thread is thought to be originating from this client, we initialise it as we may be in a // gappy sync and a thread around this event may already exist. this.initialiseThread(); - - this.rootEvent?.setThread(this); + this.setEventMetadata(this.rootEvent); } private async fetchRootEvent(): Promise { @@ -142,13 +139,7 @@ export class Thread extends ReadReceipt { } catch (e) { logger.error("Failed to fetch thread root to construct thread with", e); } - - // The root event might be not be visible to the person requesting it. - // If it wasn't fetched successfully the thread will work in "limited" mode and won't - // benefit from all the APIs a homeserver can provide to enhance the thread experience - this.rootEvent?.setThread(this); - - this.emit(ThreadEvent.Update, this); + await this.processEvent(this.rootEvent); } public static setServerSideSupport( @@ -168,6 +159,12 @@ export class Thread extends ReadReceipt { Thread.hasServerSideListSupport = status; } + public static setServerSideFwdPaginationSupport( + status: FeatureSupport, + ): void { + Thread.hasServerSideFwdPaginationSupport = status; + } + private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent) => { if (event?.isRelation(THREAD_RELATION_TYPE.name) && this.room.eventShouldLiveIn(event).threadId === this.id && @@ -179,42 +176,27 @@ export class Thread extends ReadReceipt { } }; - private onRedaction = (event: MatrixEvent) => { + private onRedaction = async (event: MatrixEvent) => { if (event.threadRootId !== this.id) return; // ignore redactions for other timelines - const events = [...this.timelineSet.getLiveTimeline().getEvents()].reverse(); - this.lastEvent = events.find(e => ( - !e.isRedacted() && - e.isRelation(THREAD_RELATION_TYPE.name) - )) ?? this.rootEvent!; - this.emit(ThreadEvent.Update, this); + if (this.replyCount <= 0) { + for (const threadEvent of this.events) { + this.clearEventMetadata(threadEvent); + } + this.lastEvent = this.rootEvent; + this._currentUserParticipated = false; + this.emit(ThreadEvent.Delete, this); + } else { + await this.initialiseThread(); + } }; - private onEcho = (event: MatrixEvent) => { + private onEcho = async (event: MatrixEvent) => { if (event.threadRootId !== this.id) return; // ignore echoes for other timelines if (this.lastEvent === event) return; if (!event.isRelation(THREAD_RELATION_TYPE.name)) return; - // There is a risk that the `localTimestamp` approximation will not be accurate - // when threads are used over federation. That could result in the reply - // count value drifting away from the value returned by the server - const isThreadReply = event.isRelation(THREAD_RELATION_TYPE.name); - if (!this.lastEvent || this.lastEvent.isRedacted() || (isThreadReply - && (event.getId() !== this.lastEvent.getId()) - && (event.localTimestamp > this.lastEvent.localTimestamp)) - ) { - this.lastEvent = event; - if (this.lastEvent.getId() !== this.id) { - // This counting only works when server side support is enabled as we started the counting - // from the value returned within the bundled relationship - if (Thread.hasServerSideSupport) { - this.replyCount++; - } - - this.emit(ThreadEvent.NewReply, this, event); - } - } - - this.emit(ThreadEvent.Update, this); + await this.initialiseThread(); + this.emit(ThreadEvent.NewReply, this, event); }; public get roomState(): RoomState { @@ -237,7 +219,7 @@ export class Thread extends ReadReceipt { public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void { events.forEach(ev => this.addEvent(ev, toStartOfTimeline, false)); - this.emit(ThreadEvent.Update, this); + this.initialiseThread(); } /** @@ -249,12 +231,11 @@ export class Thread extends ReadReceipt { * to the start (and not the end) of the timeline. * @param {boolean} emit whether to emit the Update event if the thread was updated or not. */ - public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): void { - event.setThread(this); + public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise { + this.setEventMetadata(event); - if (!this._currentUserParticipated && event.getSender() === this.client.getUserId()) { - this._currentUserParticipated = true; - } + const lastReply = this.lastReply(); + const isNewestReply = !lastReply || event.localTimestamp > lastReply!.localTimestamp; // Add all incoming events to the thread's timeline set when there's no server support if (!Thread.hasServerSideSupport) { @@ -265,16 +246,13 @@ export class Thread extends ReadReceipt { this.addEventToTimeline(event, toStartOfTimeline); this.client.decryptEventIfNeeded(event, {}); - } else if (!toStartOfTimeline && - this.initialEventsFetched && - event.localTimestamp > this.lastReply()!.localTimestamp - ) { - this.fetchEditsWhereNeeded(event); + } else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) { + await this.fetchEditsWhereNeeded(event); this.addEventToTimeline(event, false); } else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) { // Apply annotations and replace relations to the relations of the timeline only - this.timelineSet.relations.aggregateParentEvent(event); - this.timelineSet.relations.aggregateChildEvent(event, this.timelineSet); + this.timelineSet.relations?.aggregateParentEvent(event); + this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet); return; } @@ -285,7 +263,15 @@ export class Thread extends ReadReceipt { } if (emit) { - this.emit(ThreadEvent.Update, this); + this.emit(ThreadEvent.NewReply, this, event); + this.initialiseThread(); + } + } + + public async processEvent(event: Optional): Promise { + if (event) { + this.setEventMetadata(event); + await this.fetchEditsWhereNeeded(event); } } @@ -293,9 +279,9 @@ export class Thread extends ReadReceipt { return rootEvent?.getServerAggregatedRelation(THREAD_RELATION_TYPE.name); } - private async initialiseThread(): Promise { + public async initialiseThread(): Promise { let bundledRelationship = this.getRootEventBundledRelationship(); - if (Thread.hasServerSideSupport && !bundledRelationship) { + if (Thread.hasServerSideSupport) { await this.fetchRootEvent(); bundledRelationship = this.getRootEventBundledRelationship(); } @@ -304,15 +290,25 @@ export class Thread extends ReadReceipt { this.replyCount = bundledRelationship.count; this._currentUserParticipated = !!bundledRelationship.current_user_participated; - const event = new MatrixEvent({ - room_id: this.room.roomId, - ...bundledRelationship.latest_event, - }); - this.setEventMetadata(event); - event.setThread(this); - this.lastEvent = event; + const mapper = this.client.getEventMapper(); + this.lastEvent = mapper(bundledRelationship.latest_event); + await this.processEvent(this.lastEvent); + } - this.fetchEditsWhereNeeded(event); + if (!this.initialEventsFetched) { + this.initialEventsFetched = true; + // fetch initial event to allow proper pagination + try { + // if the thread has regular events, this will just load the last reply. + // if the thread is newly created, this will load the root event. + await this.client.paginateEventTimeline(this.liveTimeline, { backwards: true, limit: 1 }); + // just to make sure that, if we've created a timeline window for this thread before the thread itself + // existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly. + this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true); + } catch (e) { + logger.error("Failed to load start of newly created thread: ", e); + this.initialEventsFetched = false; + } } this.emit(ThreadEvent.Update, this); @@ -334,15 +330,18 @@ export class Thread extends ReadReceipt { })); } - public async fetchInitialEvents(): Promise { - if (this.initialEventsFetched) return; - await this.fetchEvents(); - this.initialEventsFetched = true; + public setEventMetadata(event: Optional): void { + if (event) { + EventTimeline.setEventMetadata(event, this.roomState, false); + event.setThread(this); + } } - private setEventMetadata(event: MatrixEvent): void { - EventTimeline.setEventMetadata(event, this.roomState, false); - event.setThread(this); + public clearEventMetadata(event: Optional): void { + if (event) { + event.setThread(undefined); + delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name]; + } } /** @@ -406,55 +405,6 @@ export class Thread extends ReadReceipt { return this.timelineSet.getLiveTimeline(); } - public async fetchEvents(opts: IRelationsRequestOpts = { limit: 20, dir: Direction.Backward }): Promise<{ - originalEvent?: MatrixEvent; - events: MatrixEvent[]; - nextBatch?: string | null; - prevBatch?: string; - }> { - let { - originalEvent, - events, - prevBatch, - nextBatch, - } = await this.client.relations( - this.room.roomId, - this.id, - THREAD_RELATION_TYPE.name, - null, - opts, - ); - - // When there's no nextBatch returned with a `from` request we have reached - // the end of the thread, and therefore want to return an empty one - if (!opts.to && !nextBatch && originalEvent) { - events = [...events, originalEvent]; - } - - await this.fetchEditsWhereNeeded(...events); - - await Promise.all(events.map(event => { - this.setEventMetadata(event); - return this.client.decryptEventIfNeeded(event); - })); - - const prependEvents = (opts.dir ?? Direction.Backward) === Direction.Backward; - - this.timelineSet.addEventsToTimeline( - events, - prependEvents, - this.liveTimeline, - prependEvents ? nextBatch : prevBatch, - ); - - return { - originalEvent, - events, - prevBatch, - nextBatch, - }; - } - public getUnfilteredTimelineSet(): EventTimelineSet { return this.timelineSet; } @@ -485,3 +435,12 @@ export enum ThreadFilterType { "My", "All" } + +export function threadFilterTypeToFilter(type: ThreadFilterType | null): 'all' | 'participated' { + switch (type) { + case ThreadFilterType.My: + return 'participated'; + default: + return 'all'; + } +} diff --git a/src/timeline-window.ts b/src/timeline-window.ts index ba6a16ea55f..c5560bdf4b0 100644 --- a/src/timeline-window.ts +++ b/src/timeline-window.ts @@ -133,18 +133,14 @@ export class TimelineWindow { // We avoid delaying the resolution of the promise by a reactor tick if we already have the data we need, // which is important to keep room-switching feeling snappy. - if (initialEventId) { - const timeline = this.timelineSet.getTimelineForEvent(initialEventId); - if (timeline) { - // hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does. - initFields(timeline); - return Promise.resolve(); - } - - return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields); + if (this.timelineSet.getTimelineForEvent(initialEventId)) { + initFields(this.timelineSet.getTimelineForEvent(initialEventId)); + return Promise.resolve(); + } else if (initialEventId) { + return this.client.getEventTimeline(this.timelineSet, initialEventId) + .then(initFields); } else { - const tl = this.timelineSet.getLiveTimeline(); - initFields(tl); + initFields(this.timelineSet.getLiveTimeline()); return Promise.resolve(); } } @@ -236,8 +232,9 @@ export class TimelineWindow { } } - return Boolean(tl.timeline.getNeighbouringTimeline(direction) || - tl.timeline.getPaginationToken(direction) !== null); + const hasNeighbouringTimeline = tl.timeline.getNeighbouringTimeline(direction); + const paginationToken = tl.timeline.getPaginationToken(direction); + return Boolean(hasNeighbouringTimeline) || Boolean(paginationToken); } /** @@ -262,7 +259,7 @@ export class TimelineWindow { * @return {Promise} Resolves to a boolean which is true if more events * were successfully retrieved. */ - public paginate( + public async paginate( direction: Direction, size: number, makeRequest = true, @@ -274,7 +271,7 @@ export class TimelineWindow { if (!tl) { debuglog("TimelineWindow: no timeline yet"); - return Promise.resolve(false); + return false; } if (tl.pendingPaginate) { @@ -283,20 +280,20 @@ export class TimelineWindow { // try moving the cap if (this.extend(direction, size)) { - return Promise.resolve(true); + return true; } if (!makeRequest || requestLimit === 0) { // todo: should we return something different to indicate that there // might be more events out there, but we haven't found them yet? - return Promise.resolve(false); + return false; } // try making a pagination request const token = tl.timeline.getPaginationToken(direction); - if (token === null) { + if (!token) { debuglog("TimelineWindow: no token"); - return Promise.resolve(false); + return false; } debuglog("TimelineWindow: starting request"); @@ -309,8 +306,7 @@ export class TimelineWindow { }).then((r) => { debuglog("TimelineWindow: request completed with result " + r); if (!r) { - // end of timeline - return false; + return this.paginate(direction, size, false, 0); } // recurse to advance the index into the results. diff --git a/src/utils.ts b/src/utils.ts index c70ac914de9..23ee1d36ac6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -22,6 +22,7 @@ limitations under the License. import unhomoglyph from "unhomoglyph"; import promiseRetry from "p-retry"; +import { Optional } from "matrix-events-sdk"; import { MatrixEvent } from "./models/event"; import { M_TIMESTAMP } from "./@types/location"; @@ -76,6 +77,25 @@ export function encodeParams(params: QueryDict, urlSearchParams?: URLSearchParam export type QueryDict = Record; +/** + * Replace a stable parameter with the unstable naming for params + * @param stable + * @param unstable + * @param dict + */ +export function replaceParam( + stable: string, + unstable: string, + dict: QueryDict, +): QueryDict { + const result = { + ...dict, + [unstable]: dict[stable], + }; + delete result[stable]; + return result; +} + /** * Decode a query string in `application/x-www-form-urlencoded` format. * @param {string} query A query string to decode e.g. @@ -103,13 +123,17 @@ export function decodeParams(query: string): Record { * variables with. E.g. { "$bar": "baz" }. * @return {string} The result of replacing all template variables e.g. '/foo/baz'. */ -export function encodeUri(pathTemplate: string, variables: Record): string { +export function encodeUri(pathTemplate: string, variables: Record>): string { for (const key in variables) { if (!variables.hasOwnProperty(key)) { continue; } + const value = variables[key]; + if (value === undefined || value === null) { + continue; + } pathTemplate = pathTemplate.replace( - key, encodeURIComponent(variables[key]), + key, encodeURIComponent(value), ); } return pathTemplate;