Skip to content

Commit b84a73c

Browse files
authored
Implement Sticky Events MSC4354 (#5028)
* Implement Sticky Events MSC * Renames * lint * some review work * Update for support for 4-ples * fix lint * pull through method * Fix the mistake * More tests to appease SC * Cleaner code * Review cleanup * Refactors based on review. * lint * Store sticky event expiry TS at insertion time. * proper type
1 parent a03cf05 commit b84a73c

File tree

11 files changed

+954
-72
lines changed

11 files changed

+954
-72
lines changed

spec/unit/models/event.spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/model
2020
import { emitPromise } from "../../test-utils/test-utils";
2121
import {
2222
type IAnnotatedPushRule,
23+
type IStickyEvent,
2324
type MatrixClient,
2425
PushRuleActionName,
2526
Room,
@@ -598,6 +599,39 @@ describe("MatrixEvent", () => {
598599
expect(stateEvent.isState()).toBeTruthy();
599600
expect(stateEvent.threadRootId).toBeUndefined();
600601
});
602+
603+
it("should calculate sticky duration correctly", async () => {
604+
const evData: IStickyEvent = {
605+
event_id: "$event_id",
606+
type: "some_state_event",
607+
content: {},
608+
sender: "@alice:example.org",
609+
origin_server_ts: 50,
610+
msc4354_sticky: {
611+
duration_ms: 1000,
612+
},
613+
unsigned: {
614+
msc4354_sticky_duration_ttl_ms: 5000,
615+
},
616+
};
617+
try {
618+
jest.useFakeTimers();
619+
jest.setSystemTime(50);
620+
// Prefer unsigned
621+
expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5050);
622+
// Fall back to `duration_ms`
623+
expect(
624+
new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt,
625+
).toEqual(1050);
626+
// Prefer current time if `origin_server_ts` is more recent.
627+
expect(
628+
new MatrixEvent({ ...evData, unsigned: undefined, origin_server_ts: 5000 } satisfies IStickyEvent)
629+
.unstableStickyExpiresAt,
630+
).toEqual(1050);
631+
} finally {
632+
jest.useRealTimers();
633+
}
634+
});
601635
});
602636

