diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index f32256253a..65bd26ece2 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,39 @@ 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(50); + // Prefer unsigned + 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(); + } + }); }); 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 new file mode 100644 index 0000000000..a51fe461c2 --- /dev/null +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -0,0 +1,262 @@ +import { type IStickyEvent, MatrixEvent } from "../../../src"; +import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events"; + +describe("RoomStickyEvents", () => { + let stickyEvents: RoomStickyEventsStore; + const emitSpy: jest.Mock = jest.fn(); + const stickyEvent: IStickyEvent = { + 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(() => { + emitSpy.mockReset(); + stickyEvents = new RoomStickyEventsStore(); + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + }); + + afterEach(() => { + stickyEvents?.clear(); + }); + + describe("addStickyEvents", () => { + it("should allow adding an event without a msc4354_sticky_key", () => { + stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]); + expect([...stickyEvents.getStickyEvents()]).toHaveLength(1); + }); + it("should not allow adding an event without a msc4354_sticky property", () => { + 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", () => { + 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 } }), + ]); + 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 events on ID tie break", () => { + const originalEv = new MatrixEvent({ ...stickyEvent }); + stickyEvents.addStickyEvents([originalEv]); + stickyEvents.addStickyEvents([ + new MatrixEvent({ + ...stickyEvent, + event_id: "$abc:bar", + }), + ]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); + }); + it("should not replace a newer event with an older event", () => { + const originalEv = new MatrixEvent({ ...stickyEvent }); + stickyEvents.addStickyEvents([originalEv]); + stickyEvents.addStickyEvents([ + new MatrixEvent({ + ...stickyEvent, + origin_server_ts: 1, + }), + ]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]); + }); + it("should replace an older event with a newer event", () => { + 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 }); + const anotherEv = new MatrixEvent({ + ...stickyEvent, + type: "org.example.another_type", + }); + stickyEvents.addStickyEvents([originalEv, anotherEv]); + expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]); + }); + + it("should emit when a new sticky event is added", () => { + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + const ev = new MatrixEvent({ + ...stickyEvent, + }); + stickyEvents.addStickyEvents([ev]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); + expect(emitSpy).toHaveBeenCalledWith([ev], [], []); + }); + it("should emit when a new unkeyed sticky event is added", () => { + stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy); + const ev = new MatrixEvent({ + ...stickyEvent, + content: {}, + }); + stickyEvents.addStickyEvents([ev]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); + expect(emitSpy).toHaveBeenCalledWith([ev], [], []); + }); + }); + + describe("getStickyEvents", () => { + it("should have zero sticky events", () => { + expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); + }); + it("should contain a sticky event", () => { + const ev = new MatrixEvent({ + ...stickyEvent, + }); + stickyEvents.addStickyEvents([ev]); + expect([...stickyEvents.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.addStickyEvents([ev, ev2]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]); + }); + }); + + 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(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it("should emit when a sticky event expires", () => { + jest.setSystemTime(1000); + const ev = new MatrixEvent({ + ...stickyEvent, + origin_server_ts: 0, + }); + const evLater = new MatrixEvent({ + ...stickyEvent, + event_id: "$baz:bar", + sender: "@bob:example.org", + origin_server_ts: 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(); + 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.addStickyEvents([ev1, ev2]); + expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []); + 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.addStickyEvents([ev]); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); + }); + }); +}); diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 22d3336007..d9333b4346 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,67 @@ 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], + }, + }), + ); + 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(); + }); + + 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(); + }); + }); }); function syncSkeleton( diff --git a/src/@types/requests.ts b/src/@types/requests.ts index b985bec293..b6fd916cd2 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -94,19 +94,20 @@ 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 { + 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/client.ts b/src/client.ts index 52029ce475..580f2edef6 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; @@ -551,6 +552,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"; @@ -2681,7 +2683,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. @@ -2732,29 +2735,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(); } @@ -2797,7 +2800,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 @@ -2836,16 +2843,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()!); @@ -2881,7 +2892,7 @@ export class MatrixClient extends TypedEventEmitter { room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]); @@ -2996,14 +3007,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) { @@ -3036,19 +3049,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; + }); } } @@ -3105,16 +3121,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_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", + "sendStickyEvent", + ); + } + + this.addThreadRelationIfNeeded(content, threadId, roomId); + return this.sendCompleteEvent({ + roomId, + threadId, + eventObject: { type: eventType, content }, + queryDict: { "org.matrix.msc4354.sticky_duration_ms": stickDuration }, + delayOpts, + txnId, + }); } /** @@ -3439,6 +3502,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: { "org.matrix.msc4354.sticky_duration_ms": stickDuration }, + txnId, + }); + } + /** * Get all pending delayed events for the calling user. * diff --git a/src/errors.ts b/src/errors.ts index 8baf7979bc..672aee3bb4 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 dba134b894..bc175ef0df 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 }); +export const MAX_STICKY_DURATION_MS = 3600000; export enum MatrixEventEvent { /** @@ -408,6 +411,17 @@ export class MatrixEvent extends TypedEventEmitter; + /** + * The timestamp for when this event should expire, in milliseconds. + * Prefers using the server-provided value, but will fall back to local calculation. + * + * This value is **safe** to use, as malicious start time and duration are appropriately capped. + * + * 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 * @@ -447,8 +461,17 @@ export class MatrixEvent extends TypedEventEmitter void; +}; + +/** + * Tracks sticky events on behalf of one room, and fires an event + * whenever a sticky event is updated or replaced. + */ +export class RoomStickyEventsStore extends TypedEventEmitter { + private readonly stickyEventsMap = new Map>(); // (type -> stickyKey+userId) -> event + private readonly unkeyedStickyEvents = new Set(); + + private stickyEventTimer?: ReturnType; + private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER; + + /** + * Get all sticky events that are currently active. + * @returns An iterable set of events. + */ + public *getStickyEvents(): Iterable { + yield* this.unkeyedStickyEvents; + for (const innerMap of this.stickyEventsMap.values()) { + yield* innerMap.values(); + } + } + + /** + * Get an active 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. + */ + public getKeyedStickyEvent(sender: string, type: string, stickyKey: string): StickyMatrixEvent | undefined { + return this.stickyEventsMap.get(type)?.get(`${stickyKey}${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. + */ + public getUnkeyedStickyEvent(sender: string, type: string): StickyMatrixEvent[] { + return [...this.unkeyedStickyEvents].filter((ev) => ev.getType() === type && ev.getSender() === sender); + } + + /** + * 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. + */ + private addStickyEvent(event: MatrixEvent): { added: true; prevEvent?: StickyMatrixEvent } | { added: false } { + const stickyKey = event.getContent().msc4354_sticky_key; + if (typeof stickyKey !== "string" && stickyKey !== undefined) { + throw new Error(`${event.getId()} is missing msc4354_sticky_key`); + } + + // With this we have the guarantee, that all events in stickyEventsMap are correctly formatted + if (event.unstableStickyExpiresAt === undefined) { + throw new Error(`${event.getId()} is missing msc4354_sticky.duration_ms`); + } + const sender = event.getSender(); + const type = event.getType(); + if (!sender) { + throw new Error(`${event.getId()} is missing a sender`); + } else if (event.unstableStickyExpiresAt <= 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 new Error("Expected sender to start with @"); + } + + let prevEvent: StickyMatrixEvent | undefined; + if (stickyKey !== undefined) { + // 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 innerMapKey = `${stickyKey}${sender}`; + prevEvent = this.stickyEventsMap.get(type)?.get(innerMapKey); + + // 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 }; + } + if (!this.stickyEventsMap.has(type)) { + this.stickyEventsMap.set(type, new Map()); + } + this.stickyEventsMap.get(type)!.set(innerMapKey, event as StickyMatrixEvent); + } else { + this.unkeyedStickyEvents.add(event as StickyMatrixEvent); + } + + // Recalculate the next expiry time. + this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs); + + this.scheduleStickyTimer(); + 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. + */ + public addStickyEvents(events: MatrixEvent[]): void { + const added: StickyMatrixEvent[] = []; + const updated: { current: StickyMatrixEvent; previous: StickyMatrixEvent }[] = []; + for (const event of events) { + try { + const result = this.addStickyEvent(event); + if (result.added) { + if (result.prevEvent) { + // e is validated as a StickyMatrixEvent by virtue of `addStickyEvent` returning added: true. + updated.push({ current: event as StickyMatrixEvent, previous: result.prevEvent }); + } else { + added.push(event as StickyMatrixEvent); + } + } + } catch (ex) { + logger.warn("ignored invalid sticky event", ex); + } + } + if (added.length || updated.length) this.emit(RoomStickyEventsEvent.Update, added, updated, []); + 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; + } // otherwise, schedule in the future + this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, this.nextStickyEventExpiryTs - Date.now()); + } + + /** + * Clean out any expired sticky events. + */ + private readonly cleanExpiredStickyEvents = (): void => { + const now = Date.now(); + const removedEvents: StickyMatrixEvent[] = []; + + // We will recalculate this as we check all events. + this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER; + for (const [eventType, innerEvents] of this.stickyEventsMap.entries()) { + for (const [innerMapKey, event] of innerEvents) { + // we only added items with `sticky` into this map so we can assert non-null here + 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, + event.unstableStickyExpiresAt, + ); + } + } + // Clean up map after use. + if (this.stickyEventsMap.get(eventType)?.size === 0) { + this.stickyEventsMap.delete(eventType); + } + } + for (const event of this.unkeyedStickyEvents) { + 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, event.unstableStickyExpiresAt); + } + } + 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 6cdfaa39a7..ffd35c2a7d 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 { 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 @@ -167,6 +168,7 @@ export type RoomEmittedEvents = | RoomStateEvent.NewMember | RoomStateEvent.Update | RoomStateEvent.Marker + | RoomStickyEventsEvent.Update | ThreadEvent.New | ThreadEvent.Update | ThreadEvent.NewReply @@ -320,6 +322,7 @@ export type RoomEventHandlerMap = { } & Pick & EventTimelineSetHandlerMap & Pick & + Pick & Pick< RoomStateEventHandlerMap, | RoomStateEvent.Events @@ -446,6 +449,11 @@ export class Room extends ReadReceipt { */ private roomReceipts = new RoomReceipts(this); + /** + * Stores and tracks sticky events + */ + private stickyEvents = new RoomStickyEventsStore(); + /** * Construct a new Room. * @@ -492,6 +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.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. @@ -3414,6 +3423,55 @@ 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.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_getKeyedStickyEvent( + sender: string, + type: string, + stickyKey: string, + ): ReturnType { + return this.stickyEvents.getKeyedStickyEvent(sender, type, stickyKey); + } + + /** + * 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. + */ + // eslint-disable-next-line + public _unstable_getUnkeyedStickyEvent( + sender: string, + type: string, + ): ReturnType { + return this.stickyEvents.getUnkeyedStickyEvent(sender, type); + } + + /** + * 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 { + return this.stickyEvents.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 816b45de7e..28b06870e3 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"; @@ -76,11 +76,25 @@ export interface ITimeline { prev_batch: string | null; } +type StickyEventFields = { + msc4354_sticky: { duration_ms: number }; + content: { msc4354_sticky_key?: string }; +}; + +export type IStickyEvent = IRoomEvent & StickyEventFields; + +export type IStickyStateEvent = IStateEvent & StickyEventFields; + +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 +215,14 @@ interface IRoom { _unreadNotifications: Partial; _unreadThreadNotifications?: Record>; _receipts: ReceiptAccumulator; + _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 { @@ -411,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 @@ -457,6 +480,7 @@ export class SyncAccumulator { _unreadThreadNotifications: {}, _summary: {}, _receipts: new ReceiptAccumulator(), + _stickyEvents: [], }; } const currentData = this.joinRooms[roomId]; @@ -540,6 +564,27 @@ 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(({ 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.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 // pagination tokens. if (currentData._timeline.length > this.opts.maxTimelineEntries!) { @@ -611,6 +656,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.map((e) => e.event), + } + : undefined, }; // Add account data Object.keys(roomData._accountData).forEach((evType) => { diff --git a/src/sync.ts b/src/sync.ts index 4cc23c0a18..a191fa8776 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,18 @@ export class SyncApi { // we deliberately don't add accountData to the timeline room.addAccountData(accountDataEvents); + // 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(); if (joinObj.isBrandNewRoom) { client.store.storeRoom(room); @@ -1411,11 +1426,21 @@ 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( + (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 // And decrypt all events after the recorded read receipt to ensure an accurate // notification count