matrix-js-sdk 43.0.0 → 44.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/lib/matrixrtc/CallMembership.d.ts +30 -25
  3. package/lib/matrixrtc/CallMembership.d.ts.map +1 -1
  4. package/lib/matrixrtc/CallMembership.js +91 -100
  5. package/lib/matrixrtc/CallMembership.js.map +1 -1
  6. package/lib/matrixrtc/EncryptionManager.d.ts +6 -4
  7. package/lib/matrixrtc/EncryptionManager.d.ts.map +1 -1
  8. package/lib/matrixrtc/EncryptionManager.js.map +1 -1
  9. package/lib/matrixrtc/IMembershipManager.d.ts +2 -15
  10. package/lib/matrixrtc/IMembershipManager.d.ts.map +1 -1
  11. package/lib/matrixrtc/IMembershipManager.js.map +1 -1
  12. package/lib/matrixrtc/LivekitTransport.d.ts +36 -7
  13. package/lib/matrixrtc/LivekitTransport.d.ts.map +1 -1
  14. package/lib/matrixrtc/LivekitTransport.js +29 -2
  15. package/lib/matrixrtc/LivekitTransport.js.map +1 -1
  16. package/lib/matrixrtc/MatrixRTCSession.d.ts +12 -51
  17. package/lib/matrixrtc/MatrixRTCSession.d.ts.map +1 -1
  18. package/lib/matrixrtc/MatrixRTCSession.js +10 -66
  19. package/lib/matrixrtc/MatrixRTCSession.js.map +1 -1
  20. package/lib/matrixrtc/MembershipManager.d.ts +5 -10
  21. package/lib/matrixrtc/MembershipManager.d.ts.map +1 -1
  22. package/lib/matrixrtc/MembershipManager.js +25 -44
  23. package/lib/matrixrtc/MembershipManager.js.map +1 -1
  24. package/lib/matrixrtc/RTCEncryptionManager.d.ts +8 -8
  25. package/lib/matrixrtc/RTCEncryptionManager.d.ts.map +1 -1
  26. package/lib/matrixrtc/RTCEncryptionManager.js +30 -22
  27. package/lib/matrixrtc/RTCEncryptionManager.js.map +1 -1
  28. package/lib/matrixrtc/ToDeviceKeyTransport.js +7 -8
  29. package/lib/matrixrtc/ToDeviceKeyTransport.js.map +1 -1
  30. package/lib/matrixrtc/membershipData/rtc.d.ts +8 -13
  31. package/lib/matrixrtc/membershipData/rtc.d.ts.map +1 -1
  32. package/lib/matrixrtc/membershipData/rtc.js +2 -11
  33. package/lib/matrixrtc/membershipData/rtc.js.map +1 -1
  34. package/lib/matrixrtc/membershipData/session.d.ts +6 -2
  35. package/lib/matrixrtc/membershipData/session.d.ts.map +1 -1
  36. package/lib/matrixrtc/membershipData/session.js +3 -0
  37. package/lib/matrixrtc/membershipData/session.js.map +1 -1
  38. package/lib/matrixrtc/types.d.ts +5 -0
  39. package/lib/matrixrtc/types.d.ts.map +1 -1
  40. package/lib/matrixrtc/types.js +5 -0
  41. package/lib/matrixrtc/types.js.map +1 -1
  42. package/lib/models/event-timeline-set.d.ts +9 -2
  43. package/lib/models/event-timeline-set.d.ts.map +1 -1
  44. package/lib/models/event-timeline-set.js +8 -5
  45. package/lib/models/event-timeline-set.js.map +1 -1
  46. package/lib/models/room-sticky-events.d.ts +17 -2
  47. package/lib/models/room-sticky-events.d.ts.map +1 -1
  48. package/lib/models/room-sticky-events.js +88 -47
  49. package/lib/models/room-sticky-events.js.map +1 -1
  50. package/lib/models/room.d.ts +1 -1
  51. package/lib/models/room.d.ts.map +1 -1
  52. package/lib/models/room.js +8 -2
  53. package/lib/models/room.js.map +1 -1
  54. package/lib/models/thread.d.ts.map +1 -1
  55. package/lib/models/thread.js +23 -2
  56. package/lib/models/thread.js.map +1 -1
  57. package/lib/rendezvous/MSC4108SignInWithQR.d.ts +3 -3
  58. package/lib/rendezvous/MSC4108SignInWithQR.d.ts.map +1 -1
  59. package/lib/rendezvous/MSC4108SignInWithQR.js.map +1 -1
  60. package/lib/rendezvous/SignInWithQRFlow.d.ts +95 -0
  61. package/lib/rendezvous/SignInWithQRFlow.d.ts.map +1 -0
  62. package/lib/rendezvous/SignInWithQRFlow.js +3 -0
  63. package/lib/rendezvous/SignInWithQRFlow.js.map +1 -0
  64. package/lib/rendezvous/index.d.ts +1 -0
  65. package/lib/rendezvous/index.d.ts.map +1 -1
  66. package/lib/rendezvous/index.js.map +1 -1
  67. package/lib/rust-crypto/index.js +5 -1
  68. package/lib/rust-crypto/index.js.map +1 -1
  69. package/package.json +3 -3
  70. package/src/matrixrtc/CallMembership.ts +85 -95
  71. package/src/matrixrtc/EncryptionManager.ts +6 -4
  72. package/src/matrixrtc/IMembershipManager.ts +2 -15
  73. package/src/matrixrtc/LivekitTransport.ts +58 -9
  74. package/src/matrixrtc/MatrixRTCSession.ts +21 -80
  75. package/src/matrixrtc/MembershipManager.ts +23 -40
  76. package/src/matrixrtc/RTCEncryptionManager.ts +51 -39
  77. package/src/matrixrtc/ToDeviceKeyTransport.ts +7 -7
  78. package/src/matrixrtc/membershipData/rtc.ts +9 -23
  79. package/src/matrixrtc/membershipData/session.ts +14 -2
  80. package/src/matrixrtc/types.ts +6 -0
  81. package/src/models/event-timeline-set.ts +19 -4
  82. package/src/models/room-sticky-events.ts +92 -58
  83. package/src/models/room.ts +8 -2
  84. package/src/models/thread.ts +25 -3
  85. package/src/rendezvous/MSC4108SignInWithQR.ts +3 -3
  86. package/src/rendezvous/SignInWithQRFlow.ts +117 -0
  87. package/src/rendezvous/index.ts +1 -0
  88. package/src/rust-crypto/index.ts +5 -1
