From bf14e3769efd338909266e9fce53be7ac8356f20 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 1 Oct 2025 11:57:57 +0100 Subject: [PATCH 01/15] Implement Sticky Events MSC --- spec/unit/models/room-sticky-events.spec.ts | 221 ++++++++++++++++++ spec/unit/sync-accumulator.spec.ts | 50 +++++ src/@types/requests.ts | 3 + src/client.ts | 198 ++++++++++++----- src/errors.ts | 15 +- src/models/event.ts | 34 +++ src/models/room-sticky-events.ts | 234 ++++++++++++++++++++ src/models/room.ts | 42 ++++ src/sync-accumulator.ts | 29 +++ src/sync.ts | 21 +- 10 files changed, 790 insertions(+), 57 deletions(-) create mode 100644 spec/unit/models/room-sticky-events.spec.ts create mode 100644 src/models/room-sticky-events.ts diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts new file mode 100644 index 00000000000..415c74960dd --- /dev/null +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -0,0 +1,221 @@ +import { type IEvent, MatrixEvent } from "../../../src"; +import { RoomStickyEvents, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events"; + +describe("RoomStickyEvents", () => { + let stickyEvents: RoomStickyEvents; + const stickyEvent: IEvent = { + event_id: "$foo:bar", + room_id: "!roomId", + type: "org.example.any_type", + msc4354_sticky: { + duration_ms: 15000, + }, + content: { + msc4354_sticky_key: "foobar", + }, + sender: "@alice:example.org", + origin_server_ts: Date.now(), + unsigned: {}, + }; + + beforeEach(() => { + stickyEvents = new RoomStickyEvents(); + }); + + afterEach(() => { + stickyEvents?.clear(); + }); + + describe("addStickyEvents", () => { + it("should allow adding an event without a msc4354_sticky_key", () => { + stickyEvents.unstableAddStickyEvent(new MatrixEvent({ ...stickyEvent, content: {} })); + }); + it("should not allow adding an event without a msc4354_sticky property", () => { + expect(() => + stickyEvents.unstableAddStickyEvent(new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })), + ).toThrow(`${stickyEvent.event_id} is missing msc4354_sticky.duration_ms`); + expect(() => + stickyEvents.unstableAddStickyEvent( + new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }), + ), + ).toThrow(`${stickyEvent.event_id} is missing msc4354_sticky.duration_ms`); + }); + it("should not allow adding an event without a sender", () => { + expect(() => + stickyEvents.unstableAddStickyEvent(new MatrixEvent({ ...stickyEvent, sender: undefined })), + ).toThrow(`${stickyEvent.event_id} is missing a sender`); + }); + it("should ignore old events", () => { + expect( + stickyEvents.unstableAddStickyEvent( + new MatrixEvent({ + ...stickyEvent, + origin_server_ts: 0, + msc4354_sticky: { + duration_ms: 1, + }, + }), + ), + ).toEqual({ added: false }); + }); + it("should not replace newer events", () => { + expect( + stickyEvents.unstableAddStickyEvent( + new MatrixEvent({ + ...stickyEvent, + }), + ), + ).toEqual({ added: true }); + expect( + stickyEvents.unstableAddStickyEvent( + new MatrixEvent({ + ...stickyEvent, + origin_server_ts: 1, + }), + ), + ).toEqual({ added: false }); + }); + it("should not replace events on ID tie break", () => { + expect( + stickyEvents.unstableAddStickyEvent( + new MatrixEvent({ + ...stickyEvent, + }), + ), + ).toEqual({ added: true }); + expect( + stickyEvents.unstableAddStickyEvent( + new MatrixEvent({ + ...stickyEvent, + event_id: "$abc:bar", + }), + ), + ).toEqual({ added: false }); + }); + it("should be able to just add an event", () => { + expect( + stickyEvents.unstableAddStickyEvent( + new MatrixEvent({ + ...stickyEvent, + }), + ), + ).toEqual({ added: true }); + }); + }); + + describe("unstableAddStickyEvents", () => { + it("should emit when a new sticky event is added", () => { + const emitSpy = jest.fn(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + const ev = new MatrixEvent({ + ...stickyEvent, + }); + stickyEvents.unstableAddStickyEvents([ev]); + expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); + expect(emitSpy).toHaveBeenCalledWith([ev], []); + }); + it("should emit when a new unketed sticky event is added", () => { + const emitSpy = jest.fn(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + const ev = new MatrixEvent({ + ...stickyEvent, + content: {}, + }); + stickyEvents.unstableAddStickyEvents([ev]); + expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); + expect(emitSpy).toHaveBeenCalledWith([ev], []); + }); + }); + + describe("getStickyEvents", () => { + it("should have zero sticky events", () => { + expect([...stickyEvents._unstable_getStickyEvents()]).toHaveLength(0); + }); + it("should contain a sticky event", () => { + const ev = new MatrixEvent({ + ...stickyEvent, + }); + stickyEvents.unstableAddStickyEvent( + new MatrixEvent({ + ...stickyEvent, + }), + ); + expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); + }); + it("should contain two sticky events", () => { + const ev = new MatrixEvent({ + ...stickyEvent, + }); + const ev2 = new MatrixEvent({ + ...stickyEvent, + sender: "@fibble:bobble", + content: { + msc4354_sticky_key: "bibble", + }, + }); + stickyEvents.unstableAddStickyEvent(ev); + stickyEvents.unstableAddStickyEvent(ev2); + expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev, ev2]); + }); + }); + + describe("cleanExpiredStickyEvents", () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it("should emit when a sticky event expires", () => { + const emitSpy = jest.fn(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + jest.setSystemTime(0); + const ev = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: Date.now(), + }); + stickyEvents.unstableAddStickyEvent(ev); + jest.setSystemTime(15000); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [ev]); + }); + it("should emit two events when both expire at the same time", () => { + const emitSpy = jest.fn(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + jest.setSystemTime(0); + const ev1 = new MatrixEvent({ + ...stickyEvent, + event_id: "$eventA", + origin_server_ts: 0, + }); + const ev2 = new MatrixEvent({ + ...stickyEvent, + event_id: "$eventB", + content: { + msc4354_sticky_key: "key_2", + }, + origin_server_ts: 0, + }); + stickyEvents.unstableAddStickyEvents([ev1, ev2]); + expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], []); + jest.setSystemTime(15000); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [ev1, ev2]); + }); + it("should emit when a unkeyed sticky event expires", () => { + const emitSpy = jest.fn(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + jest.setSystemTime(0); + const ev = new MatrixEvent({ + ...stickyEvent, + content: {}, + origin_server_ts: Date.now(), + }); + stickyEvents.unstableAddStickyEvent(ev); + jest.setSystemTime(15000); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [ev]); + }); + }); +}); diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 22d33360072..69ce5b6bc69 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -26,6 +26,7 @@ import { type ILeftRoom, type IRoomEvent, type IStateEvent, + type IStickyEvent, type IStrippedState, type ISyncResponse, SyncAccumulator, @@ -1067,6 +1068,55 @@ describe("SyncAccumulator", function () { ); }); }); + + describe("MSC4354 sticky events", () => { + function stickyEvent(ts = 0): IStickyEvent { + const msgData = msg("test", "test text"); + return { + ...msgData, + msc4354_sticky: { + duration_ms: 1000, + }, + origin_server_ts: ts, + }; + } + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it("should accumulate sticky events", () => { + jest.setSystemTime(0); + const ev = stickyEvent(); + sa.accumulate( + syncSkeleton({ + msc4354_sticky: { + events: [ev], + }, + }), + ); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]); + }); + it("should clear stale sticky events", () => { + jest.setSystemTime(1000); + const ev = stickyEvent(1000); + sa.accumulate( + syncSkeleton({ + msc4354_sticky: { + events: [ev, stickyEvent(0)], + }, + }), + ); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]); + jest.setSystemTime(2000); // Expire the event + sa.accumulate(syncSkeleton({})); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined(); + }); + }); }); function syncSkeleton( diff --git a/src/@types/requests.ts b/src/@types/requests.ts index b985bec2939..eeb756850ae 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -107,6 +107,9 @@ export type SendActionDelayedEventRequestOpts = ParentDelayId; export type SendDelayedEventRequestOpts = SendTimeoutDelayedEventRequestOpts | SendActionDelayedEventRequestOpts; +export function isSendDelayedEventRequestOpts(opts: object): opts is SendDelayedEventRequestOpts { + return (opts as TimeoutDelay).delay !== undefined || (opts as ParentDelayId).parent_delay_id !== undefined; +} export type SendDelayedEventResponse = { delay_id: string; }; diff --git a/src/client.ts b/src/client.ts index 1b7f27be6fd..fd152915fdc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -105,6 +105,7 @@ import { import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-member.ts"; import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts"; import { + isSendDelayedEventRequestOpts, type DelayedEventInfo, type IAddThreePidOnlyBody, type IBindThreePidBody, @@ -246,7 +247,7 @@ import { validateAuthMetadataAndKeys, } from "./oidc/index.ts"; import { type EmptyObject } from "./@types/common.ts"; -import { UnsupportedDelayedEventsEndpointError } from "./errors.ts"; +import { UnsupportedDelayedEventsEndpointError, UnsupportedStickyEventsEndpointError } from "./errors.ts"; export type Store = IStore; @@ -545,6 +546,7 @@ export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms" export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms"; export const UNSTABLE_MSC4140_DELAYED_EVENTS = "org.matrix.msc4140"; +export const UNSTABLE_MSC4354_STICKY_EVENTS = "org.matrix.msc4354"; export const UNSTABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133"; export const STABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133.stable"; @@ -2672,7 +2674,7 @@ export class MatrixClient extends TypedEventEmitter, - txnId?: string, - ): Promise; + private sendCompleteEvent(params: { + roomId: string; + threadId: string | null; + eventObject: Partial; + queryDict?: QueryDict; + txnId?: string; + }): Promise; /** * Sends a delayed event (MSC4140). * @param eventObject - An object with the partial structure of an event, to which event_id, user_id, room_id and origin_server_ts will be added. @@ -2723,29 +2726,29 @@ export class MatrixClient extends TypedEventEmitter, - delayOpts: SendDelayedEventRequestOpts, - txnId?: string, - ): Promise; - private sendCompleteEvent( - roomId: string, - threadId: string | null, - eventObject: Partial, - delayOptsOrTxnId?: SendDelayedEventRequestOpts | string, - txnIdOrVoid?: string, - ): Promise { - let delayOpts: SendDelayedEventRequestOpts | undefined; - let txnId: string | undefined; - if (typeof delayOptsOrTxnId === "string") { - txnId = delayOptsOrTxnId; - } else { - delayOpts = delayOptsOrTxnId; - txnId = txnIdOrVoid; - } - + private sendCompleteEvent(params: { + roomId: string; + threadId: string | null; + eventObject: Partial; + delayOpts: SendDelayedEventRequestOpts; + queryDict?: QueryDict; + txnId?: string; + }): Promise; + private sendCompleteEvent({ + roomId, + threadId, + eventObject, + delayOpts, + queryDict, + txnId, + }: { + roomId: string; + threadId: string | null; + eventObject: Partial; + delayOpts?: SendDelayedEventRequestOpts; + queryDict?: QueryDict; + txnId?: string; + }): Promise { if (!txnId) { txnId = this.makeTxnId(); } @@ -2788,7 +2791,7 @@ export class MatrixClient extends TypedEventEmitter; + protected async encryptAndSendEvent( + room: Room | null, + event: MatrixEvent, + queryDict?: QueryDict, + ): Promise; /** * Simply sends a delayed event without encrypting it. * TODO: Allow encrypted delayed events, and encrypt them properly @@ -2827,16 +2834,20 @@ export class MatrixClient extends TypedEventEmitter; + queryDict?: QueryDict, + ): Promise; protected async encryptAndSendEvent( room: Room | null, event: MatrixEvent, - delayOpts?: SendDelayedEventRequestOpts, + delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict, + queryDict?: QueryDict, ): Promise { - if (delayOpts) { - return this.sendEventHttpRequest(event, delayOpts); + let queryOpts = queryDict; + if (delayOptsOrQuery && isSendDelayedEventRequestOpts(delayOptsOrQuery)) { + return this.sendEventHttpRequest(event, delayOptsOrQuery, queryOpts); + } else if (!queryOpts) { + queryOpts = delayOptsOrQuery; } - try { let cancelled: boolean; this.eventsBeingEncrypted.add(event.getId()!); @@ -2872,7 +2883,7 @@ export class MatrixClient extends TypedEventEmitter { room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]); @@ -2987,14 +2998,16 @@ export class MatrixClient extends TypedEventEmitter; + private sendEventHttpRequest(event: MatrixEvent, queryDict?: QueryDict): Promise; private sendEventHttpRequest( event: MatrixEvent, delayOpts: SendDelayedEventRequestOpts, + queryDict?: QueryDict, ): Promise; private sendEventHttpRequest( event: MatrixEvent, - delayOpts?: SendDelayedEventRequestOpts, + queryOrDelayOpts?: SendDelayedEventRequestOpts | QueryDict, + queryDict?: QueryDict, ): Promise { let txnId = event.getTxnId(); if (!txnId) { @@ -3027,19 +3040,22 @@ export class MatrixClient extends TypedEventEmitter(Method.Put, path, undefined, content).then((res) => { - this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`); - return res; - }); - } else { + if (delayOpts) { return this.http.authedRequest( Method.Put, path, - getUnstableDelayQueryOpts(delayOpts), + { ...getUnstableDelayQueryOpts(delayOpts), ...queryOpts }, content, ); + } else { + return this.http.authedRequest(Method.Put, path, queryOpts, content).then((res) => { + this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`); + return res; + }); } } @@ -3096,16 +3112,16 @@ export class MatrixClient extends TypedEventEmitter( + roomId: string, + stickDuration: number, + delayOpts: SendDelayedEventRequestOpts, + threadId: string | null, + eventType: K, + content: TimelineEvents[K] & { msc4354_sticky_key: string }, + txnId?: string, + ): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) { + throw new UnsupportedStickyEventsEndpointError( + "Server does not support the sticky events", + "sendStickyEvent", + ); + } + + this.addThreadRelationIfNeeded(content, threadId, roomId); + return this.sendCompleteEvent({ + roomId, + threadId, + eventObject: { type: eventType, content }, + queryDict: { msc4354_stick_duration_ms: stickDuration }, + delayOpts, + txnId, + }); } /** @@ -3430,6 +3486,38 @@ export class MatrixClient extends TypedEventEmitter( + roomId: string, + stickDuration: number, + threadId: string | null, + eventType: K, + content: TimelineEvents[K] & { msc4354_sticky_key: string }, + txnId?: string, + ): Promise { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) { + throw new UnsupportedStickyEventsEndpointError( + "Server does not support the sticky events", + "sendStickyEvent", + ); + } + + this.addThreadRelationIfNeeded(content, threadId, roomId); + return this.sendCompleteEvent({ + roomId, + threadId, + eventObject: { type: eventType, content }, + queryDict: { msc4354_stick_duration_ms: stickDuration }, + txnId, + }); + } + /** * Get all pending delayed events for the calling user. * diff --git a/src/errors.ts b/src/errors.ts index 8baf7979bc4..672aee3bb42 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -54,7 +54,7 @@ export class ClientStoppedError extends Error { } /** - * This error is thrown when the Homeserver does not support the delayed events enpdpoints. + * This error is thrown when the Homeserver does not support the delayed events endpoints. */ export class UnsupportedDelayedEventsEndpointError extends Error { public constructor( @@ -65,3 +65,16 @@ export class UnsupportedDelayedEventsEndpointError extends Error { this.name = "UnsupportedDelayedEventsEndpointError"; } } + +/** + * This error is thrown when the Homeserver does not support the sticky events endpoints. + */ +export class UnsupportedStickyEventsEndpointError extends Error { + public constructor( + message: string, + public clientEndpoint: "sendStickyEvent" | "sendStickyStateEvent", + ) { + super(message); + this.name = "UnsupportedStickyEventsEndpointError"; + } +} diff --git a/src/models/event.ts b/src/models/event.ts index dba134b894f..36bad274d54 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -75,6 +75,7 @@ export interface IUnsigned { "transaction_id"?: string; "invite_room_state"?: StrippedState[]; "m.relations"?: Record; // No common pattern for aggregated relations + "msc4354_sticky_duration_ttl_ms"?: number; [UNSIGNED_THREAD_ID_FIELD.name]?: string; } @@ -96,6 +97,7 @@ export interface IEvent { membership?: Membership; unsigned: IUnsigned; redacts?: string; + msc4354_sticky?: { duration_ms: number }; } export interface IAggregatedRelation { @@ -213,6 +215,7 @@ export interface IMessageVisibilityHidden { } // A singleton implementing `IMessageVisibilityVisible`. const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true }); +const MAX_STICKY_DURATION_MS = 3600000; export enum MatrixEventEvent { /** @@ -408,6 +411,14 @@ export class MatrixEvent extends TypedEventEmitter; + /** + * The timestamp for when this event should expire, in milliseconds. + * Prefers using the serve-provided value, but will fall back to local calculation. + * If the event is not a sticky event (or not supported by the server), + * then this returns `undefined`. + */ + public readonly unstableStickyExpiresAt: number | undefined; + /** * Construct a Matrix Event object * @@ -449,6 +460,13 @@ export class MatrixEvent extends TypedEventEmitter removed.includes(e));` + * for a list of all new events use: + * `const addedNew = added.filter(e => !removed.includes(e));` + * for a list of all removed events use: + * `const removedOnly = removed.filter(e => !added.includes(e));` + * @param added - The events that were added to the map of sticky events (can be updated events for existing keys or new keys) + * @param removed - The events that were removed from the map of sticky events (caused by expiration or updated keys) + */ + [RoomStickyEventsEvent.Update]: (added: MatrixEvent[], removed: MatrixEvent[]) => void; +}; + +export class RoomStickyEvents extends TypedEventEmitter { + private stickyEventsMap = new Map>(); // stickyKey+userId -> events + private stickyEventTimer?: NodeJS.Timeout; + private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER; + private unkeyedStickyEvents = new Set(); + + public constructor() { + super(); + } + + // eslint-disable-next-line + public *_unstable_getStickyEvents(): Iterable { + yield* this.unkeyedStickyEvents; + for (const element of this.stickyEventsMap.values()) { + yield* element; + } + } + + /** + * Adds a sticky event into the local sticky event map. + * + * NOTE: This will not cause `RoomEvent.StickyEvents` to be emitted. + * + * @throws If the `event` does not contain valid sticky data. + * @param event The MatrixEvent that contains sticky data. + * @returns An object describing whether the event was added to the map, + * and the previous event it may have replaced. + */ + // eslint-disable-next-line + public _unstable_addStickyEvent(event: MatrixEvent): { added: true; prevEvent?: MatrixEvent } | { added: false } { + const stickyKey = event.getContent().msc4354_sticky_key; + if (typeof stickyKey !== "string" && stickyKey !== undefined) { + throw Error(`${event.getId()} is missing msc4354_sticky_key`); + } + const expiresAtTs = event.unstableStickyExpiresAt; + // With this we have the guarantee, that all events in stickyEventsMap are correctly formatted + if (expiresAtTs === undefined) { + throw Error(`${event.getId()} is missing msc4354_sticky.duration_ms`); + } + const sender = event.getSender(); + if (!sender) { + throw Error(`${event.getId()} is missing a sender`); + } else if (expiresAtTs <= Date.now()) { + logger.info("ignored sticky event with older expiration time than current time", stickyKey); + return { added: false }; + } + + // While we fully expect the server to always provide the correct value, + // this is just insurance to protect against attacks on our Map. + if (!sender.startsWith("@")) { + throw Error("Expected sender to start with @"); + } + + let prevEvent: MatrixEvent | undefined; + if (stickyKey) { + // Why this is safe: + // A type may contain anything but the *sender* is tightly + // constrained so that a key will always end with a @ + // E.g. Where a malicous event type might be "rtc.member.event@foo:bar" the key becomes: + // "rtc.member.event.@foo:bar@bar:baz" + const mapKey = `${stickyKey}${sender}`; + const prevEvent = this.stickyEventsMap + .get(mapKey) + ?.find((ev) => ev.getContent().msc4354_sticky_key === stickyKey); + + // sticky events are not allowed to expire sooner than their predecessor. + if (prevEvent && event.unstableStickyExpiresAt! < prevEvent.unstableStickyExpiresAt!) { + logger.info("ignored sticky event with older expiry time", stickyKey); + return { added: false }; + } else if ( + prevEvent && + event.getTs() === prevEvent.getTs() && + (event.getId() ?? "") < (prevEvent.getId() ?? "") + ) { + // This path is unlikely, as it requires both events to have the same TS. + logger.info("ignored sticky event due to 'id tie break rule' on sticky_key", stickyKey); + return { added: false }; + } + this.stickyEventsMap.set(mapKey, [ + ...(this.stickyEventsMap.get(mapKey)?.filter((ev) => ev !== prevEvent) ?? []), + event, + ]); + } else { + this.unkeyedStickyEvents.add(event); + } + + // Recalculate the next expiry time. + this.nextStickyEventExpiryTs = Math.min(expiresAtTs, this.nextStickyEventExpiryTs); + + // Schedule this in the background + setTimeout(() => this.scheduleStickyTimer(), 1); + return { added: true, prevEvent }; + } + + /** + * Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any + * changes were made. + * @param events A set of new sticky events. + */ + // eslint-disable-next-line + public _unstable_AddStickyEvents(events: MatrixEvent[]): void { + const added = []; + const removed = []; + for (const e of events) { + try { + const result = this._unstable_addStickyEvent(e); + if (result.added) { + added.push(e); + if (result.prevEvent) { + removed.push(result.prevEvent); + } + } + } catch (ex) { + logger.warn("ignored invalid sticky event", ex); + } + } + if (added.length) this.emit(RoomStickyEventsEvent.Update, added, removed); + this.scheduleStickyTimer(); + } + + /** + * Schedule the sticky event expiry timer. The timer will + * run immediately if an event has already expired. + */ + private scheduleStickyTimer(): void { + if (this.stickyEventTimer) { + clearTimeout(this.stickyEventTimer); + this.stickyEventTimer = undefined; + } + if (this.nextStickyEventExpiryTs === Number.MAX_SAFE_INTEGER) { + // We have no events due to expire. + return; + } else if (Date.now() > this.nextStickyEventExpiryTs) { + // Event has ALREADY expired, so run immediately. + this.cleanExpiredStickyEvents(); + return; + } // otherwise, schedule in the future + this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, this.nextStickyEventExpiryTs - Date.now()); + } + + /** + * Clean out any expired sticky events. + */ + private cleanExpiredStickyEvents = (): void => { + //logger.info('Running event expiry'); + const now = Date.now(); + const removedEvents: MatrixEvent[] = []; + + // We will recalculate this as we check all events. + this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER; + for (const [mapKey, events] of this.stickyEventsMap.entries()) { + for (const event of events) { + const expiresAtTs = event.unstableStickyExpiresAt; + if (!expiresAtTs) { + // We will have checked this already, but just for type safety skip this. + logger.error("Should not have an event with a missing duration_ms!"); + removedEvents.push(event); + break; + } + // we only added items with `sticky` into this map so we can assert non-null here + if (now >= expiresAtTs) { + logger.debug("Expiring sticky event", event.getId()); + removedEvents.push(event); + } else { + // If not removing the event, check to see if it's the next lowest expiry. + this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, expiresAtTs); + } + } + const newEventSet = events.filter((ev) => !removedEvents.includes(ev)); + if (newEventSet.length) { + this.stickyEventsMap.set(mapKey, newEventSet); + } else { + this.stickyEventsMap.delete(mapKey); + } + } + for (const event of this.unkeyedStickyEvents) { + const expiresAtTs = event.unstableStickyExpiresAt; + if (!expiresAtTs) { + // We will have checked this already, but just for type safety skip this. + logger.error("Should not have an event with a missing duration_ms!"); + removedEvents.push(event); + break; + } + if (now >= expiresAtTs) { + logger.debug("Expiring sticky event", event.getId()); + this.unkeyedStickyEvents.delete(event); + removedEvents.push(event); + } else { + // If not removing the event, check to see if it's the next lowest expiry. + this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, expiresAtTs); + } + } + if (removedEvents.length) { + this.emit(RoomStickyEventsEvent.Update, [], removedEvents); + } + // Finally, schedule the next run. + this.scheduleStickyTimer(); + }; + + /** + * Clear all events and stop the timer from firing. + */ + public clear(): void { + this.stickyEventsMap.clear(); + // Unschedule timer. + this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER; + this.scheduleStickyTimer(); + } +} diff --git a/src/models/room.ts b/src/models/room.ts index 6cdfaa39a7c..e72438813fb 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts"; import { KnownMembership, type Membership } from "../@types/membership.ts"; import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts"; import { type MSC4186Hero } from "../sliding-sync.ts"; +import { RoomStickyEvents, RoomStickyEventsEvent } from "./room-sticky-events.ts"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -158,6 +159,7 @@ export enum RoomEvent { HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline", UnreadNotifications = "Room.UnreadNotifications", Summary = "Room.Summary", + StickyEvents = "Room.StickyEvents", } export type RoomEmittedEvents = @@ -311,6 +313,19 @@ export type RoomEventHandlerMap = { * @param summary - the room summary object */ [RoomEvent.Summary]: (summary: IRoomSummary) => void; + /** + * Fires when sticky events are updated for a room. + * For a list of all updated events use: + * `const updated = added.filter(e => removed.includes(e));` + * for a list of all new events use: + * `const addedNew = added.filter(e => !removed.includes(e));` + * for a list of all removed events use: + * `const removedOnly = removed.filter(e => !added.includes(e));` + * @param added - The events that were added to the map of sticky events (can be updated events for existing keys or new keys) + * @param removed - The events that were removed from the map of sticky events (caused by expiration or updated keys) + * @param room - The room containing the sticky events + */ + [RoomEvent.StickyEvents]: (added: MatrixEvent[], removed: MatrixEvent[], room: Room) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; /** * Fires when a new poll instance is added to the room state @@ -446,6 +461,11 @@ export class Room extends ReadReceipt { */ private roomReceipts = new RoomReceipts(this); + /** + * Stores and tracks sticky events + */ + private stickyEvents = new RoomStickyEvents(); + /** * Construct a new Room. * @@ -493,6 +513,10 @@ export class Room extends ReadReceipt { // receipts. No need to remove the listener: it's on ourself anyway. this.on(RoomEvent.Receipt, this.onReceipt); + this.stickyEvents.on(RoomStickyEventsEvent.Update, (added, removed) => + this.emit(RoomEvent.StickyEvents, added, removed, this), + ); + // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. this.timelineSets = [new EventTimelineSet(this, opts)]; @@ -3414,6 +3438,24 @@ export class Room extends ReadReceipt { return this.accountData.get(type); } + /** + * Get an iterator of currently active sticky events. + */ + // eslint-disable-next-line + public _unstable_getStickyEvents(): ReturnType { + return this.stickyEvents._unstable_getStickyEvents(); + } + + /** + * Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any + * changes were made. + * @param events A set of new sticky events. + */ + // eslint-disable-next-line + public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType { + return this.stickyEvents._unstable_AddStickyEvents(events); + } + /** * Returns whether the syncing user has permission to send a message in the room * @returns true if the user should be permitted to send diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 816b45de7e9..1e0c668512b 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -76,11 +76,24 @@ export interface ITimeline { prev_batch: string | null; } +export interface IStickyEvent extends IRoomEvent { + msc4354_sticky: { duration_ms: number }; +} + +export interface IStickyStateEvent extends IStateEvent { + msc4354_sticky: { duration_ms: number }; +} + +export interface ISticky { + events: Array; +} + export interface IJoinedRoom { "summary": IRoomSummary; // One of `state` or `state_after` is required. "state"?: IState; "org.matrix.msc4222.state_after"?: IState; // https://github.com/matrix-org/matrix-spec-proposals/pull/4222 + "msc4354_sticky"?: ISticky; // https://github.com/matrix-org/matrix-spec-proposals/pull/4354 "timeline": ITimeline; "ephemeral": IEphemeral; "account_data": IAccountData; @@ -201,6 +214,7 @@ interface IRoom { _unreadNotifications: Partial; _unreadThreadNotifications?: Record>; _receipts: ReceiptAccumulator; + _stickyEvents: (IStickyEvent | IStickyStateEvent)[]; } export interface ISyncData { @@ -457,6 +471,7 @@ export class SyncAccumulator { _unreadThreadNotifications: {}, _summary: {}, _receipts: new ReceiptAccumulator(), + _stickyEvents: [], }; } const currentData = this.joinRooms[roomId]; @@ -540,6 +555,15 @@ export class SyncAccumulator { }); }); + // We want this to be fast, so don't worry about clobbering events here. + if (data.msc4354_sticky?.events) { + currentData._stickyEvents = currentData._stickyEvents.concat(data.msc4354_sticky?.events); + } + // But always prune any stale events, as we don't need to keep those in storage. + currentData._stickyEvents = currentData._stickyEvents.filter((ev) => { + return Date.now() < ev.msc4354_sticky.duration_ms + ev.origin_server_ts; + }); + // attempt to prune the timeline by jumping between events which have // pagination tokens. if (currentData._timeline.length > this.opts.maxTimelineEntries!) { @@ -611,6 +635,11 @@ export class SyncAccumulator { "unread_notifications": roomData._unreadNotifications, "unread_thread_notifications": roomData._unreadThreadNotifications, "summary": roomData._summary as IRoomSummary, + "msc4354_sticky": roomData._stickyEvents?.length + ? { + events: roomData._stickyEvents, + } + : undefined, }; // Add account data Object.keys(roomData._accountData).forEach((evType) => { diff --git a/src/sync.ts b/src/sync.ts index 4cc23c0a18a..9bc547ea25c 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1082,6 +1082,8 @@ export class SyncApi { // highlight_count: 0, // notification_count: 0, // } + // "org.matrix.msc4222.state_after": { events: [] }, // only if "org.matrix.msc4222.use_state_after" is true + // msc4354_sticky: { events: [] }, // only if "org.matrix.msc4354.sticky" is true // } // }, // leave: { @@ -1219,6 +1221,7 @@ export class SyncApi { const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false); const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral); const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data); + const stickyEvents = this.mapSyncEventsFormat(joinObj.msc4354_sticky); // If state_after is present, this is the events that form the state at the end of the timeline block and // regular timeline events do *not* count towards state. If it's not present, then the state is formed by @@ -1402,6 +1405,14 @@ export class SyncApi { // we deliberately don't add accountData to the timeline room.addAccountData(accountDataEvents); + // events from the sticky section of the sync come first (those are the ones that would be skipped due to gappy syncs) + // hence we consider them as older. + // and we add the events from the timeline at the end (newer) + const stickyEventsAndStickyEventsFromTheTimeline = stickyEvents.concat( + timelineEvents.filter((e) => e.unstableStickyContent !== undefined), + ); + room._unstable_addStickyEvents(stickyEventsAndStickyEventsFromTheTimeline); + room.recalculate(); if (joinObj.isBrandNewRoom) { client.store.storeRoom(room); @@ -1411,11 +1422,19 @@ export class SyncApi { this.processEventsForNotifs(room, timelineEvents); const emitEvent = (e: MatrixEvent): boolean => client.emit(ClientEvent.Event, e); + // this fires a couple of times for some events. (eg state events are in the timeline and the state) + // should this get a sync section as an additional event emission param (e, syncSection))? stateEvents.forEach(emitEvent); timelineEvents.forEach(emitEvent); ephemeralEvents.forEach(emitEvent); accountDataEvents.forEach(emitEvent); - + stickyEvents + .filter( + (e) => + // Ensure we do not emit twice. + !timelineEvents.some((te) => te.getId() === e.getId()), + ) + .forEach(emitEvent); // Decrypt only the last message in all rooms to make sure we can generate a preview // And decrypt all events after the recorded read receipt to ensure an accurate // notification count From aef07fc7a3ef82549ac956d28891f50eb20f322c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 1 Oct 2025 12:14:30 +0100 Subject: [PATCH 02/15] Renames --- spec/unit/models/room-sticky-events.spec.ts | 38 ++++++++++----------- src/models/room-sticky-events.ts | 2 +- src/models/room.ts | 4 +-- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index 415c74960dd..37d57fdd06f 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -28,26 +28,26 @@ describe("RoomStickyEvents", () => { describe("addStickyEvents", () => { it("should allow adding an event without a msc4354_sticky_key", () => { - stickyEvents.unstableAddStickyEvent(new MatrixEvent({ ...stickyEvent, content: {} })); + stickyEvents._unstable_addStickyEvent(new MatrixEvent({ ...stickyEvent, content: {} })); }); it("should not allow adding an event without a msc4354_sticky property", () => { expect(() => - stickyEvents.unstableAddStickyEvent(new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })), + stickyEvents._unstable_addStickyEvent(new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })), ).toThrow(`${stickyEvent.event_id} is missing msc4354_sticky.duration_ms`); expect(() => - stickyEvents.unstableAddStickyEvent( + stickyEvents._unstable_addStickyEvent( new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }), ), ).toThrow(`${stickyEvent.event_id} is missing msc4354_sticky.duration_ms`); }); it("should not allow adding an event without a sender", () => { expect(() => - stickyEvents.unstableAddStickyEvent(new MatrixEvent({ ...stickyEvent, sender: undefined })), + stickyEvents._unstable_addStickyEvent(new MatrixEvent({ ...stickyEvent, sender: undefined })), ).toThrow(`${stickyEvent.event_id} is missing a sender`); }); it("should ignore old events", () => { expect( - stickyEvents.unstableAddStickyEvent( + stickyEvents._unstable_addStickyEvent( new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, @@ -60,14 +60,14 @@ describe("RoomStickyEvents", () => { }); it("should not replace newer events", () => { expect( - stickyEvents.unstableAddStickyEvent( + stickyEvents._unstable_addStickyEvent( new MatrixEvent({ ...stickyEvent, }), ), ).toEqual({ added: true }); expect( - stickyEvents.unstableAddStickyEvent( + stickyEvents._unstable_addStickyEvent( new MatrixEvent({ ...stickyEvent, origin_server_ts: 1, @@ -77,14 +77,14 @@ describe("RoomStickyEvents", () => { }); it("should not replace events on ID tie break", () => { expect( - stickyEvents.unstableAddStickyEvent( + stickyEvents._unstable_addStickyEvent( new MatrixEvent({ ...stickyEvent, }), ), ).toEqual({ added: true }); expect( - stickyEvents.unstableAddStickyEvent( + stickyEvents._unstable_addStickyEvent( new MatrixEvent({ ...stickyEvent, event_id: "$abc:bar", @@ -94,7 +94,7 @@ describe("RoomStickyEvents", () => { }); it("should be able to just add an event", () => { expect( - stickyEvents.unstableAddStickyEvent( + stickyEvents._unstable_addStickyEvent( new MatrixEvent({ ...stickyEvent, }), @@ -103,14 +103,14 @@ describe("RoomStickyEvents", () => { }); }); - describe("unstableAddStickyEvents", () => { + describe("_unstable_addStickyEvents(", () => { it("should emit when a new sticky event is added", () => { const emitSpy = jest.fn(); stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); const ev = new MatrixEvent({ ...stickyEvent, }); - stickyEvents.unstableAddStickyEvents([ev]); + stickyEvents._unstable_addStickyEvents(([ev])); expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); expect(emitSpy).toHaveBeenCalledWith([ev], []); }); @@ -121,7 +121,7 @@ describe("RoomStickyEvents", () => { ...stickyEvent, content: {}, }); - stickyEvents.unstableAddStickyEvents([ev]); + stickyEvents._unstable_addStickyEvents(([ev])); expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); expect(emitSpy).toHaveBeenCalledWith([ev], []); }); @@ -135,7 +135,7 @@ describe("RoomStickyEvents", () => { const ev = new MatrixEvent({ ...stickyEvent, }); - stickyEvents.unstableAddStickyEvent( + stickyEvents._unstable_addStickyEvent( new MatrixEvent({ ...stickyEvent, }), @@ -153,8 +153,8 @@ describe("RoomStickyEvents", () => { msc4354_sticky_key: "bibble", }, }); - stickyEvents.unstableAddStickyEvent(ev); - stickyEvents.unstableAddStickyEvent(ev2); + stickyEvents._unstable_addStickyEvent(ev); + stickyEvents._unstable_addStickyEvent(ev2); expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev, ev2]); }); }); @@ -175,7 +175,7 @@ describe("RoomStickyEvents", () => { ...stickyEvent, origin_server_ts: Date.now(), }); - stickyEvents.unstableAddStickyEvent(ev); + stickyEvents._unstable_addStickyEvent(ev); jest.setSystemTime(15000); jest.advanceTimersByTime(15000); expect(emitSpy).toHaveBeenCalledWith([], [ev]); @@ -197,7 +197,7 @@ describe("RoomStickyEvents", () => { }, origin_server_ts: 0, }); - stickyEvents.unstableAddStickyEvents([ev1, ev2]); + stickyEvents._unstable_addStickyEvents(([ev1, ev2])); expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], []); jest.setSystemTime(15000); jest.advanceTimersByTime(15000); @@ -212,7 +212,7 @@ describe("RoomStickyEvents", () => { content: {}, origin_server_ts: Date.now(), }); - stickyEvents.unstableAddStickyEvent(ev); + stickyEvents._unstable_addStickyEvent(ev); jest.setSystemTime(15000); jest.advanceTimersByTime(15000); expect(emitSpy).toHaveBeenCalledWith([], [ev]); diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 3c8f9174d80..d79323b86e3 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -123,7 +123,7 @@ export class RoomStickyEvents extends TypedEventEmitter { * @param events A set of new sticky events. */ // eslint-disable-next-line - public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType { - return this.stickyEvents._unstable_AddStickyEvents(events); + public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType { + return this.stickyEvents._unstable_addStickyEvents(events); } /** From dc69e8bccff30b856e4497aad165e338657a7449 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 1 Oct 2025 12:15:30 +0100 Subject: [PATCH 03/15] lint --- spec/unit/models/room-sticky-events.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index 37d57fdd06f..aa0efd51e80 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -110,7 +110,7 @@ describe("RoomStickyEvents", () => { const ev = new MatrixEvent({ ...stickyEvent, }); - stickyEvents._unstable_addStickyEvents(([ev])); + stickyEvents._unstable_addStickyEvents([ev]); expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); expect(emitSpy).toHaveBeenCalledWith([ev], []); }); @@ -121,7 +121,7 @@ describe("RoomStickyEvents", () => { ...stickyEvent, content: {}, }); - stickyEvents._unstable_addStickyEvents(([ev])); + stickyEvents._unstable_addStickyEvents([ev]); expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); expect(emitSpy).toHaveBeenCalledWith([ev], []); }); @@ -197,7 +197,7 @@ describe("RoomStickyEvents", () => { }, origin_server_ts: 0, }); - stickyEvents._unstable_addStickyEvents(([ev1, ev2])); + stickyEvents._unstable_addStickyEvents([ev1, ev2]); expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], []); jest.setSystemTime(15000); jest.advanceTimersByTime(15000); From 437a37adec930d9e9d0d06c52e8e301fc4e1ddff Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 1 Oct 2025 22:12:41 +0100 Subject: [PATCH 04/15] some review work --- spec/unit/models/room-sticky-events.spec.ts | 134 ++++++++------------ src/client.ts | 19 ++- src/models/event.ts | 10 +- src/models/room-sticky-events.ts | 31 ++++- src/models/room.ts | 12 +- src/sync-accumulator.ts | 13 +- src/sync.ts | 2 +- 7 files changed, 109 insertions(+), 112 deletions(-) diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index aa0efd51e80..09c15f1bb7d 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -1,8 +1,8 @@ import { type IEvent, MatrixEvent } from "../../../src"; -import { RoomStickyEvents, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events"; +import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events"; describe("RoomStickyEvents", () => { - let stickyEvents: RoomStickyEvents; + let stickyEvents: RoomStickyEventsStore; const stickyEvent: IEvent = { event_id: "$foo:bar", room_id: "!roomId", @@ -19,7 +19,7 @@ describe("RoomStickyEvents", () => { }; beforeEach(() => { - stickyEvents = new RoomStickyEvents(); + stickyEvents = new RoomStickyEventsStore(); }); afterEach(() => { @@ -28,78 +28,53 @@ describe("RoomStickyEvents", () => { describe("addStickyEvents", () => { it("should allow adding an event without a msc4354_sticky_key", () => { - stickyEvents._unstable_addStickyEvent(new MatrixEvent({ ...stickyEvent, content: {} })); + stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(1); }); it("should not allow adding an event without a msc4354_sticky property", () => { - expect(() => - stickyEvents._unstable_addStickyEvent(new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })), - ).toThrow(`${stickyEvent.event_id} is missing msc4354_sticky.duration_ms`); - expect(() => - stickyEvents._unstable_addStickyEvent( - new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }), - ), - ).toThrow(`${stickyEvent.event_id} is missing msc4354_sticky.duration_ms`); + stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + stickyEvents.addStickyEvents([ + new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }), + ]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); }); it("should not allow adding an event without a sender", () => { - expect(() => - stickyEvents._unstable_addStickyEvent(new MatrixEvent({ ...stickyEvent, sender: undefined })), - ).toThrow(`${stickyEvent.event_id} is missing a sender`); + stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); }); it("should ignore old events", () => { - expect( - stickyEvents._unstable_addStickyEvent( - new MatrixEvent({ - ...stickyEvent, - origin_server_ts: 0, - msc4354_sticky: { - duration_ms: 1, - }, - }), - ), - ).toEqual({ added: false }); + stickyEvents.addStickyEvents([ + new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }), + ]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + }); + it("should be able to just add an event", () => { + const originalEv = new MatrixEvent({ ...stickyEvent }); + stickyEvents.addStickyEvents([originalEv]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); }); it("should not replace newer events", () => { - expect( - stickyEvents._unstable_addStickyEvent( - new MatrixEvent({ - ...stickyEvent, - }), - ), - ).toEqual({ added: true }); - expect( - stickyEvents._unstable_addStickyEvent( - new MatrixEvent({ - ...stickyEvent, - origin_server_ts: 1, - }), - ), - ).toEqual({ added: false }); + const originalEv = new MatrixEvent({ ...stickyEvent }); + stickyEvents.addStickyEvents([originalEv]); + stickyEvents.addStickyEvents([ + new MatrixEvent({ + ...stickyEvent, + origin_server_ts: 1, + }), + ]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); }); it("should not replace events on ID tie break", () => { - expect( - stickyEvents._unstable_addStickyEvent( - new MatrixEvent({ - ...stickyEvent, - }), - ), - ).toEqual({ added: true }); - expect( - stickyEvents._unstable_addStickyEvent( - new MatrixEvent({ - ...stickyEvent, - event_id: "$abc:bar", - }), - ), - ).toEqual({ added: false }); - }); - it("should be able to just add an event", () => { - expect( - stickyEvents._unstable_addStickyEvent( - new MatrixEvent({ - ...stickyEvent, - }), - ), - ).toEqual({ added: true }); + const originalEv = new MatrixEvent({ ...stickyEvent }); + stickyEvents.addStickyEvents([originalEv]); + stickyEvents.addStickyEvents([ + new MatrixEvent({ + ...stickyEvent, + event_id: "$abc:bar", + }), + ]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); }); }); @@ -110,8 +85,8 @@ describe("RoomStickyEvents", () => { const ev = new MatrixEvent({ ...stickyEvent, }); - stickyEvents._unstable_addStickyEvents([ev]); - expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); + stickyEvents.addStickyEvents([ev]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); expect(emitSpy).toHaveBeenCalledWith([ev], []); }); it("should emit when a new unketed sticky event is added", () => { @@ -121,26 +96,22 @@ describe("RoomStickyEvents", () => { ...stickyEvent, content: {}, }); - stickyEvents._unstable_addStickyEvents([ev]); - expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); + stickyEvents.addStickyEvents([ev]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); expect(emitSpy).toHaveBeenCalledWith([ev], []); }); }); describe("getStickyEvents", () => { it("should have zero sticky events", () => { - expect([...stickyEvents._unstable_getStickyEvents()]).toHaveLength(0); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); }); it("should contain a sticky event", () => { const ev = new MatrixEvent({ ...stickyEvent, }); - stickyEvents._unstable_addStickyEvent( - new MatrixEvent({ - ...stickyEvent, - }), - ); - expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev]); + stickyEvents.addStickyEvents([ev]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); }); it("should contain two sticky events", () => { const ev = new MatrixEvent({ @@ -153,9 +124,8 @@ describe("RoomStickyEvents", () => { msc4354_sticky_key: "bibble", }, }); - stickyEvents._unstable_addStickyEvent(ev); - stickyEvents._unstable_addStickyEvent(ev2); - expect([...stickyEvents._unstable_getStickyEvents()]).toEqual([ev, ev2]); + stickyEvents.addStickyEvents([ev, ev2]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]); }); }); @@ -175,7 +145,7 @@ describe("RoomStickyEvents", () => { ...stickyEvent, origin_server_ts: Date.now(), }); - stickyEvents._unstable_addStickyEvent(ev); + stickyEvents.addStickyEvents([ev]); jest.setSystemTime(15000); jest.advanceTimersByTime(15000); expect(emitSpy).toHaveBeenCalledWith([], [ev]); @@ -197,7 +167,7 @@ describe("RoomStickyEvents", () => { }, origin_server_ts: 0, }); - stickyEvents._unstable_addStickyEvents([ev1, ev2]); + stickyEvents.addStickyEvents([ev1, ev2]); expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], []); jest.setSystemTime(15000); jest.advanceTimersByTime(15000); @@ -212,7 +182,7 @@ describe("RoomStickyEvents", () => { content: {}, origin_server_ts: Date.now(), }); - stickyEvents._unstable_addStickyEvent(ev); + stickyEvents.addStickyEvents([ev]); jest.setSystemTime(15000); jest.advanceTimersByTime(15000); expect(emitSpy).toHaveBeenCalledWith([], [ev]); diff --git a/src/client.ts b/src/client.ts index fd152915fdc..399d01e8cbd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3419,10 +3419,11 @@ export class MatrixClient extends TypedEventEmitter( @@ -3434,6 +3435,12 @@ export class MatrixClient extends TypedEventEmitter { + if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) { + throw new UnsupportedDelayedEventsEndpointError( + "Server does not support the delayed events API", + "getDelayedEvents", + ); + } if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) { throw new UnsupportedStickyEventsEndpointError( "Server does not support the sticky events", @@ -3446,7 +3453,7 @@ export class MatrixClient extends TypedEventEmitter( @@ -3513,7 +3520,7 @@ export class MatrixClient extends TypedEventEmitter void; }; -export class RoomStickyEvents extends TypedEventEmitter { +/** + * Tracks sticky events on behalf of one room, and fires an event + * whenever a sticky even is updated or replaced. + */ +export class RoomStickyEventsStore extends TypedEventEmitter { private stickyEventsMap = new Map>(); // stickyKey+userId -> events private stickyEventTimer?: NodeJS.Timeout; private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER; @@ -33,14 +37,28 @@ export class RoomStickyEvents extends TypedEventEmitter { + public *getStickyEvents(): Iterable { yield* this.unkeyedStickyEvents; for (const element of this.stickyEventsMap.values()) { yield* element; } } + /** + * Get all sticky events that match a `sender` and `stickyKey` + * @param sender The sender of the sticky event. + * @param stickyKey The sticky key used by the event. + * @returns An iterable set of events. + */ + public getStickyEventsBySenderAndKey(sender: string, stickyKey: string): Iterable { + return this.stickyEventsMap.get(`${stickyKey}${sender}`) ?? []; + } + /** * Adds a sticky event into the local sticky event map. * @@ -52,7 +70,7 @@ export class RoomStickyEvents extends TypedEventEmitter this.scheduleStickyTimer(), 1); + this.scheduleStickyTimer(); return { added: true, prevEvent }; } @@ -123,12 +140,12 @@ export class RoomStickyEvents extends TypedEventEmitter { /** * Stores and tracks sticky events */ - private stickyEvents = new RoomStickyEvents(); + private stickyEvents = new RoomStickyEventsStore(); /** * Construct a new Room. @@ -3442,8 +3442,8 @@ export class Room extends ReadReceipt { * Get an iterator of currently active sticky events. */ // eslint-disable-next-line - public _unstable_getStickyEvents(): ReturnType { - return this.stickyEvents._unstable_getStickyEvents(); + public _unstable_getStickyEvents(): ReturnType { + return this.stickyEvents.getStickyEvents(); } /** @@ -3452,8 +3452,8 @@ export class Room extends ReadReceipt { * @param events A set of new sticky events. */ // eslint-disable-next-line - public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType { - return this.stickyEvents._unstable_addStickyEvents(events); + public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType { + return this.stickyEvents.addStickyEvents(events); } /** diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 1e0c668512b..f5c2fbb7d07 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -555,15 +555,18 @@ export class SyncAccumulator { }); }); - // We want this to be fast, so don't worry about clobbering events here. - if (data.msc4354_sticky?.events) { - currentData._stickyEvents = currentData._stickyEvents.concat(data.msc4354_sticky?.events); - } - // But always prune any stale events, as we don't need to keep those in storage. + // Prune out any events in our stores that have since expired, do this before we + // insert new events. currentData._stickyEvents = currentData._stickyEvents.filter((ev) => { return Date.now() < ev.msc4354_sticky.duration_ms + ev.origin_server_ts; }); + // We want this to be fast, so don't worry about duplicate events here. The RoomStickyEventsStore will + // process these events into the correct mapped order. + if (data.msc4354_sticky?.events) { + currentData._stickyEvents = currentData._stickyEvents.concat(data.msc4354_sticky?.events); + } + // attempt to prune the timeline by jumping between events which have // pagination tokens. if (currentData._timeline.length > this.opts.maxTimelineEntries!) { diff --git a/src/sync.ts b/src/sync.ts index 9bc547ea25c..9cbf35d1e8c 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1409,7 +1409,7 @@ export class SyncApi { // hence we consider them as older. // and we add the events from the timeline at the end (newer) const stickyEventsAndStickyEventsFromTheTimeline = stickyEvents.concat( - timelineEvents.filter((e) => e.unstableStickyContent !== undefined), + timelineEvents.filter((e) => e.unstableStickyInfo !== undefined), ); room._unstable_addStickyEvents(stickyEventsAndStickyEventsFromTheTimeline); From 831a87e7fa819c27613d31539be08ac48d16e929 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 2 Oct 2025 09:19:15 +0100 Subject: [PATCH 05/15] Update for support for 4-ples --- spec/unit/models/room-sticky-events.spec.ts | 9 ++++ src/models/room-sticky-events.ts | 49 +++++++++------------ 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index 09c15f1bb7d..63df52761da 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -76,6 +76,15 @@ describe("RoomStickyEvents", () => { ]); expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); }); + it("should allow multiple events with the same sticky key for different event types", () => { + const originalEv = new MatrixEvent({ ...stickyEvent }); + const anotherEv = new MatrixEvent({ + ...stickyEvent, + type: "org.example.another_type", + }); + stickyEvents.addStickyEvents([originalEv, anotherEv]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]); + }); }); describe("_unstable_addStickyEvents(", () => { diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 468fb6b633e..7a95234cadb 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -28,7 +28,7 @@ export type RoomStickyEventsMap = { * whenever a sticky even is updated or replaced. */ export class RoomStickyEventsStore extends TypedEventEmitter { - private stickyEventsMap = new Map>(); // stickyKey+userId -> events + private stickyEventsMap = new Map>(); // (type -> stickyKey+userId) -> event private stickyEventTimer?: NodeJS.Timeout; private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER; private unkeyedStickyEvents = new Set(); @@ -44,19 +44,20 @@ export class RoomStickyEventsStore extends TypedEventEmitter { yield* this.unkeyedStickyEvents; - for (const element of this.stickyEventsMap.values()) { - yield* element; + for (const innerMap of this.stickyEventsMap.values()) { + yield* innerMap.values(); } } /** - * Get all sticky events that match a `sender` and `stickyKey` + * Get a sticky event that match the given `type`, `sender`, and `stickyKey` + * @param type The event `type`. * @param sender The sender of the sticky event. * @param stickyKey The sticky key used by the event. - * @returns An iterable set of events. + * @returns A matching active sticky event, or undefined. */ - public getStickyEventsBySenderAndKey(sender: string, stickyKey: string): Iterable { - return this.stickyEventsMap.get(`${stickyKey}${sender}`) ?? []; + public getStickyEvent(sender: string, stickyKey: string, type: string): MatrixEvent | undefined { + return this.stickyEventsMap.get("type")?.get(`${stickyKey}${sender}`); } /** @@ -75,15 +76,16 @@ export class RoomStickyEventsStore extends TypedEventEmitter // E.g. Where a malicous event type might be "rtc.member.event@foo:bar" the key becomes: // "rtc.member.event.@foo:bar@bar:baz" - const mapKey = `${stickyKey}${sender}`; - const prevEvent = this.stickyEventsMap - .get(mapKey) - ?.find((ev) => ev.getContent().msc4354_sticky_key === stickyKey); + const innerMapKey = `${stickyKey}${sender}`; + const prevEvent = this.stickyEventsMap.get(type)?.get(innerMapKey); // sticky events are not allowed to expire sooner than their predecessor. if (prevEvent && event.unstableStickyExpiresAt! < prevEvent.unstableStickyExpiresAt!) { @@ -119,16 +119,16 @@ export class RoomStickyEventsStore extends TypedEventEmitter ev !== prevEvent) ?? []), - event, - ]); + if (!this.stickyEventsMap.has(type)) { + this.stickyEventsMap.set(type, new Map()); + } + this.stickyEventsMap.get(type)!.set(innerMapKey, event); } else { this.unkeyedStickyEvents.add(event); } // Recalculate the next expiry time. - this.nextStickyEventExpiryTs = Math.min(expiresAtTs, this.nextStickyEventExpiryTs); + this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs); this.scheduleStickyTimer(); return { added: true, prevEvent }; @@ -190,8 +190,8 @@ export class RoomStickyEventsStore extends TypedEventEmitter= expiresAtTs) { logger.debug("Expiring sticky event", event.getId()); removedEvents.push(event); + this.stickyEventsMap.get(eventType)!.delete(innerMapKey); } else { // If not removing the event, check to see if it's the next lowest expiry. this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, expiresAtTs); } } - const newEventSet = events.filter((ev) => !removedEvents.includes(ev)); - if (newEventSet.length) { - this.stickyEventsMap.set(mapKey, newEventSet); - } else { - this.stickyEventsMap.delete(mapKey); - } } for (const event of this.unkeyedStickyEvents) { const expiresAtTs = event.unstableStickyExpiresAt; From bec246de916345ce4bb69d102a9598bd2cc638ff Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 2 Oct 2025 10:04:35 +0100 Subject: [PATCH 06/15] fix lint --- spec/unit/sync-accumulator.spec.ts | 4 ++-- src/sync-accumulator.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 69ce5b6bc69..e6ea66fd15d 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -1101,13 +1101,13 @@ describe("SyncAccumulator", function () { ); expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]); }); - it("should clear stale sticky events", () => { + it.only("should clear stale sticky events", () => { jest.setSystemTime(1000); const ev = stickyEvent(1000); sa.accumulate( syncSkeleton({ msc4354_sticky: { - events: [ev, stickyEvent(0)], + events: [ev], }, }), ); diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index f5c2fbb7d07..0e561bfa12b 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -558,7 +558,7 @@ export class SyncAccumulator { // Prune out any events in our stores that have since expired, do this before we // insert new events. currentData._stickyEvents = currentData._stickyEvents.filter((ev) => { - return Date.now() < ev.msc4354_sticky.duration_ms + ev.origin_server_ts; + return Date.now() > ev.msc4354_sticky.duration_ms + ev.origin_server_ts; }); // We want this to be fast, so don't worry about duplicate events here. The RoomStickyEventsStore will From 301957660b0572c4075b607647b000800f21a449 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 2 Oct 2025 12:03:22 +0100 Subject: [PATCH 07/15] pull through method --- src/models/room-sticky-events.ts | 5 +---- src/models/room.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 7a95234cadb..0a004afc046 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -41,7 +41,6 @@ export class RoomStickyEventsStore extends TypedEventEmitter { yield* this.unkeyedStickyEvents; for (const innerMap of this.stickyEventsMap.values()) { @@ -57,7 +56,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter { return this.stickyEvents.getStickyEvents(); } + /** + * Get a sticky event that match the given `type`, `sender`, and `stickyKey` + * @param type The event `type`. + * @param sender The sender of the sticky event. + * @param stickyKey The sticky key used by the event. + * @returns A matching active sticky event, or undefined. + */ + // eslint-disable-next-line + public _unstable_getStickyEvent(sender: string, stickyKey: string, type: string): ReturnType { + return this.stickyEvents.getStickyEvent(sender, stickyKey, type); + } + /** * Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any * changes were made. From dae2f3953abd05145dbf83bb3df258c4fa0d235d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 2 Oct 2025 12:05:19 +0100 Subject: [PATCH 08/15] Fix the mistake --- spec/unit/sync-accumulator.spec.ts | 2 +- src/sync-accumulator.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index e6ea66fd15d..304ab4c4309 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -1101,7 +1101,7 @@ describe("SyncAccumulator", function () { ); expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]); }); - it.only("should clear stale sticky events", () => { + it("should clear stale sticky events", () => { jest.setSystemTime(1000); const ev = stickyEvent(1000); sa.accumulate( diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 0e561bfa12b..f5c2fbb7d07 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -558,7 +558,7 @@ export class SyncAccumulator { // Prune out any events in our stores that have since expired, do this before we // insert new events. currentData._stickyEvents = currentData._stickyEvents.filter((ev) => { - return Date.now() > ev.msc4354_sticky.duration_ms + ev.origin_server_ts; + return Date.now() < ev.msc4354_sticky.duration_ms + ev.origin_server_ts; }); // We want this to be fast, so don't worry about duplicate events here. The RoomStickyEventsStore will From 48a8e3740988ddb3a132154cd4306055b66a0440 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 2 Oct 2025 12:45:51 +0100 Subject: [PATCH 09/15] More tests to appease SC --- spec/unit/models/event.spec.ts | 29 +++++++ spec/unit/models/room-sticky-events.spec.ts | 89 +++++++++++++++++---- src/models/room-sticky-events.ts | 33 +++++--- src/models/room.ts | 22 ++++- src/sync-accumulator.ts | 11 +-- 5 files changed, 149 insertions(+), 35 deletions(-) diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index f32256253ab..1766344cf62 100644 --- a/spec/unit/models/event.spec.ts +++ b/spec/unit/models/event.spec.ts @@ -20,6 +20,7 @@ import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/model import { emitPromise } from "../../test-utils/test-utils"; import { type IAnnotatedPushRule, + type IStickyEvent, type MatrixClient, PushRuleActionName, Room, @@ -598,6 +599,34 @@ describe("MatrixEvent", () => { expect(stateEvent.isState()).toBeTruthy(); expect(stateEvent.threadRootId).toBeUndefined(); }); + + it("should calculate sticky duration correctly", async () => { + const evData: IStickyEvent = { + event_id: "$event_id", + type: "some_state_event", + content: {}, + sender: "@alice:example.org", + origin_server_ts: 50, + msc4354_sticky: { + duration_ms: 1000, + }, + unsigned: { + msc4354_sticky_duration_ttl_ms: 5000, + }, + }; + try { + jest.useFakeTimers(); + jest.setSystemTime(0); + // Prefer unsigned + expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5000); + // Fall back to `duration_ms` + expect( + new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt, + ).toEqual(1050); + } finally { + jest.useRealTimers(); + } + }); }); function mainTimelineLiveEventIds(room: Room): Array { diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index 63df52761da..2a6cae3b901 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -1,9 +1,9 @@ -import { type IEvent, MatrixEvent } from "../../../src"; +import { type IStickyEvent, MatrixEvent } from "../../../src"; import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events"; describe("RoomStickyEvents", () => { let stickyEvents: RoomStickyEventsStore; - const stickyEvent: IEvent = { + const stickyEvent: IStickyEvent = { event_id: "$foo:bar", room_id: "!roomId", type: "org.example.any_type", @@ -43,6 +43,10 @@ describe("RoomStickyEvents", () => { stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]); expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); }); + it("should not allow adding an event with an invalid sender", () => { + stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: "not_a_real_sender" })]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + }); it("should ignore old events", () => { stickyEvents.addStickyEvents([ new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }), @@ -54,28 +58,38 @@ describe("RoomStickyEvents", () => { stickyEvents.addStickyEvents([originalEv]); expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); }); - it("should not replace newer events", () => { + it("should not replace events on ID tie break", () => { const originalEv = new MatrixEvent({ ...stickyEvent }); stickyEvents.addStickyEvents([originalEv]); stickyEvents.addStickyEvents([ new MatrixEvent({ ...stickyEvent, - origin_server_ts: 1, + event_id: "$abc:bar", }), ]); expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); }); - it("should not replace events on ID tie break", () => { + it("should not replace a newer event with an older event", () => { const originalEv = new MatrixEvent({ ...stickyEvent }); stickyEvents.addStickyEvents([originalEv]); stickyEvents.addStickyEvents([ new MatrixEvent({ ...stickyEvent, - event_id: "$abc:bar", + origin_server_ts: 1, }), ]); expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); }); + it("should replace an older event with a newer event", () => { + const originalEv = new MatrixEvent({ ...stickyEvent }); + const newerEv = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: Date.now() + 2000, + }); + stickyEvents.addStickyEvents([originalEv]); + stickyEvents.addStickyEvents([newerEv]); + expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]); + }); it("should allow multiple events with the same sticky key for different event types", () => { const originalEv = new MatrixEvent({ ...stickyEvent }); const anotherEv = new MatrixEvent({ @@ -85,9 +99,7 @@ describe("RoomStickyEvents", () => { stickyEvents.addStickyEvents([originalEv, anotherEv]); expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]); }); - }); - describe("_unstable_addStickyEvents(", () => { it("should emit when a new sticky event is added", () => { const emitSpy = jest.fn(); stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); @@ -98,7 +110,7 @@ describe("RoomStickyEvents", () => { expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); expect(emitSpy).toHaveBeenCalledWith([ev], []); }); - it("should emit when a new unketed sticky event is added", () => { + it("should emit when a new unkeyed sticky event is added", () => { const emitSpy = jest.fn(); stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); const ev = new MatrixEvent({ @@ -138,6 +150,47 @@ describe("RoomStickyEvents", () => { }); }); + describe("getKeyedStickyEvent", () => { + it("should have zero sticky events", () => { + expect( + stickyEvents.getKeyedStickyEvent( + stickyEvent.sender, + stickyEvent.type, + stickyEvent.content.msc4354_sticky_key!, + ), + ).toBeUndefined(); + }); + it("should return a sticky event", () => { + const ev = new MatrixEvent({ + ...stickyEvent, + }); + stickyEvents.addStickyEvents([ev]); + expect( + stickyEvents.getKeyedStickyEvent( + stickyEvent.sender, + stickyEvent.type, + stickyEvent.content.msc4354_sticky_key!, + ), + ).toEqual(ev); + }); + }); + + describe("getUnkeyedStickyEvent", () => { + it("should have zero sticky events", () => { + expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([]); + }); + it("should return a sticky event", () => { + const ev = new MatrixEvent({ + ...stickyEvent, + content: { + msc4354_sticky_key: undefined, + }, + }); + stickyEvents.addStickyEvents([ev]); + expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([ev]); + }); + }); + describe("cleanExpiredStickyEvents", () => { beforeAll(() => { jest.useFakeTimers(); @@ -147,17 +200,25 @@ describe("RoomStickyEvents", () => { }); it("should emit when a sticky event expires", () => { - const emitSpy = jest.fn(); - stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); jest.setSystemTime(0); const ev = new MatrixEvent({ ...stickyEvent, origin_server_ts: Date.now(), }); - stickyEvents.addStickyEvents([ev]); - jest.setSystemTime(15000); + const evLater = new MatrixEvent({ + ...stickyEvent, + event_id: "$baz:bar", + sender: "@bob:example.org", + origin_server_ts: Date.now() + 1000, + }); + stickyEvents.addStickyEvents([ev, evLater]); + const emitSpy = jest.fn(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); jest.advanceTimersByTime(15000); expect(emitSpy).toHaveBeenCalledWith([], [ev]); + // Then expire the next event + jest.advanceTimersByTime(1000); + expect(emitSpy).toHaveBeenCalledWith([], [evLater]); }); it("should emit two events when both expire at the same time", () => { const emitSpy = jest.fn(); @@ -178,7 +239,6 @@ describe("RoomStickyEvents", () => { }); stickyEvents.addStickyEvents([ev1, ev2]); expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], []); - jest.setSystemTime(15000); jest.advanceTimersByTime(15000); expect(emitSpy).toHaveBeenCalledWith([], [ev1, ev2]); }); @@ -192,7 +252,6 @@ describe("RoomStickyEvents", () => { origin_server_ts: Date.now(), }); stickyEvents.addStickyEvents([ev]); - jest.setSystemTime(15000); jest.advanceTimersByTime(15000); expect(emitSpy).toHaveBeenCalledWith([], [ev]); }); diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 0a004afc046..7c58de63ee3 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -28,10 +28,11 @@ export type RoomStickyEventsMap = { * whenever a sticky even is updated or replaced. */ export class RoomStickyEventsStore extends TypedEventEmitter { - private stickyEventsMap = new Map>(); // (type -> stickyKey+userId) -> event + private readonly stickyEventsMap = new Map>(); // (type -> stickyKey+userId) -> event + private unkeyedStickyEvents = new Set(); + private stickyEventTimer?: NodeJS.Timeout; private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER; - private unkeyedStickyEvents = new Set(); public constructor() { super(); @@ -49,16 +50,26 @@ export class RoomStickyEventsStore extends TypedEventEmitter ev.getType() === type && ev.getSender() === sender); + } + /** * Adds a sticky event into the local sticky event map. * @@ -72,17 +83,17 @@ export class RoomStickyEventsStore extends TypedEventEmitter this.nextStickyEventExpiryTs) { - // Event has ALREADY expired, so run immediately. - this.cleanExpiredStickyEvents(); - return; } // otherwise, schedule in the future this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, this.nextStickyEventExpiryTs - Date.now()); } diff --git a/src/models/room.ts b/src/models/room.ts index 6f2b987cf54..df50d2f695d 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -3454,8 +3454,26 @@ export class Room extends ReadReceipt { * @returns A matching active sticky event, or undefined. */ // eslint-disable-next-line - public _unstable_getStickyEvent(sender: string, stickyKey: string, type: string): ReturnType { - return this.stickyEvents.getStickyEvent(sender, stickyKey, type); + public _unstable_getKeyedStickyEvent( + sender: string, + type: string, + stickyKey: string, + ): ReturnType { + return this.stickyEvents.getKeyedStickyEvent(sender, type, stickyKey); + } + + /** + * Get an active sticky events that match the given `type` and `sender`. + * @param type The event `type`. + * @param sender The sender of the sticky event. + * @returns An array of matching sticky events. + */ + // eslint-disable-next-line + public _unstable_getUnkeyedStickyEvent( + sender: string, + type: string, + ): ReturnType { + return this.stickyEvents.getUnkeyedStickyEvent(sender, type); } /** diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index f5c2fbb7d07..8e9a7084b10 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -76,13 +76,14 @@ export interface ITimeline { prev_batch: string | null; } -export interface IStickyEvent extends IRoomEvent { +type StickyEventFields = { msc4354_sticky: { duration_ms: number }; -} + content: IRoomEvent["content"] & { msc4354_sticky_key?: string }; +}; -export interface IStickyStateEvent extends IStateEvent { - msc4354_sticky: { duration_ms: number }; -} +export type IStickyEvent = IRoomEvent & StickyEventFields; + +export type IStickyStateEvent = IStateEvent & StickyEventFields; export interface ISticky { events: Array; From 79aa439f508b260de97b8e9651c5b1552e9f15c6 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Thu, 2 Oct 2025 12:57:35 +0100 Subject: [PATCH 10/15] Cleaner code --- src/models/room-sticky-events.ts | 40 ++++++++++++-------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 7c58de63ee3..04e158f0ad6 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -23,13 +23,15 @@ export type RoomStickyEventsMap = { [RoomStickyEventsEvent.Update]: (added: MatrixEvent[], removed: MatrixEvent[]) => void; }; +type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number }; + /** * Tracks sticky events on behalf of one room, and fires an event * whenever a sticky even is updated or replaced. */ export class RoomStickyEventsStore extends TypedEventEmitter { - private readonly stickyEventsMap = new Map>(); // (type -> stickyKey+userId) -> event - private unkeyedStickyEvents = new Set(); + private readonly stickyEventsMap = new Map>(); // (type -> stickyKey+userId) -> event + private readonly unkeyedStickyEvents = new Set(); private stickyEventTimer?: NodeJS.Timeout; private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER; @@ -116,7 +118,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter { - //logger.info('Running event expiry'); + private readonly cleanExpiredStickyEvents = (): void => { const now = Date.now(); const removedEvents: MatrixEvent[] = []; @@ -196,39 +197,28 @@ export class RoomStickyEventsStore extends TypedEventEmitter= expiresAtTs) { + if (now >= event.unstableStickyExpiresAt) { logger.debug("Expiring sticky event", event.getId()); removedEvents.push(event); this.stickyEventsMap.get(eventType)!.delete(innerMapKey); } else { // If not removing the event, check to see if it's the next lowest expiry. - this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, expiresAtTs); + this.nextStickyEventExpiryTs = Math.min( + this.nextStickyEventExpiryTs, + event.unstableStickyExpiresAt, + ); } } } for (const event of this.unkeyedStickyEvents) { - const expiresAtTs = event.unstableStickyExpiresAt; - if (!expiresAtTs) { - // We will have checked this already, but just for type safety skip this. - logger.error("Should not have an event with a missing duration_ms!"); - removedEvents.push(event); - break; - } - if (now >= expiresAtTs) { + if (now >= event.unstableStickyExpiresAt) { logger.debug("Expiring sticky event", event.getId()); this.unkeyedStickyEvents.delete(event); removedEvents.push(event); } else { // If not removing the event, check to see if it's the next lowest expiry. - this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, expiresAtTs); + this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, event.unstableStickyExpiresAt); } } if (removedEvents.length) { From 7b7f74d0da77d135ecfbbaf6859a6bc3c092aec2 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 3 Oct 2025 13:07:14 +0100 Subject: [PATCH 11/15] Review cleanup --- spec/unit/models/room-sticky-events.spec.ts | 23 +++++++------ src/@types/requests.ts | 24 ++++++------- src/models/room-sticky-events.ts | 38 ++++++++++----------- src/models/room.ts | 29 ++++++++-------- src/sync-accumulator.ts | 6 ++-- src/sync.ts | 18 ++++++---- 6 files changed, 73 insertions(+), 65 deletions(-) diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index 2a6cae3b901..5d84ceb5862 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -3,6 +3,7 @@ import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/model describe("RoomStickyEvents", () => { let stickyEvents: RoomStickyEventsStore; + const emitSpy: jest.Mock = jest.fn(); const stickyEvent: IStickyEvent = { event_id: "$foo:bar", room_id: "!roomId", @@ -19,7 +20,9 @@ describe("RoomStickyEvents", () => { }; beforeEach(() => { + emitSpy.mockReset(); stickyEvents = new RoomStickyEventsStore(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); }); afterEach(() => { @@ -81,14 +84,16 @@ describe("RoomStickyEvents", () => { expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); }); it("should replace an older event with a newer event", () => { - const originalEv = new MatrixEvent({ ...stickyEvent }); + const originalEv = new MatrixEvent({ ...stickyEvent, event_id: "$old" }); const newerEv = new MatrixEvent({ ...stickyEvent, + event_id: "$new", origin_server_ts: Date.now() + 2000, }); stickyEvents.addStickyEvents([originalEv]); stickyEvents.addStickyEvents([newerEv]); expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]); + expect(emitSpy).toHaveBeenCalledWith([], [{ current: newerEv, previous: originalEv }], []); }); it("should allow multiple events with the same sticky key for different event types", () => { const originalEv = new MatrixEvent({ ...stickyEvent }); @@ -101,17 +106,15 @@ describe("RoomStickyEvents", () => { }); it("should emit when a new sticky event is added", () => { - const emitSpy = jest.fn(); stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); const ev = new MatrixEvent({ ...stickyEvent, }); stickyEvents.addStickyEvents([ev]); expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); - expect(emitSpy).toHaveBeenCalledWith([ev], []); + expect(emitSpy).toHaveBeenCalledWith([ev], [], []); }); it("should emit when a new unkeyed sticky event is added", () => { - const emitSpy = jest.fn(); stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); const ev = new MatrixEvent({ ...stickyEvent, @@ -119,7 +122,7 @@ describe("RoomStickyEvents", () => { }); stickyEvents.addStickyEvents([ev]); expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); - expect(emitSpy).toHaveBeenCalledWith([ev], []); + expect(emitSpy).toHaveBeenCalledWith([ev], [], []); }); }); @@ -215,10 +218,10 @@ describe("RoomStickyEvents", () => { const emitSpy = jest.fn(); stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); jest.advanceTimersByTime(15000); - expect(emitSpy).toHaveBeenCalledWith([], [ev]); + expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); // Then expire the next event jest.advanceTimersByTime(1000); - expect(emitSpy).toHaveBeenCalledWith([], [evLater]); + expect(emitSpy).toHaveBeenCalledWith([], [], [evLater]); }); it("should emit two events when both expire at the same time", () => { const emitSpy = jest.fn(); @@ -238,9 +241,9 @@ describe("RoomStickyEvents", () => { origin_server_ts: 0, }); stickyEvents.addStickyEvents([ev1, ev2]); - expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], []); + expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []); jest.advanceTimersByTime(15000); - expect(emitSpy).toHaveBeenCalledWith([], [ev1, ev2]); + expect(emitSpy).toHaveBeenCalledWith([], [], [ev1, ev2]); }); it("should emit when a unkeyed sticky event expires", () => { const emitSpy = jest.fn(); @@ -253,7 +256,7 @@ describe("RoomStickyEvents", () => { }); stickyEvents.addStickyEvents([ev]); jest.advanceTimersByTime(15000); - expect(emitSpy).toHaveBeenCalledWith([], [ev]); + expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); }); }); }); diff --git a/src/@types/requests.ts b/src/@types/requests.ts index eeb756850ae..b6fd916cd2a 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -94,21 +94,19 @@ export interface ISendEventResponse { event_id: string; } -export type TimeoutDelay = { - delay: number; -}; - -export type ParentDelayId = { - parent_delay_id: string; -}; - -export type SendTimeoutDelayedEventRequestOpts = TimeoutDelay & Partial; -export type SendActionDelayedEventRequestOpts = ParentDelayId; - -export type SendDelayedEventRequestOpts = SendTimeoutDelayedEventRequestOpts | SendActionDelayedEventRequestOpts; +export type SendDelayedEventRequestOpts = { parent_delay_id: string } | { delay: number; parent_delay_id?: string }; export function isSendDelayedEventRequestOpts(opts: object): opts is SendDelayedEventRequestOpts { - return (opts as TimeoutDelay).delay !== undefined || (opts as ParentDelayId).parent_delay_id !== undefined; + if ("parent_delay_id" in opts && typeof opts.parent_delay_id !== "string") { + // Invalid type, reject + return false; + } + if ("delay" in opts && typeof opts.delay !== "number") { + // Invalid type, reject. + return true; + } + // At least one of these fields must be specified. + return "delay" in opts || "parent_delay_id" in opts; } export type SendDelayedEventResponse = { delay_id: string; diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 04e158f0ad6..41daec3af23 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -10,17 +10,16 @@ export enum RoomStickyEventsEvent { export type RoomStickyEventsMap = { /** - * Fires when sticky events are updated for a room. - * For a list of all updated events use: - * `const updated = added.filter(e => removed.includes(e));` - * for a list of all new events use: - * `const addedNew = added.filter(e => !removed.includes(e));` - * for a list of all removed events use: - * `const removedOnly = removed.filter(e => !added.includes(e));` - * @param added - The events that were added to the map of sticky events (can be updated events for existing keys or new keys) - * @param removed - The events that were removed from the map of sticky events (caused by expiration or updated keys) + * Fires when any sticky event changes happen in a room. + * @param added Any new sticky events with no predecessor events (matching sender, type, and sticky_key) + * @param updated Any sticky events that supersede an existing event (matching sender, type, and sticky_key) + * @param removed The events that were removed from the map due to expiry. */ - [RoomStickyEventsEvent.Update]: (added: MatrixEvent[], removed: MatrixEvent[]) => void; + [RoomStickyEventsEvent.Update]: ( + added: MatrixEvent[], + updated: { current: MatrixEvent; previous: MatrixEvent }[], + removed: MatrixEvent[], + ) => void; }; type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number }; @@ -63,7 +62,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter void; /** - * Fires when sticky events are updated for a room. - * For a list of all updated events use: - * `const updated = added.filter(e => removed.includes(e));` - * for a list of all new events use: - * `const addedNew = added.filter(e => !removed.includes(e));` - * for a list of all removed events use: - * `const removedOnly = removed.filter(e => !added.includes(e));` - * @param added - The events that were added to the map of sticky events (can be updated events for existing keys or new keys) - * @param removed - The events that were removed from the map of sticky events (caused by expiration or updated keys) - * @param room - The room containing the sticky events - */ - [RoomEvent.StickyEvents]: (added: MatrixEvent[], removed: MatrixEvent[], room: Room) => void; + * Fires when any sticky event changes happen in a room. + * @param added Any new sticky events with no predecessor events (matching sender, type, and sticky_key) + * @param updated Any sticky events that supersede an existing event (matching sender, type, and sticky_key) + * @param removed The events that were removed from the map due to expiry. + */ + [RoomEvent.StickyEvents]: ( + added: MatrixEvent[], + updated: { current: MatrixEvent; previous: MatrixEvent }[], + removed: MatrixEvent[], + room: Room, + ) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; /** * Fires when a new poll instance is added to the room state @@ -513,8 +512,8 @@ export class Room extends ReadReceipt { // receipts. No need to remove the listener: it's on ourself anyway. this.on(RoomEvent.Receipt, this.onReceipt); - this.stickyEvents.on(RoomStickyEventsEvent.Update, (added, removed) => - this.emit(RoomEvent.StickyEvents, added, removed, this), + this.stickyEvents.on(RoomStickyEventsEvent.Update, (...props) => + this.emit(RoomEvent.StickyEvents, ...props, this), ); // all our per-room timeline sets. the first one is the unfiltered ones; @@ -3463,7 +3462,7 @@ export class Room extends ReadReceipt { } /** - * Get an active sticky events that match the given `type` and `sender`. + * Get an active sticky event that match the given `type` and `sender`. * @param type The event `type`. * @param sender The sender of the sticky event. * @returns An array of matching sticky events. diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 8e9a7084b10..d63ef7bfaf5 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -78,7 +78,7 @@ export interface ITimeline { type StickyEventFields = { msc4354_sticky: { duration_ms: number }; - content: IRoomEvent["content"] & { msc4354_sticky_key?: string }; + content: { msc4354_sticky_key?: string }; }; export type IStickyEvent = IRoomEvent & StickyEventFields; @@ -558,8 +558,10 @@ export class SyncAccumulator { // Prune out any events in our stores that have since expired, do this before we // insert new events. + const now = Date.now(); currentData._stickyEvents = currentData._stickyEvents.filter((ev) => { - return Date.now() < ev.msc4354_sticky.duration_ms + ev.origin_server_ts; + // If `origin_server_ts` claims to have been from the future, we still bound it to now. + return now < ev.msc4354_sticky.duration_ms + Math.min(now, ev.origin_server_ts); }); // We want this to be fast, so don't worry about duplicate events here. The RoomStickyEventsStore will diff --git a/src/sync.ts b/src/sync.ts index 9cbf35d1e8c..a191fa87760 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -1405,12 +1405,16 @@ export class SyncApi { // we deliberately don't add accountData to the timeline room.addAccountData(accountDataEvents); - // events from the sticky section of the sync come first (those are the ones that would be skipped due to gappy syncs) - // hence we consider them as older. - // and we add the events from the timeline at the end (newer) + // Sticky events primarily come via the `timeline` field, with the + // sticky info field marking them as sticky. + // If the sync is "gappy" (meaning it is skipping events to catch up) then + // sticky events will instead come down the sticky section. + // This ensures we collect sticky events from both places. const stickyEventsAndStickyEventsFromTheTimeline = stickyEvents.concat( timelineEvents.filter((e) => e.unstableStickyInfo !== undefined), ); + // Note: We calculate sticky events before emitting `.Room` as it's nice to have + // sticky events calculated and ready to go. room._unstable_addStickyEvents(stickyEventsAndStickyEventsFromTheTimeline); room.recalculate(); @@ -1430,9 +1434,11 @@ export class SyncApi { accountDataEvents.forEach(emitEvent); stickyEvents .filter( - (e) => - // Ensure we do not emit twice. - !timelineEvents.some((te) => te.getId() === e.getId()), + (stickyEvent) => + // This is highly unlikey, but in the case where a sticky event + // has appeared in the timeline AND the sticky section, we only + // want to emit the event once. + !timelineEvents.some((timelineEvent) => timelineEvent.getId() === stickyEvent.getId()), ) .forEach(emitEvent); // Decrypt only the last message in all rooms to make sure we can generate a preview From ffdca00a0d2d8755f0ab8678a632b3d6c786ba05 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 11:19:38 +0100 Subject: [PATCH 12/15] Refactors based on review. --- src/models/event.ts | 17 ++++++++---- src/models/room-sticky-events.ts | 47 ++++++++++++++++---------------- src/models/room.ts | 25 ++++------------- src/sync-accumulator.ts | 10 +++++-- 4 files changed, 49 insertions(+), 50 deletions(-) diff --git a/src/models/event.ts b/src/models/event.ts index 5387ae06656..bc175ef0df2 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -215,7 +215,7 @@ export interface IMessageVisibilityHidden { } // A singleton implementing `IMessageVisibilityVisible`. const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true }); -const MAX_STICKY_DURATION_MS = 3600000; +export const MAX_STICKY_DURATION_MS = 3600000; export enum MatrixEventEvent { /** @@ -413,7 +413,10 @@ export class MatrixEvent extends TypedEventEmitter void; }; -type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number }; /** * Tracks sticky events on behalf of one room, and fires an event - * whenever a sticky even is updated or replaced. + * whenever a sticky event is updated or replaced. */ export class RoomStickyEventsStore extends TypedEventEmitter { private readonly stickyEventsMap = new Map>(); // (type -> stickyKey+userId) -> event @@ -35,15 +36,11 @@ export class RoomStickyEventsStore extends TypedEventEmitter { + public *getStickyEvents(): Iterable { yield* this.unkeyedStickyEvents; for (const innerMap of this.stickyEventsMap.values()) { yield* innerMap.values(); @@ -54,20 +51,20 @@ export class RoomStickyEventsStore extends TypedEventEmitter ev.getType() === type && ev.getSender() === sender); } @@ -81,7 +78,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter @@ -150,16 +147,17 @@ export class RoomStickyEventsStore extends TypedEventEmitter { const now = Date.now(); - const removedEvents: MatrixEvent[] = []; + const removedEvents: StickyMatrixEvent[] = []; // We will recalculate this as we check all events. this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER; @@ -210,6 +208,9 @@ export class RoomStickyEventsStore extends TypedEventEmitter= event.unstableStickyExpiresAt) { diff --git a/src/models/room.ts b/src/models/room.ts index 500a9ae412f..b5dbf57b0cb 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -77,7 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts"; import { KnownMembership, type Membership } from "../@types/membership.ts"; import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts"; import { type MSC4186Hero } from "../sliding-sync.ts"; -import { RoomStickyEventsStore, RoomStickyEventsEvent } from "./room-sticky-events.ts"; +import { RoomStickyEventsStore, RoomStickyEventsEvent, type RoomStickyEventsMap } from "./room-sticky-events.ts"; // These constants are used as sane defaults when the homeserver doesn't support // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be @@ -159,7 +159,6 @@ export enum RoomEvent { HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline", UnreadNotifications = "Room.UnreadNotifications", Summary = "Room.Summary", - StickyEvents = "Room.StickyEvents", } export type RoomEmittedEvents = @@ -169,6 +168,7 @@ export type RoomEmittedEvents = | RoomStateEvent.NewMember | RoomStateEvent.Update | RoomStateEvent.Marker + | RoomStickyEventsEvent.Update | ThreadEvent.New | ThreadEvent.Update | ThreadEvent.NewReply @@ -313,18 +313,6 @@ export type RoomEventHandlerMap = { * @param summary - the room summary object */ [RoomEvent.Summary]: (summary: IRoomSummary) => void; - /** - * Fires when any sticky event changes happen in a room. - * @param added Any new sticky events with no predecessor events (matching sender, type, and sticky_key) - * @param updated Any sticky events that supersede an existing event (matching sender, type, and sticky_key) - * @param removed The events that were removed from the map due to expiry. - */ - [RoomEvent.StickyEvents]: ( - added: MatrixEvent[], - updated: { current: MatrixEvent; previous: MatrixEvent }[], - removed: MatrixEvent[], - room: Room, - ) => void; [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void; /** * Fires when a new poll instance is added to the room state @@ -334,6 +322,7 @@ export type RoomEventHandlerMap = { } & Pick & EventTimelineSetHandlerMap & Pick & + Pick & Pick< RoomStateEventHandlerMap, | RoomStateEvent.Events @@ -511,10 +500,7 @@ export class Room extends ReadReceipt { // Listen to our own receipt event as a more modular way of processing our own // receipts. No need to remove the listener: it's on ourself anyway. this.on(RoomEvent.Receipt, this.onReceipt); - - this.stickyEvents.on(RoomStickyEventsEvent.Update, (...props) => - this.emit(RoomEvent.StickyEvents, ...props, this), - ); + this.reEmitter.reEmit(this.stickyEvents, [RoomStickyEventsEvent.Update]) // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. @@ -3462,7 +3448,7 @@ export class Room extends ReadReceipt { } /** - * Get an active sticky event that match the given `type` and `sender`. + * Get active sticky events without a sticky key that match the given `type` and `sender`. * @param type The event `type`. * @param sender The sender of the sticky event. * @returns An array of matching sticky events. @@ -3479,6 +3465,7 @@ export class Room extends ReadReceipt { * Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any * changes were made. * @param events A set of new sticky events. + * @internal */ // eslint-disable-next-line public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType { diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index d63ef7bfaf5..0065d72a3b4 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -20,7 +20,7 @@ limitations under the License. import { logger } from "./logger.ts"; import { deepCopy } from "./utils.ts"; -import { type IContent, type IUnsigned } from "./models/event.ts"; +import { MAX_STICKY_DURATION_MS, type IContent, type IUnsigned } from "./models/event.ts"; import { type IRoomSummary } from "./models/room-summary.ts"; import { type EventType } from "./@types/event.ts"; import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts"; @@ -560,14 +560,18 @@ export class SyncAccumulator { // insert new events. const now = Date.now(); currentData._stickyEvents = currentData._stickyEvents.filter((ev) => { + // If `duration_ms` exceeds the spec limit of a hour, we cap it. + const cappedDuration = Math.min(ev.msc4354_sticky.duration_ms, MAX_STICKY_DURATION_MS); // If `origin_server_ts` claims to have been from the future, we still bound it to now. - return now < ev.msc4354_sticky.duration_ms + Math.min(now, ev.origin_server_ts); + const sanitisedOriginTs = Math.min(now, ev.origin_server_ts); + const expiresAt = cappedDuration + sanitisedOriginTs; + return expiresAt > now; }); // We want this to be fast, so don't worry about duplicate events here. The RoomStickyEventsStore will // process these events into the correct mapped order. if (data.msc4354_sticky?.events) { - currentData._stickyEvents = currentData._stickyEvents.concat(data.msc4354_sticky?.events); + currentData._stickyEvents = currentData._stickyEvents.concat(data.msc4354_sticky.events); } // attempt to prune the timeline by jumping between events which have From 5601a0dcfd7960017a66c35173b92b6b178cc106 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 11:31:07 +0100 Subject: [PATCH 13/15] lint --- spec/unit/models/event.spec.ts | 9 +++++++-- spec/unit/models/room-sticky-events.spec.ts | 6 +++--- src/models/room-sticky-events.ts | 2 +- src/models/room.ts | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index 1766344cf62..65bd26ece27 100644 --- a/spec/unit/models/event.spec.ts +++ b/spec/unit/models/event.spec.ts @@ -616,13 +616,18 @@ describe("MatrixEvent", () => { }; try { jest.useFakeTimers(); - jest.setSystemTime(0); + jest.setSystemTime(50); // Prefer unsigned - expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5000); + expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5050); // Fall back to `duration_ms` expect( new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt, ).toEqual(1050); + // Prefer current time if `origin_server_ts` is more recent. + expect( + new MatrixEvent({ ...evData, unsigned: undefined, origin_server_ts: 5000 } satisfies IStickyEvent) + .unstableStickyExpiresAt, + ).toEqual(1050); } finally { jest.useRealTimers(); } diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index 5d84ceb5862..a51fe461c25 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -203,16 +203,16 @@ describe("RoomStickyEvents", () => { }); it("should emit when a sticky event expires", () => { - jest.setSystemTime(0); + jest.setSystemTime(1000); const ev = new MatrixEvent({ ...stickyEvent, - origin_server_ts: Date.now(), + origin_server_ts: 0, }); const evLater = new MatrixEvent({ ...stickyEvent, event_id: "$baz:bar", sender: "@bob:example.org", - origin_server_ts: Date.now() + 1000, + origin_server_ts: 1000, }); stickyEvents.addStickyEvents([ev, evLater]); const emitSpy = jest.fn(); diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 2673ad546d0..5ce6ca6bec0 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -24,7 +24,6 @@ export type RoomStickyEventsMap = { ) => void; }; - /** * Tracks sticky events on behalf of one room, and fires an event * whenever a sticky event is updated or replaced. @@ -208,6 +207,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter { // Listen to our own receipt event as a more modular way of processing our own // receipts. No need to remove the listener: it's on ourself anyway. this.on(RoomEvent.Receipt, this.onReceipt); - this.reEmitter.reEmit(this.stickyEvents, [RoomStickyEventsEvent.Update]) + this.reEmitter.reEmit(this.stickyEvents, [RoomStickyEventsEvent.Update]); // all our per-room timeline sets. the first one is the unfiltered ones; // the subsequent ones are the filtered ones in no particular order. From ff778489d7ab5f7cd73203d968d87a45f88746a9 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 17:28:23 +0100 Subject: [PATCH 14/15] Store sticky event expiry TS at insertion time. --- spec/unit/sync-accumulator.spec.ts | 12 ++++++++++ src/sync-accumulator.ts | 35 ++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 304ab4c4309..d9333b43464 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -1116,6 +1116,18 @@ describe("SyncAccumulator", function () { sa.accumulate(syncSkeleton({})); expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined(); }); + + it("clears stale sticky events that pretend to be from the distant future", () => { + jest.setSystemTime(0); + const eventFarInTheFuture = stickyEvent(999999999999); + sa.accumulate(syncSkeleton({ msc4354_sticky: { events: [eventFarInTheFuture] } })); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ + eventFarInTheFuture, + ]); + jest.setSystemTime(1000); // Expire the event + sa.accumulate(syncSkeleton({})); + expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined(); + }); }); }); diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 0065d72a3b4..28b06870e38 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -215,7 +215,14 @@ interface IRoom { _unreadNotifications: Partial; _unreadThreadNotifications?: Record>; _receipts: ReceiptAccumulator; - _stickyEvents: (IStickyEvent | IStickyStateEvent)[]; + _stickyEvents: { + readonly event: IStickyEvent | IStickyStateEvent; + /** + * This is the timestamp at which point it is safe to remove this event from the store. + * This value is immutable + */ + readonly expiresTs: number; + }[]; } export interface ISyncData { @@ -426,6 +433,7 @@ export class SyncAccumulator { // Accumulate timeline and state events in a room. private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void { + const now = Date.now(); // We expect this function to be called a lot (every /sync) so we want // this to be fast. /sync stores events in an array but we often want // to clobber based on type/state_key. Rather than convert arrays to @@ -558,20 +566,23 @@ export class SyncAccumulator { // Prune out any events in our stores that have since expired, do this before we // insert new events. - const now = Date.now(); - currentData._stickyEvents = currentData._stickyEvents.filter((ev) => { - // If `duration_ms` exceeds the spec limit of a hour, we cap it. - const cappedDuration = Math.min(ev.msc4354_sticky.duration_ms, MAX_STICKY_DURATION_MS); - // If `origin_server_ts` claims to have been from the future, we still bound it to now. - const sanitisedOriginTs = Math.min(now, ev.origin_server_ts); - const expiresAt = cappedDuration + sanitisedOriginTs; - return expiresAt > now; - }); + currentData._stickyEvents = currentData._stickyEvents.filter(({ expiresTs }) => expiresTs > now); // We want this to be fast, so don't worry about duplicate events here. The RoomStickyEventsStore will // process these events into the correct mapped order. if (data.msc4354_sticky?.events) { - currentData._stickyEvents = currentData._stickyEvents.concat(data.msc4354_sticky.events); + currentData._stickyEvents = currentData._stickyEvents.concat( + data.msc4354_sticky.events.map((event) => { + // If `duration_ms` exceeds the spec limit of a hour, we cap it. + const cappedDuration = Math.min(event.msc4354_sticky.duration_ms, MAX_STICKY_DURATION_MS); + // If `origin_server_ts` claims to have been from the future, we still bound it to now. + const createdTs = Math.min(event.origin_server_ts, now); + return { + event, + expiresTs: cappedDuration + createdTs, + }; + }), + ); } // attempt to prune the timeline by jumping between events which have @@ -647,7 +658,7 @@ export class SyncAccumulator { "summary": roomData._summary as IRoomSummary, "msc4354_sticky": roomData._stickyEvents?.length ? { - events: roomData._stickyEvents, + events: roomData._stickyEvents.map((e) => e.event), } : undefined, }; From 5a7de2e56c9c93f58573231d2a1f2537709808bf Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 17:48:17 +0100 Subject: [PATCH 15/15] proper type --- src/models/room-sticky-events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 5ce6ca6bec0..3e9e26a2bd5 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -32,7 +32,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter>(); // (type -> stickyKey+userId) -> event private readonly unkeyedStickyEvents = new Set(); - private stickyEventTimer?: NodeJS.Timeout; + private stickyEventTimer?: ReturnType; private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER; /**