matrix-js-sdk 42.4.0 → 43.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 (138) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/lib/@types/PushRules.d.ts +3 -1
  3. package/lib/@types/PushRules.d.ts.map +1 -1
  4. package/lib/@types/PushRules.js +2 -0
  5. package/lib/@types/PushRules.js.map +1 -1
  6. package/lib/@types/event.d.ts +1 -0
  7. package/lib/@types/event.d.ts.map +1 -1
  8. package/lib/@types/event.js.map +1 -1
  9. package/lib/@types/matrix-sdk-crypto-wasm.d.js.map +1 -1
  10. package/lib/client.d.ts +88 -37
  11. package/lib/client.d.ts.map +1 -1
  12. package/lib/client.js +72 -47
  13. package/lib/client.js.map +1 -1
  14. package/lib/crypto-api/index.d.ts +3 -3
  15. package/lib/crypto-api/index.d.ts.map +1 -1
  16. package/lib/crypto-api/index.js.map +1 -1
  17. package/lib/embedded.d.ts +17 -1
  18. package/lib/embedded.d.ts.map +1 -1
  19. package/lib/embedded.js +46 -1
  20. package/lib/embedded.js.map +1 -1
  21. package/lib/errors.d.ts +1 -1
  22. package/lib/errors.d.ts.map +1 -1
  23. package/lib/errors.js +2 -2
  24. package/lib/errors.js.map +1 -1
  25. package/lib/http-api/fetch.d.ts +7 -1
  26. package/lib/http-api/fetch.d.ts.map +1 -1
  27. package/lib/http-api/fetch.js +14 -5
  28. package/lib/http-api/fetch.js.map +1 -1
  29. package/lib/http-api/interface.d.ts +34 -8
  30. package/lib/http-api/interface.d.ts.map +1 -1
  31. package/lib/http-api/interface.js +10 -3
  32. package/lib/http-api/interface.js.map +1 -1
  33. package/lib/http-api/refresh.d.ts +30 -11
  34. package/lib/http-api/refresh.d.ts.map +1 -1
  35. package/lib/http-api/refresh.js +72 -5
  36. package/lib/http-api/refresh.js.map +1 -1
  37. package/lib/matrix.d.ts +1 -1
  38. package/lib/matrix.d.ts.map +1 -1
  39. package/lib/matrix.js.map +1 -1
  40. package/lib/matrixrtc/CallMembership.d.ts.map +1 -1
  41. package/lib/matrixrtc/CallMembership.js +20 -6
  42. package/lib/matrixrtc/CallMembership.js.map +1 -1
  43. package/lib/matrixrtc/IMembershipManager.d.ts +5 -0
  44. package/lib/matrixrtc/IMembershipManager.d.ts.map +1 -1
  45. package/lib/matrixrtc/IMembershipManager.js.map +1 -1
  46. package/lib/matrixrtc/MatrixRTCSession.d.ts +27 -1
  47. package/lib/matrixrtc/MatrixRTCSession.d.ts.map +1 -1
  48. package/lib/matrixrtc/MatrixRTCSession.js +58 -36
  49. package/lib/matrixrtc/MatrixRTCSession.js.map +1 -1
  50. package/lib/matrixrtc/MatrixRTCSessionManager.js +1 -1
  51. package/lib/matrixrtc/MatrixRTCSessionManager.js.map +1 -1
  52. package/lib/matrixrtc/MembershipManager.d.ts +76 -0
  53. package/lib/matrixrtc/MembershipManager.d.ts.map +1 -1
  54. package/lib/matrixrtc/MembershipManager.js +264 -47
  55. package/lib/matrixrtc/MembershipManager.js.map +1 -1
  56. package/lib/matrixrtc/index.d.ts +1 -1
  57. package/lib/matrixrtc/index.d.ts.map +1 -1
  58. package/lib/matrixrtc/index.js +1 -1
  59. package/lib/matrixrtc/index.js.map +1 -1
  60. package/lib/matrixrtc/membershipData/common.d.ts +8 -0
  61. package/lib/matrixrtc/membershipData/common.d.ts.map +1 -1
  62. package/lib/matrixrtc/membershipData/common.js +10 -0
  63. package/lib/matrixrtc/membershipData/common.js.map +1 -1
  64. package/lib/matrixrtc/membershipData/index.d.ts +1 -1
  65. package/lib/matrixrtc/membershipData/index.d.ts.map +1 -1
  66. package/lib/matrixrtc/membershipData/index.js +1 -1
  67. package/lib/matrixrtc/membershipData/index.js.map +1 -1
  68. package/lib/matrixrtc/types.d.ts +104 -11
  69. package/lib/matrixrtc/types.d.ts.map +1 -1
  70. package/lib/matrixrtc/types.js +263 -19
  71. package/lib/matrixrtc/types.js.map +1 -1
  72. package/lib/matrixrtc/utils.d.ts +22 -1
  73. package/lib/matrixrtc/utils.d.ts.map +1 -1
  74. package/lib/matrixrtc/utils.js +38 -0
  75. package/lib/matrixrtc/utils.js.map +1 -1
  76. package/lib/models/room-sticky-events.d.ts +7 -0
  77. package/lib/models/room-sticky-events.d.ts.map +1 -1
  78. package/lib/models/room-sticky-events.js +27 -0
  79. package/lib/models/room-sticky-events.js.map +1 -1
  80. package/lib/models/thread.d.ts.map +1 -1
  81. package/lib/models/thread.js +8 -0
  82. package/lib/models/thread.js.map +1 -1
  83. package/lib/oauth/authorize.js +1 -1
  84. package/lib/oauth/authorize.js.map +1 -1
  85. package/lib/oauth/index.d.ts +3 -5
  86. package/lib/oauth/index.d.ts.map +1 -1
  87. package/lib/oauth/index.js +7 -7
  88. package/lib/oauth/index.js.map +1 -1
  89. package/lib/oauth/tokenRefresher.d.ts +10 -4
  90. package/lib/oauth/tokenRefresher.d.ts.map +1 -1
  91. package/lib/oauth/tokenRefresher.js +34 -24
  92. package/lib/oauth/tokenRefresher.js.map +1 -1
  93. package/lib/receipt-accumulator.d.ts +10 -11
  94. package/lib/receipt-accumulator.d.ts.map +1 -1
  95. package/lib/receipt-accumulator.js +26 -25
  96. package/lib/receipt-accumulator.js.map +1 -1
  97. package/lib/room-summary-api.d.ts +71 -0
  98. package/lib/room-summary-api.d.ts.map +1 -0
  99. package/lib/room-summary-api.js +82 -0
  100. package/lib/room-summary-api.js.map +1 -0
  101. package/lib/rust-crypto/backup.d.ts +7 -0
  102. package/lib/rust-crypto/backup.d.ts.map +1 -1
  103. package/lib/rust-crypto/backup.js +41 -25
  104. package/lib/rust-crypto/backup.js.map +1 -1
  105. package/lib/sync.d.ts.map +1 -1
  106. package/lib/sync.js +6 -1
  107. package/lib/sync.js.map +1 -1
  108. package/package.json +6 -6
  109. package/src/@types/PushRules.ts +2 -0
  110. package/src/@types/event.ts +1 -1
  111. package/src/@types/matrix-sdk-crypto-wasm.d.ts +15 -12
  112. package/src/client.ts +105 -62
  113. package/src/crypto-api/index.ts +3 -3
  114. package/src/embedded.ts +61 -2
  115. package/src/errors.ts +2 -1
  116. package/src/http-api/fetch.ts +14 -5
  117. package/src/http-api/interface.ts +36 -8
  118. package/src/http-api/refresh.ts +101 -17
  119. package/src/matrix.ts +1 -1
  120. package/src/matrixrtc/CallMembership.ts +22 -4
  121. package/src/matrixrtc/IMembershipManager.ts +6 -0
  122. package/src/matrixrtc/MatrixRTCSession.ts +72 -49
  123. package/src/matrixrtc/MatrixRTCSessionManager.ts +1 -1
  124. package/src/matrixrtc/MembershipManager.ts +284 -61
  125. package/src/matrixrtc/index.ts +7 -1
  126. package/src/matrixrtc/membershipData/common.ts +12 -0
  127. package/src/matrixrtc/membershipData/index.ts +1 -1
  128. package/src/matrixrtc/types.ts +351 -23
  129. package/src/matrixrtc/utils.ts +54 -1
  130. package/src/models/room-sticky-events.ts +26 -1
  131. package/src/models/thread.ts +9 -0
  132. package/src/oauth/authorize.ts +1 -1
  133. package/src/oauth/index.ts +7 -8
  134. package/src/oauth/tokenRefresher.ts +15 -5
  135. package/src/receipt-accumulator.ts +30 -26
  136. package/src/room-summary-api.ts +114 -0
  137. package/src/rust-crypto/backup.ts +46 -33
  138. package/src/sync.ts +9 -1
