You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-12-13 07:42:14 +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:
@@ -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> {
|
||||||
|
|||||||
262
spec/unit/models/room-sticky-events.spec.ts
Normal file
262
spec/unit/models/room-sticky-events.spec.ts
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
205
src/client.ts
205
src/client.ts
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
241
src/models/room-sticky-events.ts
Normal file
241
src/models/room-sticky-events.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
27
src/sync.ts
27
src/sync.ts
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user