diff --git a/spec/integ/sliding-sync-sdk.spec.ts b/spec/integ/sliding-sync-sdk.spec.ts index 1ac35438df..76a4077655 100644 --- a/spec/integ/sliding-sync-sdk.spec.ts +++ b/spec/integ/sliding-sync-sdk.spec.ts @@ -1104,4 +1104,100 @@ describe("SlidingSyncSdk", () => { // we expect it not to crash }); }); + + describe("ExtensionStickyEvents", () => { + let ext: Extension; + + const stickyEvent = (durationMs: number, stickyKey: string) => ({ + type: "m.test.sticky", + sender: selfUserId, + event_id: `$sticky_${stickyKey}`, + origin_server_ts: Date.now(), + msc4354_sticky: { duration_ms: durationMs }, + content: { msc4354_sticky_key: stickyKey }, + }); + + beforeAll(async () => { + await setupClient(); + const hasSynced = sdk!.sync(); + await httpBackend!.flushAllExpected(); + await hasSynced; + ext = findExtension("org.matrix.msc4354.sticky_events"); + }); + + it("gets enabled all the time", async () => { + let reqJson: any = await ext.onRequest(true); + expect(reqJson.enabled).toEqual(true); + expect(reqJson.limit).toBeGreaterThan(0); + expect(reqJson.since).toBeUndefined(); + reqJson = await ext.onRequest(false); + expect(reqJson.enabled).toEqual(true); + expect(reqJson.since).toBeUndefined(); + }); + + it("updates the since value", async () => { + await ext.onResponse({ next_batch: "12345" }); + expect(await ext.onRequest(false)).toMatchObject({ since: "12345" }); + }); + + it("keeps the previous since value when there are no changes", async () => { + await ext.onResponse({ next_batch: "23456" }); + await ext.onResponse({}); + expect(await ext.onRequest(false)).toMatchObject({ since: "23456" }); + }); + + it("adds sticky events to the room", async () => { + const roomId = "!sticky:localhost"; + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { + name: "Room with sticky events", + required_state: [ + mkOwnStateEvent(EventType.RoomCreate, {}, ""), + mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId), + mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), + ], + timeline: [mkOwnEvent(EventType.RoomMessage, { body: "hello" })], + initial: true, + }); + await emitPromise(client!, ClientEvent.Room); + const room = client!.getRoom(roomId)!; + expect(room).toBeTruthy(); + + await ext.onResponse({ + next_batch: "34567", + rooms: { [roomId]: { events: [stickyEvent(300000, "key1")] } }, + }); + + const events = Array.from(room._unstable_getStickyEvents()); + expect(events.map((e) => e.getId())).toEqual(["$sticky_key1"]); + }); + + it("adds sticky events that arrived in the timeline", async () => { + const roomId = "!sticky_timeline:localhost"; + mockSlidingSync!.emit(SlidingSyncEvent.RoomData, roomId, { + name: "Room with a sticky timeline event", + required_state: [ + mkOwnStateEvent(EventType.RoomCreate, {}, ""), + mkOwnStateEvent(EventType.RoomMember, { membership: KnownMembership.Join }, selfUserId), + mkOwnStateEvent(EventType.RoomPowerLevels, { users: { [selfUserId]: 100 } }, ""), + ], + timeline: [stickyEvent(300000, "from_timeline") as unknown as IRoomEvent], + initial: true, + }); + await emitPromise(client!, ClientEvent.Room); + + const room = client!.getRoom(roomId)!; + const events = Array.from(room._unstable_getStickyEvents()); + expect(events.map((e) => e.getId())).toEqual(["$sticky_from_timeline"]); + }); + + // eslint-disable-next-line @vitest/expect-expect + it("gracefully handles missing rooms and fields", async () => { + await ext.onResponse({ + next_batch: "45678", + rooms: { "!unknown:localhost": { events: [stickyEvent(300000, "key2")] } }, + }); + await ext.onResponse({ next_batch: "56789", rooms: {} }); + // we expect it not to crash + }); + }); }); diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index 733328b4ae..b3e9f0cbb6 100644 --- a/src/sliding-sync-sdk.ts +++ b/src/sliding-sync-sdk.ts @@ -35,6 +35,8 @@ import { type IMinimalEvent, type IRoomEvent, type IStateEvent, + type IStickyEvent, + type IStickyStateEvent, type IStrippedState, type ISyncResponse, type ReceivedToDeviceMessage, @@ -311,6 +313,73 @@ class ExtensionReceipts implements Extension }>; +}; + +/** + * Delivers sticky events (MSC4354) over sliding sync. + * https://github.com/matrix-org/matrix-spec-proposals/pull/4480 + * + * Sticky events expire after a duration instead of living in the timeline forever, and the server + * re-sends the unexpired ones (e.g. on join) so late joiners still see them. + * + * The server sends them for every room matched by a list or subscription, even rooms currently + * outside the list window. Sticky events already in a room's timeline are excluded here, so + * `processRoomData` picks those up separately. + */ +class ExtensionStickyEvents implements Extension { + private nextBatch?: string; + + public constructor(private readonly client: MatrixClient) {} + + public name(): string { + // Keeps MSC4354's number, as the extension was originally specified there. + return "org.matrix.msc4354.sticky_events"; + } + + public when(): ExtensionState { + // Sticky events are stored on a Room, so the room has to exist first. + return ExtensionState.PostProcess; + } + + public async onRequest(isInitial: boolean): Promise { + return { + enabled: true, + limit: 100, + // Undefined until the first response, which asks for all unexpired sticky events. + since: this.nextBatch, + }; + } + + public async onResponse(data: ExtensionStickyEventsResponse): Promise { + for (const [roomId, roomData] of Object.entries(data?.rooms ?? {})) { + const room = this.client.getRoom(roomId); + if (!room) { + // Dropping is safe: unexpired sticky events are re-sent once we know the room. + logger.debug(`Ignoring sticky events for unknown room ${roomId}`); + continue; + } + room._unstable_addStickyEvents(mapEvents(this.client, roomId, roomData.events ?? [])); + } + + // next_batch is only returned when there were changes, and must be echoed back as `since`. + if (data?.next_batch) { + this.nextBatch = data.next_batch; + } + } +} + /** * A copy of SyncApi such that it can be used as a drop-in replacement for sync v2. For the actual * sliding sync API, see sliding-sync.ts or the class SlidingSync. @@ -344,6 +413,7 @@ export class SlidingSyncSdk { new ExtensionAccountData(this.client), new ExtensionTyping(this.client), new ExtensionReceipts(this.client), + new ExtensionStickyEvents(this.client), ]; if (this.syncOpts.cryptoCallbacks) { extensions.push(new ExtensionE2EE(this.syncOpts.cryptoCallbacks)); @@ -705,6 +775,10 @@ export class SlidingSyncSdk { room.setMSC4186SummaryData(roomData.heroes, roomData.joined_count, roomData.invited_count); + // The MSC4480 extension excludes sticky events already present in the timeline, so we have + // to pick those up here. See ExtensionStickyEvents for the rest. + room._unstable_addStickyEvents(timelineEvents.filter((e) => e.unstableStickyInfo !== undefined)); + room.recalculate(); if (roomData.initial) { client.store.storeRoom(room);