diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index 3a43f9e12..72a7eeaa2 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -928,4 +928,90 @@ describe("SlidingSyncSdk", () => { expect(room.getMember(selfUserId)?.typing).toEqual(false); }); }); + + describe("ExtensionReceipts", () => { + let ext: Extension; + + const generateReceiptResponse = ( + userId: string, roomId: string, eventId: string, recType: string, ts: number, + ) => { + return { + rooms: { + [roomId]: { + type: EventType.Receipt, + content: { + [eventId]: { + [recType]: { + [userId]: { + ts: ts, + }, + }, + }, + }, + }, + }, + }; + }; + + beforeAll(async () => { + await setupClient(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); + await hasSynced; + ext = findExtension("receipts"); + }); + + it("gets enabled on the initial request only", () => { + expect(ext.onRequest(true)).toEqual({ + enabled: true, + }); + expect(ext.onRequest(false)).toEqual(undefined); + }); + + it("processes receipts", async () => { + const roomId = "!room:id"; + const alice = "@alice:alice"; + const lastEvent = mkOwnEvent(EventType.RoomMessage, { body: "hello" }); + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { + name: "Room with receipts", + required_state: [], + timeline: [ + mkOwnStateEvent(EventType.RoomCreate, { creator: selfUserId }, ""), + mkOwnStateEvent(EventType.RoomMember, { membership: "join" }, selfUserId), + mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), + { + type: EventType.RoomMember, + state_key: alice, + content: { membership: "join" }, + sender: alice, + origin_server_ts: Date.now(), + event_id: "$alice", + }, + lastEvent, + ], + initial: true, + }); + const room = client!.getRoom(roomId)!; + expect(room).toBeDefined(); + expect(room.getReadReceiptForUserId(alice, true)).toBeNull(); + ext.onResponse( + generateReceiptResponse(alice, roomId, lastEvent.event_id, "m.read", 1234567), + ); + const receipt = room.getReadReceiptForUserId(alice); + expect(receipt).toBeDefined(); + expect(receipt?.eventId).toEqual(lastEvent.event_id); + expect(receipt?.data.ts).toEqual(1234567); + expect(receipt?.data.thread_id).toBeFalsy(); + }); + + it("gracefully handles missing rooms when receiving receipts", async () => { + const roomId = "!room:id"; + const alice = "@alice:alice"; + const eventId = "$something"; + ext.onResponse( + generateReceiptResponse(alice, roomId, eventId, "m.read", 1234567), + ); + // we expect it not to crash + }); + }); }); diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index f12b51c65..a787b4e34 100644 --- a/src/sliding-sync-sdk.ts +++ b/src/sliding-sync-sdk.ts @@ -246,29 +246,55 @@ class ExtensionTyping implements Extension { public onRequest(isInitial: boolean): object | undefined { if (!isInitial) { - return undefined; + return undefined; // don't send a JSON object for subsequent requests, we don't need to. } return { enabled: true, }; } - public onResponse(data: {rooms: Record}): void { + public onResponse(data: {rooms: Record}): void { if (!data || !data.rooms) { return; } for (const roomId in data.rooms) { - const ephemeralEvents = mapEvents(this.client, roomId, [data.rooms[roomId]]); - const room = this.client.getRoom(roomId); - if (!room) { - logger.warn("got typing events for room but room doesn't exist on client:", roomId); - continue; - } - room.addEphemeralEvents(ephemeralEvents); - ephemeralEvents.forEach((e) => { - this.client.emit(ClientEvent.Event, e); - }); + processEphemeralEvents( + this.client, roomId, [data.rooms[roomId]], + ); + } + } +} + +class ExtensionReceipts implements Extension { + public constructor(private readonly client: MatrixClient) {} + + public name(): string { + return "receipts"; + } + + public when(): ExtensionState { + return ExtensionState.PostProcess; + } + + public onRequest(isInitial: boolean): object | undefined { + if (isInitial) { + return { + enabled: true, + }; + } + return undefined; // don't send a JSON object for subsequent requests, we don't need to. + } + + public onResponse(data: {rooms: Record}): void { + if (!data || !data.rooms) { + return; + } + + for (const roomId in data.rooms) { + processEphemeralEvents( + this.client, roomId, [data.rooms[roomId]], + ); } } } @@ -314,6 +340,7 @@ export class SlidingSyncSdk { new ExtensionToDevice(this.client), new ExtensionAccountData(this.client), new ExtensionTyping(this.client), + new ExtensionReceipts(this.client), ]; if (this.opts.crypto) { extensions.push( @@ -929,3 +956,16 @@ function mapEvents(client: MatrixClient, roomId: string | undefined, events: obj return mapper(e); }); } + +function processEphemeralEvents(client: MatrixClient, roomId: string, ephEvents: IMinimalEvent[]): void { + const ephemeralEvents = mapEvents(client, roomId, ephEvents); + const room = client.getRoom(roomId); + if (!room) { + logger.warn("got ephemeral events for room but room doesn't exist on client:", roomId); + return; + } + room.addEphemeralEvents(ephemeralEvents); + ephemeralEvents.forEach((e) => { + client.emit(ClientEvent.Event, e); + }); +}