@@ -19,13 +19,14 @@ import {
19
19
  getEncryptionKeyMapKey,
20
20
  type IEncryptionManager,
21
21
  } from "./EncryptionManager.ts";
22
- import { type EncryptionConfig, type MembershipConfig } from "./MatrixRTCSession.ts";
22
+ import { type EncryptionConfig } from "./MatrixRTCSession.ts";
23
23
  import type { CallMembership } from "./CallMembership.ts";
24
24
  import { decodeBase64, encodeBase64 } from "../base64.ts";
25
25
  import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
26
26
  import { type Logger } from "../logger.ts";
27
27
  import { sleep } from "../utils.ts";
28
28
  import {
29
+ type Transport,
29
30
  type EncryptionKeyMapKey,
30
31
  type InboundEncryptionSession,
31
32
  type OutboundEncryptionSession,
@@ -33,6 +34,7 @@ import {
33
34
  } from "./types.ts";
34
35
  import { OutdatedKeyFilter } from "./utils.ts";
35
36
  import { computeRtcIdentityRaw } from "./membershipData/rtc.ts";
37
+ import { isUnstableLivekitTransport } from "./LivekitTransport.ts";
36
38
 
37
39
  /**
38
40
  * Default for {@link EncryptionConfig.keyRotationParticipantLimit}.
@@ -61,8 +63,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
61
63
  // This will be done when removing the legacy EncryptionManager.
62
64
  private manageMediaKeys = false;
63
65
 
64
- private useHashedRtcBackendIdentity = false;
65
- private ownRtcBackendIdentityCache: string | undefined;
66
+ private publishedTransports: Transport[] = [];
66
67
 
67
68
  /**
68
69
  * Store the key rings for each participant.
@@ -75,7 +76,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
75
76
  key: Uint8Array<ArrayBuffer>;
76
77
  keyIndex: number;
77
78
  membership: CallMembershipIdentityParts;
78
- rtcBackendIdentity: string;
79
+ backendIdentity: string;
79
80
  }>
80
81
  >();
81
82
 
@@ -130,7 +131,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
130
131
 
131
132
  private logger: Logger | undefined = undefined;
132
133
 
133
- private readonly rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
134
+ private readonly rtcIdentityProvider: (userId: string, memberId: string) => Promise<string>;
134
135
 
135
136
  /**
136
137
  *
@@ -151,10 +152,10 @@ export class RTCEncryptionManager implements IEncryptionManager {
151
152
  keyBin: Uint8Array<ArrayBuffer>,
152
153
  encryptionKeyIndex: number,
153
154
  membership: CallMembershipIdentityParts,
154
- rtcBackendIdentity: string,
155
+ backendIdentity: string,
155
156
  ) => void,
156
157
  parentLogger?: Logger,
157
- rtcBackendIdProvider?: (userId: string, deviceId: string, memberId: string) => Promise<string>,
158
+ rtcBackendIdProvider?: (userId: string, memberId: string) => Promise<string>,
158
159
  ) {
159
160
  this.logger = parentLogger?.getChild(`[EncryptionManager]`);
160
161
  this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw;
@@ -175,20 +176,25 @@ export class RTCEncryptionManager implements IEncryptionManager {
175
176
  return this.getMemberships().length >= this.keyRotationParticipantLimit;
176
177
  }
177
178
 
178
- private async getOwnRtcBackendIdentity(): Promise<string> {
179
- if (this.ownRtcBackendIdentityCache) return this.ownRtcBackendIdentityCache;
179
+ private ownBackendIdentitiesPromise: Promise<string[]> | undefined;
180
180
 
181
- if (this.useHashedRtcBackendIdentity) {
181
+ private getOwnBackendIdentities(): Promise<string[]> {
182
+ this.ownBackendIdentitiesPromise ??= (async (): Promise<string[]> => {
182
183
  const { userId, deviceId, memberId } = this.ownMembership;
183
- this.logger?.info(
184
- // If we see this log multiple times, we need to reconsider the precompute call of getOwnRtcBackendIdentity
185
- `Computing RTC backend identity for ${userId}:${deviceId}:${memberId} (SHOULD ONLY BE CALLED ONCE)`,
186
- );
187
- this.ownRtcBackendIdentityCache = await this.rtcIdentityProvider(userId, deviceId, memberId);
188
- } else {
189
- this.ownRtcBackendIdentityCache = `${this.ownMembership.userId}:${this.ownMembership.deviceId}`;
190
- }
191
- return this.ownRtcBackendIdentityCache;
184
+ const livekitTransports = this.publishedTransports.filter(isUnstableLivekitTransport);
185
+ const backendIdentities: string[] = [];
186
+
187
+ if (livekitTransports.some((t) => "url" in t)) {
188
+ this.logger?.info(`Computing RTC backend identity for ${userId}:${deviceId}:${memberId}`);
189
+ backendIdentities.push(await this.rtcIdentityProvider(userId, memberId));
190
+ }
191
+ if (livekitTransports.some((t) => "livekit_service_url" in t)) {
192
+ backendIdentities.push(`${userId}:${deviceId}`);
193
+ }
194
+
195
+ return backendIdentities;
196
+ })();
197
+ return this.ownBackendIdentitiesPromise;
192
198
  }
193
199
 
194
200
  public getEncryptionKeys(): ReadonlyMap<
@@ -197,7 +203,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
197
203
  key: Uint8Array<ArrayBuffer>;
198
204
  keyIndex: number;
199
205
  membership: CallMembershipIdentityParts;
200
- rtcBackendIdentity: string;
206
+ backendIdentity: string;
201
207
  }>
202
208
  > {
203
209
  return new Map(this.participantKeyRings);
@@ -233,33 +239,36 @@ export class RTCEncryptionManager implements IEncryptionManager {
233
239
  this.keysWithoutMatchingRTCMembership.push({ key, keyIndex, membership });
234
240
  return;
235
241
  }
236
- this.addKeyToParticipantWithBackendIdentity(key, keyIndex, membership, fullMembership.rtcBackendIdentity);
242
+ for (const backendIdentity of fullMembership.backendIdentities) {
243
+ this.addKeyToParticipantWithBackendIdentity(key, keyIndex, membership, backendIdentity);
244
+ }
237
245
  }
238
246
 
239
247
  private addKeyToParticipantWithBackendIdentity(
240
248
  key: Uint8Array<ArrayBuffer>,
241
249
  keyIndex: number,
242
250
  membership: CallMembershipIdentityParts,
243
- rtcBackendIdentity: string,
251
+ backendIdentity: string,
244
252
  ): void {
245
253
  const mapKey = getEncryptionKeyMapKey(membership);
246
254
  if (!this.participantKeyRings.has(mapKey)) {
247
255
  this.participantKeyRings.set(mapKey, []);
248
256
  }
249
- this.participantKeyRings.get(mapKey)!.push({ key, keyIndex, membership, rtcBackendIdentity });
250
- this.onEncryptionKeysChanged(key, keyIndex, membership, rtcBackendIdentity);
257
+ this.participantKeyRings.get(mapKey)!.push({ key, keyIndex, membership, backendIdentity });
258
+ this.onEncryptionKeysChanged(key, keyIndex, membership, backendIdentity);
251
259
  }
252
260
 
253
- public join(joinConfig: (EncryptionConfig & MembershipConfig) | undefined): void {
261
+ public join(joinConfig: EncryptionConfig | undefined, publishedTransports: Transport[]): void {
254
262
  this.manageMediaKeys = joinConfig?.manageMediaKeys ?? true; // default to true
255
- this.useHashedRtcBackendIdentity = joinConfig?.unstableSendStickyEvents ?? false;
263
+ this.publishedTransports = publishedTransports;
264
+ this.ownBackendIdentitiesPromise = undefined;
256
265
  this.useKeyDelay = joinConfig?.useKeyDelay ?? 1000;
257
266
  this.keyRotationGracePeriodMs = joinConfig?.keyRotationGracePeriodMs ?? 10_000;
258
267
  this.keyRotationParticipantLimit =
259
268
  joinConfig?.keyRotationParticipantLimit ?? DEFAULT_KEY_ROTATION_PARTICIPANT_LIMIT;
260
269
 
261
270
  this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
262
- void this.getOwnRtcBackendIdentity(); // precompute own identity
271
+ void this.getOwnBackendIdentities(); // precompute own identity
263
272
 
264
273
  this.logger?.info(`Joining room`);
265
274
  this.transport.start();
@@ -364,12 +373,14 @@ export class RTCEncryptionManager implements IEncryptionManager {
364
373
  keyId: 0,
365
374
  };
366
375
  this.outboundSession = firstKey;
367
- this.addKeyToParticipantWithBackendIdentity(
368
- firstKey.key,
369
- firstKey.keyId,
370
- this.ownMembership,
371
- await this.getOwnRtcBackendIdentity(),
372
- );
376
+ const ids = await this.getOwnBackendIdentities();
377
+ for (const backendIdentity of ids)
378
+ this.addKeyToParticipantWithBackendIdentity(
379
+ firstKey.key,
380
+ firstKey.keyId,
381
+ this.ownMembership,
382
+ backendIdentity,
383
+ );
373
384
  }
374
385
  // get current memberships
375
386
  const toShareWith: ParticipantDeviceInfo[] = this.getMemberships()
@@ -467,12 +478,13 @@ export class RTCEncryptionManager implements IEncryptionManager {
467
478
  this.logger?.trace(`Delay Rollout for key:${newOutboundEncryptionSession.keyId}...`);
468
479
  await sleep(this.useKeyDelay);
469
480
  this.logger?.trace(`...Delayed rollout of index:${newOutboundEncryptionSession.keyId} `);
470
- this.addKeyToParticipantWithBackendIdentity(
471
- newOutboundEncryptionSession.key,
472
- newOutboundEncryptionSession.keyId,
473
- this.ownMembership,
474
- await this.getOwnRtcBackendIdentity(),
475
- );
481
+ for (const backendIdentity of await this.getOwnBackendIdentities())
482
+ this.addKeyToParticipantWithBackendIdentity(
483
+ newOutboundEncryptionSession.key,
484
+ newOutboundEncryptionSession.keyId,
485
+ this.ownMembership,
486
+ backendIdentity,
487
+ );
476
488
  }
477
489
  } catch (err) {
478
490
  this.logger?.error(`Failed to rollout key`, err);
@@ -152,13 +152,13 @@ export class ToDeviceKeyTransport
152
152
  return;
153
153
  }
154
154
 
155
- // TODO: Not possible to check if the event is encrypted or not
156
- // see https://github.com/matrix-org/matrix-rust-sdk/issues/4883
157
- // if (evnt.getWireType() != EventType.RoomMessageEncrypted) {
158
- // // WARN: The call keys were sent in clear. Ignore them
159
- // logger.warn(`Call encryption keys sent in clear from: ${event.getSender()}`);
160
- // return;
161
- // }
155
+ // NB: When received via the widget driver, the to-device events
156
+ // are properly reconstructed as if they are encrypted (see MatrixEvent#makeEncrypted).
157
+ if (event.getWireType() != EventType.RoomMessageEncrypted) {
158
+ // WARN: The call keys were sent in clear. Ignore them
159
+ this.logger.warn(`Call encryption keys sent in clear from: ${event.getSender()}`);
160
+ return;
161
+ }
162
162
 
163
163
  const content = this.getValidEventContent(event);
164
164
  if (!content) return;
@@ -16,7 +16,6 @@ limitations under the License.
16
16
 
17
17
  import { MXID_PATTERN } from "../../models/room-member.ts";
18
18
  import type { IContent } from "../../models/event.ts";
19
- import type { RelationType } from "../../types.ts";
20
19
  import { type RtcSlotApplicationContent, type Transport } from "../types.ts";
21
20
  import { MatrixRTCMembershipParseError } from "./common.ts";
22
21
  import { sha256 } from "../../digest.ts";
@@ -27,24 +26,20 @@ import { slotIdToDescription } from "../utils.ts";
27
26
  * Represents the current form of MSC4143, which uses sticky events to store membership.
28
27
  */
29
28
  export interface RtcMembershipData {
30
- "slot_id": string;
31
- "member": {
29
+ slot_id: string;
30
+ member: {
32
31
  user_id: string;
33
32
  device_id: string;
34
33
  id: string;
35
34
  };
36
- "m.relates_to"?: {
37
- event_id: string;
38
- rel_type: RelationType.Reference;
39
- };
40
- "application": RtcSlotApplicationContent;
41
- "transports": {
35
+ application: RtcSlotApplicationContent;
36
+ transports: {
42
37
  published: Transport[];
43
38
  can_subscribe: string[];
44
39
  };
45
- "versions": string[];
46
- "msc4354_sticky_key"?: string;
47
- "sticky_key"?: string;
40
+ versions: string[];
41
+ msc4354_sticky_key?: string;
42
+ sticky_key?: string;
48
43
  }
49
44
 
50
45
  /**
@@ -142,15 +137,6 @@ export const checkRtcMembershipData = (data: IContent, sender: string): data is
142
137
  ) {
143
138
  errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
144
139
  }
145
- if (data["m.relates_to"] !== undefined) {
146
- const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
147
- if (typeof rel !== "object" || rel === null) {
148
- errors.push(prefix + "m.relates_to must be an object if provided");
149
- } else {
150
- if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
151
- if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
152
- }
153
- }
154
140
 
155
141
  if (errors.length) {
156
142
  throw new MatrixRTCMembershipParseError("RtcMembership", errors);
@@ -159,9 +145,9 @@ export const checkRtcMembershipData = (data: IContent, sender: string): data is
159
145
  return true;
160
146
  };
161
147
 
162
- export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
148
+ export async function computeRtcIdentityRaw(userId: string, memberId: string): Promise<string> {
163
149
  // canonical JSON serialization (Matrix canonical JSON for arrays)
164
- const jsonStr = JSON.stringify([userId, deviceId, memberId]);
150
+ const jsonStr = JSON.stringify([userId, memberId]);
165
151
  const hashBuffer = await sha256(jsonStr);
166
152
  const hashedString = encodeUnpaddedBase64(hashBuffer);
167
153
  return hashedString;
@@ -15,7 +15,7 @@ limitations under the License.
15
15
  */
16
16
 
17
17
  import { type IContent } from "../../matrix.ts";
18
- import { type RTCCallIntent, type Transport } from "../types.ts";
18
+ import { type RTCCallCapability, type RTCCallIntent, type Transport } from "../types.ts";
19
19
  import { MatrixRTCMembershipParseError } from "./common.ts";
20
20
 
21
21
  /**
@@ -48,7 +48,7 @@ export type SessionMembershipData = {
48
48
  */
49
49
  "focus_active": {
50
50
  type: "livekit" | string;
51
- focus_selection: "oldest_membership" | "multi_sfu" | string;
51
+ focus_selection: "multi_sfu" | string;
52
52
  };
53
53
 
54
54
  /**
@@ -87,6 +87,11 @@ export type SessionMembershipData = {
87
87
  */
88
88
  "m.call.intent"?: RTCCallIntent;
89
89
 
90
+ /**
91
+ * The kinds of media this member's device is capable of handling, e.g. audio and/or video.
92
+ */
93
+ "capabilities"?: RTCCallCapability[];
94
+
90
95
  /**
91
96
  * The id used on the media backend.
92
97
  * (With livekit this is the participant identity on the LK SFU)
@@ -138,6 +143,13 @@ export const checkSessionsMembershipData = (data: IContent): data is SessionMemb
138
143
  errors.push(prefix + "m.call.intent must be a string");
139
144
  }
140
145
 
146
+ if (
147
+ data.capabilities !== undefined &&
148
+ !(Array.isArray(data.capabilities) && data.capabilities.every((c: unknown) => typeof c === "string"))
149
+ ) {
150
+ errors.push(prefix + "capabilities must be an array of strings");
151
+ }
152
+
141
153
  if (errors.length) {
142
154
  throw new MatrixRTCMembershipParseError("SessionMembership", errors);
143
155
  }
@@ -111,6 +111,12 @@ export type RTCNotificationType = "ring" | "notification";
111
111
  */
112
112
  export type RTCCallIntent = "audio" | "video" | string;
113
113
 
114
+ /**
115
+ * Represents one kind of media a member's device is capable of handling.
116
+ * May be any string, although `"m.render_audio"` and `"m.render_video"` are commonly accepted values.
117
+ */
118
+ export type RTCCallCapability = "m.render_audio" | "m.render_video" | string;
119
+
114
120
  /**
115
121
  * The maximum `lifetime` of an RTC notification as per MSC4075. Larger values are capped to this.
116
122
  *
@@ -63,6 +63,12 @@ export interface IAddEventToTimelineOptions extends Pick<
63
63
  > {
64
64
  /** Whether the sync response came from cache */
65
65
  fromCache?: boolean;
66
+ /**
67
+ * The other events being added in the same batch. A relation whose parent is in the batch but not yet
68
+ * in a timeline (back-pagination adds newest first) is placed as its parent will be, rather than dropped
69
+ * as a relation to an unknown event.
70
+ */
71
+ batch?: MatrixEvent[];
66
72
  }
67
73
 
68
74
  export interface IAddLiveEventOptions extends Pick<
@@ -501,6 +507,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
501
507
  this.addEventToTimeline(event, timeline, {
502
508
  toStartOfTimeline,
503
509
  addToState,
510
+ batch: events,
504
511
  });
505
512
  lastEventWasNew = true;
506
513
  didUpdate = true;
@@ -656,7 +663,14 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
656
663
  public addEventToTimeline(
657
664
  event: MatrixEvent,
658
665
  timeline: EventTimeline,
659
- { toStartOfTimeline, fromCache = false, roomState, timelineWasEmpty, addToState }: IAddEventToTimelineOptions,
666
+ {
667
+ toStartOfTimeline,
668
+ fromCache = false,
669
+ roomState,
670
+ timelineWasEmpty,
671
+ addToState,
672
+ batch,
673
+ }: IAddEventToTimelineOptions,
660
674
  ): void {
661
675
  if (timeline.getTimelineSet() !== this) {
662
676
  throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " +
@@ -672,7 +686,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
672
686
  //
673
687
  // We can only run this check for timelines with a `room` because `canContain`
674
688
  // requires it
675
- if (this.room && !this.canContain(event)) {
689
+ if (this.room && !this.canContain(event, batch)) {
676
690
  let eventDebugString = `event=${eventId}`;
677
691
  if (event.threadRootId) {
678
692
  eventDebugString += `(belongs to thread=${event.threadRootId})`;
@@ -932,10 +946,11 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
932
946
  * Requires the `room` property to have been set at EventTimelineSet construction time.
933
947
  *
934
948
  * @param event - the event to check whether it belongs to this timeline set.
949
+ * @param batch - other events being added alongside, in which the event's parent may be found
935
950
  * @throws Error if `room` was not set when constructing this timeline set.
936
951
  * @returns whether the event belongs to this timeline set.
937
952
  */
938
- public canContain(event: MatrixEvent): boolean {
953
+ public canContain(event: MatrixEvent, batch?: MatrixEvent[]): boolean {
939
954
  if (!this.room) {
940
955
  throw new Error(
941
956
  "Cannot call `EventTimelineSet::canContain without a `room` set. " +
@@ -943,7 +958,7 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
943
958
  );
944
959
  }
945
960
 
946
- const { threadId, shouldLiveInRoom, shouldLiveInThread } = this.room.eventShouldLiveIn(event);
961
+ const { threadId, shouldLiveInRoom, shouldLiveInThread } = this.room.eventShouldLiveIn(event, batch);
947
962
 
948
963
  if (this.thread) {
949
964
  return this.thread.id === threadId;
@@ -40,7 +40,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
40
40
  * Sticky event map is a nested map of:
41
41
  * eventType -> `content.sticky_key sender` -> StickyMatrixEvent[]
42
42
  *
43
- * The events are ordered in latest to earliest expiry, so that the first event
43
+ * The events are ordered descendingly by expiry, tie breaking on the event ID, so that the first event
44
44
  * in the array will always be the "current" one.
45
45
  */
46
46
  private readonly stickyEventsMap = new Map<string, Map<string, StickyMatrixEvent[]>>();
@@ -54,25 +54,41 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
54
54
  private nextStickyEventExpiryTs: number = Number.MAX_SAFE_INTEGER;
55
55
 
56
56
  /**
57
- * Sort two sticky events by order of expiry. This assumes the sticky events have the same
57
+ * Sort two sticky events by expiry, tie breaking on the event ID. This assumes the sticky events have the same
58
58
  * `type`, `sticky_key` and `sender`.
59
59
  * @returns A positive value if event A will expire sooner, or a negative value if event B will expire sooner.
60
60
  */
61
61
  private static sortStickyEvent(eventA: StickyMatrixEvent, eventB: StickyMatrixEvent): number {
62
- // Sticky events with the same key have to use the same expiration duration.
63
- // Hence, comparing via `origin_server_ts` yields the exact same result as comparing their expiration time.
64
- if (eventB.getTs() !== eventA.getTs()) {
65
- return eventB.getTs() - eventA.getTs();
62
+ // First, compare by expiry (`origin_server_ts + sticky.duration_ms`).
63
+ const expiryA = eventA.getTs() + eventA.unstableStickyInfo!.duration_ms;
64
+ const expiryB = eventB.getTs() + eventB.unstableStickyInfo!.duration_ms;
65
+ if (expiryB !== expiryA) {
66
+ return expiryB - expiryA;
66
67
  }
67
68
 
68
- if ((eventB.getId() ?? "") > (eventA.getId() ?? "")) {
69
+ // Tie break on the highest lexicographical event ID.
70
+ const idA = eventA.getId() ?? "";
71
+ const idB = eventB.getId() ?? "";
72
+ if (idB > idA) {
69
73
  return 1;
74
+ } else if (idB < idA) {
75
+ return -1;
70
76
  }
71
77
 
72
78
  // This should fail as we've got corruption in our sticky array.
73
79
  throw Error("Comparing two sticky events with the same event ID is not allowed.");
74
80
  }
75
81
 
82
+ /**
83
+ * Check whether a sticky event has expired.
84
+ * @param event The sticky event to check.
85
+ * @param now The current time in milliseconds since the epoch.
86
+ * @returns True if the event has expired, false otherwise.
87
+ */
88
+ private static isExpired(event: StickyMatrixEvent, now: number): boolean {
89
+ return event.unstableStickyExpiresAt <= now;
90
+ }
91
+
76
92
  /**
77
93
  * Generate the correct key for an event to be found in the inner maps of `stickyEventsMap`.
78
94
  * @param stickyKey The sticky key of an event.
@@ -133,7 +149,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
133
149
  private addStickyEvent(event: MatrixEvent): { added: true; prevEvent?: StickyMatrixEvent } | { added: false } {
134
150
  // The map is keyed on the event's type and sticky key, both of which are encrypted. Wait for the
135
151
  // event to be decrypted, otherwise it would be filed under `m.room.encrypted` with no sticky key.
136
- if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
152
+ if (event.isBeingDecrypted() || event.shouldAttemptDecryption() || event.isDecryptionFailure()) {
137
153
  this.addStickyEventOnceDecrypted(event);
138
154
  return { added: false };
139
155
  }
@@ -147,10 +163,12 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
147
163
  if (event.unstableStickyExpiresAt === undefined) {
148
164
  throw new Error(`${event.getId()} is missing msc4354_sticky.duration_ms`);
149
165
  }
166
+ const stickyEvent = event as StickyMatrixEvent;
167
+
150
168
  const sender = event.getSender();
151
169
  const type = event.getType();
152
170
  assertIsUserId(sender);
153
- if (event.unstableStickyExpiresAt <= Date.now()) {
171
+ if (RoomStickyEventsStore.isExpired(stickyEvent, Date.now())) {
154
172
  logger.info("ignored sticky event with older expiration time than current time", stickyKey);
155
173
  return { added: false };
156
174
  }
@@ -161,8 +179,6 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
161
179
  throw new Error("Expected sender to start with @");
162
180
  }
163
181
 
164
- const stickyEvent = event as StickyMatrixEvent;
165
-
166
182
  if (stickyKey === undefined) {
167
183
  this.unkeyedStickyEvents.add(stickyEvent);
168
184
  // Recalculate the next expiry time.
@@ -269,7 +285,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
269
285
  for (const [eventType, innerEvents] of this.stickyEventsMap.entries()) {
270
286
  for (const [innerMapKey, [currentEvent, ...previousEvents]] of innerEvents) {
271
287
  // we only added items with `sticky` into this map so we can assert non-null here
272
- if (now >= currentEvent.unstableStickyExpiresAt) {
288
+ if (RoomStickyEventsStore.isExpired(currentEvent, now)) {
273
289
  logger.debug("Expiring sticky event", currentEvent.getId());
274
290
  removedEvents.push(currentEvent);
275
291
  this.stickyEventsMap.get(eventType)!.delete(innerMapKey);
@@ -279,7 +295,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
279
295
  .get(eventType)!
280
296
  .set(innerMapKey, [
281
297
  currentEvent,
282
- ...previousEvents.filter((e) => e.unstableStickyExpiresAt <= now),
298
+ ...previousEvents.filter((e) => !RoomStickyEventsStore.isExpired(e, now)),
283
299
  ]);
284
300
  // If not removing the event, check to see if it's the next lowest expiry.
285
301
  this.nextStickyEventExpiryTs = Math.min(
@@ -294,7 +310,7 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
294
310
  }
295
311
  }
296
312
  for (const event of this.unkeyedStickyEvents) {
297
- if (now >= event.unstableStickyExpiresAt) {
313
+ if (RoomStickyEventsStore.isExpired(event, now)) {
298
314
  logger.debug("Expiring sticky event", event.getId());
299
315
  this.unkeyedStickyEvents.delete(event);
300
316
  removedEvents.push(event);
@@ -328,6 +344,9 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
328
344
 
329
345
  // Handle unkeyedStickyEvents first since it's *quick*.
330
346
  const redactEventId = typeof redactedEvent === "string" ? redactedEvent : redactedEvent.getId();
347
+ if (!redactEventId) {
348
+ return;
349
+ }
331
350
  for (const event of this.unkeyedStickyEvents) {
332
351
  if (event.getId() === redactEventId) {
333
352
  this.unkeyedStickyEvents.delete(event);
@@ -339,60 +358,75 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
339
358
  // Faster method of finding the event since we have the event cached.
340
359
  if (typeof redactedEvent !== "string" && !redactedEvent.isRedacted()) {
341
360
  const stickyKey = redactedEvent.getContent().msc4354_sticky_key;
342
- if (typeof stickyKey !== "string" && stickyKey !== undefined) {
361
+ if (typeof stickyKey !== "string") {
343
362
  return; // Not a sticky event.
344
363
  }
345
- const eventType = redactedEvent.getType();
346
364
  const sender = redactedEvent.getSender();
347
365
  assertIsUserId(sender);
348
- const innerMap = this.stickyEventsMap.get(eventType);
349
- if (!innerMap) {
350
- return;
351
- }
352
- const mapKey = RoomStickyEventsStore.stickyMapKey(stickyKey, sender);
353
- const [currentEvent, ...previousEvents] = innerMap.get(mapKey) ?? [];
354
- if (!currentEvent) {
355
- // No event current in the map so ignore.
356
- return;
357
- }
358
- logger.debug(`Redaction for ${redactEventId} under sticky key ${stickyKey}`);
359
- // Revert to previous state, taking care to skip any other redacted events.
360
- const newEvents = previousEvents.filter((e) => !e.isRedacted()).sort(RoomStickyEventsStore.sortStickyEvent);
361
- this.stickyEventsMap.get(eventType)?.set(mapKey, newEvents);
362
- if (newEvents.length) {
363
- this.emit(
364
- RoomStickyEventsEvent.Update,
365
- [],
366
- [
367
- {
368
- // This looks confusing. This emits that the newer event
369
- // has been redacted and the previous event has taken it's place.
370
- previous: currentEvent,
371
- current: newEvents[0],
372
- },
373
- ],
374
- [],
375
- );
376
- } else {
377
- // We did not find a previous event, so just expire.
378
- innerMap.delete(mapKey);
379
- if (innerMap.size === 0) {
380
- this.stickyEventsMap.delete(eventType);
381
- }
382
- this.emit(RoomStickyEventsEvent.Update, [], [], [currentEvent]);
383
- }
366
+ this.removeKeyedStickyEvent(
367
+ redactedEvent.getType(),
368
+ RoomStickyEventsStore.stickyMapKey(stickyKey, sender),
369
+ redactEventId,
370
+ );
384
371
  return;
385
372
  }
386
373
 
387
374
  // We only know the event ID of the redacted event, so we need to
388
375
  // traverse the map to find our event.
389
- for (const innerMap of this.stickyEventsMap.values()) {
390
- for (const [currentEvent] of innerMap.values()) {
391
- if (currentEvent.getId() !== redactEventId) {
392
- continue;
376
+ for (const [eventType, innerMap] of this.stickyEventsMap) {
377
+ for (const [mapKey, events] of innerMap) {
378
+ if (events.some((e) => e.getId() === redactEventId)) {
379
+ // Found the event.
380
+ this.removeKeyedStickyEvent(eventType, mapKey, redactEventId);
381
+ return;
393
382
  }
394
- // Found the event.
395
- return this.handleRedaction(currentEvent);
383
+ }
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Remove a redacted event from an entry of the keyed sticky map, emitting
389
+ * `RoomEvent.StickyEvents` if this changes the "current" event of that entry.
390
+ * @param eventType The event `type` of the redacted event.
391
+ * @param mapKey The inner map key of the redacted event, see {@link stickyMapKey}.
392
+ * @param redactEventId The ID of the redacted event.
393
+ */
394
+ private removeKeyedStickyEvent(eventType: string, mapKey: string, redactEventId: string): void {
395
+ const innerMap = this.stickyEventsMap.get(eventType);
396
+ const events = innerMap?.get(mapKey);
397
+ if (!innerMap || !events) {
398
+ return;
399
+ }
400
+ const index = events.findIndex((e) => e.getId() === redactEventId);
401
+ if (index === -1) {
402
+ // The event is not in the map so ignore.
403
+ return;
404
+ }
405
+ logger.debug(`Redaction for ${redactEventId} under sticky map key ${mapKey}`);
406
+
407
+ // The current event always sits at index 0.
408
+ const currentEvent = events[0];
409
+ const currentEventRedacted = index === 0;
410
+
411
+ // Drop the redacted event and any other events that may have already been redacted before.
412
+ const newEvents = events.filter((e) => e.getId() !== redactEventId && !e.isRedacted());
413
+ if (newEvents.length) {
414
+ innerMap.set(mapKey, newEvents);
415
+ } else {
416
+ innerMap.delete(mapKey);
417
+ if (innerMap.size === 0) {
418
+ this.stickyEventsMap.delete(eventType);
419
+ }
420
+ }
421
+
422
+ // Emit an update if the current event was redacted.
423
+ if (currentEventRedacted) {
424
+ if (newEvents.length) {
425
+ // The current event was redacted and most recent superseded event takes its place.
426
+ this.emit(RoomStickyEventsEvent.Update, [], [{ previous: currentEvent, current: newEvents[0] }], []);
427
+ } else {
428
+ // The current event was redacted and no superseded events remain to replace it.
429
+ this.emit(RoomStickyEventsEvent.Update, [], [], [currentEvent]);
396
430
  }
397
431
  }
398
432
  }