Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions spec/integ/sliding-sync-sdk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1104,4 +1104,100 @@
// we expect it not to crash
});
});

describe("ExtensionStickyEvents", () => {
let ext: Extension<any, any>;

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 () => {

Check failure on line 1194 in spec/integ/sliding-sync-sdk.spec.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add at least one assertion to this test case.

See more on https://sonarcloud.io/project/issues?id=matrix-js-sdk&issues=AZ_Gr-V7uumdHscEhlX9&open=AZ_Gr-V7uumdHscEhlX9&pullRequest=5458
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
});
});
});
74 changes: 74 additions & 0 deletions src/sliding-sync-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
type IMinimalEvent,
type IRoomEvent,
type IStateEvent,
type IStickyEvent,
type IStickyStateEvent,
type IStrippedState,
type ISyncResponse,
type ReceivedToDeviceMessage,
Expand Down Expand Up @@ -311,6 +313,73 @@
}
}

type ExtensionStickyEventsRequest = {
enabled: boolean;
/** Max events per response; the server may return fewer. */
limit?: number;
/** The `next_batch` of the previous response. */
since?: string;
};

type ExtensionStickyEventsResponse = {
/** Only sent when there were changes. */
next_batch?: string;
rooms?: Record<string, { events: Array<IStickyEvent | IStickyStateEvent> }>;
};

/**
* 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<ExtensionStickyEventsRequest, ExtensionStickyEventsResponse> {
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 {

Check warning on line 351 in src/sliding-sync-sdk.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 351 is not covered by tests
// Sticky events are stored on a Room, so the room has to exist first.
return ExtensionState.PostProcess;

Check warning on line 353 in src/sliding-sync-sdk.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 353 is not covered by tests
}

public async onRequest(isInitial: boolean): Promise<ExtensionStickyEventsRequest> {
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<void> {
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}`);

Check warning on line 370 in src/sliding-sync-sdk.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

'logger' is deprecated.

See more on https://sonarcloud.io/project/issues?id=matrix-js-sdk&issues=AZ_Gr-QauumdHscEhlX8&open=AZ_Gr-QauumdHscEhlX8&pullRequest=5458
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.
Expand Down Expand Up @@ -344,6 +413,7 @@
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));
Expand Down Expand Up @@ -705,6 +775,10 @@

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);
Expand Down
Loading