1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-12-14 19:22:15 +03:00

Implement Sticky Events MSC4354 (#5028)

* Implement Sticky Events MSC

* Renames

* lint

* some review work

* Update for support for 4-ples

* fix lint

* pull through method

* Fix the mistake

* More tests to appease SC

* Cleaner code

* Review cleanup

* Refactors based on review.

* lint

* Store sticky event expiry TS at insertion time.

* proper type
This commit is contained in:
Will Hunt
2025-10-07 18:24:10 +01:00
committed by GitHub
parent a03cf054a8
commit b84a73c7cc
11 changed files with 953 additions and 71 deletions

View File

@@ -20,6 +20,7 @@ import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/model
import { emitPromise } from "../../test-utils/test-utils"; import { emitPromise } from "../../test-utils/test-utils";
import { import {
type IAnnotatedPushRule, type IAnnotatedPushRule,
type IStickyEvent,
type MatrixClient, type MatrixClient,
PushRuleActionName, PushRuleActionName,
Room, Room,
@@ -598,6 +599,39 @@ describe("MatrixEvent", () => {
expect(stateEvent.isState()).toBeTruthy(); expect(stateEvent.isState()).toBeTruthy();
expect(stateEvent.threadRootId).toBeUndefined(); expect(stateEvent.threadRootId).toBeUndefined();
}); });
it("should calculate sticky duration correctly", async () => {
const evData: IStickyEvent = {
event_id: "$event_id",
type: "some_state_event",
content: {},
sender: "@alice:example.org",
origin_server_ts: 50,
msc4354_sticky: {
duration_ms: 1000,
},
unsigned: {
msc4354_sticky_duration_ttl_ms: 5000,
},
};
try {
jest.useFakeTimers();
jest.setSystemTime(50);
// Prefer unsigned
expect(new MatrixEvent({ ...evData } satisfies IStickyEvent).unstableStickyExpiresAt).toEqual(5050);
// Fall back to `duration_ms`
expect(
new MatrixEvent({ ...evData, unsigned: undefined } satisfies IStickyEvent).unstableStickyExpiresAt,
).toEqual(1050);
// Prefer current time if `origin_server_ts` is more recent.
expect(
new MatrixEvent({ ...evData, unsigned: undefined, origin_server_ts: 5000 } satisfies IStickyEvent)
.unstableStickyExpiresAt,
).toEqual(1050);
} finally {
jest.useRealTimers();
}
});
}); });
function mainTimelineLiveEventIds(room: Room): Array<string> { function mainTimelineLiveEventIds(room: Room): Array<string> {

View File

@@ -0,0 +1,262 @@
import { type IStickyEvent, MatrixEvent } from "../../../src";
import { RoomStickyEventsStore, RoomStickyEventsEvent } from "../../../src/models/room-sticky-events";
describe("RoomStickyEvents", () => {
let stickyEvents: RoomStickyEventsStore;
const emitSpy: jest.Mock = jest.fn();
const stickyEvent: IStickyEvent = {
event_id: "$foo:bar",
room_id: "!roomId",
type: "org.example.any_type",
msc4354_sticky: {
duration_ms: 15000,
},
content: {
msc4354_sticky_key: "foobar",
},
sender: "@alice:example.org",
origin_server_ts: Date.now(),
unsigned: {},
};
beforeEach(() => {
emitSpy.mockReset();
stickyEvents = new RoomStickyEventsStore();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
});
afterEach(() => {
stickyEvents?.clear();
});
describe("addStickyEvents", () => {
it("should allow adding an event without a msc4354_sticky_key", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, content: {} })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(1);
});
it("should not allow adding an event without a msc4354_sticky property", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, msc4354_sticky: undefined })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
stickyEvents.addStickyEvents([
new MatrixEvent({ ...stickyEvent, msc4354_sticky: { duration_ms: undefined } as any }),
]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should not allow adding an event without a sender", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: undefined })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should not allow adding an event with an invalid sender", () => {
stickyEvents.addStickyEvents([new MatrixEvent({ ...stickyEvent, sender: "not_a_real_sender" })]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should ignore old events", () => {
stickyEvents.addStickyEvents([
new MatrixEvent({ ...stickyEvent, origin_server_ts: 0, msc4354_sticky: { duration_ms: 1 } }),
]);
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should be able to just add an event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should not replace events on ID tie break", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([
new MatrixEvent({
...stickyEvent,
event_id: "$abc:bar",
}),
]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should not replace a newer event with an older event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([
new MatrixEvent({
...stickyEvent,
origin_server_ts: 1,
}),
]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv]);
});
it("should replace an older event with a newer event", () => {
const originalEv = new MatrixEvent({ ...stickyEvent, event_id: "$old" });
const newerEv = new MatrixEvent({
...stickyEvent,
event_id: "$new",
origin_server_ts: Date.now() + 2000,
});
stickyEvents.addStickyEvents([originalEv]);
stickyEvents.addStickyEvents([newerEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([newerEv]);
expect(emitSpy).toHaveBeenCalledWith([], [{ current: newerEv, previous: originalEv }], []);
});
it("should allow multiple events with the same sticky key for different event types", () => {
const originalEv = new MatrixEvent({ ...stickyEvent });
const anotherEv = new MatrixEvent({
...stickyEvent,
type: "org.example.another_type",
});
stickyEvents.addStickyEvents([originalEv, anotherEv]);
expect([...stickyEvents.getStickyEvents()]).toEqual([originalEv, anotherEv]);
});
it("should emit when a new sticky event is added", () => {
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
});
it("should emit when a new unkeyed sticky event is added", () => {
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
const ev = new MatrixEvent({
...stickyEvent,
content: {},
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
expect(emitSpy).toHaveBeenCalledWith([ev], [], []);
});
});
describe("getStickyEvents", () => {
it("should have zero sticky events", () => {
expect([...stickyEvents.getStickyEvents()]).toHaveLength(0);
});
it("should contain a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev]);
});
it("should contain two sticky events", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
const ev2 = new MatrixEvent({
...stickyEvent,
sender: "@fibble:bobble",
content: {
msc4354_sticky_key: "bibble",
},
});
stickyEvents.addStickyEvents([ev, ev2]);
expect([...stickyEvents.getStickyEvents()]).toEqual([ev, ev2]);
});
});
describe("getKeyedStickyEvent", () => {
it("should have zero sticky events", () => {
expect(
stickyEvents.getKeyedStickyEvent(
stickyEvent.sender,
stickyEvent.type,
stickyEvent.content.msc4354_sticky_key!,
),
).toBeUndefined();
});
it("should return a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
});
stickyEvents.addStickyEvents([ev]);
expect(
stickyEvents.getKeyedStickyEvent(
stickyEvent.sender,
stickyEvent.type,
stickyEvent.content.msc4354_sticky_key!,
),
).toEqual(ev);
});
});
describe("getUnkeyedStickyEvent", () => {
it("should have zero sticky events", () => {
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([]);
});
it("should return a sticky event", () => {
const ev = new MatrixEvent({
...stickyEvent,
content: {
msc4354_sticky_key: undefined,
},
});
stickyEvents.addStickyEvents([ev]);
expect(stickyEvents.getUnkeyedStickyEvent(stickyEvent.sender, stickyEvent.type)).toEqual([ev]);
});
});
describe("cleanExpiredStickyEvents", () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it("should emit when a sticky event expires", () => {
jest.setSystemTime(1000);
const ev = new MatrixEvent({
...stickyEvent,
origin_server_ts: 0,
});
const evLater = new MatrixEvent({
...stickyEvent,
event_id: "$baz:bar",
sender: "@bob:example.org",
origin_server_ts: 1000,
});
stickyEvents.addStickyEvents([ev, evLater]);
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
// Then expire the next event
jest.advanceTimersByTime(1000);
expect(emitSpy).toHaveBeenCalledWith([], [], [evLater]);
});
it("should emit two events when both expire at the same time", () => {
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.setSystemTime(0);
const ev1 = new MatrixEvent({
...stickyEvent,
event_id: "$eventA",
origin_server_ts: 0,
});
const ev2 = new MatrixEvent({
...stickyEvent,
event_id: "$eventB",
content: {
msc4354_sticky_key: "key_2",
},
origin_server_ts: 0,
});
stickyEvents.addStickyEvents([ev1, ev2]);
expect(emitSpy).toHaveBeenCalledWith([ev1, ev2], [], []);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev1, ev2]);
});
it("should emit when a unkeyed sticky event expires", () => {
const emitSpy = jest.fn();
stickyEvents.on(RoomStickyEventsEvent.Update, emitSpy);
jest.setSystemTime(0);
const ev = new MatrixEvent({
...stickyEvent,
content: {},
origin_server_ts: Date.now(),
});
stickyEvents.addStickyEvents([ev]);
jest.advanceTimersByTime(15000);
expect(emitSpy).toHaveBeenCalledWith([], [], [ev]);
});
});
});