603637
function mainTimelineLiveEventIds(room: Room): Array<string> {
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { type IStickyEvent, MatrixEvent } from "../../../src";
2+
import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events";
3+
4+
describe("RoomStickyEvents", () => {
5+
let stickyEvents: RoomStickyEventsStore;
6+
const emitSpy: jest.Mock = jest.fn();
7+
const stickyEvent: IStickyEvent = {
8+
event_id: "$foo:bar",
9+
room_id: "!roomId",
10+
type: "org.example.any_type",
11+
msc4354_sticky: {
12+
duration_ms: 15000,
13+
},
14+
content: {
15+
msc4354_sticky_key: "foobar",
16+
},
17+
sender: "@alice:example.org",
18+
origin_server_ts: Date.now(),
19+
unsigned: {},
20+
};
21+
22+
beforeEach(() => {
23+
emitSpy.mockReset();
24+
stickyEvents = new RoomStickyEventsStore();
25+
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
26+
});
27+
28+
afterEach(() => {
29+
stickyEvents?.clear();
30+
});
31+
32+
describe("addStickyEvents", () => {
33+
it("should allow adding an event without a msc4354_sticky_key", () => {
34+
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]);
35+
expect([...stickyEvents.getStickyEvents()]).toHaveLength(1);
36+
});
37+
it("should not allow adding an event without a msc4354_sticky property", () => {
38+
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })]);
39+
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
40+
stickyEvents.addStickyEvents([
41+
new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }),
42+
]);
43+
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
44+
});
45+
it("should not allow adding an event without a sender", () => {
46+
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]);
47+
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
48+
});
49+
it("should not allow adding an event with an invalid sender", () => {
50+
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: "not_a_real_sender" })]);
51+
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
52+
});
53+
it("should ignore old events", () => {
54+
stickyEvents.addStickyEvents([
55+
new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }),
56+
]);
57+
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
58+
});
59+
it("should be able to just add an event", () => {
60+
const originalEv = new MatrixEvent({ ...stickyEvent });
61+
stickyEvents.addStickyEvents([originalEv]);
62+
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
63+
});
64+
it("should not replace events on ID tie break", () => {
65+
const originalEv = new MatrixEvent({ ...stickyEvent });
66+
stickyEvents.addStickyEvents([originalEv]);
67+
stickyEvents.addStickyEvents([
68+
new MatrixEvent({
69+
...stickyEvent,
70+
event_id: "$abc:bar",
71+
}),
72+
]);
73+
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
74+
});
75+
it("should not replace a newer event with an older event", () => {
76+
const originalEv = new MatrixEvent({ ...stickyEvent });
77+
stickyEvents.addStickyEvents([originalEv]);
78+
stickyEvents.addStickyEvents([
79+
new MatrixEvent({
80+
...stickyEvent,
81+
origin_server_ts: 1,
82+
}),
83+
]);
84+
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
85+
});
86+
it("should replace an older event with a newer event", () => {
87+
const originalEv = new MatrixEvent({ ...stickyEvent, event_id: "$old" });
88+
const newerEv = new MatrixEvent({
89+
...stickyEvent,
90+
event_id: "$new",
91+
origin_server_ts: Date.now() + 2000,
92+
});
93+
stickyEvents.addStickyEvents([originalEv]);
94+
stickyEvents.addStickyEvents([newerEv]);
95+
expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]);
96+
expect(emitSpy).toHaveBeenCalledWith([], [{ current: newerEv, previous: originalEv }], []);
97+
});
98+
it("should allow multiple events with the same sticky key for different event types", () => {
99+
const originalEv = new MatrixEvent({ ...stickyEvent });
100+
const anotherEv = new MatrixEvent({
101+
...stickyEvent,
102+
type: "org.example.another_type",
103+
});
104+
stickyEvents.addStickyEvents([originalEv, anotherEv]);
105+
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]);
106+
});
107+
108+
it("should emit when a new sticky event is added", () => {
109+
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
110+
const ev = new MatrixEvent({
111+
...stickyEvent,
112+
});
113+
stickyEvents.addStickyEvents([ev]);
114+
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
115+
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
116+
});
117+
it("should emit when a new unkeyed sticky event is added", () => {
118+
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
119+
const ev = new MatrixEvent({
120+
...stickyEvent,
121+
content: {},
122+
});
123+
stickyEvents.addStickyEvents([ev]);
124+
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
125+
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
126+
});
127+
});
128+
129+
describe("getStickyEvents", () => {
130+
it("should have zero sticky events", () => {
131+
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
132+
});
133+
it("should contain a sticky event", () => {
134+
const ev = new MatrixEvent({
135+
...stickyEvent,
136+
});
137+
stickyEvents.addStickyEvents([ev]);
138+
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
139+
});
140+
it("should contain two sticky events", () => {
141+
const ev = new MatrixEvent({
142+
...stickyEvent,
143+
});
144+
const ev2 = new MatrixEvent({
145+
...stickyEvent,
146+
sender: "@fibble:bobble",
147+
content: {
148+
msc4354_sticky_key: "bibble",
149+
},
150+
});
151+
stickyEvents.addStickyEvents([ev, ev2]);
152+
expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]);
153+
});
154+
});
155+
156+
describe("getKeyedStickyEvent", () => {
157+
it("should have zero sticky events", () => {
158+
expect(
159+
stickyEvents.getKeyedStickyEvent(
160+
stickyEvent.sender,
161+
stickyEvent.type,
162+
stickyEvent.content.msc4354_sticky_key!,
163+
),
164+
).toBeUndefined();
165+
});
166+
it("should return a sticky event", () => {
167+
const ev = new MatrixEvent({
168+
...stickyEvent,
169+
});
170+
stickyEvents.addStickyEvents([ev]);
171+
expect(
172+
stickyEvents.getKeyedStickyEvent(
173+
stickyEvent.sender,
174+
stickyEvent.type,
175+
stickyEvent.content.msc4354_sticky_key!,
176+
),
177+
).toEqual(ev);
178+
});
179+
});
180+
181+
describe("getUnkeyedStickyEvent", () => {
182+
it("should have zero sticky events", () => {
183+
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([]);
184+
});
185+
it("should return a sticky event", () => {
186+
const ev = new MatrixEvent({
187+
...stickyEvent,
188+
content: {
189+
msc4354_sticky_key: undefined,
190+
},
191+
});
192+
stickyEvents.addStickyEvents([ev]);
193+
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([ev]);
194+
});
195+
});
196+
197+
describe("cleanExpiredStickyEvents", () => {
198+
beforeAll(() => {
199+
jest.useFakeTimers();
200+
});
201+
afterAll(() => {
202+
jest.useRealTimers();
203+
});
204+
205+
it("should emit when a sticky event expires", () => {
206+
jest.setSystemTime(1000);
207+
const ev = new MatrixEvent({
208+
...stickyEvent,
209+
origin_server_ts: 0,
210+
});
211+
const evLater = new MatrixEvent({
212+
...stickyEvent,
213+
event_id: "$baz:bar",
214+
sender: "@bob:example.org",
215+
origin_server_ts: 1000,
216+
});
217+
stickyEvents.addStickyEvents([ev, evLater]);
218+
const emitSpy = jest.fn();
219+
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
220+
jest.advanceTimersByTime(15000);
221+
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
222+
// Then expire the next event
223+
jest.advanceTimersByTime(1000);
224+
expect(emitSpy).toHaveBeenCalledWith([], [], [evLater]);
225+
});
226+
it("should emit two events when both expire at the same time", () => {
227+
const emitSpy = jest.fn();
228+
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
229+
jest.setSystemTime(0);
230+
const ev1 = new MatrixEvent({
231+
...stickyEvent,
232+
event_id: "$eventA",
233+
origin_server_ts: 0,
234+
});
235+
const ev2 = new MatrixEvent({
236+
...stickyEvent,
237+
event_id: "$eventB",
238+
content: {
239+
msc4354_sticky_key: "key_2",
240+
},
241+
origin_server_ts: 0,
242+
});
243+
stickyEvents.addStickyEvents([ev1, ev2]);
244+
expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []);
245+
jest.advanceTimersByTime(15000);
246+
expect(emitSpy).toHaveBeenCalledWith([], [], [ev1, ev2]);
247+
});
248+
it("should emit when a unkeyed sticky event expires", () => {
249+
const emitSpy = jest.fn();
250+
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
251+
jest.setSystemTime(0);
252+
const ev = new MatrixEvent({
253+
...stickyEvent,
254+
content: {},
255+
origin_server_ts: Date.now(),
256+
});
257+
stickyEvents.addStickyEvents([ev]);
258+
jest.advanceTimersByTime(15000);
259+
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
260+
});
261+
});
262+
});