@@ -15,8 +15,14 @@ limitations under the License.
15
15
  */
16
16
  import type { IContent, IMentions } from "../matrix.ts";
17
17
  import type { RelationEvent } from "../types.ts";
18
- import type { CallMembership } from "./CallMembership.ts";
18
+ import type { MatrixEvent } from "../models/event.ts";
19
+ import type { Room } from "../models/room.ts";
20
+ import { EventTimeline } from "../models/event-timeline.ts";
21
+ import { EventType, RelationType } from "../@types/event.ts";
22
+ import { CallMembership } from "./CallMembership.ts";
19
23
  import { type CallMembershipIdentityParts } from "./EncryptionManager.ts";
24
+ import { isLeftMembershipContent } from "./membershipData/index.ts";
25
+ import { isSlotOpen, slotIdToDescription } from "./utils.ts";
20
26
 
21
27
  export type EncryptionKeyMapKey = string;
22
28
 
@@ -106,35 +112,323 @@ export type RTCNotificationType = "ring" | "notification";
106
112
  export type RTCCallIntent = "audio" | "video" | string;
107
113
 
108
114
  /**
109
- * This will check if the content has all the expected fields to be a valid IRTCNotificationContent.
110
- * It will also cap the lifetime to 90000ms (1.5 min) if a higher value is provided.
111
- * @param content
112
- * @throws if the content is invalid
113
- * @returns a parsed IRTCNotificationContent
115
+ * The maximum `lifetime` of an RTC notification as per MSC4075. Larger values are capped to this.
116
+ *
117
+ * Exported for use within the js-sdk only. Not re-exported from `matrixrtc/index.ts`.
118
+ * @internal
114
119
  */