View File

@@ -26,6 +26,7 @@ import {
type ILeftRoom, type ILeftRoom,
type IRoomEvent, type IRoomEvent,
type IStateEvent, type IStateEvent,
type IStickyEvent,
type IStrippedState, type IStrippedState,
type ISyncResponse, type ISyncResponse,
SyncAccumulator, SyncAccumulator,
@@ -1067,6 +1068,67 @@ describe("SyncAccumulator", function () {
); );
}); });
}); });
describe("MSC4354 sticky events", () => {
function stickyEvent(ts = 0): IStickyEvent {
const msgData = msg("test", "test text");
return {
...msgData,
msc4354_sticky: {
duration_ms: 1000,
},
origin_server_ts: ts,
};
}
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
it("should accumulate sticky events", () => {
jest.setSystemTime(0);
const ev = stickyEvent();
sa.accumulate(
syncSkeleton({
msc4354_sticky: {
events: [ev],
},
}),
);
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
});
it("should clear stale sticky events", () => {
jest.setSystemTime(1000);
const ev = stickyEvent(1000);
sa.accumulate(
syncSkeleton({
msc4354_sticky: {
events: [ev],
},
}),
);
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([ev]);
jest.setSystemTime(2000); // Expire the event
sa.accumulate(syncSkeleton({}));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
});
it("clears stale sticky events that pretend to be from the distant future", () => {
jest.setSystemTime(0);
const eventFarInTheFuture = stickyEvent(999999999999);
sa.accumulate(syncSkeleton({ msc4354_sticky: { events: [eventFarInTheFuture] } }));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toEqual([
eventFarInTheFuture,
]);
jest.setSystemTime(1000); // Expire the event
sa.accumulate(syncSkeleton({}));
expect(sa.getJSON().roomsData[Category.Join]["!foo:bar"].msc4354_sticky?.events).toBeUndefined();
});
});
}); });
function syncSkeleton( function syncSkeleton(

View File

@@ -96,19 +96,20 @@ export interface ISendEventResponse {
event_id: string; event_id: string;
} }
export type TimeoutDelay = { export type SendDelayedEventRequestOpts = { parent_delay_id: string } | { delay: number; parent_delay_id?: string };
delay: number;
};
export type ParentDelayId = {
parent_delay_id: string;
};
export type SendTimeoutDelayedEventRequestOpts = TimeoutDelay & Partial<ParentDelayId>;
export type SendActionDelayedEventRequestOpts = ParentDelayId;
export type SendDelayedEventRequestOpts = SendTimeoutDelayedEventRequestOpts | SendActionDelayedEventRequestOpts;
export function isSendDelayedEventRequestOpts(opts: object): opts is SendDelayedEventRequestOpts {
if ("parent_delay_id" in opts && typeof opts.parent_delay_id !== "string") {
// Invalid type, reject
return false;
}
if ("delay" in opts && typeof opts.delay !== "number") {
// Invalid type, reject.
return true;
}
// At least one of these fields must be specified.
return "delay" in opts || "parent_delay_id" in opts;
}
export type SendDelayedEventResponse = { export type SendDelayedEventResponse = {
delay_id: string; delay_id: string;
}; };

View File

@@ -105,6 +105,7 @@ import {
import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-member.ts"; import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-member.ts";
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts"; import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
import { import {
isSendDelayedEventRequestOpts,
type DelayedEventInfo, type DelayedEventInfo,
type IAddThreePidOnlyBody, type IAddThreePidOnlyBody,
type IBindThreePidBody, type IBindThreePidBody,
@@ -246,7 +247,7 @@ import {
validateAuthMetadataAndKeys, validateAuthMetadataAndKeys,
} from "./oidc/index.ts"; } from "./oidc/index.ts";
import { type EmptyObject } from "./@types/common.ts"; import { type EmptyObject } from "./@types/common.ts";
import { UnsupportedDelayedEventsEndpointError } from "./errors.ts"; import { UnsupportedDelayedEventsEndpointError, UnsupportedStickyEventsEndpointError } from "./errors.ts";
export type Store = IStore; export type Store = IStore;
@@ -551,6 +552,7 @@ export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms"
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms"; export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";
export const UNSTABLE_MSC4140_DELAYED_EVENTS = "org.matrix.msc4140"; 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 UNSTABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133";
export const STABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133.stable"; export const STABLE_MSC4133_EXTENDED_PROFILES = "uk.tcpip.msc4133.stable";
@@ -2683,7 +2685,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
this.addThreadRelationIfNeeded(content, threadId, roomId); this.addThreadRelationIfNeeded(content, threadId, roomId);
return this.sendCompleteEvent(roomId, threadId, { type: eventType, content }, txnId); return this.sendCompleteEvent({ roomId, threadId, eventObject: { type: eventType, content }, txnId });
} }
/** /**
@@ -2720,12 +2722,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: to an empty object `{}` * @returns Promise which resolves: to an empty object `{}`
* @returns Rejects: with an error response. * @returns Rejects: with an error response.
*/ */
private sendCompleteEvent( private sendCompleteEvent(params: {
roomId: string, roomId: string;
threadId: string | null, threadId: string | null;
eventObject: Partial<IEvent>, eventObject: Partial<IEvent>;
txnId?: string, queryDict?: QueryDict;
): Promise<ISendEventResponse>; txnId?: string;
}): Promise<ISendEventResponse>;
/** /**
* Sends a delayed event (MSC4140). * 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. * @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.
@@ -2734,29 +2737,29 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Promise which resolves: to an empty object `{}` * @returns Promise which resolves: to an empty object `{}`
* @returns Rejects: with an error response. * @returns Rejects: with an error response.
*/ */
private sendCompleteEvent( private sendCompleteEvent(params: {
roomId: string, roomId: string;
threadId: string | null, threadId: string | null;
eventObject: Partial<IEvent>, eventObject: Partial<IEvent>;
delayOpts: SendDelayedEventRequestOpts, delayOpts: SendDelayedEventRequestOpts;
txnId?: string, queryDict?: QueryDict;
): Promise<SendDelayedEventResponse>; txnId?: string;
private sendCompleteEvent( }): Promise<SendDelayedEventResponse>;
roomId: string, private sendCompleteEvent({
threadId: string | null, roomId,
eventObject: Partial<IEvent>, threadId,
delayOptsOrTxnId?: SendDelayedEventRequestOpts | string, eventObject,
txnIdOrVoid?: string, delayOpts,
): Promise<ISendEventResponse | SendDelayedEventResponse> { queryDict,
let delayOpts: SendDelayedEventRequestOpts | undefined; txnId,
let txnId: string | undefined; }: {
if (typeof delayOptsOrTxnId === "string") { roomId: string;
txnId = delayOptsOrTxnId; threadId: string | null;
} else { eventObject: Partial<IEvent>;
delayOpts = delayOptsOrTxnId; delayOpts?: SendDelayedEventRequestOpts;
txnId = txnIdOrVoid; queryDict?: QueryDict;
} txnId?: string;
}): Promise<SendDelayedEventResponse | ISendEventResponse> {
if (!txnId) { if (!txnId) {
txnId = this.makeTxnId(); txnId = this.makeTxnId();
} }
@@ -2799,7 +2802,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const type = localEvent.getType(); const type = localEvent.getType();
this.logger.debug( this.logger.debug(
`sendEvent of type ${type} in ${roomId} with txnId ${txnId}${delayOpts ? " (delayed event)" : ""}`, `sendEvent of type ${type} in ${roomId} with txnId ${txnId}${delayOpts ? " (delayed event)" : ""}${queryDict ? " query params: " + JSON.stringify(queryDict) : ""}`,
); );
localEvent.setTxnId(txnId); localEvent.setTxnId(txnId);
@@ -2817,9 +2820,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return Promise.reject(new Error("Event blocked by other events not yet sent")); return Promise.reject(new Error("Event blocked by other events not yet sent"));
} }
return this.encryptAndSendEvent(room, localEvent); return this.encryptAndSendEvent(room, localEvent, queryDict);
} else { } else {
return this.encryptAndSendEvent(room, localEvent, delayOpts); return this.encryptAndSendEvent(room, localEvent, delayOpts, queryDict);
} }
} }
@@ -2827,7 +2830,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* encrypts the event if necessary; adds the event to the queue, or sends it; marks the event as sent/unsent * encrypts the event if necessary; adds the event to the queue, or sends it; marks the event as sent/unsent
* @returns returns a promise which resolves with the result of the send request * @returns returns a promise which resolves with the result of the send request
*/ */
protected async encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse>; protected async encryptAndSendEvent(
room: Room | null,
event: MatrixEvent,
queryDict?: QueryDict,
): Promise<ISendEventResponse>;
/** /**
* Simply sends a delayed event without encrypting it. * Simply sends a delayed event without encrypting it.
* TODO: Allow encrypted delayed events, and encrypt them properly * TODO: Allow encrypted delayed events, and encrypt them properly
@@ -2838,16 +2845,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
room: Room | null, room: Room | null,
event: MatrixEvent, event: MatrixEvent,
delayOpts: SendDelayedEventRequestOpts, delayOpts: SendDelayedEventRequestOpts,
): Promise<SendDelayedEventResponse>; queryDict?: QueryDict,
): Promise<ISendEventResponse>;
protected async encryptAndSendEvent( protected async encryptAndSendEvent(
room: Room | null, room: Room | null,
event: MatrixEvent, event: MatrixEvent,
delayOpts?: SendDelayedEventRequestOpts, delayOptsOrQuery?: SendDelayedEventRequestOpts | QueryDict,
queryDict?: QueryDict,
): Promise<ISendEventResponse | SendDelayedEventResponse> { ): Promise<ISendEventResponse | SendDelayedEventResponse> {
if (delayOpts) { let queryOpts = queryDict;
return this.sendEventHttpRequest(event, delayOpts); if (delayOptsOrQuery && isSendDelayedEventRequestOpts(delayOptsOrQuery)) {
return this.sendEventHttpRequest(event, delayOptsOrQuery, queryOpts);
} else if (!queryOpts) {
queryOpts = delayOptsOrQuery;
} }
try { try {
let cancelled: boolean; let cancelled: boolean;
this.eventsBeingEncrypted.add(event.getId()!); this.eventsBeingEncrypted.add(event.getId()!);
@@ -2883,7 +2894,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
if (!promise) { if (!promise) {
promise = this.sendEventHttpRequest(event); promise = this.sendEventHttpRequest(event, queryOpts);
if (room) { if (room) {
promise = promise.then((res) => { promise = promise.then((res) => {
room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]); room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]);
@@ -2998,14 +3009,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
} }
private sendEventHttpRequest(event: MatrixEvent): Promise<ISendEventResponse>; private sendEventHttpRequest(event: MatrixEvent, queryDict?: QueryDict): Promise<ISendEventResponse>;
private sendEventHttpRequest( private sendEventHttpRequest(
event: MatrixEvent, event: MatrixEvent,
delayOpts: SendDelayedEventRequestOpts, delayOpts: SendDelayedEventRequestOpts,
queryDict?: QueryDict,
): Promise<SendDelayedEventResponse>; ): Promise<SendDelayedEventResponse>;
private sendEventHttpRequest( private sendEventHttpRequest(
event: MatrixEvent, event: MatrixEvent,
delayOpts?: SendDelayedEventRequestOpts, queryOrDelayOpts?: SendDelayedEventRequestOpts | QueryDict,
queryDict?: QueryDict,
): Promise<ISendEventResponse | SendDelayedEventResponse> { ): Promise<ISendEventResponse | SendDelayedEventResponse> {
let txnId = event.getTxnId(); let txnId = event.getTxnId();
if (!txnId) { if (!txnId) {
@@ -3038,19 +3051,22 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams); path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams);
} }
const delayOpts =
queryOrDelayOpts && isSendDelayedEventRequestOpts(queryOrDelayOpts) ? queryOrDelayOpts : undefined;
const queryOpts = !delayOpts ? queryOrDelayOpts : queryDict;
const content = event.getWireContent(); const content = event.getWireContent();
if (!delayOpts) { if (delayOpts) {
return this.http.authedRequest<ISendEventResponse>(Method.Put, path, undefined, content).then((res) => {
this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
return res;
});
} else {
return this.http.authedRequest<SendDelayedEventResponse>( return this.http.authedRequest<SendDelayedEventResponse>(
Method.Put, Method.Put,
path, path,
getUnstableDelayQueryOpts(delayOpts), { ...getUnstableDelayQueryOpts(delayOpts), ...queryOpts },
content, content,
); );
} else {
return this.http.authedRequest<ISendEventResponse>(Method.Put, path, queryOpts, content).then((res) => {
this.logger.debug(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
return res;
});
} }
} }
@@ -3107,16 +3123,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
content[withRelTypesPropName] = opts.with_rel_types; content[withRelTypesPropName] = opts.with_rel_types;
} }
return this.sendCompleteEvent( return this.sendCompleteEvent({
roomId, roomId,
threadId, threadId,
{ eventObject: {
type: EventType.RoomRedaction, type: EventType.RoomRedaction,
content, content,
redacts: eventId, redacts: eventId,
}, },
txnId as string, txnId: txnId as string,
); });
} }
/** /**
@@ -3404,7 +3420,54 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
} }
this.addThreadRelationIfNeeded(content, threadId, roomId); this.addThreadRelationIfNeeded(content, threadId, roomId);
return this.sendCompleteEvent(roomId, threadId, { type: eventType, content }, delayOpts, txnId); return this.sendCompleteEvent({
roomId,
threadId,
eventObject: { type: eventType, content },
delayOpts,
txnId,
});
}
/**
* Send a delayed sticky timeline event.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) and
* [MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) for more details.
*/
// eslint-disable-next-line
public async _unstable_sendStickyDelayedEvent<K extends keyof TimelineEvents>(
roomId: string,
stickDuration: number,
delayOpts: SendDelayedEventRequestOpts,
threadId: string | null,
eventType: K,
content: TimelineEvents[K] & { msc4354_sticky_key: string },
txnId?: string,
): Promise<SendDelayedEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4140_DELAYED_EVENTS))) {
throw new UnsupportedDelayedEventsEndpointError(
"Server does not support the delayed events API",
"getDelayedEvents",
);
}
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) {
throw new UnsupportedStickyEventsEndpointError(
"Server does not support the sticky events",
"sendStickyEvent",
);
}
this.addThreadRelationIfNeeded(content, threadId, roomId);
return this.sendCompleteEvent({
roomId,
threadId,
eventObject: { type: eventType, content },
queryDict: { "org.matrix.msc4354.sticky_duration_ms": stickDuration },
delayOpts,
txnId,
});
} }
/** /**
@@ -3441,6 +3504,38 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.http.authedRequest(Method.Put, path, getUnstableDelayQueryOpts(delayOpts), content as Body, opts); return this.http.authedRequest(Method.Put, path, getUnstableDelayQueryOpts(delayOpts), content as Body, opts);
} }
/**
* Send a sticky timeline event.
*
* Note: This endpoint is unstable, and can throw an `Error`.
* Check progress on [MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354) for more details.
*/
// eslint-disable-next-line
public async _unstable_sendStickyEvent<K extends keyof TimelineEvents>(
roomId: string,
stickDuration: number,
threadId: string | null,
eventType: K,
content: TimelineEvents[K] & { msc4354_sticky_key: string },
txnId?: string,
): Promise<ISendEventResponse> {
if (!(await this.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS))) {
throw new UnsupportedStickyEventsEndpointError(
"Server does not support the sticky events",
"sendStickyEvent",
);
}
this.addThreadRelationIfNeeded(content, threadId, roomId);
return this.sendCompleteEvent({
roomId,
threadId,
eventObject: { type: eventType, content },
queryDict: { "org.matrix.msc4354.sticky_duration_ms": stickDuration },
txnId,
});
}
/** /**
* Get all pending delayed events for the calling user. * Get all pending delayed events for the calling user.
* *

View File

@@ -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 { export class UnsupportedDelayedEventsEndpointError extends Error {
public constructor( public constructor(
@@ -65,3 +65,16 @@ export class UnsupportedDelayedEventsEndpointError extends Error {
this.name = "UnsupportedDelayedEventsEndpointError"; 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";
}
}

View File

@@ -75,6 +75,7 @@ export interface IUnsigned {
"transaction_id"?: string; "transaction_id"?: string;
"invite_room_state"?: StrippedState[]; "invite_room_state"?: StrippedState[];
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations "m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
"msc4354_sticky_duration_ttl_ms"?: number;
[UNSIGNED_THREAD_ID_FIELD.name]?: string; [UNSIGNED_THREAD_ID_FIELD.name]?: string;
} }
@@ -96,6 +97,7 @@ export interface IEvent {
membership?: Membership; membership?: Membership;
unsigned: IUnsigned; unsigned: IUnsigned;
redacts?: string; redacts?: string;
msc4354_sticky?: { duration_ms: number };
} }
export interface IAggregatedRelation { export interface IAggregatedRelation {
@@ -213,6 +215,7 @@ export interface IMessageVisibilityHidden {
} }
// A singleton implementing `IMessageVisibilityVisible`. // A singleton implementing `IMessageVisibilityVisible`.
const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true }); const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true });
export const MAX_STICKY_DURATION_MS = 3600000;
export enum MatrixEventEvent { export enum MatrixEventEvent {
/** /**
@@ -408,6 +411,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
private readonly reEmitter: TypedReEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap>; private readonly reEmitter: TypedReEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap>;
/**
* The timestamp for when this event should expire, in milliseconds.
* Prefers using the server-provided value, but will fall back to local calculation.
*
* This value is **safe** to use, as malicious start time and duration are appropriately capped.
*
* If the event is not a sticky event (or not supported by the server),
* then this returns `undefined`.
*/
public readonly unstableStickyExpiresAt: number | undefined;
/** /**
* Construct a Matrix Event object * Construct a Matrix Event object
* *
@@ -447,8 +461,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
// The fallback in these cases will be to use the origin_server_ts. // The fallback in these cases will be to use the origin_server_ts.
// For EDUs, the origin_server_ts also is not defined so we use Date.now(). // For EDUs, the origin_server_ts also is not defined so we use Date.now().
const age = this.getAge(); const age = this.getAge();
this.localTimestamp = age !== undefined ? Date.now() - age : (this.getTs() ?? Date.now()); const now = Date.now();
this.localTimestamp = age !== undefined ? now - age : (this.getTs() ?? now);
this.reEmitter = new TypedReEmitter(this); this.reEmitter = new TypedReEmitter(this);
if (this.unstableStickyInfo) {
if (this.unstableStickyInfo.duration_ttl_ms) {
this.unstableStickyExpiresAt = now + this.unstableStickyInfo.duration_ttl_ms;
} else {
// Bound the timestamp so it doesn't come from the future.
this.unstableStickyExpiresAt = Math.min(now, this.getTs()) + this.unstableStickyInfo.duration_ms;
}
}
} }
/** /**
@@ -1739,6 +1762,24 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
public setThreadId(threadId?: string): void { public setThreadId(threadId?: string): void {
this.threadId = threadId; this.threadId = threadId;
} }
/**
* Unstable getter to try and get the sticky information for the event.
* If the event is not a sticky event (or not supported by the server),
* then this returns `undefined`.
*
* `duration_ms` is safely bounded to a hour.
*/
public get unstableStickyInfo(): { duration_ms: number; duration_ttl_ms?: number } | undefined {
if (!this.event.msc4354_sticky?.duration_ms) {
return undefined;
}
return {
duration_ms: Math.min(MAX_STICKY_DURATION_MS, this.event.msc4354_sticky.duration_ms),
// This is assumed to be bounded server-side.
duration_ttl_ms: this.event.unsigned?.msc4354_sticky_duration_ttl_ms,
};
}
} }
/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted /* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted

View File

@@ -0,0 +1,241 @@
import { logger as loggerInstance } from "../logger.ts";
import { type MatrixEvent } from "./event.ts";
import { TypedEventEmitter } from "./typed-event-emitter.ts";
const logger = loggerInstance.getChild("RoomStickyEvents");
export enum RoomStickyEventsEvent {
Update = "RoomStickyEvents.Update",
}
type StickyMatrixEvent = MatrixEvent & { unstableStickyExpiresAt: number };
export type RoomStickyEventsMap = {
/**
* 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: StickyMatrixEvent[],
updated: { current: StickyMatrixEvent; previous: StickyMatrixEvent }[],
removed: StickyMatrixEvent[],
) => void;
};
/**
* Tracks sticky events on behalf of one room, and fires an event
* whenever a sticky event is updated or replaced.
*/
export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEvent, RoomStickyEventsMap> {
private readonly stickyEventsMap = new Map<string, Map<string, StickyMatrixEvent>>(); // (type -> stickyKey+userId) -> event
private readonly unkeyedStickyEvents = new Set<StickyMatrixEvent>();
private stickyEventTimer?: ReturnType<typeof setTimeout>;
private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER;
/**
* Get all sticky events that are currently active.
* @returns An iterable set of events.
*/
public *getStickyEvents(): Iterable<StickyMatrixEvent> {
yield* this.unkeyedStickyEvents;
for (const innerMap of this.stickyEventsMap.values()) {
yield* innerMap.values();
}
}
/**
* Get an active sticky event that match the given `type`, `sender`, and `stickyKey`
* @param type The event `type`.
* @param sender The sender of the sticky event.
* @param stickyKey The sticky key used by the event.
* @returns A matching active sticky event, or undefined.
*/
public getKeyedStickyEvent(sender: string, type: string, stickyKey: string): StickyMatrixEvent | undefined {
return this.stickyEventsMap.get(type)?.get(`${stickyKey}${sender}`);
}
/**
* Get active sticky events without a sticky key that match the given `type` and `sender`.
* @param type The event `type`.
* @param sender The sender of the sticky event.
* @returns An array of matching sticky events.
*/
public getUnkeyedStickyEvent(sender: string, type: string): StickyMatrixEvent[] {
return [...this.unkeyedStickyEvents].filter((ev) => ev.getType() === type && ev.getSender() === sender);
}
/**
* Adds a sticky event into the local sticky event map.
*
* NOTE: This will not cause `RoomEvent.StickyEvents` to be emitted.
*
* @throws If the `event` does not contain valid sticky data.
* @param event The MatrixEvent that contains sticky data.
* @returns An object describing whether the event was added to the map,
* and the previous event it may have replaced.
*/
private addStickyEvent(event: MatrixEvent): { added: true; prevEvent?: StickyMatrixEvent } | { added: false } {
const stickyKey = event.getContent().msc4354_sticky_key;
if (typeof stickyKey !== "string" && stickyKey !== undefined) {
throw new Error(`${event.getId()} is missing msc4354_sticky_key`);
}
// With this we have the guarantee, that all events in stickyEventsMap are correctly formatted
if (event.unstableStickyExpiresAt === undefined) {
throw new Error(`${event.getId()} is missing msc4354_sticky.duration_ms`);
}
const sender = event.getSender();
const type = event.getType();
if (!sender) {
throw new Error(`${event.getId()} is missing a sender`);
} else if (event.unstableStickyExpiresAt <= Date.now()) {
logger.info("ignored sticky event with older expiration time than current time", stickyKey);
return { added: false };
}
// While we fully expect the server to always provide the correct value,
// this is just insurance to protect against attacks on our Map.
if (!sender.startsWith("@")) {
throw new Error("Expected sender to start with @");
}
let prevEvent: StickyMatrixEvent | undefined;
if (stickyKey !== undefined) {
// Why this is safe:
// A type may contain anything but the *sender* is tightly
// constrained so that a key will always end with a @<user_id>
// E.g. Where a malicous event type might be "rtc.member.event@foo:bar" the key becomes:
// "rtc.member.event.@foo:bar@bar:baz"
const innerMapKey = `${stickyKey}${sender}`;
prevEvent = this.stickyEventsMap.get(type)?.get(innerMapKey);
// sticky events are not allowed to expire sooner than their predecessor.
if (prevEvent && event.unstableStickyExpiresAt < prevEvent.unstableStickyExpiresAt) {
logger.info("ignored sticky event with older expiry time", stickyKey);
return { added: false };
} else if (
prevEvent &&
event.getTs() === prevEvent.getTs() &&
(event.getId() ?? "") < (prevEvent.getId() ?? "")
) {
// This path is unlikely, as it requires both events to have the same TS.
logger.info("ignored sticky event due to 'id tie break rule' on sticky_key", stickyKey);
return { added: false };
}
if (!this.stickyEventsMap.has(type)) {
this.stickyEventsMap.set(type, new Map());
}
this.stickyEventsMap.get(type)!.set(innerMapKey, event as StickyMatrixEvent);
} else {
this.unkeyedStickyEvents.add(event as StickyMatrixEvent);
}
// Recalculate the next expiry time.
this.nextStickyEventExpiryTs = Math.min(event.unstableStickyExpiresAt, this.nextStickyEventExpiryTs);
this.scheduleStickyTimer();
return { added: true, prevEvent };
}
/**
* Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any
* changes were made.
* @param events A set of new sticky events.
*/
public addStickyEvents(events: MatrixEvent[]): void {
const added: StickyMatrixEvent[] = [];
const updated: { current: StickyMatrixEvent; previous: StickyMatrixEvent }[] = [];
for (const event of events) {
try {
const result = this.addStickyEvent(event);
if (result.added) {
if (result.prevEvent) {
// e is validated as a StickyMatrixEvent by virtue of `addStickyEvent` returning added: true.
updated.push({ current: event as StickyMatrixEvent, previous: result.prevEvent });
} else {
added.push(event as StickyMatrixEvent);
}
}
} catch (ex) {
logger.warn("ignored invalid sticky event", ex);
}
}
if (added.length || updated.length) this.emit(RoomStickyEventsEvent.Update, added, updated, []);
this.scheduleStickyTimer();
}
/**
* Schedule the sticky event expiry timer. The timer will
* run immediately if an event has already expired.
*/
private scheduleStickyTimer(): void {
if (this.stickyEventTimer) {
clearTimeout(this.stickyEventTimer);
this.stickyEventTimer = undefined;
}
if (this.nextStickyEventExpiryTs === Number.MAX_SAFE_INTEGER) {
// We have no events due to expire.
return;
} // otherwise, schedule in the future
this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, this.nextStickyEventExpiryTs - Date.now());
}
/**
* Clean out any expired sticky events.
*/
private readonly cleanExpiredStickyEvents = (): void => {
const now = Date.now();
const removedEvents: StickyMatrixEvent[] = [];
// We will recalculate this as we check all events.
this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER;
for (const [eventType, innerEvents] of this.stickyEventsMap.entries()) {
for (const [innerMapKey, event] of innerEvents) {
// we only added items with `sticky` into this map so we can assert non-null here
if (now >= event.unstableStickyExpiresAt) {
logger.debug("Expiring sticky event", event.getId());
removedEvents.push(event);
this.stickyEventsMap.get(eventType)!.delete(innerMapKey);
} else {
// If not removing the event, check to see if it's the next lowest expiry.
this.nextStickyEventExpiryTs = Math.min(
this.nextStickyEventExpiryTs,
event.unstableStickyExpiresAt,
);
}
}
// Clean up map after use.
if (this.stickyEventsMap.get(eventType)?.size === 0) {
this.stickyEventsMap.delete(eventType);
}
}
for (const event of this.unkeyedStickyEvents) {
if (now >= event.unstableStickyExpiresAt) {
logger.debug("Expiring sticky event", event.getId());
this.unkeyedStickyEvents.delete(event);
removedEvents.push(event);
} else {
// If not removing the event, check to see if it's the next lowest expiry.
this.nextStickyEventExpiryTs = Math.min(this.nextStickyEventExpiryTs, event.unstableStickyExpiresAt);
}
}
if (removedEvents.length) {
this.emit(RoomStickyEventsEvent.Update, [], [], removedEvents);
}
// Finally, schedule the next run.
this.scheduleStickyTimer();
};
/**
* Clear all events and stop the timer from firing.
*/
public clear(): void {
this.stickyEventsMap.clear();
// Unschedule timer.
this.nextStickyEventExpiryTs = Number.MAX_SAFE_INTEGER;
this.scheduleStickyTimer();
}
}

