Skip to content

Commit 22b9bc1

Browse files
committed
Refactor all of this away to it's own accumulator and class.
1 parent ae5fd64 commit 22b9bc1

File tree

6 files changed

+259
-130
lines changed

6 files changed

+259
-130
lines changed

src/models/event.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1740,6 +1740,16 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
17401740
public setThreadId(threadId?: string): void {
17411741
this.threadId = threadId;
17421742
}
1743+
1744+
/**
1745+
* Unstable getter to try and get the sticky information for the event.
1746+
* If the event is not a sticky event (or not supported by the server),
1747+
* then this returns `undefined`.
1748+
*
1749+
*/
1750+
public get unstableStickyContent(): IEvent["msc4354_sticky"] {
1751+
return this.event.msc4354_sticky;
1752+
}
17431753
}
17441754

17451755
/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted

src/models/room-sticky-events.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { logger as loggerInstance } from "../logger.ts";
2+
import { MatrixEvent } from "./event";
3+
import { TypedEventEmitter } from "./typed-event-emitter";
4+
5+
const logger = loggerInstance.getChild("RoomStickyEvents");
6+
7+
export enum RoomStickyEventsEvent {
8+
Update = "RoomStickyEvents.Update",
9+
}
10+
11+
export type RoomStickyEventsMap = {
12+
/**
13+
* Fires when sticky events are updated for a room.
14+
* For a list of all updated events use:
15+
* `const updated = added.filter(e => removed.includes(e));`
16+
* for a list of all new events use:
17+
* `const addedNew = added.filter(e => !removed.includes(e));`
18+
* for a list of all removed events use:
19+
* `const removedOnly = removed.filter(e => !added.includes(e));`
20+
* @param added - The events that were added to the map of sticky events (can be updated events for existing keys or new keys)
21+
* @param removed - The events that were removed from the map of sticky events (caused by expiration or updated keys)
22+
*/
23+
[RoomStickyEventsEvent.Update]: (added: MatrixEvent[], removed: MatrixEvent[]) => void;
24+
}
25+
26+
export class RoomStickyEvents extends TypedEventEmitter<RoomStickyEventsEvent, RoomStickyEventsMap> {
27+
private stickyEventsMap = new Map<string, Array<MatrixEvent>>(); // type+userId -> events
28+
private stickyEventTimer?: NodeJS.Timeout;
29+
private nextStickyEventExpiry: number = 0;
30+
31+
constructor() {
32+
super();
33+
}
34+
35+
36+
public * unstableGetStickyEvents(): MapIterator<MatrixEvent> {
37+
for (const element of this.stickyEventsMap.values()) {
38+
yield * element;
39+
}
40+
}
41+
42+
/**
43+
* Adds a sticky event into the local sticky event map.
44+
*
45+
* NOTE: This will not cause `RoomEvent.StickyEvents` to be emitted.
46+
*
47+
* @throws If the `event` does not contain valid sticky data.
48+
* @param event The MatrixEvent that contains sticky data.
49+
* @returns An object describing whether the event was added to the map,
50+
* and the previous event it may have replaced.
51+
*/
52+
public unstableAddStickyEvent(event: MatrixEvent): { added: true; prevEvent?: MatrixEvent } | { added: false } {
53+
const stickyKey = event.getContent().msc4354_sticky_key;
54+
if (!stickyKey) {
55+
throw Error(`${event.getId()} is missing msc4354_sticky_key`);
56+
}
57+
const stickyInfo = event.unstableStickyContent;
58+
// With this we have the guarantee, that all events in stickyEventsMap are correctly formatted
59+
if (
60+
!(
61+
stickyInfo &&
62+
"duration_ms" in stickyInfo &&
63+
stickyInfo.duration_ms !== undefined &&
64+
typeof stickyInfo.duration_ms === "number"
65+
)
66+
) {
67+
throw Error(`${event.getId()} is missing msc4354_sticky.duration_ms`);
68+
}
69+
const ev = event as MatrixEvent & { event: { msc4354_sticky: { duration_ms: number } } };
70+
const sender = event.getSender();
71+
const expirationTs = stickyInfo.duration_ms + ev.getTs();
72+
if (!sender) {
73+
throw Error(`${event.getId()} is missing a sender`);
74+
} else if (expirationTs <= Date.now()) {
75+
logger.info("ignored sticky event with older expiration time than current time", stickyKey);
76+
return { added: false };
77+
}
78+
79+
// While we fully expect the server to always provide the correct value,
80+
// this is just insurance to protect against attacks on our Map.
81+
if (!sender.startsWith("@")) {
82+
throw Error('Expected sender to start with @')
83+
}
84+
// Why this is safe:
85+
// A type may contain anything but the *sender* is tightly
86+
// constrained so that a key will always end with a @<user_id>
87+
// E.g. Where a malicous event type might be "rtc.member.event@foo:bar" the key becomes:
88+
// "rtc.member.event.@foo:bar@bar:baz"
89+
const mapKey = ev.getType() + sender;
90+
91+
92+
const prevEvent = this.stickyEventsMap.get(mapKey)?.find((ev) => ev.getContent().msc4354_sticky_key === stickyKey);
93+
94+
if (prevEvent && ev.getTs() < prevEvent.getTs()) {
95+
logger.info("ignored sticky event with older timestamp for on sticky_key", stickyKey);
96+
return { added: false };
97+
} else if (prevEvent && ev.getTs() === prevEvent.getTs() && (ev.getId() ?? "") < (prevEvent.getId() ?? "")) {
98+
// This path is unlikely, as it requires both events to have the same TS.
99+
logger.info("ignored sticky event due to 'id tie break rule' on sticky_key", stickyKey);
100+
return { added: false };
101+
}
102+
this.stickyEventsMap.set(mapKey, [...this.stickyEventsMap.get(sender)?.filter(ev => ev !== prevEvent) ?? [], event]);
103+
// Recalculate the next expiry time.
104+
this.nextStickyEventExpiry = Math.min(expirationTs, this.nextStickyEventExpiry);
105+
return { added: true, prevEvent };
106+
}
107+
108+
/**
109+
* Schedule the sticky event expiry timer. The timer may
110+
* run immediately if an event has already expired.
111+
*/
112+
private scheduleStickyTimer(): void {
113+
if (this.stickyEventTimer) {
114+
clearTimeout(this.stickyEventTimer);
115+
this.stickyEventTimer = undefined;
116+
}
117+
if (this.nextStickyEventExpiry === 0) {
118+
return;
119+
} else if (this.nextStickyEventExpiry < Date.now()) {
120+
this.cleanExpiredStickyEvents();
121+
return;
122+
}
123+
this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, this.nextStickyEventExpiry - Date.now());
124+
}
125+
126+
/**
127+
* Clean out any expired sticky events.
128+
*/
129+
private cleanExpiredStickyEvents = (): void => {
130+
const now = Date.now();
131+
const removedEvents: MatrixEvent[] = [];
132+
for (const [mapKey, events] of this.stickyEventsMap.entries()) {
133+
for (const event of events) {
134+
const creationTs = event.getTs();
135+
if (!event.unstableStickyContent?.duration_ms) {
136+
return;
137+
}
138+
// we only added items with `sticky` into this map so we can assert non-null here
139+
if (now > creationTs + event.unstableStickyContent?.duration_ms) {
140+
removedEvents.push(event);
141+
}
142+
}
143+
const newEventSet = events.filter(ev => removedEvents.includes(ev));
144+
if (newEventSet.length) {
145+
this.stickyEventsMap.set(mapKey, newEventSet);
146+
} else {
147+
this.stickyEventsMap.delete(mapKey)
148+
}
149+
}
150+
if (removedEvents.length) {
151+
this.emit(RoomStickyEventsEvent.Update, [], removedEvents);
152+
}
153+
this.scheduleStickyTimer();
154+
}
155+
156+
/**
157+
* Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any
158+
* changes were made.
159+
* @param events A set of new sticky events.
160+
*/
161+
public unstableAddStickyEvents(events: MatrixEvent[]): void {
162+
const added = [];
163+
const removed = [];
164+
for (const e of events) {
165+
try {
166+
const result = this.unstableAddStickyEvent(e);
167+
if (result.added) {
168+
added.push(e);
169+
if (result.prevEvent) {
170+
removed.push(result.prevEvent);
171+
}
172+
}
173+
} catch (ex) {
174+
logger.warn("ignored invalid sticky event", ex);
175+
}
176+
}
177+
if (added.length) this.emit(RoomStickyEventsEvent.Update, added, removed);
178+
this.scheduleStickyTimer();
179+
}
180+
}