spec/unit/sync-accumulator.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
type ILeftRoom,
2727
type IRoomEvent,
2828
type IStateEvent,
29+
type IStickyEvent,
2930
type IStrippedState,
3031
type ISyncResponse,
3132
SyncAccumulator,
@@ -1067,6 +1068,67 @@ describe("SyncAccumulator", function () {
10671068
);
10681069
});
10691070
});
1071+
1072+
describe("MSC4354 sticky events", () => {
1073+
function stickyEvent(ts = 0): IStickyEvent {
1074+
const msgData = msg("test", "test text");
1075+
return {
1076+
...msgData,
1077+
msc4354_sticky: {
1078+
duration_ms: 1000,
1079+
},
1080+
origin_server_ts: ts,
1081+
};
1082+
}
1083+
1084+
beforeAll(() => {
1085+
jest.useFakeTimers();
1086+
});
1087+
1088+
afterAll(() => {
1089+
jest.useRealTimers();
1090+
});
1091+
1092+
it("should accumulate sticky events", () => {
1093+
jest.setSystemTime(0);
1094+
const ev = stickyEvent();
1095+
sa.accumulate(
1096+
syncSkeleton({
1097+
msc4354_sticky: {
1098+
events: [ev],
1099+
},
1100+
}),
1101+
);
1102+
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
1103+
});
1104+
it("should clear stale sticky events", () => {
1105+
jest.setSystemTime(1000);
1106+
const ev = stickyEvent(1000);
1107+
sa.accumulate(
1108+
syncSkeleton({
1109+
msc4354_sticky: {
1110+
events: [ev],
1111+
},
1112+
}),
1113+
);
1114+
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
1115+
jest.setSystemTime(2000); // Expire the event
1116+
sa.accumulate(syncSkeleton({}));
1117+
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
1118+
});
1119+
1120+
it("clears stale sticky events that pretend to be from the distant future", () => {
1121+
jest.setSystemTime(0);
1122+
const eventFarInTheFuture = stickyEvent(999999999999);
1123+
sa.accumulate(syncSkeleton({ msc4354_sticky: { events: [eventFarInTheFuture] } }));
1124+
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([
1125+
eventFarInTheFuture,
1126+
]);
1127+
jest.setSystemTime(1000); // Expire the event
1128+
sa.accumulate(syncSkeleton({}));
1129+
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
1130+
});
1131+
});
10701132
});
10711133

10721134
function syncSkeleton(

0 commit comments

Comments
 (0)