View File

@@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts";
import { KnownMembership, type Membership } from "../@types/membership.ts"; import { KnownMembership, type Membership } from "../@types/membership.ts";
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts"; import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
import { type MSC4186Hero } from "../sliding-sync.ts"; import { type MSC4186Hero } from "../sliding-sync.ts";
import { RoomStickyEventsStore, RoomStickyEventsEvent, type RoomStickyEventsMap } from "./room-sticky-events.ts";
// These constants are used as sane defaults when the homeserver doesn't support // 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 // the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -167,6 +168,7 @@ export type RoomEmittedEvents =
| RoomStateEvent.NewMember | RoomStateEvent.NewMember
| RoomStateEvent.Update | RoomStateEvent.Update
| RoomStateEvent.Marker | RoomStateEvent.Marker
| RoomStickyEventsEvent.Update
| ThreadEvent.New | ThreadEvent.New
| ThreadEvent.Update | ThreadEvent.Update
| ThreadEvent.NewReply | ThreadEvent.NewReply
@@ -320,6 +322,7 @@ export type RoomEventHandlerMap = {
} & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> & } & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> &
EventTimelineSetHandlerMap & EventTimelineSetHandlerMap &
Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> & Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> &
Pick<RoomStickyEventsMap, RoomStickyEventsEvent.Update> &
Pick< Pick<
RoomStateEventHandlerMap, RoomStateEventHandlerMap,
| RoomStateEvent.Events | RoomStateEvent.Events
@@ -446,6 +449,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/ */
private roomReceipts = new RoomReceipts(this); private roomReceipts = new RoomReceipts(this);
/**
* Stores and tracks sticky events
*/
private stickyEvents = new RoomStickyEventsStore();
/** /**
* Construct a new Room. * Construct a new Room.
* *
@@ -492,6 +500,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// Listen to our own receipt event as a more modular way of processing our own // 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. // receipts. No need to remove the listener: it's on ourself anyway.
this.on(RoomEvent.Receipt, this.onReceipt); this.on(RoomEvent.Receipt, this.onReceipt);
this.reEmitter.reEmit(this.stickyEvents, [RoomStickyEventsEvent.Update]);
// all our per-room timeline sets. the first one is the unfiltered ones; // all our per-room timeline sets. the first one is the unfiltered ones;
// the subsequent ones are the filtered ones in no particular order. // the subsequent ones are the filtered ones in no particular order.
@@ -3414,6 +3423,55 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return this.accountData.get(type); return this.accountData.get(type);
} }
/**
* Get an iterator of currently active sticky events.
*/
// eslint-disable-next-line
public _unstable_getStickyEvents(): ReturnType<RoomStickyEventsStore["getStickyEvents"]> {
return this.stickyEvents.getStickyEvents();
}
/**
* Get a sticky event that match the given `type`, `sender`, and `stickyKey`
* @param type The event `type`.
* @param sender The sender of the sticky event.
* @param stickyKey The sticky key used by the event.
* @returns A matching active sticky event, or undefined.
*/
// eslint-disable-next-line
public _unstable_getKeyedStickyEvent(
sender: string,
type: string,
stickyKey: string,
): ReturnType<RoomStickyEventsStore["getKeyedStickyEvent"]> {
return this.stickyEvents.getKeyedStickyEvent(sender, type, stickyKey);
}
/**
* Get active sticky events without a sticky key that match the given `type` and `sender`.
* @param type The event `type`.
* @param sender The sender of the sticky event.
* @returns An array of matching sticky events.
*/
// eslint-disable-next-line
public _unstable_getUnkeyedStickyEvent(
sender: string,
type: string,
): ReturnType<RoomStickyEventsStore["getUnkeyedStickyEvent"]> {
return this.stickyEvents.getUnkeyedStickyEvent(sender, type);
}
/**
* Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any
* changes were made.
* @param events A set of new sticky events.
* @internal
*/
// eslint-disable-next-line
public _unstable_addStickyEvents(events: MatrixEvent[]): ReturnType<RoomStickyEventsStore["addStickyEvents"]> {
return this.stickyEvents.addStickyEvents(events);
}
/** /**
* Returns whether the syncing user has permission to send a message in the room * Returns whether the syncing user has permission to send a message in the room
* @returns true if the user should be permitted to send * @returns true if the user should be permitted to send

View File

@@ -20,7 +20,7 @@ limitations under the License.
import { logger } from "./logger.ts"; import { logger } from "./logger.ts";
import { deepCopy } from "./utils.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 IRoomSummary } from "./models/room-summary.ts";
import { type EventType } from "./@types/event.ts"; import { type EventType } from "./@types/event.ts";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts"; import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
@@ -76,11 +76,25 @@ export interface ITimeline {
prev_batch: string | null; prev_batch: string | null;
} }
type StickyEventFields = {
msc4354_sticky: { duration_ms: number };
content: { msc4354_sticky_key?: string };
};
export type IStickyEvent = IRoomEvent & StickyEventFields;
export type IStickyStateEvent = IStateEvent & StickyEventFields;
export interface ISticky {
events: Array<IStickyEvent | IStickyStateEvent>;
}
export interface IJoinedRoom { export interface IJoinedRoom {
"summary": IRoomSummary; "summary": IRoomSummary;
// One of `state` or `state_after` is required. // One of `state` or `state_after` is required.
"state"?: IState; "state"?: IState;
"org.matrix.msc4222.state_after"?: IState; // https://github.com/matrix-org/matrix-spec-proposals/pull/4222 "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; "timeline": ITimeline;
"ephemeral": IEphemeral; "ephemeral": IEphemeral;
"account_data": IAccountData; "account_data": IAccountData;
@@ -201,6 +215,14 @@ interface IRoom {
_unreadNotifications: Partial<UnreadNotificationCounts>; _unreadNotifications: Partial<UnreadNotificationCounts>;
_unreadThreadNotifications?: Record<string, Partial<UnreadNotificationCounts>>; _unreadThreadNotifications?: Record<string, Partial<UnreadNotificationCounts>>;
_receipts: ReceiptAccumulator; _receipts: ReceiptAccumulator;
_stickyEvents: {
readonly event: IStickyEvent | IStickyStateEvent;
/**
* This is the timestamp at which point it is safe to remove this event from the store.
* This value is immutable
*/
readonly expiresTs: number;
}[];
} }
export interface ISyncData { export interface ISyncData {
@@ -411,6 +433,7 @@ export class SyncAccumulator {
// Accumulate timeline and state events in a room. // Accumulate timeline and state events in a room.
private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void { private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void {
const now = Date.now();
// We expect this function to be called a lot (every /sync) so we want // We expect this function to be called a lot (every /sync) so we want
// this to be fast. /sync stores events in an array but we often want // this to be fast. /sync stores events in an array but we often want
// to clobber based on type/state_key. Rather than convert arrays to // to clobber based on type/state_key. Rather than convert arrays to
@@ -457,6 +480,7 @@ export class SyncAccumulator {
_unreadThreadNotifications: {}, _unreadThreadNotifications: {},
_summary: {}, _summary: {},
_receipts: new ReceiptAccumulator(), _receipts: new ReceiptAccumulator(),
_stickyEvents: [],
}; };
} }
const currentData = this.joinRooms[roomId]; const currentData = this.joinRooms[roomId];
@@ -540,6 +564,27 @@ export class SyncAccumulator {
}); });
}); });
// Prune out any events in our stores that have since expired, do this before we
// insert new events.
currentData._stickyEvents = currentData._stickyEvents.filter(({ expiresTs }) => expiresTs > now);
// We want this to be fast, so don't worry about duplicate events here. The RoomStickyEventsStore will
// process these events into the correct mapped order.
if (data.msc4354_sticky?.events) {
currentData._stickyEvents = currentData._stickyEvents.concat(
data.msc4354_sticky.events.map((event) => {
// If `duration_ms` exceeds the spec limit of a hour, we cap it.
const cappedDuration = Math.min(event.msc4354_sticky.duration_ms, MAX_STICKY_DURATION_MS);
// If `origin_server_ts` claims to have been from the future, we still bound it to now.
const createdTs = Math.min(event.origin_server_ts, now);
return {
event,
expiresTs: cappedDuration + createdTs,
};
}),
);
}
// attempt to prune the timeline by jumping between events which have // attempt to prune the timeline by jumping between events which have
// pagination tokens. // pagination tokens.
if (currentData._timeline.length > this.opts.maxTimelineEntries!) { if (currentData._timeline.length > this.opts.maxTimelineEntries!) {
@@ -611,6 +656,11 @@ export class SyncAccumulator {
"unread_notifications": roomData._unreadNotifications, "unread_notifications": roomData._unreadNotifications,
"unread_thread_notifications": roomData._unreadThreadNotifications, "unread_thread_notifications": roomData._unreadThreadNotifications,
"summary": roomData._summary as IRoomSummary, "summary": roomData._summary as IRoomSummary,
"msc4354_sticky": roomData._stickyEvents?.length
? {
events: roomData._stickyEvents.map((e) => e.event),
}
: undefined,
}; };
// Add account data // Add account data
Object.keys(roomData._accountData).forEach((evType) => { Object.keys(roomData._accountData).forEach((evType) => {

View File

@@ -1082,6 +1082,8 @@ export class SyncApi {
// highlight_count: 0, // highlight_count: 0,
// notification_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: { // leave: {
@@ -1219,6 +1221,7 @@ export class SyncApi {
const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false); const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false);
const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral); const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral);
const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data); 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 // 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 // regular timeline events do *not* count towards state. If it's not present, then the state is formed by
@@ -1402,6 +1405,18 @@ export class SyncApi {
// we deliberately don't add accountData to the timeline // we deliberately don't add accountData to the timeline
room.addAccountData(accountDataEvents); room.addAccountData(accountDataEvents);
// Sticky events primarily come via the `timeline` field, with the
// sticky info field marking them as sticky.
// If the sync is "gappy" (meaning it is skipping events to catch up) then
// sticky events will instead come down the sticky section.
// This ensures we collect sticky events from both places.
const stickyEventsAndStickyEventsFromTheTimeline = stickyEvents.concat(
timelineEvents.filter((e) => e.unstableStickyInfo !== undefined),
);
// Note: We calculate sticky events before emitting `.Room` as it's nice to have
// sticky events calculated and ready to go.
room._unstable_addStickyEvents(stickyEventsAndStickyEventsFromTheTimeline);
room.recalculate(); room.recalculate();
if (joinObj.isBrandNewRoom) { if (joinObj.isBrandNewRoom) {
client.store.storeRoom(room); client.store.storeRoom(room);
@@ -1411,11 +1426,21 @@ export class SyncApi {
this.processEventsForNotifs(room, timelineEvents); this.processEventsForNotifs(room, timelineEvents);
const emitEvent = (e: MatrixEvent): boolean => client.emit(ClientEvent.Event, e); 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); stateEvents.forEach(emitEvent);
timelineEvents.forEach(emitEvent); timelineEvents.forEach(emitEvent);
ephemeralEvents.forEach(emitEvent); ephemeralEvents.forEach(emitEvent);
accountDataEvents.forEach(emitEvent); accountDataEvents.forEach(emitEvent);
stickyEvents
.filter(
(stickyEvent) =>
// This is highly unlikey, but in the case where a sticky event
// has appeared in the timeline AND the sticky section, we only
// want to emit the event once.
!timelineEvents.some((timelineEvent) => timelineEvent.getId() === stickyEvent.getId()),
)
.forEach(emitEvent);
// Decrypt only the last message in all rooms to make sure we can generate a preview // 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 // And decrypt all events after the recorded read receipt to ensure an accurate
// notification count // notification count