src/models/room.ts

Lines changed: 36 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
import { Direction, EventTimeline } from "./event-timeline.ts";
2626
import { getHttpUriForMxc } from "../content-repo.ts";
2727
import * as utils from "../utils.ts";
28-
import { normalize, noUnsafeEventProps, removeElement, MultiKeyMap } from "../utils.ts";
28+
import { normalize, noUnsafeEventProps, removeElement } from "../utils.ts";
2929
import {
3030
type IEvent,
3131
type IThreadBundledRelationship,
@@ -77,6 +77,7 @@ import { compareEventOrdering } from "./compare-event-ordering.ts";
7777
import { KnownMembership, type Membership } from "../@types/membership.ts";
7878
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
7979
import { type MSC4186Hero } from "../sliding-sync.ts";
80+
import { RoomStickyEvents, RoomStickyEventsEvent } from "./room-sticky-events.ts";
8081

8182
// These constants are used as sane defaults when the homeserver doesn't support
8283
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -325,6 +326,19 @@ export type RoomEventHandlerMap = {
325326
* @param room - The room containing the sticky events
326327
*/
327328
[RoomEvent.StickyEvents]: (added: MatrixEvent[], removed: MatrixEvent[], room: Room) => void;
329+
/**
330+
* Fires when sticky events are updated for a room.
331+
* For a list of all updated events use:
332+
* `const updated = added.filter(e => removed.includes(e));`
333+
* for a list of all new events use:
334+
* `const addedNew = added.filter(e => !removed.includes(e));`
335+
* for a list of all removed events use:
336+
* `const removedOnly = removed.filter(e => !added.includes(e));`
337+
* @param added - The events that were added to the map of sticky events (can be updated events for existing keys or new keys)
338+
* @param removed - The events that were removed from the map of sticky events (caused by expiration or updated keys)
339+
* @param room - The room containing the sticky events
340+
*/
341+
[RoomEvent.StickyEvents]: (added: MatrixEvent[], removed: MatrixEvent[], room: Room) => void;
328342
[ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
329343
/**
330344
* Fires when a new poll instance is added to the room state
@@ -390,8 +404,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
390404
private getTypeWarning = false;
391405
private membersPromise?: Promise<boolean>;
392406

393-
private stickyEventsMap = new MultiKeyMap<MatrixEvent & { event: { msc4354_sticky: { duration_ms: number } } }>();
394-
395407
// XXX: These should be read-only
396408
/**
397409
* The human-readable display name for this room.
@@ -462,6 +474,11 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
462474
*/
463475
private roomReceipts = new RoomReceipts(this);
464476

477+
/**
478+
* Stores and tracks sticky events
479+
*/
480+
private stickyEvents = new RoomStickyEvents();
481+
465482
/**
466483
* Construct a new Room.
467484
*
@@ -509,6 +526,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
509526
// receipts. No need to remove the listener: it's on ourself anyway.
510527
this.on(RoomEvent.Receipt, this.onReceipt);
511528

529+
this.stickyEvents.on(RoomStickyEventsEvent.Update, (added, removed) =>
530+
this.emit(RoomEvent.StickyEvents, added, removed, this),
531+
);
532+
512533
// all our per-room timeline sets. the first one is the unfiltered ones;
513534
// the subsequent ones are the filtered ones in no particular order.
514535
this.timelineSets = [new EventTimelineSet(this, opts)];
@@ -3430,109 +3451,20 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
34303451
return this.accountData.get(type);
34313452
}
34323453

3433-
public getStickyEvents(): MapIterator<MatrixEvent> {
3434-
return this.stickyEventsMap.values();
3435-
}
3436-
3437-
public addStickyEvents(event: MatrixEvent[]): void {
3438-
const added = [];
3439-
const removed = [];
3440-
for (const e of event) {
3441-
const mapStickyKey = Room.stickyKey(e);
3442-
if (!mapStickyKey) {
3443-
logger.warn("Event from sticky sync section is missing sticky_key, ignoring", e);
3444-
continue;
3445-
}
3446-
// With this we have the guarantee, that all events in stickyEventsMap are correctly formatted
3447-
if (
3448-
!(
3449-
e.event.msc4354_sticky &&
3450-
"duration_ms" in e.event.msc4354_sticky &&
3451-
e.event.msc4354_sticky.duration_ms !== undefined &&
3452-
typeof e.event.msc4354_sticky.duration_ms === "number"
3453-
)
3454-
) {
3455-
logger.warn("Event from sticky sync section is missing sticky timeout", e);
3456-
continue;
3457-
}
3458-
const ev = e as MatrixEvent & { event: { msc4354_sticky: { duration_ms: number } } };
3459-
const prevEv = this.stickyEventsMap.get(mapStickyKey);
3460-
const sender = e.getSender();
3461-
if (!sender) {
3462-
logger.warn("Event from sticky sync section is missing sender", e);
3463-
continue;
3464-
} else if (prevEv && ev.getTs() < prevEv.getTs()) {
3465-
logger.info(
3466-
"ignored sticky event with older timestamp for on sticky_key",
3467-
ev.getContent().sticky_key,
3468-
ev,
3469-
);
3470-
} else if (prevEv && ev.getTs() === prevEv.getTs() && (ev.getId() ?? "") < (prevEv.getId() ?? "")) {
3471-
logger.info(
3472-
"ignored sticky event due to 'id tie break rule' on sticky_key",
3473-
ev.getContent().sticky_key,
3474-
ev,
3475-
);
3476-
} else {
3477-
added.push(ev);
3478-
if (prevEv) removed.push(prevEv);
3479-
this.stickyEventsMap.set(mapStickyKey, ev);
3480-
}
3481-
}
3482-
if (added.length) this.emit(RoomEvent.StickyEvents, added, removed, this);
3483-
this.checkStickyTimer();
3484-
}
3485-
private stickyEventTimer?: NodeJS.Timeout;
3486-
private checkStickyTimer(): void {
3487-
if (this.stickyEventTimer) {
3488-
clearTimeout(this.stickyEventTimer);
3489-
this.stickyEventTimer = undefined;
3490-
}
3491-
const nextExpiry = this.getNextStickyExpiryTs();
3492-
if (nextExpiry && nextExpiry < Date.now()) {
3493-
this.cleanExpiredStickyEvents();
3494-
} else if (nextExpiry) {
3495-
this.stickyEventTimer = setTimeout(this.cleanExpiredStickyEvents, nextExpiry - Date.now());
3496-
}
3497-
}
3498-
3499-
private getNextStickyExpiryTs(): number | null {
3500-
let nextExpiry = null;
3501-
for (const event of this.stickyEventsMap.values()) {
3502-
const expiry = event.getTs() + event.event.msc4354_sticky.duration_ms;
3503-
if (nextExpiry === null || expiry < nextExpiry) {
3504-
nextExpiry = expiry;
3505-
}
3506-
}
3507-
return nextExpiry;
3508-
}
3509-
private cleanExpiredStickyEvents(): void {
3510-
const now = Date.now();
3511-
const toRemove: string[][] = [];
3512-
const removedEvents: MatrixEvent[] = [];
3513-
for (const [key, event] of this.stickyEventsMap.entries()) {
3514-
const creationTs = event.getTs();
3515-
// we only added items with `sticky` into this map so we can assert non-null here
3516-
if (now > creationTs + event.event.msc4354_sticky.duration_ms) {
3517-
toRemove.push(key);
3518-
removedEvents.push(event);
3519-
}
3520-
}
3521-
for (const key of toRemove) {
3522-
this.stickyEventsMap.delete(key);
3523-
}
3524-
if (removedEvents.length) {
3525-
this.emit(RoomEvent.StickyEvents, [], removedEvents, this);
3526-
}
3527-
this.checkStickyTimer();
3454+
/**
3455+
* Get an iterator of currently active sticky events.
3456+
*/
3457+
public unstableGetStickyEvents() {
3458+
return this.stickyEvents.unstableGetStickyEvents();
35283459
}
35293460

3530-
private static stickyKey(event: MatrixEvent): string[] | undefined {
3531-
const stickyKey = event.getContent().sticky_key;
3532-
if (!stickyKey) {
3533-
return undefined;
3534-
}
3535-
return [event.getSender(), event.getType(), stickyKey];
3461+
/**
3462+
* Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any
3463+
* changes were made.
3464+
* @param events A set of new sticky events.
3465+
*/
3466+
public unstableAddStickyEvents(events: MatrixEvent[]): void {
3467+
return this.stickyEvents.unstableAddStickyEvents(events);
35363468
}
35373469

35383470
/**

0 commit comments

Comments
 (0)