From bf14e3769efd338909266e9fce53be7ac8356f20 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Wed, 1 Oct 2025 11:57:57 +0100 Subject: [PATCH 01/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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/59] 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 b06d5dfbd25ded5118c2c277c220f6453606f8c3 Mon Sep 17 00:00:00 2001 From: Timo K Date: Tue, 23 Sep 2025 19:49:27 +0200 Subject: [PATCH 14/59] Add sticky event support to the js-sdk Signed-off-by: Timo K --- src/@types/event.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/@types/event.ts b/src/@types/event.ts index 6e4d0ddff19..700ba91aa9e 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -337,6 +337,9 @@ export interface TimelineEvents { [M_BEACON.name]: MBeaconEventContent; [M_POLL_START.name]: PollStartEventContent; [M_POLL_END.name]: PollEndEventContent; + // MSC3401 Adding this to the timeline events as well for sending this event as a sticky event. + // { sticky_key: string } is the empty object but we always need a sticky key + [EventType.GroupCallMemberPrefix]: SessionMembershipData | EmptyObject; } /** From cd2914c7bcfb236a6a7c2c5ec76748342baff5df Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 26 Sep 2025 15:00:23 +0100 Subject: [PATCH 15/59] use sticky events for matrixRTC Signed-off-by: Timo K --- src/matrixrtc/CallMembership.ts | 4 ++ src/matrixrtc/MatrixRTCSession.ts | 41 ++++++++++++------ src/matrixrtc/MembershipManager.ts | 68 ++++++++++++++++++++---------- 3 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 00e42baa17a..e225a57d343 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -93,6 +93,10 @@ export type SessionMembershipData = { * something else. */ "m.call.intent"?: RTCCallIntent; + /** + * the sticky key for sticky events packed application + device_id making up the used slot + device. + */ + "sticky_key"?: string; }; const checkSessionsMembershipData = ( diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 020f768558d..7df53783d6c 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -17,7 +17,7 @@ limitations under the License. import { type Logger, logger as rootLogger } from "../logger.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { EventTimeline } from "../models/event-timeline.ts"; -import { type Room } from "../models/room.ts"; +import { RoomEvent, type Room } from "../models/room.ts"; import { type MatrixClient } from "../client.ts"; import { EventType, RelationType } from "../@types/event.ts"; import { KnownMembership } from "../@types/membership.ts"; @@ -50,6 +50,7 @@ import { } from "./RoomAndToDeviceKeyTransport.ts"; import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; +import { type MatrixEvent } from "src/matrix.ts"; /** * Events emitted by MatrixRTCSession @@ -291,7 +292,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead. */ public static callMembershipsForRoom( - room: Pick, + room: Pick, ): CallMembership[] { return MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", @@ -304,16 +305,24 @@ export class MatrixRTCSession extends TypedEventEmitter< * oldest first. */ public static sessionMembershipsForRoom( - room: Pick, + room: Pick, sessionDescription: SessionDescription, + useStickyEvents: boolean = true, ): CallMembership[] { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); - const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); - if (!roomState) { - logger.warn("Couldn't get state for room " + room.roomId); - throw new Error("Could't get state for room " + room.roomId); + let callMemberEvents; + if (useStickyEvents) { + callMemberEvents = Array.from(room.getStickyEventsMap().values()).filter( + (e) => e.getType() === EventType.GroupCallMemberPrefix, + ); + } else { + const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); + if (!roomState) { + logger.warn("Couldn't get state for room " + room.roomId); + throw new Error("Could't get state for room " + room.roomId); + } + callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); } - const callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); const callMemberships: CallMembership[] = []; for (const memberEvent of callMemberEvents) { @@ -428,10 +437,10 @@ export class MatrixRTCSession extends TypedEventEmitter< MatrixClient, | "getUserId" | "getDeviceId" - | "sendStateEvent" - | "_unstable_sendDelayedStateEvent" - | "_unstable_updateDelayedEvent" | "sendEvent" + | "_unstable_updateDelayedEvent" + | "_unstable_sendStickyEvent" + | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" | "encryptAndSendToDevice" | "off" @@ -455,9 +464,10 @@ export class MatrixRTCSession extends TypedEventEmitter< const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); + this.roomSubset.on(RoomEvent.StickyEvents, this.onStickyEventUpdate); + this.setExpiryTimer(); } - /* * Returns true if we intend to be participating in the MatrixRTC session. * This is determined by checking if the relativeExpiry has been set. @@ -477,6 +487,7 @@ export class MatrixRTCSession extends TypedEventEmitter< } const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate); + this.roomSubset.off(RoomEvent.StickyEvents, this.onStickyEventUpdate); } private reEmitter = new TypedReEmitter< MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent, @@ -761,6 +772,12 @@ export class MatrixRTCSession extends TypedEventEmitter< this.recalculateSessionMembers(); }; + private onStickyEventUpdate = (stickyEvents: Map, room: Room): void => { + if (Array.from(stickyEvents.values()).some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { + this.recalculateSessionMembers(); + } + }; + /** * Call this when something changed that may impacts the current MatrixRTC members in this session. */ diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 9e50e92d448..d95a08b3744 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -34,6 +34,7 @@ import { type IMembershipManager, type MembershipManagerEventHandlerMap, } from "./IMembershipManager.ts"; +import { type EmptyObject } from "src/matrix.ts"; /* MembershipActionTypes: @@ -77,6 +78,8 @@ On Leave: ───────── STOP ALL ABOVE (s) Successful restart/resend */ +const STICK_DURATION_MS = 60 * 60 * 1000; // 60 minutes + /** * The different types of actions the MembershipManager can take. * @internal @@ -311,9 +314,9 @@ export class MembershipManager MatrixClient, | "getUserId" | "getDeviceId" - | "sendStateEvent" - | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "_unstable_sendStickyEvent" + | "_unstable_sendStickyDelayedEvent" >, private getOldestMembership: () => CallMembership | undefined, public readonly sessionDescription: SessionDescription, @@ -388,7 +391,11 @@ export class MembershipManager return this.joinConfig?.membershipEventExpiryHeadroomMs ?? 5_000; } private computeNextExpiryActionTs(iteration: number): number { - return this.state.startTime + this.membershipEventExpiryMs * iteration - this.membershipEventExpiryHeadroomMs; + return ( + this.state.startTime + + Math.min(this.membershipEventExpiryMs, STICK_DURATION_MS) * iteration - + this.membershipEventExpiryHeadroomMs + ); } private get delayedLeaveEventDelayMs(): number { return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000; @@ -467,14 +474,15 @@ export class MembershipManager // (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...) // In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}" return await this.client - ._unstable_sendDelayedStateEvent( + ._unstable_sendStickyDelayedEvent( this.room.roomId, + STICK_DURATION_MS, { delay: this.delayedLeaveEventDelayMs, }, + null, EventType.GroupCallMemberPrefix, - {}, // leave event - this.stateKey, + { sticky_key: this.stateKey } as EmptyObject & { sticky_key: string }, // leave event ) .then((response) => { this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs; @@ -499,7 +507,7 @@ export class MembershipManager if (this.manageMaxDelayExceededSituation(e)) { return createInsertActionUpdate(repeatActionType); } - const update = this.actionUpdateFromErrors(e, repeatActionType, "sendDelayedStateEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendStickyDelayedEvent"); if (update) return update; if (this.state.hasMemberStateEvent) { @@ -657,12 +665,10 @@ export class MembershipManager private async sendJoinEvent(): Promise { return await this.client - .sendStateEvent( - this.room.roomId, - EventType.GroupCallMemberPrefix, - this.makeMyMembership(this.membershipEventExpiryMs), - this.stateKey, - ) + ._unstable_sendStickyEvent(this.room.roomId, STICK_DURATION_MS, null, EventType.GroupCallMemberPrefix, { + ...this.makeMyMembership(this.membershipEventExpiryMs), + sticky_key: this.stateKey, + }) .then(() => { this.setAndEmitProbablyLeft(false); this.state.startTime = Date.now(); @@ -694,7 +700,11 @@ export class MembershipManager }; }) .catch((e) => { - const update = this.actionUpdateFromErrors(e, MembershipActionType.SendJoinEvent, "sendStateEvent"); + const update = this.actionUpdateFromErrors( + e, + MembershipActionType.SendJoinEvent, + "_unstable_sendStickyEvent", + ); if (update) return update; throw e; }); @@ -703,12 +713,10 @@ export class MembershipManager private async updateExpiryOnJoinedEvent(): Promise { const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1; return await this.client - .sendStateEvent( - this.room.roomId, - EventType.GroupCallMemberPrefix, - this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), - this.stateKey, - ) + ._unstable_sendStickyEvent(this.room.roomId, STICK_DURATION_MS, null, EventType.GroupCallMemberPrefix, { + ...this.makeMyMembership(this.membershipEventExpiryMs), + sticky_key: this.stateKey, + }) .then(() => { // Success, we reset retries and schedule update. this.resetRateLimitCounter(MembershipActionType.UpdateExpiry); @@ -723,7 +731,11 @@ export class MembershipManager }; }) .catch((e) => { - const update = this.actionUpdateFromErrors(e, MembershipActionType.UpdateExpiry, "sendStateEvent"); + const update = this.actionUpdateFromErrors( + e, + MembershipActionType.UpdateExpiry, + "_unstable_sendStickyEvent", + ); if (update) return update; throw e; @@ -731,14 +743,24 @@ export class MembershipManager } private async sendFallbackLeaveEvent(): Promise { return await this.client - .sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, {}, this.stateKey) + ._unstable_sendStickyEvent( + this.room.roomId, + STICK_DURATION_MS, + null, + EventType.GroupCallMemberPrefix, + { sticky_key: this.stateKey } as EmptyObject & { sticky_key: string }, // leave event + ) .then(() => { this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.state.hasMemberStateEvent = false; return { replace: [] }; }) .catch((e) => { - const update = this.actionUpdateFromErrors(e, MembershipActionType.SendLeaveEvent, "sendStateEvent"); + const update = this.actionUpdateFromErrors( + e, + MembershipActionType.SendLeaveEvent, + "_unstable_sendStickyEvent", + ); if (update) return update; throw e; }); From d6c99de5dba40d52ba97381e95ae871934ba1d30 Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 24 Sep 2025 11:33:34 +0200 Subject: [PATCH 16/59] make sticky events a non breaking change (default to state events. use joinConfig to use sticky events) Signed-off-by: Timo K --- src/matrixrtc/MatrixRTCSession.ts | 24 +++++++- src/matrixrtc/MembershipManager.ts | 98 +++++++++++++++++++----------- 2 files changed, 85 insertions(+), 37 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 7df53783d6c..4d0c033e185 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -186,6 +186,12 @@ export interface MembershipConfig { * but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.) */ delayedLeaveEventRestartLocalTimeoutMs?: number; + + /** + * If the membership manager should publish its own membership via sticky events or via the room state. + * @default false (room state) + */ + useStickyEvents?: boolean; } export interface EncryptionConfig { @@ -307,21 +313,31 @@ export class MatrixRTCSession extends TypedEventEmitter< public static sessionMembershipsForRoom( room: Pick, sessionDescription: SessionDescription, + // default both true this implied we combine sticky and state events for the final call state + // (prefer sticky events in case of a duplicate) useStickyEvents: boolean = true, + useStateEvents: boolean = true, ): CallMembership[] { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); - let callMemberEvents; + let callMemberEvents = [] as MatrixEvent[]; if (useStickyEvents) { + // prefill with sticky events callMemberEvents = Array.from(room.getStickyEventsMap().values()).filter( (e) => e.getType() === EventType.GroupCallMemberPrefix, ); - } else { + } + if (useStateEvents) { const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); if (!roomState) { logger.warn("Couldn't get state for room " + room.roomId); throw new Error("Could't get state for room " + room.roomId); } - callMemberEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); + const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); + // only care about state events which have keys which we have not yet seen in the sticky events. + callMemberStateEvents.filter((e) => + callMemberEvents.some((stickyEvent) => stickyEvent.getContent().state_key === e.getStateKey()), + ); + callMemberEvents.concat(callMemberStateEvents); } const callMemberships: CallMembership[] = []; @@ -438,6 +454,8 @@ export class MatrixRTCSession extends TypedEventEmitter< | "getUserId" | "getDeviceId" | "sendEvent" + | "sendStateEvent" + | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" | "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent" diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index d95a08b3744..35131c76787 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -16,7 +16,11 @@ limitations under the License. import { AbortError } from "p-retry"; import { EventType } from "../@types/event.ts"; -import { UpdateDelayedEventAction } from "../@types/requests.ts"; +import { + type ISendEventResponse, + type SendDelayedEventResponse, + UpdateDelayedEventAction, +} from "../@types/requests.ts"; import { type MatrixClient } from "../client.ts"; import { UnsupportedDelayedEventsEndpointError } from "../errors.ts"; import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts"; @@ -314,6 +318,8 @@ export class MembershipManager MatrixClient, | "getUserId" | "getDeviceId" + | "sendStateEvent" + | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" | "_unstable_sendStickyEvent" | "_unstable_sendStickyDelayedEvent" @@ -413,6 +419,11 @@ export class MembershipManager private get delayedLeaveEventRestartLocalTimeoutMs(): number { return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000; } + + private get useStickyEvents(): boolean { + return this.joinConfig?.useStickyEvents ?? false; + } + // LOOP HANDLER: private async membershipLoopHandler(type: MembershipActionType): Promise { switch (type) { @@ -467,23 +478,36 @@ export class MembershipManager } } + // an abstraction to switch between sending state or a sticky event + private clientSendDelayedEvent: (myMembership: EmptyObject) => Promise = ( + myMembership, + ) => + this.useStickyEvents + ? this.client._unstable_sendStickyDelayedEvent( + this.room.roomId, + STICK_DURATION_MS, + { delay: this.delayedLeaveEventDelayMs }, + null, + EventType.GroupCallMemberPrefix, + Object.assign(myMembership, { sticky_key: this.stateKey }), + ) + : this.client._unstable_sendDelayedStateEvent( + this.room.roomId, + { delay: this.delayedLeaveEventDelayMs }, + EventType.GroupCallMemberPrefix, + myMembership, + this.stateKey, + ); + private sendDelayedEventMethodName: () => string = () => + this.useStickyEvents ? "_unstable_sendStickyDelayedEvent" : "_unstable_sendDelayedStateEvent"; + // HANDLERS (used in the membershipLoopHandler) private async sendOrResendDelayedLeaveEvent(): Promise { // We can reach this at the start of a call (where we do not yet have a membership: state.hasMemberStateEvent=false) // or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event. // (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...) // In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}" - return await this.client - ._unstable_sendStickyDelayedEvent( - this.room.roomId, - STICK_DURATION_MS, - { - delay: this.delayedLeaveEventDelayMs, - }, - null, - EventType.GroupCallMemberPrefix, - { sticky_key: this.stateKey } as EmptyObject & { sticky_key: string }, // leave event - ) + return await this.clientSendDelayedEvent({}) .then((response) => { this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs; this.setAndEmitProbablyLeft(false); @@ -507,7 +531,7 @@ export class MembershipManager if (this.manageMaxDelayExceededSituation(e)) { return createInsertActionUpdate(repeatActionType); } - const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendStickyDelayedEvent"); + const update = this.actionUpdateFromErrors(e, repeatActionType, this.sendDelayedEventMethodName()); if (update) return update; if (this.state.hasMemberStateEvent) { @@ -663,12 +687,27 @@ export class MembershipManager }); } + private clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = ( + myMembership, + ) => + this.useStickyEvents + ? this.client._unstable_sendStickyEvent( + this.room.roomId, + STICK_DURATION_MS, + null, + EventType.GroupCallMemberPrefix, + Object.assign(myMembership, { sticky_key: this.stateKey }), + ) + : this.client.sendStateEvent( + this.room.roomId, + EventType.GroupCallMemberPrefix, + myMembership, + this.stateKey, + ); + private sendMembershipMethodName: () => string = () => + this.useStickyEvents ? "_unstable_sendStickyEvent" : "sendStateEvent"; private async sendJoinEvent(): Promise { - return await this.client - ._unstable_sendStickyEvent(this.room.roomId, STICK_DURATION_MS, null, EventType.GroupCallMemberPrefix, { - ...this.makeMyMembership(this.membershipEventExpiryMs), - sticky_key: this.stateKey, - }) + return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs)) .then(() => { this.setAndEmitProbablyLeft(false); this.state.startTime = Date.now(); @@ -703,7 +742,7 @@ export class MembershipManager const update = this.actionUpdateFromErrors( e, MembershipActionType.SendJoinEvent, - "_unstable_sendStickyEvent", + this.sendMembershipMethodName(), ); if (update) return update; throw e; @@ -712,11 +751,9 @@ export class MembershipManager private async updateExpiryOnJoinedEvent(): Promise { const nextExpireUpdateIteration = this.state.expireUpdateIterations + 1; - return await this.client - ._unstable_sendStickyEvent(this.room.roomId, STICK_DURATION_MS, null, EventType.GroupCallMemberPrefix, { - ...this.makeMyMembership(this.membershipEventExpiryMs), - sticky_key: this.stateKey, - }) + return await this.clientSendMembership( + this.makeMyMembership(this.membershipEventExpiryMs * nextExpireUpdateIteration), + ) .then(() => { // Success, we reset retries and schedule update. this.resetRateLimitCounter(MembershipActionType.UpdateExpiry); @@ -734,7 +771,7 @@ export class MembershipManager const update = this.actionUpdateFromErrors( e, MembershipActionType.UpdateExpiry, - "_unstable_sendStickyEvent", + this.sendMembershipMethodName(), ); if (update) return update; @@ -742,14 +779,7 @@ export class MembershipManager }); } private async sendFallbackLeaveEvent(): Promise { - return await this.client - ._unstable_sendStickyEvent( - this.room.roomId, - STICK_DURATION_MS, - null, - EventType.GroupCallMemberPrefix, - { sticky_key: this.stateKey } as EmptyObject & { sticky_key: string }, // leave event - ) + return await this.clientSendMembership({}) .then(() => { this.resetRateLimitCounter(MembershipActionType.SendLeaveEvent); this.state.hasMemberStateEvent = false; @@ -759,7 +789,7 @@ export class MembershipManager const update = this.actionUpdateFromErrors( e, MembershipActionType.SendLeaveEvent, - "_unstable_sendStickyEvent", + this.sendMembershipMethodName(), ); if (update) return update; throw e; From 8dd68c028b3f2f05bc3954fcd73f734b0c77bde1 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 25 Sep 2025 17:52:02 +0200 Subject: [PATCH 17/59] review - fix types (`msc4354_sticky:number` -> `msc4354_sticky?: { duration_ms: number };`) - add `MultiKeyMap` Signed-off-by: Timo K --- src/utils.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/utils.ts b/src/utils.ts index 4366bc89c0b..0d2cccfa09c 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -765,3 +765,24 @@ export class MapWithDefault extends Map { return this.get(key)!; } } + +export class MultiKeyMap { + private map = new Map(); + public get(key: Array): V | undefined { + return this.map.get(JSON.stringify(key)); + } + public set(key: Array, value: V): void { + this.map.set(JSON.stringify(key), value); + } + public values(): MapIterator { + return this.map.values(); + } + public entries(): MapIterator<[string[], V]> { + return Array.from(this.map.entries()) + .map<[string[], V]>(([k, v]) => [JSON.parse(k) as string[], v]) + .values(); + } + public delete(key: Array): boolean { + return this.map.delete(JSON.stringify(key)); + } +} From adcb2bb8ee7b508fcd38acfb3ca84cad5c4363a6 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 26 Sep 2025 14:54:06 +0100 Subject: [PATCH 18/59] Refactor all of this away to it's own accumulator and class. --- src/utils.ts | 21 --------------------- 1 file changed, 21 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 0d2cccfa09c..4366bc89c0b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -765,24 +765,3 @@ export class MapWithDefault extends Map { return this.get(key)!; } } - -export class MultiKeyMap { - private map = new Map(); - public get(key: Array): V | undefined { - return this.map.get(JSON.stringify(key)); - } - public set(key: Array, value: V): void { - this.map.set(JSON.stringify(key), value); - } - public values(): MapIterator { - return this.map.values(); - } - public entries(): MapIterator<[string[], V]> { - return Array.from(this.map.entries()) - .map<[string[], V]>(([k, v]) => [JSON.parse(k) as string[], v]) - .values(); - } - public delete(key: Array): boolean { - return this.map.delete(JSON.stringify(key)); - } -} From 510c2c3e05297051ecb41179b7d584d5dfe2fcdc Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 26 Sep 2025 14:54:11 +0100 Subject: [PATCH 19/59] Add tests --- spec/unit/models/room.spec.ts | 1 - spec/unit/sync-accumulator.spec.ts | 8 ++++++++ src/matrixrtc/MatrixRTCSession.ts | 10 +++++----- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/spec/unit/models/room.spec.ts b/spec/unit/models/room.spec.ts index 6efa9d22e39..a39256bbfe3 100644 --- a/spec/unit/models/room.spec.ts +++ b/spec/unit/models/room.spec.ts @@ -13,7 +13,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ - import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src"; import type { MockedObject } from "jest-mock"; diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index 304ab4c4309..e10d9a523a1 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -26,7 +26,11 @@ import { type ILeftRoom, type IRoomEvent, type IStateEvent, +<<<<<<< HEAD type IStickyEvent, +======= + IStickyEvent, +>>>>>>> 7750916d3 (Add tests) type IStrippedState, type ISyncResponse, SyncAccumulator, @@ -1069,7 +1073,11 @@ describe("SyncAccumulator", function () { }); }); +<<<<<<< HEAD describe("MSC4354 sticky events", () => { +======= + describe.only("MSC4354 sticky events", () => { +>>>>>>> 7750916d3 (Add tests) function stickyEvent(ts = 0): IStickyEvent { const msgData = msg("test", "test text"); return { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 4d0c033e185..7b5ee96cbea 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -298,7 +298,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead. */ public static callMembershipsForRoom( - room: Pick, + room: Pick, ): CallMembership[] { return MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", @@ -311,7 +311,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * oldest first. */ public static sessionMembershipsForRoom( - room: Pick, + room: Pick, sessionDescription: SessionDescription, // default both true this implied we combine sticky and state events for the final call state // (prefer sticky events in case of a duplicate) @@ -322,7 +322,7 @@ export class MatrixRTCSession extends TypedEventEmitter< let callMemberEvents = [] as MatrixEvent[]; if (useStickyEvents) { // prefill with sticky events - callMemberEvents = Array.from(room.getStickyEventsMap().values()).filter( + callMemberEvents = Array.from(room.unstableGetStickyEvents()).filter( (e) => e.getType() === EventType.GroupCallMemberPrefix, ); } @@ -790,8 +790,8 @@ export class MatrixRTCSession extends TypedEventEmitter< this.recalculateSessionMembers(); }; - private onStickyEventUpdate = (stickyEvents: Map, room: Room): void => { - if (Array.from(stickyEvents.values()).some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { + private onStickyEventUpdate = (added: MatrixEvent[], _removed: MatrixEvent[], room: Room): void => { + if ([...added, ..._removed].some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { this.recalculateSessionMembers(); } }; From ab310dfe803efe6ba4a61c6882d81fbc489cbf94 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 26 Sep 2025 15:39:09 +0100 Subject: [PATCH 20/59] tidyup --- spec/unit/sync-accumulator.spec.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/spec/unit/sync-accumulator.spec.ts b/spec/unit/sync-accumulator.spec.ts index e10d9a523a1..304ab4c4309 100644 --- a/spec/unit/sync-accumulator.spec.ts +++ b/spec/unit/sync-accumulator.spec.ts @@ -26,11 +26,7 @@ import { type ILeftRoom, type IRoomEvent, type IStateEvent, -<<<<<<< HEAD type IStickyEvent, -======= - IStickyEvent, ->>>>>>> 7750916d3 (Add tests) type IStrippedState, type ISyncResponse, SyncAccumulator, @@ -1073,11 +1069,7 @@ describe("SyncAccumulator", function () { }); }); -<<<<<<< HEAD describe("MSC4354 sticky events", () => { -======= - describe.only("MSC4354 sticky events", () => { ->>>>>>> 7750916d3 (Add tests) function stickyEvent(ts = 0): IStickyEvent { const msgData = msg("test", "test text"); return { From caf916a45d45839a209c32fedde028ba421fc3ec Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 26 Sep 2025 15:56:12 +0100 Subject: [PATCH 21/59] more test cleaning --- spec/unit/matrixrtc/mocks.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index d61670d79fa..a7e8abfbabb 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -81,6 +81,7 @@ export function makeMockRoom( getState: jest.fn().mockReturnValue(roomState), }), getVersion: jest.fn().mockReturnValue("default"), + unstableGetStickyEvents: jest.fn().mockReturnValue([]), }) as unknown as Room; return Object.assign(room, { emitTimelineEvent: (event: MatrixEvent) => From 953b7d7dea484eb716f2d625b716addd8cf5f3f6 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 29 Sep 2025 09:16:56 +0100 Subject: [PATCH 22/59] lint --- spec/unit/matrixrtc/mocks.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index a7e8abfbabb..ea0dfe76f71 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -51,6 +51,8 @@ export type MockClient = Pick< | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" + | "_unstable_sendStickyEvent" + | "_unstable_sendStickyDelayedEvent" | "cancelPendingEvent" >; /** @@ -65,6 +67,8 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { cancelPendingEvent: jest.fn(), _unstable_updateDelayedEvent: jest.fn(), _unstable_sendDelayedStateEvent: jest.fn(), + _unstable_sendStickyEvent: jest.fn(), + _unstable_sendStickyDelayedEvent: jest.fn(), }; } From d376e942c936243679b78dbc6e6a7d020f87a448 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 29 Sep 2025 12:36:05 +0100 Subject: [PATCH 23/59] Updates and tests --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 245 ++++++++++--------- spec/unit/matrixrtc/mocks.ts | 7 +- src/matrixrtc/MatrixRTCSession.ts | 79 +++--- src/matrixrtc/MembershipManager.ts | 166 +++++++------ 4 files changed, 276 insertions(+), 221 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 70cbe927df0..b5238235d14 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -18,7 +18,6 @@ import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEve import { KnownMembership } from "../../../src/@types/membership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; -import { secureRandomString } from "../../../src/randomstring"; import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; @@ -47,91 +46,111 @@ describe("MatrixRTCSession", () => { sess = undefined; }); - describe("roomSessionForRoom", () => { - it("creates a room-scoped session from room state", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].sessionDescription.id).toEqual(""); - expect(sess?.memberships[0].scope).toEqual("m.room"); - expect(sess?.memberships[0].application).toEqual("m.call"); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - expect(sess?.memberships[0].isExpired()).toEqual(false); - expect(sess?.sessionDescription.id).toEqual(""); - }); - - it("ignores memberships where application is not m.call", () => { - const testMembership = Object.assign({}, membershipTemplate, { - application: "not-m.call", + describe.each([ + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: false, + }, + { + listenForStickyEvents: false, + listenForMemberStateEvents: true, + testCreateSticky: false, + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: true, + }, + { + listenForStickyEvents: true, + listenForMemberStateEvents: false, + testCreateSticky: true, + }, + ])( + "roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForStickyEvents testCreateSticky=$testCreateSticky", + (testConfig) => { + it("creates a room-scoped session from room state", () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].sessionDescription.id).toEqual(""); + expect(sess?.memberships[0].scope).toEqual("m.room"); + expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + expect(sess?.memberships[0].isExpired()).toEqual(false); + expect(sess?.sessionDescription.id).toEqual(""); }); - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); - it("ignores memberships where callId is not empty", () => { - const testMembership = Object.assign({}, membershipTemplate, { - call_id: "not-empty", - scope: "m.room", + it("ignores memberships where application is not m.call", () => { + const testMembership = Object.assign({}, membershipTemplate, { + application: "not-m.call", + }); + const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); + const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships).toHaveLength(0); }); - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); - it("ignores expired memberships events", () => { - jest.useFakeTimers(); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.expires = 1000; - expiredMembership.device_id = "EXPIRED"; - const mockRoom = makeMockRoom([membershipTemplate, expiredMembership]); + it("ignores memberships where callId is not empty", () => { + const testMembership = Object.assign({}, membershipTemplate, { + call_id: "not-empty", + scope: "m.room", + }); + const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); + const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships).toHaveLength(0); + }); - jest.advanceTimersByTime(2000); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - jest.useRealTimers(); - }); + it("ignores expired memberships events", () => { + jest.useFakeTimers(); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.expires = 1000; + expiredMembership.device_id = "EXPIRED"; + const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky); + + jest.advanceTimersByTime(2000); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + jest.useRealTimers(); + }); - it("ignores memberships events of members not in the room", () => { - const mockRoom = makeMockRoom([membershipTemplate]); - mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships.length).toEqual(0); - }); + it("ignores memberships events of members not in the room", () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships.length).toEqual(0); + }); - it("honours created_ts", () => { - jest.useFakeTimers(); - jest.setSystemTime(500); - const expiredMembership = Object.assign({}, membershipTemplate); - expiredMembership.created_ts = 500; - expiredMembership.expires = 1000; - const mockRoom = makeMockRoom([expiredMembership]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); - jest.useRealTimers(); - }); + it("honours created_ts", () => { + jest.useFakeTimers(); + jest.setSystemTime(500); + const expiredMembership = Object.assign({}, membershipTemplate); + expiredMembership.created_ts = 500; + expiredMembership.expires = 1000; + const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); + jest.useRealTimers(); + }); - it("returns empty session if no membership events are present", () => { - const mockRoom = makeMockRoom([]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess?.memberships).toHaveLength(0); - }); + it("returns empty session if no membership events are present", () => { + const mockRoom = makeMockRoom([], testConfig.testCreateSticky); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess?.memberships).toHaveLength(0); + }); - it("safely ignores events with no memberships section", () => { - const roomId = secureRandomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({}), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ + it("safely ignores events with no memberships section", () => { + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({}), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = makeMockRoom([]); + mockRoom.getLiveTimeline = jest.fn().mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), @@ -148,25 +167,21 @@ describe("MatrixRTCSession", () => { ], ]), }), - }), - }; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); - expect(sess.memberships).toHaveLength(0); - }); + }); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess.memberships).toHaveLength(0); + }); - it("safely ignores events with junk memberships section", () => { - const roomId = secureRandomString(8); - const event = { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getTs: jest.fn().mockReturnValue(1000), - getLocalAge: jest.fn().mockReturnValue(0), - }; - const mockRoom = { - ...makeMockRoom([]), - roomId, - getLiveTimeline: jest.fn().mockReturnValue({ + it("safely ignores events with junk memberships section", () => { + const event = { + getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), + getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getTs: jest.fn().mockReturnValue(1000), + getLocalAge: jest.fn().mockReturnValue(0), + }; + const mockRoom = makeMockRoom([]); + mockRoom.getLiveTimeline = jest.fn().mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), @@ -183,28 +198,28 @@ describe("MatrixRTCSession", () => { ], ]), }), - }), - }; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom as unknown as Room, callSession); - expect(sess.memberships).toHaveLength(0); - }); + }); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess.memberships).toHaveLength(0); + }); - it("ignores memberships with no device_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.device_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess.memberships).toHaveLength(0); - }); + it("ignores memberships with no device_id", () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.device_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess.memberships).toHaveLength(0); + }); - it("ignores memberships with no call_id", () => { - const testMembership = Object.assign({}, membershipTemplate); - (testMembership.call_id as string | undefined) = undefined; - const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession); - expect(sess.memberships).toHaveLength(0); - }); - }); + it("ignores memberships with no call_id", () => { + const testMembership = Object.assign({}, membershipTemplate); + (testMembership.call_id as string | undefined) = undefined; + const mockRoom = makeMockRoom([testMembership]); + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + expect(sess.memberships).toHaveLength(0); + }); + }, + ); describe("getOldestMembership", () => { it("returns the oldest membership event", () => { diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index ea0dfe76f71..5cd87d17110 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -74,10 +74,11 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { export function makeMockRoom( membershipData: MembershipData[], + useStickyEvents = false, ): Room & { emitTimelineEvent: (event: MatrixEvent) => void } { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` - const roomState = makeMockRoomState(membershipData, roomId); + const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId); const room = Object.assign(new EventEmitter(), { roomId: roomId, hasMembershipState: jest.fn().mockReturnValue(true), @@ -85,7 +86,9 @@ export function makeMockRoom( getState: jest.fn().mockReturnValue(roomState), }), getVersion: jest.fn().mockReturnValue("default"), - unstableGetStickyEvents: jest.fn().mockReturnValue([]), + unstableGetStickyEvents: jest + .fn() + .mockReturnValue(useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId)) : []), }) as unknown as Room; return Object.assign(room, { emitTimelineEvent: (event: MatrixEvent) => diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 7b5ee96cbea..e4e69a42432 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -25,7 +25,7 @@ import { type ISendEventResponse } from "../@types/requests.ts"; import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { type Focus } from "./focus.ts"; -import { MembershipManager } from "./MembershipManager.ts"; +import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { deepCompare, logDurationSync } from "../utils.ts"; import { @@ -117,14 +117,6 @@ export interface SessionDescription { // - we use a `Ms` postfix if the option is a duration to avoid using words like: // `time`, `duration`, `delay`, `timeout`... that might be mistaken/confused with technical terms. export interface MembershipConfig { - /** - * Use the new Manager. - * - * Default: `false`. - * @deprecated does nothing anymore we always default to the new membership manager. - */ - useNewMembershipManager?: boolean; - /** * The timeout (in milliseconds) after we joined the call, that our membership should expire * unless we have explicitly updated it. @@ -188,10 +180,11 @@ export interface MembershipConfig { delayedLeaveEventRestartLocalTimeoutMs?: number; /** - * If the membership manager should publish its own membership via sticky events or via the room state. - * @default false (room state) + * Send membership using sticky events rather than state events. + * + * **WARNING**: This is an unstable feature and not all clients will support it. */ - useStickyEvents?: boolean; + unstableSendStickyEvents?: boolean; } export interface EncryptionConfig { @@ -237,6 +230,11 @@ export interface EncryptionConfig { } export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig; +interface SessionMembershipsForRoomOpts { + listenForStickyEvents: boolean; + listenForMemberStateEvents: boolean; +} + /** * A MatrixRTCSession manages the membership & properties of a MatrixRTC session. * This class doesn't deal with media at all, just membership & properties of a session. @@ -315,18 +313,21 @@ export class MatrixRTCSession extends TypedEventEmitter< sessionDescription: SessionDescription, // default both true this implied we combine sticky and state events for the final call state // (prefer sticky events in case of a duplicate) - useStickyEvents: boolean = true, - useStateEvents: boolean = true, + { listenForStickyEvents, listenForMemberStateEvents }: SessionMembershipsForRoomOpts = { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }, ): CallMembership[] { const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); let callMemberEvents = [] as MatrixEvent[]; - if (useStickyEvents) { + if (listenForStickyEvents) { + logger.info("useStickyEvents"); // prefill with sticky events callMemberEvents = Array.from(room.unstableGetStickyEvents()).filter( (e) => e.getType() === EventType.GroupCallMemberPrefix, ); } - if (useStateEvents) { + if (listenForMemberStateEvents) { const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); if (!roomState) { logger.warn("Couldn't get state for room " + room.roomId); @@ -337,7 +338,7 @@ export class MatrixRTCSession extends TypedEventEmitter< callMemberStateEvents.filter((e) => callMemberEvents.some((stickyEvent) => stickyEvent.getContent().state_key === e.getStateKey()), ); - callMemberEvents.concat(callMemberStateEvents); + callMemberEvents = callMemberEvents.concat(callMemberStateEvents); } const callMemberships: CallMembership[] = []; @@ -406,8 +407,16 @@ export class MatrixRTCSession extends TypedEventEmitter< * * @deprecated Use `MatrixRTCSession.sessionForRoom` with sessionDescription `{ id: "", application: "m.call" }` instead. */ - public static roomSessionForRoom(client: MatrixClient, room: Room): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", application: "m.call" }); + public static roomSessionForRoom( + client: MatrixClient, + room: Room, + opts?: SessionMembershipsForRoomOpts, + ): MatrixRTCSession { + const callMemberships = MatrixRTCSession.sessionMembershipsForRoom( + room, + { id: "", application: "m.call" }, + opts, + ); return new MatrixRTCSession(client, room, callMemberships, { id: "", application: "m.call" }); } @@ -420,8 +429,9 @@ export class MatrixRTCSession extends TypedEventEmitter< client: MatrixClient, room: Room, sessionDescription: SessionDescription, + opts?: SessionMembershipsForRoomOpts, ): MatrixRTCSession { - const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription); + const callMemberships = MatrixRTCSession.sessionMembershipsForRoom(room, sessionDescription, opts); return new MatrixRTCSession(client, room, callMemberships, sessionDescription); } @@ -507,6 +517,7 @@ export class MatrixRTCSession extends TypedEventEmitter< roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate); this.roomSubset.off(RoomEvent.StickyEvents, this.onStickyEventUpdate); } + private reEmitter = new TypedReEmitter< MatrixRTCSessionEvent | RoomAndToDeviceEvents | MembershipManagerEvent, MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap & MembershipManagerEventHandlerMap @@ -532,15 +543,23 @@ export class MatrixRTCSession extends TypedEventEmitter< return; } else { // Create MembershipManager and pass the RTCSession logger (with room id info) - - this.membershipManager = new MembershipManager( - joinConfig, - this.roomSubset, - this.client, - () => this.getOldestMembership(), - this.sessionDescription, - this.logger, - ); + this.membershipManager = joinConfig?.unstableSendStickyEvents + ? new StickyEventMembershipManager( + joinConfig, + this.roomSubset, + this.client, + () => this.getOldestMembership(), + this.sessionDescription, + this.logger, + ) + : new MembershipManager( + joinConfig, + this.roomSubset, + this.client, + () => this.getOldestMembership(), + this.sessionDescription, + this.logger, + ); this.reEmitter.reEmit(this.membershipManager!, [ MembershipManagerEvent.ProbablyLeft, @@ -790,7 +809,7 @@ export class MatrixRTCSession extends TypedEventEmitter< this.recalculateSessionMembers(); }; - private onStickyEventUpdate = (added: MatrixEvent[], _removed: MatrixEvent[], room: Room): void => { + private onStickyEventUpdate = (added: MatrixEvent[], _removed: MatrixEvent[]): void => { if ([...added, ..._removed].some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { this.recalculateSessionMembers(); } diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 35131c76787..0db9a5fb13f 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -144,6 +144,18 @@ export interface MembershipManagerState { probablyLeft: boolean; } +function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { + return { + insert: [{ ts: Date.now() + (offset ?? 0), type }], + }; +} + +function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { + return { + replace: [{ ts: Date.now() + (offset ?? 0), type }], + }; +} + /** * This class is responsible for sending all events relating to the own membership of a matrixRTC call. * It has the following tasks: @@ -313,7 +325,7 @@ export class MembershipManager */ public constructor( private joinConfig: (SessionConfig & MembershipConfig) | undefined, - private room: Pick, + protected room: Pick, private client: Pick< MatrixClient, | "getUserId" @@ -321,8 +333,6 @@ export class MembershipManager | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" - | "_unstable_sendStickyEvent" - | "_unstable_sendStickyDelayedEvent" >, private getOldestMembership: () => CallMembership | undefined, public readonly sessionDescription: SessionDescription, @@ -380,7 +390,7 @@ export class MembershipManager } // Membership Event static parameters: private deviceId: string; - private stateKey: string; + protected stateKey: string; private fociPreferred?: Focus[]; private focusActive?: Focus; @@ -403,7 +413,7 @@ export class MembershipManager this.membershipEventExpiryHeadroomMs ); } - private get delayedLeaveEventDelayMs(): number { + protected get delayedLeaveEventDelayMs(): number { return this.delayedLeaveEventDelayMsOverride ?? this.joinConfig?.delayedLeaveEventDelayMs ?? 8_000; } private get delayedLeaveEventRestartMs(): number { @@ -420,10 +430,6 @@ export class MembershipManager return this.joinConfig?.delayedLeaveEventRestartLocalTimeoutMs ?? 2000; } - private get useStickyEvents(): boolean { - return this.joinConfig?.useStickyEvents ?? false; - } - // LOOP HANDLER: private async membershipLoopHandler(type: MembershipActionType): Promise { switch (type) { @@ -479,27 +485,16 @@ export class MembershipManager } // an abstraction to switch between sending state or a sticky event - private clientSendDelayedEvent: (myMembership: EmptyObject) => Promise = ( + protected clientSendDelayedEvent: (myMembership: EmptyObject) => Promise = ( myMembership, ) => - this.useStickyEvents - ? this.client._unstable_sendStickyDelayedEvent( - this.room.roomId, - STICK_DURATION_MS, - { delay: this.delayedLeaveEventDelayMs }, - null, - EventType.GroupCallMemberPrefix, - Object.assign(myMembership, { sticky_key: this.stateKey }), - ) - : this.client._unstable_sendDelayedStateEvent( - this.room.roomId, - { delay: this.delayedLeaveEventDelayMs }, - EventType.GroupCallMemberPrefix, - myMembership, - this.stateKey, - ); - private sendDelayedEventMethodName: () => string = () => - this.useStickyEvents ? "_unstable_sendStickyDelayedEvent" : "_unstable_sendDelayedStateEvent"; + this.client._unstable_sendDelayedStateEvent( + this.room.roomId, + { delay: this.delayedLeaveEventDelayMs }, + EventType.GroupCallMemberPrefix, + myMembership, + this.stateKey, + ); // HANDLERS (used in the membershipLoopHandler) private async sendOrResendDelayedLeaveEvent(): Promise { @@ -531,7 +526,7 @@ export class MembershipManager if (this.manageMaxDelayExceededSituation(e)) { return createInsertActionUpdate(repeatActionType); } - const update = this.actionUpdateFromErrors(e, repeatActionType, this.sendDelayedEventMethodName()); + const update = this.actionUpdateFromErrors(e, repeatActionType, "_unstable_sendDelayedStateEvent"); if (update) return update; if (this.state.hasMemberStateEvent) { @@ -687,25 +682,10 @@ export class MembershipManager }); } - private clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = ( - myMembership, - ) => - this.useStickyEvents - ? this.client._unstable_sendStickyEvent( - this.room.roomId, - STICK_DURATION_MS, - null, - EventType.GroupCallMemberPrefix, - Object.assign(myMembership, { sticky_key: this.stateKey }), - ) - : this.client.sendStateEvent( - this.room.roomId, - EventType.GroupCallMemberPrefix, - myMembership, - this.stateKey, - ); - private sendMembershipMethodName: () => string = () => - this.useStickyEvents ? "_unstable_sendStickyEvent" : "sendStateEvent"; + protected clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = + (myMembership) => + this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, myMembership, this.stateKey); + private async sendJoinEvent(): Promise { return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs)) .then(() => { @@ -739,11 +719,7 @@ export class MembershipManager }; }) .catch((e) => { - const update = this.actionUpdateFromErrors( - e, - MembershipActionType.SendJoinEvent, - this.sendMembershipMethodName(), - ); + const update = this.actionUpdateFromErrors(e, MembershipActionType.SendJoinEvent, "sendStateEvent"); if (update) return update; throw e; }); @@ -768,11 +744,7 @@ export class MembershipManager }; }) .catch((e) => { - const update = this.actionUpdateFromErrors( - e, - MembershipActionType.UpdateExpiry, - this.sendMembershipMethodName(), - ); + const update = this.actionUpdateFromErrors(e, MembershipActionType.UpdateExpiry, "sendStateEvent"); if (update) return update; throw e; @@ -786,11 +758,7 @@ export class MembershipManager return { replace: [] }; }) .catch((e) => { - const update = this.actionUpdateFromErrors( - e, - MembershipActionType.SendLeaveEvent, - this.sendMembershipMethodName(), - ); + const update = this.actionUpdateFromErrors(e, MembershipActionType.SendLeaveEvent, "sendStateEvent"); if (update) return update; throw e; }); @@ -857,7 +825,7 @@ export class MembershipManager return false; } - private actionUpdateFromErrors( + protected actionUpdateFromErrors( error: unknown, type: MembershipActionType, method: string, @@ -905,7 +873,7 @@ export class MembershipManager return createInsertActionUpdate(type, resendDelay); } - throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + "): " + (error as Error)); + throw Error("Exceeded maximum retries for " + type + " attempts (client." + method + ")", { cause: error }); } /** @@ -1049,14 +1017,64 @@ export class MembershipManager } } -function createInsertActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { - return { - insert: [{ ts: Date.now() + (offset ?? 0), type }], - }; -} +/** + * Implementation of the Membership manager that uses sticky events + * rather than state events. + */ +export class StickyEventMembershipManager extends MembershipManager { + public constructor( + joinConfig: (SessionConfig & MembershipConfig) | undefined, + room: Pick, + private readonly clientWithSticky: Pick< + MatrixClient, + | "getUserId" + | "getDeviceId" + | "sendStateEvent" + | "_unstable_sendDelayedStateEvent" + | "_unstable_updateDelayedEvent" + | "_unstable_sendStickyEvent" + | "_unstable_sendStickyDelayedEvent" + >, + getOldestMembership: () => CallMembership | undefined, + sessionDescription: SessionDescription, + parentLogger?: Logger, + ) { + super(joinConfig, room, clientWithSticky, getOldestMembership, sessionDescription, parentLogger); + } -function createReplaceActionUpdate(type: MembershipActionType, offset?: number): ActionUpdate { - return { - replace: [{ ts: Date.now() + (offset ?? 0), type }], - }; + protected clientSendDelayedEvent: (myMembership: EmptyObject) => Promise = ( + myMembership, + ) => + this.clientWithSticky._unstable_sendStickyDelayedEvent( + this.room.roomId, + STICK_DURATION_MS, + { delay: this.delayedLeaveEventDelayMs }, + null, + EventType.GroupCallMemberPrefix, + Object.assign(myMembership, { sticky_key: this.stateKey }), + ); + + protected clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = + (myMembership) => + this.clientWithSticky._unstable_sendStickyEvent( + this.room.roomId, + STICK_DURATION_MS, + null, + EventType.GroupCallMemberPrefix, + Object.assign(myMembership, { sticky_key: this.stateKey }), + ); + + protected actionUpdateFromErrors( + error: unknown, + type: MembershipActionType, + method: string, + ): ActionUpdate | undefined { + // Override method name. + if (method === "sendStateEvent") { + method = "_unstable_sendStickyEvent"; + } else if (method === "_unstable_sendDelayedStateEvent") { + method = "_unstable_sendStickyDelayedEvent"; + } + return super.actionUpdateFromErrors(error, type, method); + } } From 0d38f14652c2faf2f851219edf16872465321a7d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 29 Sep 2025 14:02:28 +0100 Subject: [PATCH 24/59] fix filter --- src/matrixrtc/MatrixRTCSession.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index e4e69a42432..2e2e76fa173 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -335,10 +335,9 @@ export class MatrixRTCSession extends TypedEventEmitter< } const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); // only care about state events which have keys which we have not yet seen in the sticky events. - callMemberStateEvents.filter((e) => - callMemberEvents.some((stickyEvent) => stickyEvent.getContent().state_key === e.getStateKey()), - ); - callMemberEvents = callMemberEvents.concat(callMemberStateEvents); + callMemberEvents = callMemberEvents.concat(callMemberStateEvents.filter((e) => + callMemberEvents.some((stickyEvent) => stickyEvent.getContent().msc4354_sticky_key === e.getStateKey()), + )); } const callMemberships: CallMembership[] = []; @@ -805,10 +804,13 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Call this when the Matrix room members have changed. */ - public onRoomMemberUpdate = (): void => { + private onRoomMemberUpdate = (): void => { this.recalculateSessionMembers(); }; + /** + * Call this when a sticky event update has occured. + */ private onStickyEventUpdate = (added: MatrixEvent[], _removed: MatrixEvent[]): void => { if ([...added, ..._removed].some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { this.recalculateSessionMembers(); From 207bab8af2465fae0831d4f6fd07e1d66b0eb8fc Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 29 Sep 2025 14:02:39 +0100 Subject: [PATCH 25/59] fix filter with lint --- src/matrixrtc/MatrixRTCSession.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 2e2e76fa173..bd4c058e035 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -335,9 +335,13 @@ export class MatrixRTCSession extends TypedEventEmitter< } const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); // only care about state events which have keys which we have not yet seen in the sticky events. - callMemberEvents = callMemberEvents.concat(callMemberStateEvents.filter((e) => - callMemberEvents.some((stickyEvent) => stickyEvent.getContent().msc4354_sticky_key === e.getStateKey()), - )); + callMemberEvents = callMemberEvents.concat( + callMemberStateEvents.filter((e) => + callMemberEvents.some( + (stickyEvent) => stickyEvent.getContent().msc4354_sticky_key === e.getStateKey(), + ), + ), + ); } const callMemberships: CallMembership[] = []; From 0b1fae59f323478854d7590c9694d6d28222be42 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 29 Sep 2025 19:12:02 +0100 Subject: [PATCH 26/59] Add timer tests --- spec/unit/models/room-sticky-events.spec.ts | 65 +++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index a51fe461c25..b0f960bfaaa 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -126,6 +126,23 @@ describe("RoomStickyEvents", () => { }); }); + 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.addStickyEvents([ + new MatrixEvent({ + ...stickyEvent, + }), + ]); + expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); + expect(emitSpy).toHaveBeenCalledWith([ev], []); + }); + }); + describe("getStickyEvents", () => { it("should have zero sticky events", () => { expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); @@ -259,4 +276,52 @@ describe("RoomStickyEvents", () => { expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); }); }); + + describe("cleanExpiredStickyEvents", () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + + it("should emit when a sticky event expires", () => { + const stickyEvents = new RoomStickyEventsStore(); + 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); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [ev]); + }); + it("should emit two events when both expire at the same time", () => { + const stickyEvents = new RoomStickyEventsStore(); + 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.setSystemTime(15000); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [ev1, ev2]); + }); + }); }); From 791b119d07eb70d699afea1179d16d2f501cb74d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 12:09:54 +0100 Subject: [PATCH 27/59] Add tests for MatrixRTCSessionManager --- .../matrixrtc/MatrixRTCSessionManager.spec.ts | 247 +++++++++--------- spec/unit/matrixrtc/mocks.ts | 17 +- 2 files changed, 142 insertions(+), 122 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 9472dc16edd..80290506a5f 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -14,136 +14,145 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ClientEvent, EventTimeline, MatrixClient } from "../../../src"; +import { ClientEvent, EventTimeline, MatrixClient, Room } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; -import { makeMockRoom, membershipTemplate, mockRoomState } from "./mocks"; +import { makeMockRoom, MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks"; import { logger } from "../../../src/logger"; -describe("MatrixRTCSessionManager", () => { - let client: MatrixClient; - - beforeEach(() => { - client = new MatrixClient({ baseUrl: "base_url" }); - client.matrixRTC.start(); - }); - - afterEach(() => { - client.stopClient(); - client.matrixRTC.stop(); - }); - - it("Fires event when session starts", () => { - const onStarted = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - - try { - const room1 = makeMockRoom([membershipTemplate]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - - client.emit(ClientEvent.Room, room1); - expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); +describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( + "MatrixRTCSessionManager ($eventKind)", + ({ eventKind }) => { + let client: MatrixClient; + + function sendLeaveMembership(room: Room, membershipData: MembershipData[]): void { + if (eventKind === "memberState") { + mockRoomState(room, [{ user_id: membershipTemplate.user_id }]); + const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!; + const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; + client.emit(RoomStateEvent.Events, membEvent, roomState, null); + } else { + membershipData.splice(0, 1, { user_id: membershipTemplate.user_id }); + client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000)); + } } - }); - - it("Doesn't fire event if unrelated sessions starts", () => { - const onStarted = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - try { - const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]); + beforeEach(() => { + client = new MatrixClient({ baseUrl: "base_url" }); + client.matrixRTC.start(); + }); + + afterEach(() => { + client.stopClient(); + client.matrixRTC.stop(); + }); + + it("Fires event when session starts", () => { + const onStarted = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + + client.emit(ClientEvent.Room, room1); + expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + } + }); + + it("Doesn't fire event if unrelated sessions starts", () => { + const onStarted = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + + client.emit(ClientEvent.Room, room1); + expect(onStarted).not.toHaveBeenCalled(); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + } + }); + + it("Fires event when session ends", async () => { + const onEnded = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + const membershipData: MembershipData[] = [membershipTemplate]; + const room1 = makeMockRoom(membershipData, eventKind === "sticky"); jest.spyOn(client, "getRooms").mockReturnValue([room1]); - + jest.spyOn(client, "getRoom").mockReturnValue(room1); client.emit(ClientEvent.Room, room1); - expect(onStarted).not.toHaveBeenCalled(); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - } - }); - - it("Fires event when session ends", () => { - const onEnded = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const room1 = makeMockRoom([membershipTemplate]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - jest.spyOn(client, "getRoom").mockReturnValue(room1); - - client.emit(ClientEvent.Room, room1); - - mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); - - const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEvent, roomState, null); - expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - }); - - it("Fires correctly with for with custom sessionDescription", () => { - const onStarted = jest.fn(); - const onEnded = jest.fn(); - // create a session manager with a custom session description - const sessionManager = new MatrixRTCSessionManager(logger, client, { id: "test", application: "m.notCall" }); - - // manually start the session manager (its not the default one started by the client) - sessionManager.start(); - sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - - try { - const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }]); + sendLeaveMembership(room1, membershipData); + + expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + }); + + it("Fires correctly with custom sessionDescription", () => { + const onStarted = jest.fn(); + const onEnded = jest.fn(); + // create a session manager with a custom session description + const sessionManager = new MatrixRTCSessionManager(logger, client, { + id: "test", + application: "m.notCall", + }); + + // manually start the session manager (its not the default one started by the client) + sessionManager.start(); + sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + + try { + // Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id + const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }]; + const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + client.emit(ClientEvent.Room, room1); + expect(onStarted).not.toHaveBeenCalled(); + onStarted.mockClear(); + + // Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id + const room2MembershipData: MembershipData[] = [ + { ...membershipTemplate, application: "m.notCall", call_id: "test" }, + ]; + const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky"); + jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); + client.emit(ClientEvent.Room, room2); + expect(onStarted).toHaveBeenCalled(); + onStarted.mockClear(); + + // Stop room1's RTC session. Tracked. + jest.spyOn(client, "getRoom").mockReturnValue(room2); + sendLeaveMembership(room2, room2MembershipData); + expect(onEnded).toHaveBeenCalled(); + onEnded.mockClear(); + + // Stop room1's RTC session. Not tracked. + jest.spyOn(client, "getRoom").mockReturnValue(room1); + sendLeaveMembership(room1, room1MembershipData); + expect(onEnded).not.toHaveBeenCalled(); + } finally { + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); + client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + } + }); + + it("Doesn't fire event if unrelated sessions ends", () => { + const onEnded = jest.fn(); + client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); + const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }]; + const room1 = makeMockRoom(membership, eventKind === "sticky"); jest.spyOn(client, "getRooms").mockReturnValue([room1]); - - client.emit(ClientEvent.Room, room1); - expect(onStarted).not.toHaveBeenCalled(); - onStarted.mockClear(); - - const room2 = makeMockRoom([{ ...membershipTemplate, application: "m.notCall", call_id: "test" }]); - jest.spyOn(client, "getRooms").mockReturnValue([room1, room2]); - - client.emit(ClientEvent.Room, room2); - expect(onStarted).toHaveBeenCalled(); - onStarted.mockClear(); - - mockRoomState(room2, [{ user_id: membershipTemplate.user_id }]); - jest.spyOn(client, "getRoom").mockReturnValue(room2); - - const roomState = room2.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEvent, roomState, null); - expect(onEnded).toHaveBeenCalled(); - onEnded.mockClear(); - - mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); jest.spyOn(client, "getRoom").mockReturnValue(room1); - const roomStateOther = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEventOther = roomStateOther.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEventOther, roomStateOther, null); - expect(onEnded).not.toHaveBeenCalled(); - } finally { - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted); - client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - } - }); - - it("Doesn't fire event if unrelated sessions ends", () => { - const onEnded = jest.fn(); - client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); - const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other_app" }]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - jest.spyOn(client, "getRoom").mockReturnValue(room1); - - client.emit(ClientEvent.Room, room1); - - mockRoomState(room1, [{ user_id: membershipTemplate.user_id }]); + client.emit(ClientEvent.Room, room1); - const roomState = room1.getLiveTimeline().getState(EventTimeline.FORWARDS)!; - const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0]; - client.emit(RoomStateEvent.Events, membEvent, roomState, null); + sendLeaveMembership(room1, membership); - expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); - }); -}); + expect(onEnded).not.toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); + }); + }, +); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 5cd87d17110..f1c838f2b31 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -88,7 +88,9 @@ export function makeMockRoom( getVersion: jest.fn().mockReturnValue("default"), unstableGetStickyEvents: jest .fn() - .mockReturnValue(useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId)) : []), + .mockImplementation(() => + useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000)) : [], + ), }) as unknown as Room; return Object.assign(room, { emitTimelineEvent: (event: MatrixEvent) => @@ -149,8 +151,17 @@ export function makeMockEvent( } as unknown as MatrixEvent; } -export function mockRTCEvent({ user_id: sender, ...membershipData }: MembershipData, roomId: string): MatrixEvent { - return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData); +export function mockRTCEvent( + { user_id: sender, ...membershipData }: MembershipData, + roomId: string, + stickyDuration?: number, +): MatrixEvent { + return { + ...makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData), + unstableStickyContent: { + duration_ms: stickyDuration, + }, + } as unknown as MatrixEvent; } export function mockCallMembership(membershipData: MembershipData, roomId: string): CallMembership { From 488d3ae5b6811f1c04f94f0e5701ae4513d75bf6 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 12:10:41 +0100 Subject: [PATCH 28/59] Listen for sticky events on MatrixRTCSessionManager --- src/matrixrtc/MatrixRTCSessionManager.ts | 27 +++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index cc25105d977..507dc54d266 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -18,7 +18,7 @@ import { type Logger } from "../logger.ts"; import { type MatrixClient, ClientEvent } from "../client.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { type Room } from "../models/room.ts"; -import { type RoomState, RoomStateEvent } from "../models/room-state.ts"; +import { RoomStateEvent } from "../models/room-state.ts"; import { type MatrixEvent } from "../models/event.ts"; import { MatrixRTCSession, type SessionDescription } from "./MatrixRTCSession.ts"; import { EventType } from "../@types/event.ts"; @@ -73,6 +73,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { + private onEvent = (event: MatrixEvent): void => { + if (!event.unstableStickyContent?.duration_ms) { + return; // Not sticky, not interested. + } + if (event.getType() !== EventType.GroupCallMemberPrefix) { + return; + } const room = this.client.getRoom(event.getRoomId()); if (!room) { - this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`); return; } + this.refreshRoom(room); + }; - if (event.getType() == EventType.GroupCallMemberPrefix) { - this.refreshRoom(room); + private onRoomState = (event: MatrixEvent): void => { + if (event.getType() !== EventType.GroupCallMemberPrefix) { + return; } + const room = this.client.getRoom(event.getRoomId()); + if (!room) { + this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`); + return; + } + + this.refreshRoom(room); }; private refreshRoom(room: Room): void { From d132e005ba658c9630af90cd5e029cf43151aec3 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 12:11:00 +0100 Subject: [PATCH 29/59] fix logic on filtering out state events --- src/matrixrtc/MatrixRTCSession.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index bd4c058e035..1f520f4dfe0 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -334,12 +334,14 @@ export class MatrixRTCSession extends TypedEventEmitter< throw new Error("Could't get state for room " + room.roomId); } const callMemberStateEvents = roomState.getStateEvents(EventType.GroupCallMemberPrefix); - // only care about state events which have keys which we have not yet seen in the sticky events. callMemberEvents = callMemberEvents.concat( - callMemberStateEvents.filter((e) => - callMemberEvents.some( - (stickyEvent) => stickyEvent.getContent().msc4354_sticky_key === e.getStateKey(), - ), + callMemberStateEvents.filter( + (callMemberStateEvent) => + !callMemberEvents.some( + // only care about state events which have keys which we have not yet seen in the sticky events. + (stickyEvent) => + stickyEvent.getContent().msc4354_sticky_key === callMemberStateEvent.getStateKey(), + ), ), ); } From 5f092e187cffe57e156821535e08bafb8c37a6a4 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 12:29:26 +0100 Subject: [PATCH 30/59] lint --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 2 +- spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts | 6 +++--- spec/unit/matrixrtc/mocks.ts | 6 ++++-- src/matrixrtc/MatrixRTCSession.ts | 1 - 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index b5238235d14..72ef9d8f89b 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -68,7 +68,7 @@ describe("MatrixRTCSession", () => { testCreateSticky: true, }, ])( - "roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForStickyEvents testCreateSticky=$testCreateSticky", + "roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", (testConfig) => { it("creates a room-scoped session from room state", () => { const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 80290506a5f..a6d862cb0ba 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ClientEvent, EventTimeline, MatrixClient, Room } from "../../../src"; +import { ClientEvent, EventTimeline, MatrixClient, type Room } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; -import { makeMockRoom, MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks"; +import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks"; import { logger } from "../../../src/logger"; describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( @@ -77,7 +77,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])( } }); - it("Fires event when session ends", async () => { + it("Fires event when session ends", () => { const onEnded = jest.fn(); client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded); const membershipData: MembershipData[] = [membershipTemplate]; diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index f1c838f2b31..ed5c680e586 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -79,6 +79,7 @@ export function makeMockRoom( const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId); + const ts = Date.now(); const room = Object.assign(new EventEmitter(), { roomId: roomId, hasMembershipState: jest.fn().mockReturnValue(true), @@ -89,7 +90,7 @@ export function makeMockRoom( unstableGetStickyEvents: jest .fn() .mockImplementation(() => - useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000)) : [], + useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [], ), }) as unknown as Room; return Object.assign(room, { @@ -155,9 +156,10 @@ export function mockRTCEvent( { user_id: sender, ...membershipData }: MembershipData, roomId: string, stickyDuration?: number, + timestamp?: number, ): MatrixEvent { return { - ...makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData), + ...makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData, timestamp), unstableStickyContent: { duration_ms: stickyDuration, }, diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 1f520f4dfe0..58d2eb9e584 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -321,7 +321,6 @@ export class MatrixRTCSession extends TypedEventEmitter< const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`); let callMemberEvents = [] as MatrixEvent[]; if (listenForStickyEvents) { - logger.info("useStickyEvents"); // prefill with sticky events callMemberEvents = Array.from(room.unstableGetStickyEvents()).filter( (e) => e.getType() === EventType.GroupCallMemberPrefix, From c332955b4128e7e56a909b066f915ef49fb32e2e Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 13:23:12 +0100 Subject: [PATCH 31/59] more lint --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 93 ++++++++++++++++++-- spec/unit/matrixrtc/mocks.ts | 20 +++-- src/matrixrtc/CallMembership.ts | 2 +- src/matrixrtc/MatrixRTCSession.ts | 2 +- 4 files changed, 103 insertions(+), 14 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 72ef9d8f89b..891189b4f1a 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -14,11 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src"; +import { + encodeBase64, + type EventTimeline, + EventType, + MatrixClient, + type MatrixError, + type MatrixEvent, + type Room, +} from "../../../src"; import { KnownMembership } from "../../../src/@types/membership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; import { Status, type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; -import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey, type MembershipData, mockRoomState } from "./mocks"; +import { + makeMockEvent, + makeMockRoom, + membershipTemplate, + makeKey, + type MembershipData, + mockRoomState, + mockRTCEvent, +} from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; const mockFocus = { type: "mock" }; @@ -118,7 +134,7 @@ describe("MatrixRTCSession", () => { it("ignores memberships events of members not in the room", () => { const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); - mockRoom.hasMembershipState = (state) => state === KnownMembership.Join; + mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); expect(sess?.memberships.length).toEqual(0); }); @@ -150,7 +166,7 @@ describe("MatrixRTCSession", () => { getLocalAge: jest.fn().mockReturnValue(0), }; const mockRoom = makeMockRoom([]); - mockRoom.getLiveTimeline = jest.fn().mockReturnValue({ + mockRoom.getLiveTimeline.mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), @@ -167,7 +183,7 @@ describe("MatrixRTCSession", () => { ], ]), }), - }); + } as unknown as EventTimeline); sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); expect(sess.memberships).toHaveLength(0); }); @@ -181,7 +197,7 @@ describe("MatrixRTCSession", () => { getLocalAge: jest.fn().mockReturnValue(0), }; const mockRoom = makeMockRoom([]); - mockRoom.getLiveTimeline = jest.fn().mockReturnValue({ + mockRoom.getLiveTimeline.mockReturnValue({ getState: jest.fn().mockReturnValue({ on: jest.fn(), off: jest.fn(), @@ -198,7 +214,7 @@ describe("MatrixRTCSession", () => { ], ]), }), - }); + } as unknown as EventTimeline); sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); expect(sess.memberships).toHaveLength(0); }); @@ -221,6 +237,69 @@ describe("MatrixRTCSession", () => { }, ); + describe("roomSessionForRoom combined state", () => { + it("perfers sticky events when both membership and sticky events appear for the same user", () => { + // Create a room with identical member state and sticky state for the same user. + const mockRoom = makeMockRoom([membershipTemplate]); + mockRoom.unstableGetStickyEvents.mockImplementation(() => { + const ev = mockRTCEvent( + { + ...membershipTemplate, + msc4354_sticky_key: `_${membershipTemplate.user_id}_${membershipTemplate.device_id}`, + }, + mockRoom.roomId, + ); + return [ev]; + }); + + // Expect for there to be one membership as the state has been merged down. + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }); + expect(sess?.memberships.length).toEqual(1); + expect(sess?.memberships[0].sessionDescription.id).toEqual(""); + expect(sess?.memberships[0].scope).toEqual("m.room"); + expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + expect(sess?.memberships[0].isExpired()).toEqual(false); + expect(sess?.sessionDescription.id).toEqual(""); + }); + it("combines sticky and membership events when both exist", () => { + // Create a room with identical member state and sticky state for the same user. + const mockRoom = makeMockRoom([membershipTemplate]); + const otherUserId = "@othermock:user.example"; + mockRoom.unstableGetStickyEvents.mockImplementation(() => { + const ev = mockRTCEvent( + { + ...membershipTemplate, + user_id: otherUserId, + msc4354_sticky_key: `_${otherUserId}_${membershipTemplate.device_id}`, + }, + mockRoom.roomId, + ); + return [ev]; + }); + + // Expect two membership events, sticky events always coming first. + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }); + expect(sess?.memberships.length).toEqual(2); + expect(sess?.memberships[0].sender).toEqual(otherUserId); + expect(sess?.memberships[0].sessionDescription.id).toEqual(""); + expect(sess?.memberships[0].scope).toEqual("m.room"); + expect(sess?.memberships[0].application).toEqual("m.call"); + expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); + expect(sess?.memberships[0].isExpired()).toEqual(false); + + expect(sess?.memberships[1].sender).toEqual(membershipTemplate.user_id); + + expect(sess?.sessionDescription.id).toEqual(""); + }); + }); + describe("getOldestMembership", () => { it("returns the oldest membership event", () => { jest.useFakeTimers(); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index ed5c680e586..5b6641cf3b6 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { EventEmitter } from "stream"; +import { type Mocked } from "jest-mock"; import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src"; import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; @@ -75,7 +76,7 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { export function makeMockRoom( membershipData: MembershipData[], useStickyEvents = false, -): Room & { emitTimelineEvent: (event: MatrixEvent) => void } { +): Mocked void }> { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` const roomState = makeMockRoomState(useStickyEvents ? [] : membershipData, roomId); @@ -91,12 +92,12 @@ export function makeMockRoom( .fn() .mockImplementation(() => useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [], - ), - }) as unknown as Room; + ) as any, + }); return Object.assign(room, { emitTimelineEvent: (event: MatrixEvent) => room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any), - }); + }) as unknown as Mocked void }>; } function makeMockRoomState(membershipData: MembershipData[], roomId: string) { @@ -140,6 +141,7 @@ export function makeMockEvent( roomId: string | undefined, content: any, timestamp?: number, + stateKey?: string, ): MatrixEvent { return { getType: jest.fn().mockReturnValue(type), @@ -148,6 +150,7 @@ export function makeMockEvent( getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()), getRoomId: jest.fn().mockReturnValue(roomId), getId: jest.fn().mockReturnValue(secureRandomString(8)), + getStateKey: jest.fn().mockReturnValue(stateKey), isDecryptionFailure: jest.fn().mockReturnValue(false), } as unknown as MatrixEvent; } @@ -159,7 +162,14 @@ export function mockRTCEvent( timestamp?: number, ): MatrixEvent { return { - ...makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData, timestamp), + ...makeMockEvent( + EventType.GroupCallMemberPrefix, + sender, + roomId, + membershipData, + timestamp, + !stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "", + ), unstableStickyContent: { duration_ms: stickyDuration, }, diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index e225a57d343..37c96c38bdd 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -96,7 +96,7 @@ export type SessionMembershipData = { /** * the sticky key for sticky events packed application + device_id making up the used slot + device. */ - "sticky_key"?: string; + "msc4354_sticky_key"?: string; }; const checkSessionsMembershipData = ( diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 58d2eb9e584..b1272744cce 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -322,7 +322,7 @@ export class MatrixRTCSession extends TypedEventEmitter< let callMemberEvents = [] as MatrixEvent[]; if (listenForStickyEvents) { // prefill with sticky events - callMemberEvents = Array.from(room.unstableGetStickyEvents()).filter( + callMemberEvents = [...room.unstableGetStickyEvents()].filter( (e) => e.getType() === EventType.GroupCallMemberPrefix, ); } From b686dbba423687103763d8579f119fea6a638119 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 13:47:53 +0100 Subject: [PATCH 32/59] tweaks --- src/@types/event.ts | 4 +--- src/matrixrtc/MatrixRTCSession.ts | 3 ++- src/matrixrtc/MembershipManager.ts | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/@types/event.ts b/src/@types/event.ts index 700ba91aa9e..42d920e3fa1 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -337,9 +337,7 @@ export interface TimelineEvents { [M_BEACON.name]: MBeaconEventContent; [M_POLL_START.name]: PollStartEventContent; [M_POLL_END.name]: PollEndEventContent; - // MSC3401 Adding this to the timeline events as well for sending this event as a sticky event. - // { sticky_key: string } is the empty object but we always need a sticky key - [EventType.GroupCallMemberPrefix]: SessionMembershipData | EmptyObject; + [EventType.GroupCallMemberPrefix]: SessionMembershipData | {}; } /** diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index b1272744cce..0f29203c408 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -348,7 +348,8 @@ export class MatrixRTCSession extends TypedEventEmitter< const callMemberships: CallMembership[] = []; for (const memberEvent of callMemberEvents) { const content = memberEvent.getContent(); - const eventKeysCount = Object.keys(content).length; + // Ignore sticky keys for the count + const eventKeysCount = Object.keys(content).filter((k) => k !== "msc4354_sticky_key").length; // Dont even bother about empty events (saves us from costly type/"key in" checks in bigger rooms) if (eventKeysCount === 0) continue; diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 0db9a5fb13f..622fb919cd8 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -1051,7 +1051,7 @@ export class StickyEventMembershipManager extends MembershipManager { { delay: this.delayedLeaveEventDelayMs }, null, EventType.GroupCallMemberPrefix, - Object.assign(myMembership, { sticky_key: this.stateKey }), + { msc4354_sticky_key: this.stateKey }, ); protected clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = @@ -1061,7 +1061,7 @@ export class StickyEventMembershipManager extends MembershipManager { STICK_DURATION_MS, null, EventType.GroupCallMemberPrefix, - Object.assign(myMembership, { sticky_key: this.stateKey }), + { ...myMembership, msc4354_sticky_key: this.stateKey }, ); protected actionUpdateFromErrors( From e5027c7d7d5b1e1cde5ff3d07276c3a8f89fd12c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 14:31:45 +0100 Subject: [PATCH 33/59] Add logging in areas --- src/matrixrtc/MatrixRTCSession.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 0f29203c408..adf19355de9 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -398,6 +398,7 @@ export class MatrixRTCSession extends TypedEventEmitter< callMemberships.map((m) => [m.createdTs(), m.sender]), ); } + console.log("Calculated RTC membership to be", callMemberships, "from", callMemberEvents); return callMemberships; } @@ -817,8 +818,9 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Call this when a sticky event update has occured. */ - private onStickyEventUpdate = (added: MatrixEvent[], _removed: MatrixEvent[]): void => { - if ([...added, ..._removed].some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { + private onStickyEventUpdate = (added: MatrixEvent[], removed: MatrixEvent[]): void => { + this.logger.debug("Sticky event update", {added, removed}); + if ([...added, ...removed].some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { this.recalculateSessionMembers(); } }; @@ -851,6 +853,7 @@ export class MatrixRTCSession extends TypedEventEmitter< this.logger.info( `Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`, ); + console.log({ old: oldMemberships, new: this.memberships }); logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => { this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships); }); From 3e8115d6ff85719cd110224011cd3ae8f1005445 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 14:38:51 +0100 Subject: [PATCH 34/59] more debugging --- src/matrixrtc/MatrixRTCSession.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index adf19355de9..74c44e789b3 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -325,6 +325,7 @@ export class MatrixRTCSession extends TypedEventEmitter< callMemberEvents = [...room.unstableGetStickyEvents()].filter( (e) => e.getType() === EventType.GroupCallMemberPrefix, ); + console.log('All sticky events', callMemberEvents); } if (listenForMemberStateEvents) { const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); From f8f2c6d1c95aef5ced9b6e97381a48d348d5ca57 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 15:06:06 +0100 Subject: [PATCH 35/59] much more logging --- src/models/room-sticky-events.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 5ce6ca6bec0..e931ca2e9e0 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -132,7 +132,6 @@ export class RoomStickyEventsStore extends TypedEventEmitter Date: Tue, 30 Sep 2025 15:32:15 +0100 Subject: [PATCH 36/59] remove more logging --- src/matrixrtc/MatrixRTCSession.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 74c44e789b3..4b195661118 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -325,7 +325,6 @@ export class MatrixRTCSession extends TypedEventEmitter< callMemberEvents = [...room.unstableGetStickyEvents()].filter( (e) => e.getType() === EventType.GroupCallMemberPrefix, ); - console.log('All sticky events', callMemberEvents); } if (listenForMemberStateEvents) { const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS); @@ -399,7 +398,6 @@ export class MatrixRTCSession extends TypedEventEmitter< callMemberships.map((m) => [m.createdTs(), m.sender]), ); } - console.log("Calculated RTC membership to be", callMemberships, "from", callMemberEvents); return callMemberships; } @@ -854,7 +852,6 @@ export class MatrixRTCSession extends TypedEventEmitter< this.logger.info( `Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`, ); - console.log({ old: oldMemberships, new: this.memberships }); logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => { this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships); }); From d4b22522860ebfc1bf6c47307f54f500af97e19f Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 17:16:31 +0100 Subject: [PATCH 37/59] Finish supporting new MSC --- spec/unit/models/room-sticky-events.spec.ts | 14 ++++++++++++++ src/matrixrtc/MatrixRTCSession.ts | 2 +- src/models/room-sticky-events.ts | 17 +++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index b0f960bfaaa..1ff7e951962 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -275,6 +275,20 @@ describe("RoomStickyEvents", () => { jest.advanceTimersByTime(15000); expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); }); + 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.setSystemTime(15000); + jest.advanceTimersByTime(15000); + expect(emitSpy).toHaveBeenCalledWith([], [ev]); + }); }); describe("cleanExpiredStickyEvents", () => { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 4b195661118..a96965831fe 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -818,7 +818,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * Call this when a sticky event update has occured. */ private onStickyEventUpdate = (added: MatrixEvent[], removed: MatrixEvent[]): void => { - this.logger.debug("Sticky event update", {added, removed}); + this.logger.debug("Sticky event update", { added, removed }); if ([...added, ...removed].some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { this.recalculateSessionMembers(); } diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index e931ca2e9e0..da5b7c099bc 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -221,6 +221,23 @@ export class RoomStickyEventsStore extends TypedEventEmitter= 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); } From 8d449a6b443281f9e6ce027065d71967b547474d Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 30 Sep 2025 17:17:44 +0100 Subject: [PATCH 38/59] a line --- spec/unit/models/room.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/unit/models/room.spec.ts b/spec/unit/models/room.spec.ts index a39256bbfe3..6efa9d22e39 100644 --- a/spec/unit/models/room.spec.ts +++ b/spec/unit/models/room.spec.ts @@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ + import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src"; import type { MockedObject } from "jest-mock"; From 4d130314167573bfdb1393e4f49893cb891b1fcb Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 3 Oct 2025 13:51:42 +0100 Subject: [PATCH 39/59] reconnect the bits to RTC --- spec/unit/models/room-sticky-events.spec.ts | 14 -------------- src/@types/event.ts | 2 +- src/matrixrtc/MatrixRTCSession.ts | 19 ++++++++++++++----- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index 1ff7e951962..b0f960bfaaa 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -275,20 +275,6 @@ describe("RoomStickyEvents", () => { jest.advanceTimersByTime(15000); expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); }); - 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.setSystemTime(15000); - jest.advanceTimersByTime(15000); - expect(emitSpy).toHaveBeenCalledWith([], [ev]); - }); }); describe("cleanExpiredStickyEvents", () => { diff --git a/src/@types/event.ts b/src/@types/event.ts index 42d920e3fa1..9a4890a9783 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -337,7 +337,7 @@ export interface TimelineEvents { [M_BEACON.name]: MBeaconEventContent; [M_POLL_START.name]: PollStartEventContent; [M_POLL_END.name]: PollEndEventContent; - [EventType.GroupCallMemberPrefix]: SessionMembershipData | {}; + [EventType.GroupCallMemberPrefix]: SessionMembershipData | { msc4354_sticky_key?: string }; } /** diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index a96965831fe..fe369407f86 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -51,6 +51,7 @@ import { import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; import { type MatrixEvent } from "src/matrix.ts"; +import { type RoomStickyEventsEvent, type RoomStickyEventsMap } from "src/models/room-sticky-events.ts"; /** * Events emitted by MatrixRTCSession @@ -296,7 +297,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * @deprecated Use `MatrixRTCSession.sessionMembershipsForRoom` instead. */ public static callMembershipsForRoom( - room: Pick, + room: Pick, ): CallMembership[] { return MatrixRTCSession.sessionMembershipsForRoom(room, { id: "", @@ -309,7 +310,7 @@ export class MatrixRTCSession extends TypedEventEmitter< * oldest first. */ public static sessionMembershipsForRoom( - room: Pick, + room: Pick, sessionDescription: SessionDescription, // default both true this implied we combine sticky and state events for the final call state // (prefer sticky events in case of a duplicate) @@ -322,7 +323,7 @@ export class MatrixRTCSession extends TypedEventEmitter< let callMemberEvents = [] as MatrixEvent[]; if (listenForStickyEvents) { // prefill with sticky events - callMemberEvents = [...room.unstableGetStickyEvents()].filter( + callMemberEvents = [...room._unstable_getStickyEvents()].filter( (e) => e.getType() === EventType.GroupCallMemberPrefix, ); } @@ -817,9 +818,17 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Call this when a sticky event update has occured. */ - private onStickyEventUpdate = (added: MatrixEvent[], removed: MatrixEvent[]): void => { + private onStickyEventUpdate: RoomStickyEventsMap[RoomStickyEventsEvent.Update] = ( + added, + updated, + removed, + ): void => { this.logger.debug("Sticky event update", { added, removed }); - if ([...added, ...removed].some((e) => e.getType() === EventType.GroupCallMemberPrefix)) { + if ( + [...added, ...removed, ...updated.flatMap((v) => [v.current, v.previous])].some( + (e) => e.getType() === EventType.GroupCallMemberPrefix, + ) + ) { this.recalculateSessionMembers(); } }; From af8c329ebe47dfa2fe63e23493327e15cd6d0fc1 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 3 Oct 2025 13:54:38 +0100 Subject: [PATCH 40/59] fixup more bits --- spec/unit/matrixrtc/mocks.ts | 4 +--- src/matrixrtc/MatrixRTCSessionManager.ts | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 5b6641cf3b6..bff7451623b 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -170,9 +170,7 @@ export function mockRTCEvent( timestamp, !stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "", ), - unstableStickyContent: { - duration_ms: stickyDuration, - }, + unstableStickyExpiresAt:stickyDuration, } as unknown as MatrixEvent; } diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 507dc54d266..c586ffce909 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -116,7 +116,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { - if (!event.unstableStickyContent?.duration_ms) { + if (!event.unstableStickyExpiresAt) { return; // Not sticky, not interested. } if (event.getType() !== EventType.GroupCallMemberPrefix) { From 42cdbf7cdc2ca46a3b3149d39bbb9792bd3e6ebe Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 3 Oct 2025 14:06:31 +0100 Subject: [PATCH 41/59] fixup testrs --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 4 ++-- spec/unit/matrixrtc/mocks.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 891189b4f1a..d6b66fada3f 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -241,7 +241,7 @@ describe("MatrixRTCSession", () => { it("perfers sticky events when both membership and sticky events appear for the same user", () => { // Create a room with identical member state and sticky state for the same user. const mockRoom = makeMockRoom([membershipTemplate]); - mockRoom.unstableGetStickyEvents.mockImplementation(() => { + mockRoom._unstable_getStickyEvents.mockImplementation(() => { const ev = mockRTCEvent( { ...membershipTemplate, @@ -269,7 +269,7 @@ describe("MatrixRTCSession", () => { // Create a room with identical member state and sticky state for the same user. const mockRoom = makeMockRoom([membershipTemplate]); const otherUserId = "@othermock:user.example"; - mockRoom.unstableGetStickyEvents.mockImplementation(() => { + mockRoom._unstable_getStickyEvents.mockImplementation(() => { const ev = mockRTCEvent( { ...membershipTemplate, diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index bff7451623b..d32fd6066a2 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -88,7 +88,7 @@ export function makeMockRoom( getState: jest.fn().mockReturnValue(roomState), }), getVersion: jest.fn().mockReturnValue("default"), - unstableGetStickyEvents: jest + _unstable_getStickyEvents: jest .fn() .mockImplementation(() => useStickyEvents ? membershipData.map((m) => mockRTCEvent(m, roomId, 10000, ts)) : [], @@ -170,7 +170,7 @@ export function mockRTCEvent( timestamp, !stickyDuration && "device_id" in membershipData ? `_${sender}_${membershipData.device_id}` : "", ), - unstableStickyExpiresAt:stickyDuration, + unstableStickyExpiresAt: stickyDuration, } as unknown as MatrixEvent; } From 6bac0ad3a7de47f475db37f70a76f632588b8f89 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 3 Oct 2025 15:50:20 +0100 Subject: [PATCH 42/59] Ensure consistent order --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index d6b66fada3f..74ccfd59089 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -286,15 +286,16 @@ describe("MatrixRTCSession", () => { listenForStickyEvents: true, listenForMemberStateEvents: true, }); - expect(sess?.memberships.length).toEqual(2); - expect(sess?.memberships[0].sender).toEqual(otherUserId); - expect(sess?.memberships[0].sessionDescription.id).toEqual(""); - expect(sess?.memberships[0].scope).toEqual("m.room"); - expect(sess?.memberships[0].application).toEqual("m.call"); - expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); - expect(sess?.memberships[0].isExpired()).toEqual(false); - - expect(sess?.memberships[1].sender).toEqual(membershipTemplate.user_id); + const memberships = sess.memberships.sort((a,b) => [a.sender,b.sender].sort().indexOf(a.sender)); + expect(memberships.length).toEqual(2); + expect(memberships[0].sender).toEqual(otherUserId); + expect(memberships[0].sessionDescription.id).toEqual(""); + expect(memberships[0].scope).toEqual("m.room"); + expect(memberships[0].application).toEqual("m.call"); + expect(memberships[0].deviceId).toEqual("AAAAAAA"); + expect(memberships[0].isExpired()).toEqual(false); + + expect(memberships[1].sender).toEqual(membershipTemplate.user_id); expect(sess?.sessionDescription.id).toEqual(""); }); From 673b53bb53e403d964ed1fbb100c638fdaddd758 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 3 Oct 2025 15:50:30 +0100 Subject: [PATCH 43/59] lint --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 74ccfd59089..ee1fcb9f334 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -286,7 +286,7 @@ describe("MatrixRTCSession", () => { listenForStickyEvents: true, listenForMemberStateEvents: true, }); - const memberships = sess.memberships.sort((a,b) => [a.sender,b.sender].sort().indexOf(a.sender)); + const memberships = sess.memberships.sort((a, b) => [a.sender, b.sender].sort().indexOf(a.sender)); expect(memberships.length).toEqual(2); expect(memberships[0].sender).toEqual(otherUserId); expect(memberships[0].sessionDescription.id).toEqual(""); From 54f45399669c12ce9458a88fbf2eca5483b22e9c Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 6 Oct 2025 11:54:04 +0100 Subject: [PATCH 44/59] fix log line --- src/matrixrtc/MatrixRTCSession.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index fe369407f86..6729e995612 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -823,7 +823,7 @@ export class MatrixRTCSession extends TypedEventEmitter< updated, removed, ): void => { - this.logger.debug("Sticky event update", { added, removed }); + this.logger.debug("Sticky event update", { added, updated, removed }); if ( [...added, ...removed, ...updated.flatMap((v) => [v.current, v.previous])].some( (e) => e.getType() === EventType.GroupCallMemberPrefix, From 71d3e73448d3d0ac8d6104c0210bc0f9574cc6ef Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 6 Oct 2025 11:54:44 +0100 Subject: [PATCH 45/59] remove extra bit of code --- src/models/room-sticky-events.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index da5b7c099bc..e931ca2e9e0 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -221,23 +221,6 @@ export class RoomStickyEventsStore extends TypedEventEmitter= 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); } From 946c20a364b67de9cfed752388a523df93883b12 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 6 Oct 2025 12:01:53 +0100 Subject: [PATCH 46/59] revert changes to room-sticky-events.ts --- src/models/room-sticky-events.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index e931ca2e9e0..5ce6ca6bec0 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -132,6 +132,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter Date: Tue, 7 Oct 2025 11:35:04 +0100 Subject: [PATCH 47/59] fixup mocks again --- spec/unit/models/room-sticky-events.spec.ts | 65 --------------------- 1 file changed, 65 deletions(-) diff --git a/spec/unit/models/room-sticky-events.spec.ts b/spec/unit/models/room-sticky-events.spec.ts index b0f960bfaaa..a51fe461c25 100644 --- a/spec/unit/models/room-sticky-events.spec.ts +++ b/spec/unit/models/room-sticky-events.spec.ts @@ -126,23 +126,6 @@ describe("RoomStickyEvents", () => { }); }); - 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.addStickyEvents([ - new MatrixEvent({ - ...stickyEvent, - }), - ]); - expect([...stickyEvents.getStickyEvents()]).toEqual([ev]); - expect(emitSpy).toHaveBeenCalledWith([ev], []); - }); - }); - describe("getStickyEvents", () => { it("should have zero sticky events", () => { expect([...stickyEvents.getStickyEvents()]).toHaveLength(0); @@ -276,52 +259,4 @@ describe("RoomStickyEvents", () => { expect(emitSpy).toHaveBeenCalledWith([], [], [ev]); }); }); - - describe("cleanExpiredStickyEvents", () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - afterAll(() => { - jest.useRealTimers(); - }); - - it("should emit when a sticky event expires", () => { - const stickyEvents = new RoomStickyEventsStore(); - 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); - jest.advanceTimersByTime(15000); - expect(emitSpy).toHaveBeenCalledWith([], [ev]); - }); - it("should emit two events when both expire at the same time", () => { - const stickyEvents = new RoomStickyEventsStore(); - 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.setSystemTime(15000); - jest.advanceTimersByTime(15000); - expect(emitSpy).toHaveBeenCalledWith([], [ev1, ev2]); - }); - }); }); From 8b9bb74524056ad6c128277dbd7f00edd3770d33 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 11:41:55 +0100 Subject: [PATCH 48/59] lint --- src/matrixrtc/MembershipManager.ts | 38 ++++++++++-------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 622fb919cd8..da871bdc019 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -156,6 +156,11 @@ function createReplaceActionUpdate(type: MembershipActionType, offset?: number): }; } +type MembershipManagerClient = Pick< + MatrixClient, + "getUserId" | "getDeviceId" | "sendStateEvent" | "_unstable_sendDelayedStateEvent" | "_unstable_updateDelayedEvent" +>; + /** * This class is responsible for sending all events relating to the own membership of a matrixRTC call. * It has the following tasks: @@ -326,14 +331,7 @@ export class MembershipManager public constructor( private joinConfig: (SessionConfig & MembershipConfig) | undefined, protected room: Pick, - private client: Pick< - MatrixClient, - | "getUserId" - | "getDeviceId" - | "sendStateEvent" - | "_unstable_sendDelayedStateEvent" - | "_unstable_updateDelayedEvent" - >, + private client: MembershipManagerClient, private getOldestMembership: () => CallMembership | undefined, public readonly sessionDescription: SessionDescription, parentLogger?: Logger, @@ -485,14 +483,12 @@ export class MembershipManager } // an abstraction to switch between sending state or a sticky event - protected clientSendDelayedEvent: (myMembership: EmptyObject) => Promise = ( - myMembership, - ) => + protected clientSendDelayedDisconnectMembership: () => Promise = () => this.client._unstable_sendDelayedStateEvent( this.room.roomId, { delay: this.delayedLeaveEventDelayMs }, EventType.GroupCallMemberPrefix, - myMembership, + {}, this.stateKey, ); @@ -502,7 +498,7 @@ export class MembershipManager // or during a call if the state event canceled our delayed event or caused by an unexpected error that removed our delayed event. // (Another client could have canceled it, the homeserver might have removed/lost it due to a restart, ...) // In the `then` and `catch` block we treat both cases differently. "if (this.state.hasMemberStateEvent) {} else {}" - return await this.clientSendDelayedEvent({}) + return await this.clientSendDelayedDisconnectMembership() .then((response) => { this.state.expectedServerDelayLeaveTs = Date.now() + this.delayedLeaveEventDelayMs; this.setAndEmitProbablyLeft(false); @@ -1025,16 +1021,8 @@ export class StickyEventMembershipManager extends MembershipManager { public constructor( joinConfig: (SessionConfig & MembershipConfig) | undefined, room: Pick, - private readonly clientWithSticky: Pick< - MatrixClient, - | "getUserId" - | "getDeviceId" - | "sendStateEvent" - | "_unstable_sendDelayedStateEvent" - | "_unstable_updateDelayedEvent" - | "_unstable_sendStickyEvent" - | "_unstable_sendStickyDelayedEvent" - >, + private readonly clientWithSticky: MembershipManagerClient & + Pick, getOldestMembership: () => CallMembership | undefined, sessionDescription: SessionDescription, parentLogger?: Logger, @@ -1042,9 +1030,7 @@ export class StickyEventMembershipManager extends MembershipManager { super(joinConfig, room, clientWithSticky, getOldestMembership, sessionDescription, parentLogger); } - protected clientSendDelayedEvent: (myMembership: EmptyObject) => Promise = ( - myMembership, - ) => + protected clientSendDelayedEvent: () => Promise = () => this.clientWithSticky._unstable_sendStickyDelayedEvent( this.room.roomId, STICK_DURATION_MS, From 0e7ba513013c6bedf1fe484bf84dd815d8decaa0 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 11:45:49 +0100 Subject: [PATCH 49/59] fix --- src/matrixrtc/MatrixRTCSession.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 6729e995612..5a7f335611a 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -51,7 +51,7 @@ import { import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; import { type MatrixEvent } from "src/matrix.ts"; -import { type RoomStickyEventsEvent, type RoomStickyEventsMap } from "src/models/room-sticky-events.ts"; +import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "src/models/room-sticky-events.ts"; /** * Events emitted by MatrixRTCSession @@ -498,7 +498,7 @@ export class MatrixRTCSession extends TypedEventEmitter< const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); // TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate); - this.roomSubset.on(RoomEvent.StickyEvents, this.onStickyEventUpdate); + this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate); this.setExpiryTimer(); } @@ -521,7 +521,7 @@ export class MatrixRTCSession extends TypedEventEmitter< } const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS); roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate); - this.roomSubset.off(RoomEvent.StickyEvents, this.onStickyEventUpdate); + this.roomSubset.off(RoomStickyEventsEvent.Update, this.onStickyEventUpdate); } private reEmitter = new TypedReEmitter< From 06fd1bfe1ca329b115ff21f60d7552fca6092355 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 12:24:04 +0100 Subject: [PATCH 50/59] cleanup --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 5 +++-- src/matrixrtc/MatrixRTCSession.ts | 2 +- src/models/room-sticky-events.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index ee1fcb9f334..bbcff1d70ec 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -36,6 +36,7 @@ import { mockRTCEvent, } from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; +import { type StickyMatrixEvent } from "src/models/room-sticky-events.ts"; const mockFocus = { type: "mock" }; @@ -249,7 +250,7 @@ describe("MatrixRTCSession", () => { }, mockRoom.roomId, ); - return [ev]; + return [ev as StickyMatrixEvent]; }); // Expect for there to be one membership as the state has been merged down. @@ -278,7 +279,7 @@ describe("MatrixRTCSession", () => { }, mockRoom.roomId, ); - return [ev]; + return [ev as StickyMatrixEvent]; }); // Expect two membership events, sticky events always coming first. diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 5a7f335611a..1f1ac626227 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -17,7 +17,7 @@ limitations under the License. import { type Logger, logger as rootLogger } from "../logger.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { EventTimeline } from "../models/event-timeline.ts"; -import { RoomEvent, type Room } from "../models/room.ts"; +import { type Room } from "../models/room.ts"; import { type MatrixClient } from "../client.ts"; import { EventType, RelationType } from "../@types/event.ts"; import { KnownMembership } from "../@types/membership.ts"; diff --git a/src/models/room-sticky-events.ts b/src/models/room-sticky-events.ts index 5ce6ca6bec0..3014d73e4b9 100644 --- a/src/models/room-sticky-events.ts +++ b/src/models/room-sticky-events.ts @@ -8,7 +8,7 @@ export enum RoomStickyEventsEvent { Update = "RoomStickyEvents.Update", } -type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number }; +export type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number }; export type RoomStickyEventsMap = { /** From 08b867522e0c201762fe6ae089328b887687871e Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 12:51:12 +0100 Subject: [PATCH 51/59] fix paths --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 2 +- src/matrixrtc/MatrixRTCSession.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index bbcff1d70ec..216797601f7 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -36,7 +36,7 @@ import { mockRTCEvent, } from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; -import { type StickyMatrixEvent } from "src/models/room-sticky-events.ts"; +import { type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts"; const mockFocus = { type: "mock" }; diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 1f1ac626227..3d6411b8970 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -51,7 +51,7 @@ import { import { TypedReEmitter } from "../ReEmitter.ts"; import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; import { type MatrixEvent } from "src/matrix.ts"; -import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "src/models/room-sticky-events.ts"; +import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts"; /** * Events emitted by MatrixRTCSession From b4aed2c71dc7a9072661f7d97f5c7e721ef8510e Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 13:28:20 +0100 Subject: [PATCH 52/59] tweak test --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 216797601f7..9251cae6afb 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -269,33 +269,36 @@ describe("MatrixRTCSession", () => { it("combines sticky and membership events when both exist", () => { // Create a room with identical member state and sticky state for the same user. const mockRoom = makeMockRoom([membershipTemplate]); - const otherUserId = "@othermock:user.example"; + const stickyUserId = "@stickyev:user.example"; mockRoom._unstable_getStickyEvents.mockImplementation(() => { const ev = mockRTCEvent( { ...membershipTemplate, - user_id: otherUserId, - msc4354_sticky_key: `_${otherUserId}_${membershipTemplate.device_id}`, + user_id: stickyUserId, + msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, }, mockRoom.roomId, + 15000, + Date.now() - 1000, // Sticky event comes first. ); return [ev as StickyMatrixEvent]; }); - // Expect two membership events, sticky events always coming first. sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, { listenForStickyEvents: true, listenForMemberStateEvents: true, }); - const memberships = sess.memberships.sort((a, b) => [a.sender, b.sender].sort().indexOf(a.sender)); + + const memberships = sess.memberships; expect(memberships.length).toEqual(2); - expect(memberships[0].sender).toEqual(otherUserId); + expect(memberships[0].sender).toEqual(stickyUserId); expect(memberships[0].sessionDescription.id).toEqual(""); expect(memberships[0].scope).toEqual("m.room"); expect(memberships[0].application).toEqual("m.call"); expect(memberships[0].deviceId).toEqual("AAAAAAA"); expect(memberships[0].isExpired()).toEqual(false); + // Then state expect(memberships[1].sender).toEqual(membershipTemplate.user_id); expect(sess?.sessionDescription.id).toEqual(""); From b6a782ba9e4a5bbbc0d0dd1c22217730bcc38a0b Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 21:39:52 +0100 Subject: [PATCH 53/59] fixup --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 116 +++++++++++++++++-- src/matrixrtc/MatrixRTCSessionManager.ts | 4 +- src/matrixrtc/MembershipManager.ts | 10 +- 3 files changed, 111 insertions(+), 19 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 9251cae6afb..fb51e9e2ca7 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -64,6 +64,12 @@ describe("MatrixRTCSession", () => { }); describe.each([ + { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + testCreateSticky: false, + createWithDefaults: true, // Create MatrixRTCSession with defaults + }, { listenForStickyEvents: true, listenForMemberStateEvents: true, @@ -87,10 +93,33 @@ describe("MatrixRTCSession", () => { ])( "roomSessionForRoom listenForSticky=$listenForStickyEvents listenForMemberStateEvents=$listenForMemberStateEvents testCreateSticky=$testCreateSticky", (testConfig) => { + it(`will ${testConfig.listenForMemberStateEvents ? "" : "NOT"} throw if the room does not have any state stored`, () => { + const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); + mockRoom.getLiveTimeline.mockReturnValue({ + getState: jest.fn().mockReturnValue(undefined), + } as unknown as EventTimeline); + if (testConfig.listenForMemberStateEvents) { + // eslint-disable-next-line jest/no-conditional-expect + expect(() => { + MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + }).toThrow(); + } else { + // eslint-disable-next-line jest/no-conditional-expect + expect(() => { + MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + }).not.toThrow(); + } + }); + it("creates a room-scoped session from room state", () => { const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess?.memberships.length).toEqual(1); expect(sess?.memberships[0].sessionDescription.id).toEqual(""); expect(sess?.memberships[0].scope).toEqual("m.room"); @@ -105,7 +134,12 @@ describe("MatrixRTCSession", () => { application: "not-m.call", }); const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + const sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess?.memberships).toHaveLength(0); }); @@ -115,7 +149,12 @@ describe("MatrixRTCSession", () => { scope: "m.room", }); const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + const sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess?.memberships).toHaveLength(0); }); @@ -127,7 +166,12 @@ describe("MatrixRTCSession", () => { const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky); jest.advanceTimersByTime(2000); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess?.memberships.length).toEqual(1); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); jest.useRealTimers(); @@ -136,7 +180,25 @@ describe("MatrixRTCSession", () => { it("ignores memberships events of members not in the room", () => { const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); + expect(sess?.memberships.length).toEqual(0); + }); + + it("ignores memberships events with no sender", () => { + // Force the sender to be undefined. + const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky); + mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); + sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess?.memberships.length).toEqual(0); }); @@ -147,14 +209,24 @@ describe("MatrixRTCSession", () => { expiredMembership.created_ts = 500; expiredMembership.expires = 1000; const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess?.memberships[0].getAbsoluteExpiry()).toEqual(1500); jest.useRealTimers(); }); it("returns empty session if no membership events are present", () => { const mockRoom = makeMockRoom([], testConfig.testCreateSticky); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess?.memberships).toHaveLength(0); }); @@ -185,7 +257,12 @@ describe("MatrixRTCSession", () => { ]), }), } as unknown as EventTimeline); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess.memberships).toHaveLength(0); }); @@ -216,7 +293,12 @@ describe("MatrixRTCSession", () => { ]), }), } as unknown as EventTimeline); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess.memberships).toHaveLength(0); }); @@ -224,7 +306,12 @@ describe("MatrixRTCSession", () => { const testMembership = Object.assign({}, membershipTemplate); (testMembership.device_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + const sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess.memberships).toHaveLength(0); }); @@ -232,7 +319,12 @@ describe("MatrixRTCSession", () => { const testMembership = Object.assign({}, membershipTemplate); (testMembership.call_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + sess = MatrixRTCSession.sessionForRoom( + client, + mockRoom, + callSession, + testConfig.createWithDefaults ? undefined : testConfig, + ); expect(sess.memberships).toHaveLength(0); }); }, @@ -288,7 +380,7 @@ describe("MatrixRTCSession", () => { listenForStickyEvents: true, listenForMemberStateEvents: true, }); - + const memberships = sess.memberships; expect(memberships.length).toEqual(2); expect(memberships[0].sender).toEqual(stickyUserId); diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index c586ffce909..0e6b7436048 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -115,7 +115,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { + private readonly onEvent = (event: MatrixEvent): void => { if (!event.unstableStickyExpiresAt) { return; // Not sticky, not interested. } @@ -129,7 +129,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { + private readonly onRoomState = (event: MatrixEvent): void => { if (event.getType() !== EventType.GroupCallMemberPrefix) { return; } diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index da871bdc019..c558500c997 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -179,7 +179,7 @@ export class MembershipManager implements IMembershipManager { private activated = false; - private logger: Logger; + private readonly logger: Logger; private callIntent: RTCCallIntent | undefined; public isActivated(): boolean { @@ -329,10 +329,10 @@ export class MembershipManager * @param getOldestMembership */ public constructor( - private joinConfig: (SessionConfig & MembershipConfig) | undefined, - protected room: Pick, - private client: MembershipManagerClient, - private getOldestMembership: () => CallMembership | undefined, + private readonly joinConfig: (SessionConfig & MembershipConfig) | undefined, + protected readonly room: Pick, + private readonly client: MembershipManagerClient, + private readonly getOldestMembership: () => CallMembership | undefined, public readonly sessionDescription: SessionDescription, parentLogger?: Logger, ) { From 2ce019a311ee9f0f0b7ec5c5c2292b71c639e124 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Tue, 7 Oct 2025 22:49:13 +0100 Subject: [PATCH 54/59] Add more tests for coverage --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 34 +++++++++- spec/unit/matrixrtc/MembershipManager.spec.ts | 66 ++++++++++++++++++- src/matrixrtc/MatrixRTCSession.ts | 4 +- src/matrixrtc/MembershipManager.ts | 17 +++-- 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index fb51e9e2ca7..6fb60388139 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -36,7 +36,8 @@ import { mockRTCEvent, } from "./mocks"; import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts"; -import { type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts"; +import { RoomStickyEventsEvent, type StickyMatrixEvent } from "../../../src/models/room-sticky-events.ts"; +import { StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; const mockFocus = { type: "mock" }; @@ -395,6 +396,31 @@ describe("MatrixRTCSession", () => { expect(sess?.sessionDescription.id).toEqual(""); }); + it("handles an incoming sticky event to an existing session", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + const stickyUserId = "@stickyev:user.example"; + + sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, { + listenForStickyEvents: true, + listenForMemberStateEvents: true, + }); + expect(sess.memberships.length).toEqual(1); + const stickyEv = mockRTCEvent( + { + ...membershipTemplate, + user_id: stickyUserId, + msc4354_sticky_key: `_${stickyUserId}_${membershipTemplate.device_id}`, + }, + mockRoom.roomId, + 15000, + Date.now() - 1000, // Sticky event comes first. + ) as StickyMatrixEvent; + mockRoom._unstable_getStickyEvents.mockImplementation(() => { + return [stickyEv]; + }); + mockRoom.emit(RoomStickyEventsEvent.Update, [stickyEv], [], []); + expect(sess.memberships.length).toEqual(2); + }); }); describe("getOldestMembership", () => { @@ -521,6 +547,12 @@ describe("MatrixRTCSession", () => { expect(sess!.isJoined()).toEqual(true); }); + it("uses the sticky events membership manager implementation", () => { + sess!.joinRoomSession([mockFocus], mockFocus, { unstableSendStickyEvents: true }); + expect(sess!.isJoined()).toEqual(true); + expect(sess!["membershipManager"] instanceof StickyEventMembershipManager).toEqual(true); + }); + it("sends a notification when starting a call and emit DidSendCallNotification", async () => { // Simulate a join, including the update to the room state // Ensure sendEvent returns event IDs so the DidSendCallNotification payload includes them diff --git a/spec/unit/matrixrtc/MembershipManager.spec.ts b/spec/unit/matrixrtc/MembershipManager.spec.ts index c22ab18390c..f5c5f27ea31 100644 --- a/spec/unit/matrixrtc/MembershipManager.spec.ts +++ b/spec/unit/matrixrtc/MembershipManager.spec.ts @@ -23,6 +23,7 @@ import { MatrixError, UnsupportedDelayedEventsEndpointError, type Room, + MAX_STICKY_DURATION_MS, } from "../../../src"; import { MembershipManagerEvent, @@ -33,7 +34,7 @@ import { } from "../../../src/matrixrtc"; import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks"; import { logger } from "../../../src/logger.ts"; -import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; +import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts"; /** * Create a promise that will resolve once a mocked method is called. @@ -474,7 +475,7 @@ describe("MembershipManager", () => { it("does not provide focus if the selection method is unknown", () => { const manager = new MembershipManager({}, room, client, () => undefined, callSession); - manager.join([focus], Object.assign(focusActive, { type: "unknown_type" })); + manager.join([focus], { ...focusActive, type: "unknown_type" }); expect(manager.getActiveFocus()).toBe(undefined); }); }); @@ -938,6 +939,67 @@ describe("MembershipManager", () => { expect(client.sendStateEvent).toHaveBeenCalledTimes(0); }); }); + + describe("StickyEventMembershipManager", () => { + beforeEach(() => { + // Provide a default mock that is like the default "non error" server behaviour. + (client._unstable_sendStickyDelayedEvent as Mock).mockResolvedValue({ delay_id: "id" }); + (client._unstable_sendStickyEvent as Mock).mockResolvedValue(undefined); + }); + + describe("join()", () => { + describe("sends a membership event", () => { + it("sends a membership event and schedules delayed leave when joining a call", async () => { + const updateDelayedEventHandle = createAsyncHandle( + client._unstable_updateDelayedEvent as Mock, + ); + const memberManager = new StickyEventMembershipManager( + undefined, + room, + client, + () => undefined, + callSession, + ); + + memberManager.join([focus], focusActive); + + await waitForMockCall(client._unstable_sendStickyEvent, Promise.resolve({ event_id: "id" })); + // Test we sent the initial join + expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith( + room.roomId, + MAX_STICKY_DURATION_MS, + null, + "org.matrix.msc3401.call.member", + { + application: "m.call", + call_id: "", + device_id: "AAAAAAA", + expires: 14400000, + foci_preferred: [focus], + focus_active: focusActive, + scope: "m.room", + msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", + } satisfies SessionMembershipData, + ); + updateDelayedEventHandle.resolve?.(); + + // Ensure we have sent the delayed disconnect event. + expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith( + room.roomId, + MAX_STICKY_DURATION_MS, + { delay: 8000 }, + null, + "org.matrix.msc3401.call.member", + { + msc4354_sticky_key: "_@alice:example.org_AAAAAAA_m.call", + }, + ); + // ..once + expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledTimes(1); + }); + }); + }); + }); }); it("Should prefix log with MembershipManager used", () => { diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 3d6411b8970..1ef3146c855 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -811,14 +811,14 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Call this when the Matrix room members have changed. */ - private onRoomMemberUpdate = (): void => { + private readonly onRoomMemberUpdate = (): void => { this.recalculateSessionMembers(); }; /** * Call this when a sticky event update has occured. */ - private onStickyEventUpdate: RoomStickyEventsMap[RoomStickyEventsEvent.Update] = ( + private readonly onStickyEventUpdate: RoomStickyEventsMap[RoomStickyEventsEvent.Update] = ( added, updated, removed, diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index c558500c997..dc061a35a07 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -679,8 +679,14 @@ export class MembershipManager } protected clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = - (myMembership) => - this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, myMembership, this.stateKey); + (myMembership) => { + return this.client.sendStateEvent( + this.room.roomId, + EventType.GroupCallMemberPrefix, + myMembership, + this.stateKey, + ); + }; private async sendJoinEvent(): Promise { return await this.clientSendMembership(this.makeMyMembership(this.membershipEventExpiryMs)) @@ -1030,7 +1036,7 @@ export class StickyEventMembershipManager extends MembershipManager { super(joinConfig, room, clientWithSticky, getOldestMembership, sessionDescription, parentLogger); } - protected clientSendDelayedEvent: () => Promise = () => + protected clientSendDelayedDisconnectMembership: () => Promise = () => this.clientWithSticky._unstable_sendStickyDelayedEvent( this.room.roomId, STICK_DURATION_MS, @@ -1041,14 +1047,15 @@ export class StickyEventMembershipManager extends MembershipManager { ); protected clientSendMembership: (myMembership: SessionMembershipData | EmptyObject) => Promise = - (myMembership) => - this.clientWithSticky._unstable_sendStickyEvent( + (myMembership) => { + return this.clientWithSticky._unstable_sendStickyEvent( this.room.roomId, STICK_DURATION_MS, null, EventType.GroupCallMemberPrefix, { ...myMembership, msc4354_sticky_key: this.stateKey }, ); + }; protected actionUpdateFromErrors( error: unknown, From d70e4f732c09582f49df7c42519f694d50436404 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 9 Oct 2025 17:39:42 +0200 Subject: [PATCH 55/59] Small improvements Signed-off-by: Timo K --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 42 ++++++++++---------- src/matrixrtc/MatrixRTCSessionManager.ts | 15 +++---- src/matrixrtc/MembershipManager.ts | 18 +++------ 3 files changed, 33 insertions(+), 42 deletions(-) diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 2000ae696e5..8eb11ecdd18 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -102,12 +102,12 @@ describe("MatrixRTCSession", () => { if (testConfig.listenForMemberStateEvents) { // eslint-disable-next-line jest/no-conditional-expect expect(() => { - MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig); }).toThrow(); } else { // eslint-disable-next-line jest/no-conditional-expect expect(() => { - MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, testConfig); + MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, testConfig); }).not.toThrow(); } }); @@ -115,19 +115,19 @@ describe("MatrixRTCSession", () => { it("creates a room-scoped session from room state", () => { const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); - sess = MatrixRTCSession.sessionForRoom( + sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, testConfig.createWithDefaults ? undefined : testConfig, ); expect(sess?.memberships.length).toEqual(1); - expect(sess?.memberships[0].sessionDescription.id).toEqual(""); + expect(sess?.memberships[0].slotDescription.id).toEqual(""); expect(sess?.memberships[0].scope).toEqual("m.room"); expect(sess?.memberships[0].application).toEqual("m.call"); expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA"); expect(sess?.memberships[0].isExpired()).toEqual(false); - expect(sess?.sessionDescription.id).toEqual(""); + expect(sess?.slotDescription.id).toEqual(""); }); it("ignores memberships where application is not m.call", () => { @@ -135,7 +135,7 @@ describe("MatrixRTCSession", () => { application: "not-m.call", }); const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); - const sess = MatrixRTCSession.sessionForRoom( + const sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -150,7 +150,7 @@ describe("MatrixRTCSession", () => { scope: "m.room", }); const mockRoom = makeMockRoom([testMembership], testConfig.testCreateSticky); - const sess = MatrixRTCSession.sessionForRoom( + const sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -167,7 +167,7 @@ describe("MatrixRTCSession", () => { const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], testConfig.testCreateSticky); jest.advanceTimersByTime(2000); - sess = MatrixRTCSession.sessionForRoom( + sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -181,7 +181,7 @@ describe("MatrixRTCSession", () => { it("ignores memberships events of members not in the room", () => { const mockRoom = makeMockRoom([membershipTemplate], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); - sess = MatrixRTCSession.sessionForRoom( + sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -194,7 +194,7 @@ describe("MatrixRTCSession", () => { // Force the sender to be undefined. const mockRoom = makeMockRoom([{ ...membershipTemplate, user_id: "" }], testConfig.testCreateSticky); mockRoom.hasMembershipState.mockImplementation((state) => state === KnownMembership.Join); - sess = MatrixRTCSession.sessionForRoom( + sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -210,7 +210,7 @@ describe("MatrixRTCSession", () => { expiredMembership.created_ts = 500; expiredMembership.expires = 1000; const mockRoom = makeMockRoom([expiredMembership], testConfig.testCreateSticky); - sess = MatrixRTCSession.sessionForRoom( + sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -222,7 +222,7 @@ describe("MatrixRTCSession", () => { it("returns empty session if no membership events are present", () => { const mockRoom = makeMockRoom([], testConfig.testCreateSticky); - sess = MatrixRTCSession.sessionForRoom( + sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -258,7 +258,7 @@ describe("MatrixRTCSession", () => { ]), }), } as unknown as EventTimeline); - sess = MatrixRTCSession.sessionForRoom( + sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -294,7 +294,7 @@ describe("MatrixRTCSession", () => { ]), }), } as unknown as EventTimeline); - sess = MatrixRTCSession.sessionForRoom( + sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -307,7 +307,7 @@ describe("MatrixRTCSession", () => { const testMembership = Object.assign({}, membershipTemplate); (testMembership.device_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); - const sess = MatrixRTCSession.sessionForRoom( + const sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -320,7 +320,7 @@ describe("MatrixRTCSession", () => { const testMembership = Object.assign({}, membershipTemplate); (testMembership.call_id as string | undefined) = undefined; const mockRoom = makeMockRoom([testMembership]); - sess = MatrixRTCSession.sessionForRoom( + sess = MatrixRTCSession.sessionForSlot( client, mockRoom, callSession, @@ -347,7 +347,7 @@ describe("MatrixRTCSession", () => { }); // Expect for there to be one membership as the state has been merged down. - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, { + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { listenForStickyEvents: true, listenForMemberStateEvents: true, }); @@ -377,7 +377,7 @@ describe("MatrixRTCSession", () => { return [ev as StickyMatrixEvent]; }); - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, { + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { listenForStickyEvents: true, listenForMemberStateEvents: true, }); @@ -385,7 +385,7 @@ describe("MatrixRTCSession", () => { const memberships = sess.memberships; expect(memberships.length).toEqual(2); expect(memberships[0].sender).toEqual(stickyUserId); - expect(memberships[0].sessionDescription.id).toEqual(""); + expect(memberships[0].slotDescription.id).toEqual(""); expect(memberships[0].scope).toEqual("m.room"); expect(memberships[0].application).toEqual("m.call"); expect(memberships[0].deviceId).toEqual("AAAAAAA"); @@ -394,13 +394,13 @@ describe("MatrixRTCSession", () => { // Then state expect(memberships[1].sender).toEqual(membershipTemplate.user_id); - expect(sess?.sessionDescription.id).toEqual(""); + expect(sess?.slotDescription.id).toEqual(""); }); it("handles an incoming sticky event to an existing session", () => { const mockRoom = makeMockRoom([membershipTemplate]); const stickyUserId = "@stickyev:user.example"; - sess = MatrixRTCSession.sessionForRoom(client, mockRoom, callSession, { + sess = MatrixRTCSession.sessionForSlot(client, mockRoom, callSession, { listenForStickyEvents: true, listenForMemberStateEvents: true, }); diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index fc40c5857b6..63c48ac51c4 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -116,16 +116,13 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { - if (!event.unstableStickyExpiresAt) { - return; // Not sticky, not interested. - } - if (event.getType() !== EventType.GroupCallMemberPrefix) { - return; - } + if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested. + + if (event.getType() !== EventType.GroupCallMemberPrefix) return; + const room = this.client.getRoom(event.getRoomId()); - if (!room) { - return; - } + if (!room) return; + this.refreshRoom(room); }; diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 637e2ac4d2a..523e93cc3fc 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -1058,18 +1058,12 @@ export class StickyEventMembershipManager extends MembershipManager { ); }; - protected actionUpdateFromErrors( - error: unknown, - type: MembershipActionType, - method: string, - ): ActionUpdate | undefined { - // Override method name. - if (method === "sendStateEvent") { - method = "_unstable_sendStickyEvent"; - } else if (method === "_unstable_sendDelayedStateEvent") { - method = "_unstable_sendStickyDelayedEvent"; - } - return super.actionUpdateFromErrors(error, type, method); + protected actionUpdateFromErrors(e: unknown, t: MembershipActionType, m: string): ActionUpdate | undefined { + const mappedMethod = new Map([ + ["sendStateEvent", "_unstable_sendStickyEvent"], + ["_unstable_sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"], + ]).get(m); + return super.actionUpdateFromErrors(e, t, mappedMethod ?? "unknown method"); } protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { From 2042f7535697e82dc445876229f996227002c683 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 9 Oct 2025 17:57:14 +0200 Subject: [PATCH 56/59] review Signed-off-by: Timo K --- src/matrixrtc/MembershipManager.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/matrixrtc/MembershipManager.ts b/src/matrixrtc/MembershipManager.ts index 523e93cc3fc..6c2e46418a3 100644 --- a/src/matrixrtc/MembershipManager.ts +++ b/src/matrixrtc/MembershipManager.ts @@ -1058,12 +1058,12 @@ export class StickyEventMembershipManager extends MembershipManager { ); }; + private static nameMap = new Map([ + ["sendStateEvent", "_unstable_sendStickyEvent"], + ["sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"], + ]); protected actionUpdateFromErrors(e: unknown, t: MembershipActionType, m: string): ActionUpdate | undefined { - const mappedMethod = new Map([ - ["sendStateEvent", "_unstable_sendStickyEvent"], - ["_unstable_sendDelayedStateEvent", "_unstable_sendStickyDelayedEvent"], - ]).get(m); - return super.actionUpdateFromErrors(e, t, mappedMethod ?? "unknown method"); + return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown"); } protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData { From 679652f4af5109134c147fa8d820a878e141057a Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Mon, 13 Oct 2025 11:02:08 +0100 Subject: [PATCH 57/59] Document better --- src/matrixrtc/MatrixRTCSession.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 2412fd2bd8b..b6c9f6e1814 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -240,7 +240,15 @@ export interface EncryptionConfig { export type JoinSessionConfig = SessionConfig & MembershipConfig & EncryptionConfig; interface SessionMembershipsForRoomOpts { + /** + * Listen for incoming sticky member events. If disabled, this session will + * ignore any incoming sticky events. + */ listenForStickyEvents: boolean; + /** + * Listen for incoming member state events (legacy). If disabled, this session will + * ignore any incoming state events. + */ listenForMemberStateEvents: boolean; } @@ -334,6 +342,8 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Returns all the call memberships for a room that match the provided `sessionDescription`, * oldest first. + * + * By default, this will return *both* sticky and member state events. */ public static sessionMembershipsForSlot( room: Pick, From 3a8fc193dff321176af8098d2a94fffb601d0b1e Mon Sep 17 00:00:00 2001 From: Timo K Date: Wed, 15 Oct 2025 17:44:29 +0200 Subject: [PATCH 58/59] fix sticky event type Signed-off-by: Timo K --- src/matrixrtc/MatrixRTCSession.ts | 4 ++-- src/matrixrtc/MatrixRTCSessionManager.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index b6c9f6e1814..a6ffc919c66 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -360,7 +360,7 @@ export class MatrixRTCSession extends TypedEventEmitter< if (listenForStickyEvents) { // prefill with sticky events callMemberEvents = [...room._unstable_getStickyEvents()].filter( - (e) => e.getType() === EventType.GroupCallMemberPrefix, + (e) => e.getType() === EventType.RTCMembership, ); } if (listenForMemberStateEvents) { @@ -861,7 +861,7 @@ export class MatrixRTCSession extends TypedEventEmitter< this.logger.debug("Sticky event update", { added, updated, removed }); if ( [...added, ...removed, ...updated.flatMap((v) => [v.current, v.previous])].some( - (e) => e.getType() === EventType.GroupCallMemberPrefix, + (e) => e.getType() === EventType.RTCMembership, ) ) { this.recalculateSessionMembers(); diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 63c48ac51c4..a103b39db98 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -118,7 +118,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { if (!event.unstableStickyExpiresAt) return; // Not sticky, not interested. - if (event.getType() !== EventType.GroupCallMemberPrefix) return; + if (event.getType() !== EventType.RTCMembership) return; const room = this.client.getRoom(event.getRoomId()); if (!room) return; From e7f5bec51b6f70501a025b79fe5021c933385b21 Mon Sep 17 00:00:00 2001 From: Timo K Date: Thu, 16 Oct 2025 01:00:57 +0200 Subject: [PATCH 59/59] fix demo Signed-off-by: Timo K --- src/matrixrtc/MatrixRTCSession.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index a6ffc919c66..f3e9a66cd10 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -342,7 +342,7 @@ export class MatrixRTCSession extends TypedEventEmitter< /** * Returns all the call memberships for a room that match the provided `sessionDescription`, * oldest first. - * + * * By default, this will return *both* sticky and member state events. */ public static sessionMembershipsForSlot( @@ -393,7 +393,7 @@ export class MatrixRTCSession extends TypedEventEmitter< const membershipContents: any[] = []; // We first decide if its a MSC4143 event (per device state key) - if (eventKeysCount > 1 && "focus_active" in content) { + if (eventKeysCount > 1 && "application" in content) { // We have a MSC4143 event membership event membershipContents.push(content); } else if (eventKeysCount === 1 && "memberships" in content) {