115
- export function parseCallNotificationContent(content: IContent): IRTCNotificationContent {
116
- if (content["m.mentions"] && typeof content["m.mentions"] !== "object") {
117
- throw new Error("malformed m.mentions");
120
+ export const RTC_NOTIFICATION_MAX_LIFETIME_MS = 2 * 60 * 1000; // 2 minutes
121
+
122
+ /**
123
+ * The maximum amount by which a notification's `sender_ts` may lie ahead of its `origin_server_ts`
124
+ * before the `lifetime` is measured from `origin_server_ts` instead, as per MSC4075.
125
+ *
126
+ * Exported for use within the js-sdk only. Not re-exported from `matrixrtc/index.ts`.
127
+ * @internal
128
+ */
129
+ export const RTC_NOTIFICATION_MAX_SENDER_TS_AHEAD_MS = 20 * 1000; // 20 seconds
130
+
131
+ /**
132
+ * The context that is needed to evaluate the MSC4075 rules for receiving notifications.
133
+ */
134
+ export interface RTCNotificationValidationContext {
135
+ /** The room in which the notification was received. */
136
+ room: Pick<
137
+ Room,
138
+ "getLiveTimeline" | "getUnfilteredTimelineSet" | "_unstable_getStickyEvents" | "_unstable_getKeyedStickyEvent"
139
+ >;
140
+ /** The user ID of the receiving (local) user. */
141
+ userId: string;
142
+ /** The current time in milliseconds since the epoch. Defaults to `Date.now()`. */
143
+ now?: number;
144
+ }
145
+
146
+ /**
147
+ * Computes the absolute timestamp at which an RTC notification stops being valid, as per MSC4075.
148
+ *
149
+ * The `lifetime` is measured from `sender_ts` unless `sender_ts` lies more than 20 seconds ahead of
150
+ * `origin_server_ts`, in which case it is measured from `origin_server_ts` instead. Regardless of the basis,
151
+ * the remaining lifetime is capped at 2 minutes.
152
+ *
153
+ * @param content The notification content.
154
+ * @param originServerTs The `origin_server_ts` of the notification event.
155
+ * @param now The current time in milliseconds since the epoch. Defaults to `Date.now()`.
156
+ * @returns The timestamp (in milliseconds since the epoch) at which the notification expires.
157
+ */
158
+ export function getCallNotificationExpiry(
159
+ content: Pick<IRTCNotificationContent, "sender_ts" | "lifetime">,
160
+ originServerTs: number,
161
+ now: number = Date.now(),
162
+ ): number {
163
+ const basis =
164
+ content.sender_ts - originServerTs > RTC_NOTIFICATION_MAX_SENDER_TS_AHEAD_MS
165
+ ? originServerTs
166
+ : content.sender_ts;
167
+ return Math.min(basis + content.lifetime, now + RTC_NOTIFICATION_MAX_LIFETIME_MS);
168
+ }
169
+
170
+ /**
171
+ * The subset of a notification event that {@link parseCallNotificationContent} needs.
172
+ */
173
+ type RTCNotificationEvent = Pick<
174
+ MatrixEvent,
175
+ "getContent" | "getId" | "getSender" | "getTs" | "getType" | "unstableStickyInfo"
176
+ >;
177
+
178
+ /**
179
+ * Whether the content marks a withdrawn sticky event (MSC4354), i.e. it is empty apart from the sticky key.
180
+ */
181
+ function isWithdrawal(content: IContent): boolean {
182
+ return Object.keys(content).every((key) => key === "msc4354_sticky_key");
183
+ }
184
+
185
+ function isNonNegativeInteger(value: unknown): value is number {
186
+ return Number.isInteger(value) && (value as number) >= 0;
187
+ }
188
+
189
+ /**
190
+ * Looks up a legacy `m.call.member` state event by ID in the room's current state.
191
+ *
192
+ * @returns The event, or `undefined` if no current membership state event has that ID.
193
+ */
194
+ function findMembershipStateEvent(
195
+ room: RTCNotificationValidationContext["room"],
196
+ eventId: string,
197
+ ): MatrixEvent | undefined {
198
+ const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
199
+ return roomState?.getStateEvents(EventType.GroupCallMemberPrefix).find((event) => event.getId() === eventId);
200
+ }
201
+
202
+ /**
203
+ * Determines the ID of the slot that a notification invites to.
204
+ *
205
+ * Notifications predating the `slot_id` property instead carry an `m.reference` relation to the sender's
206
+ * membership event.
207
+ *
208
+ * @throws if the slot can't be determined.
209
+ * @returns A promise resolving to the slot ID.
210
+ */
211
+ async function resolveSlotId(content: IContent, room: RTCNotificationValidationContext["room"]): Promise<string> {
212
+ if (typeof content["slot_id"] === "string") {
213
+ // Throws if the ID is malformed, e.g. because it contains more than one "#".
214
+ slotIdToDescription(content["slot_id"]);
215
+ return content["slot_id"];
216
+ }
217
+ if (content["slot_id"] !== undefined) throw new Error("Invalid slot_id");
218
+
219
+ const relation = content["m.relates_to"];
220
+ const membershipEventId = relation?.rel_type === RelationType.Reference ? relation.event_id : undefined;
221
+ if (typeof membershipEventId !== "string") {
222
+ throw new Error("Missing slot_id and no m.reference relation to derive it from");
223
+ }
224
+
225
+ const membershipEvent = findMembershipStateEvent(room, membershipEventId);
226
+ if (!membershipEvent) {
227
+ throw new Error(`Missing slot_id and referenced event ${membershipEventId} is not a current membership`);
118
228
  }
119
- if (typeof content["notification_type"] !== "string") {
120
- throw new Error("Missing or invalid notification_type");
229
+
230
+ try {
231
+ return (await CallMembership.parseFromEvent(membershipEvent)).slotId;
232
+ } catch (cause) {
233
+ throw new Error(`Missing slot_id and referenced event ${membershipEventId} is not a valid membership`, {
234
+ cause,
235
+ });
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Parses the content of a received `org.matrix.msc4075.rtc.notification` event.
241
+ *
242
+ * This checks that the content matches the schema of MSC4075, caps `lifetime` to 2 minutes and applies
243
+ * the rules for receiving invites from MSC4075.
244
+ * The notification is only considered valid if all of the following hold:
245
+ * - It wasn't sent by the receiving user.
246
+ * - It is the current entry in the room's sticky event map for its sender and slot and isn't a withdrawal.
247
+ * - The slot it refers to exists and is open.
248
+ * - Its lifetime hasn't elapsed (see {@link getCallNotificationExpiry}).
249
+ * - Its `m.mentions` target the receiving user, either directly or via a room mention that the sender is
250
+ * allowed to trigger.
251
+ * - The receiving user has no joined `m.rtc.member` event for the slot in the sticky event map.
252
+ * - Any left `m.rtc.member` event of the receiving user for the slot predates the notification.
253
+ * - The receiving user has no sticky decline event referencing the notification.
254
+ *
255
+ * @param event The notification event.
256
+ * @param context The context needed to evaluate the receiving rules.
257
+ * @throws if the content is malformed or the notification isn't valid.
258
+ * @returns A promise resolving to the parsed content.
259
+ */
260
+ export async function parseCallNotificationContent(
261
+ event: RTCNotificationEvent,
262
+ context: RTCNotificationValidationContext,
263
+ ): Promise<IRTCNotificationContent> {
264
+ if (event.getType() !== EventType.RTCNotification) {
265
+ throw new Error(`Event ${event.getId()} is not an ${EventType.RTCNotification} event`);
121
266
  }
122
- if (typeof content["sender_ts"] !== "number") {
267
+
268
+ const content = event.getContent();
269
+ if (isWithdrawal(content)) {
270
+ throw new Error("Notification was withdrawn");
271
+ }
272
+
273
+ const slotId = await resolveSlotId(content, context.room);
274
+ // The sticky key is only validated when present. A notification predating it can still be valid, and one
275
+ // carrying the wrong key is filed under that key in the sticky event map, so the lookup below rejects it.
276
+ if (content["msc4354_sticky_key"] !== undefined && content["msc4354_sticky_key"] !== slotId) {
277
+ throw new Error("msc4354_sticky_key must be equal to slot_id");
278
+ }
279
+ if (!isNonNegativeInteger(content["sender_ts"])) {
123
280
  throw new Error("Missing or invalid sender_ts");
124
281
  }
125
- if (typeof content["lifetime"] !== "number") {
282
+ if (!isNonNegativeInteger(content["lifetime"])) {
126
283
  throw new Error("Missing or invalid lifetime");
127
284
  }
128
285
 
129
- if (content["relation"] && content["relation"]["rel_type"] !== "m.reference") {
130
- throw new Error("Invalid relation");
286
+ const mentions = content["m.mentions"];
287
+ if (typeof mentions !== "object" || mentions === null) {
288
+ throw new Error("Missing or malformed m.mentions");
289
+ }
290
+ if (mentions.room !== undefined && typeof mentions.room !== "boolean") {
291
+ throw new Error("malformed m.mentions.room");
292
+ }
293
+ if (
294
+ mentions.user_ids !== undefined &&
295
+ (!Array.isArray(mentions.user_ids) || !mentions.user_ids.every((userId) => typeof userId === "string"))
296
+ ) {
297
+ throw new Error("malformed m.mentions.user_ids");
131
298
  }
132
- if (content["m.call.intent"] && typeof content["m.call.intent"] !== "string") {
133
- throw new Error("Invalid m.call.intent");
299
+ if (mentions.room !== true && !mentions.user_ids?.length) {
300
+ throw new Error("m.mentions must target either the room or at least one user");
134
301
  }
135
302
 
136
- const cappedLifetime = content["lifetime"] >= 90000 ? 90000 : content["lifetime"];
137
- return { ...content, lifetime: cappedLifetime } as IRTCNotificationContent;
303
+ const parsed = {
304
+ ...content,
305
+ slot_id: slotId,
306
+ lifetime: Math.min(content["lifetime"], RTC_NOTIFICATION_MAX_LIFETIME_MS),
307
+ } as IRTCNotificationContent;
308
+
309
+ await checkCallNotificationReceivingRules(parsed, event, context);
310
+
311
+ return parsed;
312
+ }
313
+
314
+ /**
315
+ * Applies the MSC4075 rules for receiving invites to an already schema-validated notification.
316
+ * @throws if any of the rules is violated.
317
+ */
318
+ async function checkCallNotificationReceivingRules(
319
+ content: IRTCNotificationContent,
320
+ event: RTCNotificationEvent,
321
+ { room, userId, now = Date.now() }: RTCNotificationValidationContext,
322
+ ): Promise<void> {
323
+ const eventId = event.getId();
324
+ const sender = event.getSender();
325
+ if (eventId === undefined || sender === undefined) {
326
+ throw new Error("Notification event is missing its event ID or sender");
327
+ }
328
+ const originServerTs = event.getTs();
329
+ const slotId = content.slot_id;
330
+
331
+ if (sender === userId) {
332
+ throw new Error("Notification was sent by the receiving user");
333
+ }
334
+
335
+ // A notification that wasn't delivered as a sticky event can't be superseded or withdrawn, so there is
336
+ // nothing to check. It isn't in the sticky event map either, which is why this can't be checked blindly.
337
+ if (event.unstableStickyInfo !== undefined) {
338
+ if (room._unstable_getKeyedStickyEvent(sender, event.getType(), slotId)?.getId() !== eventId) {
339
+ throw new Error("Notification is not the current sticky event for its sender and slot");
340
+ }
341
+ }
342
+
343
+ // `isSlotOpen` returns undefined when no slot event exists at all, which is just as disqualifying.
344
+ if (!isSlotOpen(room, slotIdToDescription(slotId))) {
345
+ throw new Error(`Slot ${slotId} does not exist or is closed`);
346
+ }
347
+ const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
348
+
349
+ if (now >= getCallNotificationExpiry(content, originServerTs, now)) {
350
+ throw new Error("Notification has expired");
351
+ }
352
+
353
+ const mentions = content["m.mentions"];
354
+ const mentionsUser = mentions.user_ids?.includes(userId) ?? false;
355
+ const mentionsRoom = mentions.room === true && (roomState?.mayTriggerNotifOfType("room", sender) ?? false);
356
+ if (!mentionsUser && !mentionsRoom) {
357
+ throw new Error("Notification does not mention the receiving user");
358
+ }
359
+
360
+ for (const membershipEvent of ownMembershipEvents(room, userId)) {
361
+ const membershipContent = membershipEvent.getContent();
362
+ if (isLeftMembershipContent(membershipContent)) {
363
+ if (membershipContent.slot_id === slotId && membershipEvent.getTs() >= originServerTs) {
364
+ throw new Error(`Receiving user left slot ${slotId} after the notification was sent`);
365
+ }
366
+ } else if (await isJoinedToSlot(membershipEvent, slotId, now)) {
367
+ throw new Error(`Receiving user is already joined to slot ${slotId}`);
368
+ }
369
+ }
370
+
371
+ if (hasDeclined(room, userId, eventId)) {
372
+ throw new Error("Receiving user declined the notification");
373
+ }
374
+ }
375
+
376
+ /**
377
+ * Yields the receiving user's own MatrixRTC membership events, from both the sticky event map and the legacy
378
+ * member state events. Sessions that don't use sticky events keep membership in state, so both have to be
379
+ * consulted to tell whether the user is in a session.
380
+ *
381
+ * @yields the user's own `m.rtc.member` and `m.call.member` events.
382
+ */
383
+ function* ownMembershipEvents(room: RTCNotificationValidationContext["room"], userId: string): Iterable<MatrixEvent> {
384
+ for (const stickyEvent of room._unstable_getStickyEvents()) {
385
+ if (stickyEvent.getType() === EventType.RTCMembership && stickyEvent.getSender() === userId) {
386
+ yield stickyEvent;
387
+ }
388
+ }
389
+
390
+ const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS);
391
+ for (const stateEvent of roomState?.getStateEvents(EventType.GroupCallMemberPrefix) ?? []) {
392
+ if (stateEvent.getSender() === userId) yield stateEvent;
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Whether the given membership event places its sender in the given slot, i.e. it is a valid, unexpired
398
+ * membership for that slot.
399
+ */
400
+ async function isJoinedToSlot(membershipEvent: MatrixEvent, slotId: string, now: number): Promise<boolean> {
401
+ let membership: CallMembership;
402
+ try {
403
+ membership = await CallMembership.parseFromEvent(membershipEvent);
404
+ } catch {
405
+ // Not a membership we understand, so it doesn't place the user anywhere.
406
+ return false;
407
+ }
408
+ if (membership.slotId !== slotId) return false;
409
+
410
+ // Legacy memberships go stale when the client that sent them disappears without leaving. Honouring an
411
+ // expired one would suppress notifications for hours.
412
+ const expiry = membership.getAbsoluteExpiry();
413
+ return expiry === undefined || expiry > now;
414
+ }
415
+
416
+ /**
417
+ * Whether the receiving user has declined the given notification.
418
+ *
419
+ * Declines are looked up both through their `m.reference` relation and in the sticky event map. Relations
420
+ * cover declines that couldn't be sent as sticky events, but only see events that reached the timeline. A
421
+ * sticky decline delivered after a gappy or initial sync arrives via the sticky section instead, so it is
422
+ * only in the map.
423
+ */
424
+ function hasDeclined(room: RTCNotificationValidationContext["room"], userId: string, eventId: string): boolean {
425
+ const declines = room
426
+ .getUnfilteredTimelineSet()
427
+ .relations.getChildEventsForEvent(eventId, RelationType.Reference, EventType.RTCDecline);
428
+ if (declines?.getRelations().some((decline) => decline.getSender() === userId)) return true;
429
+
430
+ // Declines are keyed on the event ID of the notification they decline.
431
+ return room._unstable_getKeyedStickyEvent(userId, EventType.RTCDecline, eventId) !== undefined;
138
432
  }
139
433
 
140
434
  /**
@@ -142,21 +436,55 @@ export function parseCallNotificationContent(content: IContent): IRTCNotificatio
142
436
  * Don't cast event content to this directly. Use `parseCallNotificationContent` instead to validate the content first.
143
437
  */
144
438
  export interface IRTCNotificationContent extends RelationEvent {
145
- "m.mentions"?: IMentions;
146
- "notification_type": RTCNotificationType;
147
439
  /**
148
- * The initial intent of the calling user.
440
+ * The `state_key` of the `m.rtc.slot` event that the notification refers to.
441
+ *
442
+ * Notifications predating this property omit it. `parseCallNotificationContent` fills it in from the
443
+ * referenced membership event, so the parsed content always carries it.
444
+ */
445
+ "slot_id": string;
446
+ /**
447
+ * The users targeted by the notification. Must either mention the room or at least one user.
448
+ */
449
+ "m.mentions": IMentions;
450
+ /**
451
+ * @deprecated Not part of MSC4075 anymore. How to present a notification is up to the receiving client.
452
+ */
453
+ "notification_type"?: RTCNotificationType;
454
+ /**
455
+ * @deprecated Not part of MSC4075 anymore. The initial intent of the calling user.
149
456
  */
150
457
  "m.call.intent"?: RTCCallIntent;
458
+ /**
459
+ * The timestamp (in milliseconds since the epoch) at which the sending client created the event.
460
+ */
151
461
  "sender_ts": number;
462
+ /**
463
+ * The time (in milliseconds) that the notification is valid for, measured from `sender_ts`.
464
+ */
152
465
  "lifetime": number;
466
+ /**
467
+ * The sticky key as per MSC4354. Must be equal to `slot_id`.
468
+ *
469
+ * Optional because notifications predating MSC4354 omit it.
470
+ */
471
+ "msc4354_sticky_key"?: string;
153
472
  }
154
473
 
155
474
  /**
156
475
  * MSC4310 decline event content for `org.matrix.msc4310.rtc.decline`.
157
476
  * Sent as a standard m.reference relation to an `org.matrix.msc4075.rtc.notification` event.
158
477
  */
159
- export interface IRTCDeclineContent extends RelationEvent {}
478
+ export interface IRTCDeclineContent extends RelationEvent {
479
+ /**
480
+ * The sticky key as per MSC4354. Must be equal to the event ID of the notification being declined.
481
+ *
482
+ * Optional because servers without MSC4354 support can't deliver the decline as a sticky event. Such a
483
+ * decline still counts, but only for devices that receive it directly, since it isn't re-delivered after
484
+ * a gappy or initial sync.
485
+ */
486
+ msc4354_sticky_key?: string;
487
+ }
160
488
 
161
489
  export enum Status {
162
490
  Disconnected = "Disconnected",
@@ -15,7 +15,10 @@ limitations under the License.
15
15
  */
16
16
 
17
17
  import { getEncryptionKeyMapKey, type CallMembershipIdentityParts } from "./EncryptionManager.ts";
18
- import type { InboundEncryptionSession, EncryptionKeyMapKey, SlotDescription } from "./types.ts";
18
+ import type { InboundEncryptionSession, EncryptionKeyMapKey, RtcSlotEventContent, SlotDescription } from "./types.ts";
19
+ import type { Room } from "../models/room.ts";
20
+ import { EventTimeline } from "../models/event-timeline.ts";
21
+ import { EventType } from "../@types/event.ts";
19
22
 
20
23
  /**
21
24
  * Detects when a key for a given index is outdated.
@@ -67,3 +70,53 @@ export function slotIdToDescription(slotId: string): SlotDescription {
67
70
  export function computeSlotId(slotDescription: SlotDescription): string {
68
71
  return `${slotDescription.application}#${slotDescription.id}`;
69
72
  }
73
+
74
+ /**
75
+ * Reads the slot state event's content for the given slot description.
76
+ *
77
+ * @returns The slot event's content, or `undefined` if no slot event exists for the given description.
78
+ */
79
+ export function getSlotEventContent(
80
+ room: Pick<Room, "getLiveTimeline">,
81
+ slotDescription: SlotDescription,
82
+ ): RtcSlotEventContent | undefined {
83
+ const slotId = computeSlotId(slotDescription);
84
+ const slotEvent = room
85
+ .getLiveTimeline()
86
+ .getState(EventTimeline.FORWARDS)
87
+ ?.getStateEvents(EventType.RTCSlot, slotId);
88
+ if (!slotEvent) return undefined;
89
+
90
+ return slotEvent.getContent<RtcSlotEventContent>();
91
+ }
92
+
93
+ /**
94
+ * Whether the given slot is closed.
95
+ *
96
+ * @returns `true` if the slot is closed, `false` if the slot is open or `undefined`
97
+ * if no slot exists.
98
+ */
99
+ export function isSlotClosed(
100
+ room: Pick<Room, "getLiveTimeline">,
101
+ slotDescription: SlotDescription,
102
+ ): boolean | undefined {
103
+ const content = getSlotEventContent(room, slotDescription) as Partial<RtcSlotEventContent> | undefined;
104
+ if (content === undefined) return undefined;
105
+
106
+ return (
107
+ content.status !== "open" ||
108
+ typeof content.application !== "object" ||
109
+ content.application?.type !== slotDescription.application
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Whether the given slot is open.
115
+ *
116
+ * @returns `true` if the slot is open, `false` if the slot is closed or `undefined`
117
+ * if no slot exists.
118
+ */
119
+ export function isSlotOpen(room: Pick<Room, "getLiveTimeline">, slotDescription: SlotDescription): boolean | undefined {
120
+ const closed = isSlotClosed(room, slotDescription);
121
+ return closed === undefined ? undefined : !closed;
122
+ }
@@ -1,5 +1,5 @@
1
1
  import { logger as loggerInstance } from "../logger.ts";
2
- import { type MatrixEvent } from "./event.ts";
2
+ import { type MatrixEvent, MatrixEventEvent } from "./event.ts";
3
3
  import { TypedEventEmitter } from "./typed-event-emitter.ts";
4
4
 
5
5
  const logger = loggerInstance.getChild("RoomStickyEvents");
@@ -131,6 +131,13 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
131
131
  * and the previous event it may have replaced.
132
132
  */
133
133
  private addStickyEvent(event: MatrixEvent): { added: true; prevEvent?: StickyMatrixEvent } | { added: false } {
134
+ // The map is keyed on the event's type and sticky key, both of which are encrypted. Wait for the
135
+ // event to be decrypted, otherwise it would be filed under `m.room.encrypted` with no sticky key.
136
+ if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
137
+ this.addStickyEventOnceDecrypted(event);
138
+ return { added: false };
139
+ }
140
+
134
141
  const stickyKey = event.getContent().msc4354_sticky_key;
135
142
  if (typeof stickyKey !== "string" && stickyKey !== undefined) {
136
143
  throw new Error(`${event.getId()} is missing msc4354_sticky_key`);
@@ -189,6 +196,24 @@ export class RoomStickyEventsStore extends TypedEventEmitter<RoomStickyEventsEve
189
196
  };
190
197
  }
191
198
 
199
+ /**
200
+ * Adds the event to the map once it has been decrypted, emitting `RoomEvent.StickyEvents` at that point.
201
+ *
202
+ * Decryption may fail because the keys haven't arrived yet, in which case we keep waiting. An event that
203
+ * is never decrypted simply never enters the map.
204
+ */
205
+ private addStickyEventOnceDecrypted(event: MatrixEvent): void {
206
+ const onEventDecrypted = (): void => {
207
+ if (event.isDecryptionFailure()) {
208
+ // The event may still be decrypted later, e.g. once the keys arrive. Keep listening.
209
+ event.once(MatrixEventEvent.Decrypted, onEventDecrypted);
210
+ return;
211
+ }
212
+ this.addStickyEvents([event]); // Safe to call because it sorts events by timestamp when updating a keyed entry.
213
+ };
214
+ event.once(MatrixEventEvent.Decrypted, onEventDecrypted);
215
+ }
216
+
192
217
  /**
193
218
  * Add a series of sticky events, emitting `RoomEvent.StickyEvents` if any
194
219
  * changes were made.
@@ -369,6 +369,15 @@ export class Thread extends ReadReceipt<ThreadEmittedEvents, ThreadEventHandlerM
369
369
  // Modify this event to point at our room's state, and mark its thread
370
370
  // as this.
371
371
  this.setEventMetadata(event);
372
+
373
+ // The bundled `current_user_participated` flag from the server lags behind local activity,
374
+ // so relying on it alone drops just-sent replies out of "participated" views until the next
375
+ // root event bundle refresh. Latch it as soon as the current user authors a thread reply,
376
+ // matching the server's participated semantics: an m.thread relation, not a reaction or edit.
377
+ if (event.getSender() === this.client.getUserId() && event.isRelation(THREAD_RELATION_TYPE.name)) {
378
+ this._currentUserParticipated = true;
379
+ }
380
+
372
381
  const eventId = event.getId();
373
382
  if (
374
383
  !this.initialEventsFetched &&
@@ -24,7 +24,7 @@ import {
24
24
  hasRequiredStringProperty,
25
25
  isRecord,
26
26
  } from "../@types/type-guards.ts";
27
- import { Method } from "../http-api/index.ts";
27
+ import { Method } from "../http-api/method.ts";
28
28
  import { OAuthGrantType } from "./register.ts";
29
29
  import { sleep } from "../utils.ts";
30
30
  import { type Logger, logger as rootLogger } from "../logger.ts";
@@ -34,7 +34,8 @@ import {
34
34
  } from "./register.ts";
35
35
  import { encodeUnpaddedBase64Url } from "../base64.ts";
36
36
  import { sha256 } from "../digest.ts";
37
- import { HTTPError, isMatrixErrorResponse, MatrixError, Method } from "../http-api/index.ts";
37
+ import { HTTPError, isMatrixErrorResponse, MatrixError } from "../http-api/errors.ts";
38
+ import { Method } from "../http-api/method.ts";
38
39
  import { type Logger, logger as rootLogger } from "../logger.ts";
39
40
  import { fetchWithLogging } from "./fetch.ts";
40
41
  import { isOAuth2ErrorResponse, OAuth2Error, OAuth2HTTPError } from "./error.ts";
@@ -44,7 +45,6 @@ import { type NonEmptyArray } from "../@types/common.ts";
44
45
  export * from "./authorize.ts";
45
46
  export * from "./error.ts";
46
47
  export * from "./register.ts";
47
- export * from "./tokenRefresher.ts";
48
48
  export * from "./discover.ts";
49
49
 
50
50
  /**
@@ -57,8 +57,6 @@ type Context = {
57
57
  deviceId?: string;
58
58
  /** The seed used to generate the challenge code */
59
59
  codeVerifier?: string;
60
- /** The URI to redirect the user to with credentials after auth */
61
- redirectUri: string;
62
60
  };
63
61
 
64
62
  export class OAuth2 {
@@ -149,7 +147,6 @@ export class OAuth2 {
149
147
  ) {
150
148
  this.context = {
151
149
  clientId: context.clientId,
152
- redirectUri: context.redirectUri,
153
150
  deviceId: context.deviceId ?? secureRandomString(10),
154
151
  codeVerifier: context.codeVerifier ?? secureRandomString(96),
155
152
  };
@@ -162,12 +159,14 @@ export class OAuth2 {
162
159
  * that will allow the client to maintain state between the authorization request and the callback.
163
160
  * The app should use this to key the storage for where the rest of the auth context is saved.
164
161
  * @param responseMode - The manner in which the IdP should send the secrets back to the app. Defaults to `fragment` for privacy.
162
+ * @param redirectUri - The redirect URI this application is registered with the delegated auth server under.
165
163
  * @param prompt - Optional prompt parameter to pass to the IdP to signal intent, e.g. `create` for User registration.
166
164
  * @param scope - The OAuth2 scope to request, will be generated based on the device ID if omitted.
167
165
  * @returns a Promise with the url as a string
168
166
  */
169
167
  public async generateAuthorizationCodeGrantUrl(
170
168
  state: string,
169
+ redirectUri: string,
171
170
  responseMode: "fragment" | "query" = "fragment",
172
171
  prompt?: string,
173
172
  scope?: string,
@@ -178,7 +177,7 @@ export class OAuth2 {
178
177
  url.searchParams.set("response_type", "code");
179
178
  url.searchParams.set("response_mode", responseMode);
180
179
  url.searchParams.set("client_id", this.context.clientId);
181
- url.searchParams.set("redirect_uri", this.context.redirectUri);
180
+ url.searchParams.set("redirect_uri", redirectUri);
182
181
  url.searchParams.set("scope", scope ?? generateScope(this.context.deviceId));
183
182
  url.searchParams.set("state", state);
184
183
  url.searchParams.set("code_challenge_method", "S256");
@@ -202,12 +201,12 @@ export class OAuth2 {
202
201
  * @throws An `Error` with `message` set to an entry in {@link OAuth2Error},
203
202
  * when the request fails, or the returned token response is invalid.
204
203
  */
205
- public async completeAuthorizationCodeGrant(code: string): Promise<BearerTokenResponse> {
204
+ public async completeAuthorizationCodeGrant(code: string, redirectUri: string): Promise<BearerTokenResponse> {
206
205
  const params = new URLSearchParams();
207
206
  params.append("grant_type", "authorization_code");
208
207
  params.append("client_id", this.context.clientId);
209
208
  params.append("code_verifier", this.context.codeVerifier);
210
- params.append("redirect_uri", this.context.redirectUri);
209
+ params.append("redirect_uri", redirectUri);
211
210
  params.append("code", code);
212
211
 
213
212
  const res = await this.fetch("token", params, OAuth2Error.CodeExchangeFailed);