summaryrefslogtreecommitdiff
path: root/includes/external/matrix/node_modules/matrix-js-sdk/src
diff options
context:
space:
mode:
authorRaindropsSys <contact@minteck.org>2023-04-24 14:03:36 +0200
committerRaindropsSys <contact@minteck.org>2023-04-24 14:03:36 +0200
commit633c92eae865e957121e08de634aeee11a8b3992 (patch)
tree09d881bee1dae0b6eee49db1dfaf0f500240606c /includes/external/matrix/node_modules/matrix-js-sdk/src
parentc4657e4509733699c0f26a3c900bab47e915d5a0 (diff)
downloadpluralconnect-633c92eae865e957121e08de634aeee11a8b3992.tar.gz
pluralconnect-633c92eae865e957121e08de634aeee11a8b3992.tar.bz2
pluralconnect-633c92eae865e957121e08de634aeee11a8b3992.zip
Updated 18 files, added 1692 files and deleted includes/system/compare.inc (automated)
Diffstat (limited to 'includes/external/matrix/node_modules/matrix-js-sdk/src')
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/IIdentityServerProvider.ts24
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/PushRules.ts209
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/another-json.d.ts19
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/auth.ts117
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/beacon.ts140
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/crypto.ts99
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/event.ts251
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/extensible_events.ts151
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/global.d.ts99
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/local_notifications.ts19
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/location.ts92
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/partials.ts89
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/polls.ts119
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/read_receipts.ts61
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/requests.ts243
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/search.ts119
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/signed.ts25
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/spaces.ts37
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/synapse.ts40
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/sync.ts27
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/threepids.ts29
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/topic.ts63
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/@types/uia.ts29
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/NamespacedValue.ts120
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/ReEmitter.ts91
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/ToDeviceMessageQueue.ts148
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/autodiscovery.ts472
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/browser-index.ts47
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts9680
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/CryptoBackend.ts170
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/README.md4
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/content-helpers.ts288
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/content-repo.ts79
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto-api.ts75
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts803
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts989
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts356
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts1496
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts485
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts63
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts583
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts157
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts268
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts20
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts2208
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts329
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts127
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts813
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts50
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts271
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts161
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts3936
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts93
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts77
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts566
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts62
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts226
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts1062
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts708
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts403
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts533
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts369
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts76
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts50
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts311
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts492
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts37
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts34
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts356
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts354
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts926
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/embedded.ts347
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/errors.ts53
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/event-mapper.ts97
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/ExtensibleEvent.ts58
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/InvalidEventError.ts24
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/MessageEvent.ts145
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollEndEvent.ts97
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollResponseEvent.ts143
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollStartEvent.ts207
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/utilities.ts35
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/feature.ts75
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/filter-component.ts204
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/filter.ts242
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/errors.ts84
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/fetch.ts311
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/index.ts191
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/interface.ts147
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/method.ts22
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/prefix.ts48
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/utils.ts153
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/index.ts25
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-helpers.ts50
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-worker.ts24
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/interactive-auth.ts617
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/logger.ts82
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/matrix.ts110
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089Branch.ts258
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089TreeSpace.ts566
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/ToDeviceMessage.ts38
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/beacon.ts209
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-context.ts110
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-status.ts39
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts906
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline.ts458
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/event.ts1631
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/invites-ignorer.ts368
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/poll.ts268
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/read-receipt.ts312
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/related-relations.ts39
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations-container.ts146
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations.ts368
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-member.ts453
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-state.ts1081
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-summary.ts44
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts3487
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/search-result.ts54
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/thread.ts669
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/typed-event-emitter.ts114
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/models/user.ts281
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/pushprocessor.ts770
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/randomstring.ts42
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/realtime-callbacks.ts191
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/MSC3906Rendezvous.ts264
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousChannel.ts48
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousCode.ts25
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousError.ts23
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousFailureReason.ts31
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousIntent.ts20
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousTransport.ts58
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts259
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/index.ts17
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/index.ts23
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts193
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/index.ts17
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/room-hierarchy.ts152
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/KeyClaimManager.ts77
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/OutgoingRequestProcessor.ts116
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts153
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/browserify-index.ts31
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/constants.ts18
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/index.ts45
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/rust-crypto.ts334
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/scheduler.ts335
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/secret-storage.ts88
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/service-types.ts20
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts1027
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync.ts961
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/store/index.ts248
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-backend.ts40
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-local-backend.ts597
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-remote-backend.ts203
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-store-worker.ts157
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb.ts383
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/store/local-storage-events-emitter.ts46
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/store/memory.ts436
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/store/stub.ts267
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/sync-accumulator.ts715
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/sync.ts1898
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/timeline-window.ts507
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts770
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/audioContext.ts44
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/call.ts2962
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventHandler.ts425
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventTypes.ts92
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callFeed.ts361
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts1598
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCallEventHandler.ts232
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/mediaHandler.ts469
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStats.ts47
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStatsReporter.ts28
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/groupCallStats.ts64
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaSsrcHandler.ts57
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackHandler.ts71
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStats.ts104
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStatsHandler.ts86
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReport.ts56
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportBuilder.ts110
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportEmitter.ts33
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportGatherer.ts183
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsValueFormatter.ts27
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/trackStatsReporter.ts117
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStats.ts26
-rw-r--r--includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStatsReporter.ts48
184 files changed, 67460 insertions, 0 deletions
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/IIdentityServerProvider.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/IIdentityServerProvider.ts
new file mode 100644
index 0000000..8e30497
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/IIdentityServerProvider.ts
@@ -0,0 +1,24 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export interface IIdentityServerProvider {
+ /**
+ * Gets an access token for use against the identity server,
+ * for the associated client.
+ * @returns Promise which resolves to the access token.
+ */
+ getAccessToken(): Promise<string | null>;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/PushRules.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/PushRules.ts
new file mode 100644
index 0000000..da3b01b
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/PushRules.ts
@@ -0,0 +1,209 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// allow camelcase as these are things that go onto the wire
+/* eslint-disable camelcase */
+
+export enum PushRuleActionName {
+ DontNotify = "dont_notify",
+ Notify = "notify",
+ Coalesce = "coalesce",
+}
+
+export enum TweakName {
+ Highlight = "highlight",
+ Sound = "sound",
+}
+
+export type Tweak<N extends TweakName, V> = {
+ set_tweak: N;
+ value?: V;
+};
+
+export type TweakHighlight = Tweak<TweakName.Highlight, boolean>;
+export type TweakSound = Tweak<TweakName.Sound, string>;
+
+export type Tweaks = TweakHighlight | TweakSound;
+
+export enum ConditionOperator {
+ ExactEquals = "==",
+ LessThan = "<",
+ GreaterThan = ">",
+ GreaterThanOrEqual = ">=",
+ LessThanOrEqual = "<=",
+}
+
+export type PushRuleAction = Tweaks | PushRuleActionName;
+
+export type MemberCountCondition<N extends number, Op extends ConditionOperator = ConditionOperator.ExactEquals> =
+ | `${Op}${N}`
+ | (Op extends ConditionOperator.ExactEquals ? `${N}` : never);
+
+export type AnyMemberCountCondition = MemberCountCondition<number, ConditionOperator>;
+
+export const DMMemberCountCondition: MemberCountCondition<2> = "2";
+
+export function isDmMemberCountCondition(condition: AnyMemberCountCondition): boolean {
+ return condition === "==2" || condition === "2";
+}
+
+export enum ConditionKind {
+ EventMatch = "event_match",
+ EventPropertyIs = "event_property_is",
+ EventPropertyContains = "event_property_contains",
+ ContainsDisplayName = "contains_display_name",
+ RoomMemberCount = "room_member_count",
+ SenderNotificationPermission = "sender_notification_permission",
+ CallStarted = "call_started",
+ CallStartedPrefix = "org.matrix.msc3914.call_started",
+}
+
+export interface IPushRuleCondition<N extends ConditionKind | string> {
+ [k: string]: any; // for custom conditions, there can be other fields here
+ kind: N;
+}
+
+export interface IEventMatchCondition extends IPushRuleCondition<ConditionKind.EventMatch> {
+ key: string;
+ pattern?: string;
+ // Note that value property is an optimization for patterns which do not do
+ // any globbing and when the key is not "content.body".
+ value?: string;
+}
+
+export interface IEventPropertyIsCondition extends IPushRuleCondition<ConditionKind.EventPropertyIs> {
+ key: string;
+ value: string | boolean | null | number;
+}
+
+export interface IEventPropertyContainsCondition extends IPushRuleCondition<ConditionKind.EventPropertyContains> {
+ key: string;
+ value: string | boolean | null | number;
+}
+
+export interface IContainsDisplayNameCondition extends IPushRuleCondition<ConditionKind.ContainsDisplayName> {
+ // no additional fields
+}
+
+export interface IRoomMemberCountCondition extends IPushRuleCondition<ConditionKind.RoomMemberCount> {
+ is: AnyMemberCountCondition;
+}
+
+export interface ISenderNotificationPermissionCondition
+ extends IPushRuleCondition<ConditionKind.SenderNotificationPermission> {
+ key: string;
+}
+
+export interface ICallStartedCondition extends IPushRuleCondition<ConditionKind.CallStarted> {
+ // no additional fields
+}
+
+export interface ICallStartedPrefixCondition extends IPushRuleCondition<ConditionKind.CallStartedPrefix> {
+ // no additional fields
+}
+
+// XXX: custom conditions are possible but always fail, and break the typescript discriminated union so ignore them here
+// IPushRuleCondition<Exclude<string, ConditionKind>> unfortunately does not resolve this at the time of writing.
+export type PushRuleCondition =
+ | IEventMatchCondition
+ | IEventPropertyIsCondition
+ | IEventPropertyContainsCondition
+ | IContainsDisplayNameCondition
+ | IRoomMemberCountCondition
+ | ISenderNotificationPermissionCondition
+ | ICallStartedCondition
+ | ICallStartedPrefixCondition;
+
+export enum PushRuleKind {
+ Override = "override",
+ ContentSpecific = "content",
+ RoomSpecific = "room",
+ SenderSpecific = "sender",
+ Underride = "underride",
+}
+
+export enum RuleId {
+ Master = ".m.rule.master",
+ IsUserMention = ".org.matrix.msc3952.is_user_mention",
+ IsRoomMention = ".org.matrix.msc3952.is_room_mention",
+ ContainsDisplayName = ".m.rule.contains_display_name",
+ ContainsUserName = ".m.rule.contains_user_name",
+ AtRoomNotification = ".m.rule.roomnotif",
+ DM = ".m.rule.room_one_to_one",
+ EncryptedDM = ".m.rule.encrypted_room_one_to_one",
+ Message = ".m.rule.message",
+ EncryptedMessage = ".m.rule.encrypted",
+ InviteToSelf = ".m.rule.invite_for_me",
+ MemberEvent = ".m.rule.member_event",
+ IncomingCall = ".m.rule.call",
+ SuppressNotices = ".m.rule.suppress_notices",
+ Tombstone = ".m.rule.tombstone",
+ PollStart = ".m.rule.poll_start",
+ PollStartUnstable = ".org.matrix.msc3930.rule.poll_start",
+ PollEnd = ".m.rule.poll_end",
+ PollEndUnstable = ".org.matrix.msc3930.rule.poll_end",
+ PollStartOneToOne = ".m.rule.poll_start_one_to_one",
+ PollStartOneToOneUnstable = ".org.matrix.msc3930.rule.poll_start_one_to_one",
+ PollEndOneToOne = ".m.rule.poll_end_one_to_one",
+ PollEndOneToOneUnstable = ".org.matrix.msc3930.rule.poll_end_one_to_one",
+}
+
+export type PushRuleSet = {
+ [k in PushRuleKind]?: IPushRule[];
+};
+
+export interface IPushRule {
+ actions: PushRuleAction[];
+ conditions?: PushRuleCondition[];
+ default: boolean;
+ enabled: boolean;
+ pattern?: string;
+ rule_id: RuleId | string;
+}
+
+export interface IAnnotatedPushRule extends IPushRule {
+ kind: PushRuleKind;
+}
+
+export interface IPushRules {
+ global: PushRuleSet;
+ device?: PushRuleSet;
+}
+
+export interface IPusher {
+ "app_display_name": string;
+ "app_id": string;
+ "data": {
+ format?: string;
+ url?: string; // TODO: Required if kind==http
+ brand?: string; // TODO: For email notifications only? Unspecced field
+ };
+ "device_display_name": string;
+ "kind": "http" | string;
+ "lang": string;
+ "profile_tag"?: string;
+ "pushkey": string;
+ "enabled"?: boolean | null;
+ "org.matrix.msc3881.enabled"?: boolean | null;
+ "device_id"?: string | null;
+ "org.matrix.msc3881.device_id"?: string | null;
+}
+
+export interface IPusherRequest extends Omit<IPusher, "device_id" | "org.matrix.msc3881.device_id"> {
+ append?: boolean;
+}
+
+/* eslint-enable camelcase */
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/another-json.d.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/another-json.d.ts
new file mode 100644
index 0000000..070332a
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/another-json.d.ts
@@ -0,0 +1,19 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+declare module "another-json" {
+ export function stringify(o: object): string;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/auth.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/auth.ts
new file mode 100644
index 0000000..2b8f5d7
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/auth.ts
@@ -0,0 +1,117 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { UnstableValue } from "../NamespacedValue";
+
+// disable lint because these are wire responses
+/* eslint-disable camelcase */
+
+/**
+ * Represents a response to the CSAPI `/refresh` endpoint.
+ */
+export interface IRefreshTokenResponse {
+ access_token: string;
+ expires_in_ms: number;
+ refresh_token: string;
+}
+
+/* eslint-enable camelcase */
+
+/**
+ * Response to GET login flows as per https://spec.matrix.org/v1.3/client-server-api/#get_matrixclientv3login
+ */
+export interface ILoginFlowsResponse {
+ flows: LoginFlow[];
+}
+
+export type LoginFlow = ISSOFlow | IPasswordFlow | ILoginFlow;
+
+export interface ILoginFlow {
+ type: string;
+}
+
+export interface IPasswordFlow extends ILoginFlow {
+ type: "m.login.password";
+}
+
+export const DELEGATED_OIDC_COMPATIBILITY = new UnstableValue(
+ "delegated_oidc_compatibility",
+ "org.matrix.msc3824.delegated_oidc_compatibility",
+);
+
+/**
+ * Representation of SSO flow as per https://spec.matrix.org/v1.3/client-server-api/#client-login-via-sso
+ */
+export interface ISSOFlow extends ILoginFlow {
+ type: "m.login.sso" | "m.login.cas";
+ // eslint-disable-next-line camelcase
+ identity_providers?: IIdentityProvider[];
+ [DELEGATED_OIDC_COMPATIBILITY.name]?: boolean;
+ [DELEGATED_OIDC_COMPATIBILITY.altName]?: boolean;
+}
+
+export enum IdentityProviderBrand {
+ Gitlab = "gitlab",
+ Github = "github",
+ Apple = "apple",
+ Google = "google",
+ Facebook = "facebook",
+ Twitter = "twitter",
+}
+
+export interface IIdentityProvider {
+ id: string;
+ name: string;
+ icon?: string;
+ brand?: IdentityProviderBrand | string;
+}
+
+/**
+ * Parameters to login request as per https://spec.matrix.org/v1.3/client-server-api/#login
+ */
+/* eslint-disable camelcase */
+export interface ILoginParams {
+ identifier?: object;
+ password?: string;
+ token?: string;
+ device_id?: string;
+ initial_device_display_name?: string;
+}
+/* eslint-enable camelcase */
+
+export enum SSOAction {
+ /** The user intends to login to an existing account */
+ LOGIN = "login",
+
+ /** The user intends to register for a new account */
+ REGISTER = "register",
+}
+
+/**
+ * The result of a successful [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882)
+ * `m.login.token` issuance request.
+ * Note that this is UNSTABLE and subject to breaking changes without notice.
+ */
+export interface LoginTokenPostResponse {
+ /**
+ * The token to use with `m.login.token` to authenticate.
+ */
+ login_token: string;
+ /**
+ * Expiration in seconds.
+ */
+ expires_in: number;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/beacon.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/beacon.ts
new file mode 100644
index 0000000..e6bfb8f
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/beacon.ts
@@ -0,0 +1,140 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RelatesToRelationship, REFERENCE_RELATION } from "./extensible_events";
+import { UnstableValue } from "../NamespacedValue";
+import { MAssetEvent, MLocationEvent, MTimestampEvent } from "./location";
+
+/**
+ * Beacon info and beacon event types as described in MSC3672
+ * https://github.com/matrix-org/matrix-spec-proposals/pull/3672
+ */
+
+/**
+ * Beacon info events are state events.
+ * We have two requirements for these events:
+ * 1. they can only be written by their owner
+ * 2. a user can have an arbitrary number of beacon_info events
+ *
+ * 1. is achieved by setting the state_key to the owners mxid.
+ * Event keys in room state are a combination of `type` + `state_key`.
+ * To achieve an arbitrary number of only owner-writable state events
+ * we introduce a variable suffix to the event type
+ *
+ * @example
+ * ```
+ * {
+ * "type": "m.beacon_info.@matthew:matrix.org.1",
+ * "state_key": "@matthew:matrix.org",
+ * "content": {
+ * "m.beacon_info": {
+ * "description": "The Matthew Tracker",
+ * "timeout": 86400000,
+ * },
+ * // more content as described below
+ * }
+ * },
+ * {
+ * "type": "m.beacon_info.@matthew:matrix.org.2",
+ * "state_key": "@matthew:matrix.org",
+ * "content": {
+ * "m.beacon_info": {
+ * "description": "Another different Matthew tracker",
+ * "timeout": 400000,
+ * },
+ * // more content as described below
+ * }
+ * }
+ * ```
+ */
+
+/**
+ * Non-variable type for m.beacon_info event content
+ */
+export const M_BEACON_INFO = new UnstableValue("m.beacon_info", "org.matrix.msc3672.beacon_info");
+export const M_BEACON = new UnstableValue("m.beacon", "org.matrix.msc3672.beacon");
+
+export type MBeaconInfoContent = {
+ description?: string;
+ // how long from the last event until we consider the beacon inactive in milliseconds
+ timeout: number;
+ // true when this is a live location beacon
+ // https://github.com/matrix-org/matrix-spec-proposals/pull/3672
+ live?: boolean;
+};
+
+/**
+ * m.beacon_info Event example from the spec
+ * https://github.com/matrix-org/matrix-spec-proposals/pull/3672
+ * @example
+ * ```
+ * {
+ * "type": "m.beacon_info",
+ * "state_key": "@matthew:matrix.org",
+ * "content": {
+ * "m.beacon_info": {
+ * "description": "The Matthew Tracker", // same as an `m.location` description
+ * "timeout": 86400000, // how long from the last event until we consider the beacon inactive in milliseconds
+ * },
+ * "m.ts": 1436829458432, // creation timestamp of the beacon on the client
+ * "m.asset": {
+ * "type": "m.self" // the type of asset being tracked as per MSC3488
+ * }
+ * }
+ * }
+ * ```
+ */
+
+/**
+ * m.beacon_info.* event content
+ */
+export type MBeaconInfoEventContent = MBeaconInfoContent &
+ // creation timestamp of the beacon on the client
+ MTimestampEvent &
+ // the type of asset being tracked as per MSC3488
+ MAssetEvent;
+
+/**
+ * m.beacon event example
+ * https://github.com/matrix-org/matrix-spec-proposals/pull/3672
+ * @example
+ * ```
+ * {
+ * "type": "m.beacon",
+ * "sender": "@matthew:matrix.org",
+ * "content": {
+ * "m.relates_to": { // from MSC2674: https://github.com/matrix-org/matrix-doc/pull/2674
+ * "rel_type": "m.reference", // from MSC3267: https://github.com/matrix-org/matrix-doc/pull/3267
+ * "event_id": "$beacon_info"
+ * },
+ * "m.location": {
+ * "uri": "geo:51.5008,0.1247;u=35",
+ * "description": "Arbitrary beacon information"
+ * },
+ * "m.ts": 1636829458432,
+ * }
+ * }
+ * ```
+ */
+
+/**
+ * Content of an m.beacon event
+ */
+export type MBeaconEventContent = MLocationEvent &
+ // timestamp when location was taken
+ MTimestampEvent &
+ // relates to a beacon_info event
+ RelatesToRelationship<typeof REFERENCE_RELATION>;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/crypto.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/crypto.ts
new file mode 100644
index 0000000..7711840
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/crypto.ts
@@ -0,0 +1,99 @@
+/*
+Copyright 2022-2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import type { IClearEvent } from "../models/event";
+import type { ISignatures } from "./signed";
+
+export type OlmGroupSessionExtraData = {
+ untrusted?: boolean;
+ sharedHistory?: boolean;
+};
+
+/**
+ * The result of a (successful) call to {@link Crypto.decryptEvent}
+ */
+export interface IEventDecryptionResult {
+ /**
+ * The plaintext payload for the event (typically containing <tt>type</tt> and <tt>content</tt> fields).
+ */
+ clearEvent: IClearEvent;
+ /**
+ * List of curve25519 keys involved in telling us about the senderCurve25519Key and claimedEd25519Key.
+ * See {@link MatrixEvent#getForwardingCurve25519KeyChain}.
+ */
+ forwardingCurve25519KeyChain?: string[];
+ /**
+ * Key owned by the sender of this event. See {@link MatrixEvent#getSenderKey}.
+ */
+ senderCurve25519Key?: string;
+ /**
+ * ed25519 key claimed by the sender of this event. See {@link MatrixEvent#getClaimedEd25519Key}.
+ */
+ claimedEd25519Key?: string;
+ untrusted?: boolean;
+ /**
+ * The sender doesn't authorize the unverified devices to decrypt his messages
+ */
+ encryptedDisabledForUnverifiedDevices?: boolean;
+}
+
+interface Extensible {
+ [key: string]: any;
+}
+
+/* eslint-disable camelcase */
+
+/** The result of a call to {@link MatrixClient.exportRoomKeys} */
+export interface IMegolmSessionData extends Extensible {
+ /** Sender's Curve25519 device key */
+ sender_key: string;
+ /** Devices which forwarded this session to us (normally empty). */
+ forwarding_curve25519_key_chain: string[];
+ /** Other keys the sender claims. */
+ sender_claimed_keys: Record<string, string>;
+ /** Room this session is used in */
+ room_id: string;
+ /** Unique id for the session */
+ session_id: string;
+ /** Base64'ed key data */
+ session_key: string;
+ algorithm?: string;
+ untrusted?: boolean;
+}
+
+/* eslint-enable camelcase */
+
+/** the type of the `device_keys` parameter on `/_matrix/client/v3/keys/upload`
+ *
+ * @see https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3keysupload
+ */
+export interface IDeviceKeys {
+ algorithms: Array<string>;
+ device_id: string; // eslint-disable-line camelcase
+ user_id: string; // eslint-disable-line camelcase
+ keys: Record<string, string>;
+ signatures?: ISignatures;
+}
+
+/** the type of the `one_time_keys` and `fallback_keys` parameters on `/_matrix/client/v3/keys/upload`
+ *
+ * @see https://spec.matrix.org/v1.5/client-server-api/#post_matrixclientv3keysupload
+ */
+export interface IOneTimeKey {
+ key: string;
+ fallback?: boolean;
+ signatures?: ISignatures;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/event.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/event.ts
new file mode 100644
index 0000000..17af8df
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/event.ts
@@ -0,0 +1,251 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { UnstableValue } from "../NamespacedValue";
+
+export enum EventType {
+ // Room state events
+ RoomCanonicalAlias = "m.room.canonical_alias",
+ RoomCreate = "m.room.create",
+ RoomJoinRules = "m.room.join_rules",
+ RoomMember = "m.room.member",
+ RoomThirdPartyInvite = "m.room.third_party_invite",
+ RoomPowerLevels = "m.room.power_levels",
+ RoomName = "m.room.name",
+ RoomTopic = "m.room.topic",
+ RoomAvatar = "m.room.avatar",
+ RoomPinnedEvents = "m.room.pinned_events",
+ RoomEncryption = "m.room.encryption",
+ RoomHistoryVisibility = "m.room.history_visibility",
+ RoomGuestAccess = "m.room.guest_access",
+ RoomServerAcl = "m.room.server_acl",
+ RoomTombstone = "m.room.tombstone",
+ RoomPredecessor = "org.matrix.msc3946.room_predecessor",
+
+ SpaceChild = "m.space.child",
+ SpaceParent = "m.space.parent",
+
+ // Room timeline events
+ RoomRedaction = "m.room.redaction",
+ RoomMessage = "m.room.message",
+ RoomMessageEncrypted = "m.room.encrypted",
+ Sticker = "m.sticker",
+ CallInvite = "m.call.invite",
+ CallCandidates = "m.call.candidates",
+ CallAnswer = "m.call.answer",
+ CallHangup = "m.call.hangup",
+ CallReject = "m.call.reject",
+ CallSelectAnswer = "m.call.select_answer",
+ CallNegotiate = "m.call.negotiate",
+ CallSDPStreamMetadataChanged = "m.call.sdp_stream_metadata_changed",
+ CallSDPStreamMetadataChangedPrefix = "org.matrix.call.sdp_stream_metadata_changed",
+ CallReplaces = "m.call.replaces",
+ CallAssertedIdentity = "m.call.asserted_identity",
+ CallAssertedIdentityPrefix = "org.matrix.call.asserted_identity",
+ KeyVerificationRequest = "m.key.verification.request",
+ KeyVerificationStart = "m.key.verification.start",
+ KeyVerificationCancel = "m.key.verification.cancel",
+ KeyVerificationMac = "m.key.verification.mac",
+ KeyVerificationDone = "m.key.verification.done",
+ KeyVerificationKey = "m.key.verification.key",
+ KeyVerificationAccept = "m.key.verification.accept",
+ // Not used directly - see READY_TYPE in VerificationRequest.
+ KeyVerificationReady = "m.key.verification.ready",
+ // use of this is discouraged https://matrix.org/docs/spec/client_server/r0.6.1#m-room-message-feedback
+ RoomMessageFeedback = "m.room.message.feedback",
+ Reaction = "m.reaction",
+ PollStart = "org.matrix.msc3381.poll.start",
+
+ // Room ephemeral events
+ Typing = "m.typing",
+ Receipt = "m.receipt",
+ Presence = "m.presence",
+
+ // Room account_data events
+ FullyRead = "m.fully_read",
+ Tag = "m.tag",
+ SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230
+
+ // User account_data events
+ PushRules = "m.push_rules",
+ Direct = "m.direct",
+ IgnoredUserList = "m.ignored_user_list",
+
+ // to_device events
+ RoomKey = "m.room_key",
+ RoomKeyRequest = "m.room_key_request",
+ ForwardedRoomKey = "m.forwarded_room_key",
+ Dummy = "m.dummy",
+
+ // Group call events
+ GroupCallPrefix = "org.matrix.msc3401.call",
+ GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
+}
+
+export enum RelationType {
+ Annotation = "m.annotation",
+ Replace = "m.replace",
+ Reference = "m.reference",
+ Thread = "m.thread",
+}
+
+export enum MsgType {
+ Text = "m.text",
+ Emote = "m.emote",
+ Notice = "m.notice",
+ Image = "m.image",
+ File = "m.file",
+ Audio = "m.audio",
+ Location = "m.location",
+ Video = "m.video",
+ KeyVerificationRequest = "m.key.verification.request",
+}
+
+export const RoomCreateTypeField = "type";
+
+export enum RoomType {
+ Space = "m.space",
+ UnstableCall = "org.matrix.msc3417.call",
+ ElementVideo = "io.element.video",
+}
+
+export const ToDeviceMessageId = "org.matrix.msgid";
+
+/**
+ * Identifier for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088)
+ * room purpose. Note that this reference is UNSTABLE and subject to breaking changes,
+ * including its eventual removal.
+ */
+export const UNSTABLE_MSC3088_PURPOSE = new UnstableValue("m.room.purpose", "org.matrix.msc3088.purpose");
+
+/**
+ * Enabled flag for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088)
+ * room purpose. Note that this reference is UNSTABLE and subject to breaking changes,
+ * including its eventual removal.
+ */
+export const UNSTABLE_MSC3088_ENABLED = new UnstableValue("m.enabled", "org.matrix.msc3088.enabled");
+
+/**
+ * Subtype for an [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room.
+ * Note that this reference is UNSTABLE and subject to breaking changes, including its
+ * eventual removal.
+ */
+export const UNSTABLE_MSC3089_TREE_SUBTYPE = new UnstableValue("m.data_tree", "org.matrix.msc3089.data_tree");
+
+/**
+ * Leaf type for an event in a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room.
+ * Note that this reference is UNSTABLE and subject to breaking changes, including its
+ * eventual removal.
+ */
+export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc3089.leaf");
+
+/**
+ * Branch (Leaf Reference) type for the index approach in a
+ * [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. Note that this reference is
+ * UNSTABLE and subject to breaking changes, including its eventual removal.
+ */
+export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch");
+
+/**
+ * Marker event type to point back at imported historical content in a room. See
+ * [MSC2716](https://github.com/matrix-org/matrix-spec-proposals/pull/2716).
+ * Note that this reference is UNSTABLE and subject to breaking changes,
+ * including its eventual removal.
+ */
+export const UNSTABLE_MSC2716_MARKER = new UnstableValue("m.room.marker", "org.matrix.msc2716.marker");
+
+/**
+ * Name of the "with_relations" request property for relation based redactions.
+ * {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
+ */
+export const MSC3912_RELATION_BASED_REDACTIONS_PROP = new UnstableValue(
+ "with_relations",
+ "org.matrix.msc3912.with_relations",
+);
+
+/**
+ * Functional members type for declaring a purpose of room members (e.g. helpful bots).
+ * Note that this reference is UNSTABLE and subject to breaking changes, including its
+ * eventual removal.
+ *
+ * Schema (TypeScript):
+ * ```
+ * {
+ * service_members?: string[]
+ * }
+ * ```
+ *
+ * @example
+ * ```
+ * {
+ * "service_members": [
+ * "@helperbot:localhost",
+ * "@reminderbot:alice.tdl"
+ * ]
+ * }
+ * ```
+ */
+export const UNSTABLE_ELEMENT_FUNCTIONAL_USERS = new UnstableValue(
+ "io.element.functional_members",
+ "io.element.functional_members",
+);
+
+/**
+ * A type of message that affects visibility of a message,
+ * as per https://github.com/matrix-org/matrix-doc/pull/3531
+ *
+ * @experimental
+ */
+export const EVENT_VISIBILITY_CHANGE_TYPE = new UnstableValue("m.visibility", "org.matrix.msc3531.visibility");
+
+/**
+ * https://github.com/matrix-org/matrix-doc/pull/3881
+ *
+ * @experimental
+ */
+export const PUSHER_ENABLED = new UnstableValue("enabled", "org.matrix.msc3881.enabled");
+
+/**
+ * https://github.com/matrix-org/matrix-doc/pull/3881
+ *
+ * @experimental
+ */
+export const PUSHER_DEVICE_ID = new UnstableValue("device_id", "org.matrix.msc3881.device_id");
+
+/**
+ * https://github.com/matrix-org/matrix-doc/pull/3890
+ *
+ * @experimental
+ */
+export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
+ "m.local_notification_settings",
+ "org.matrix.msc3890.local_notification_settings",
+);
+
+export interface IEncryptedFile {
+ url: string;
+ mimetype?: string;
+ key: {
+ alg: string;
+ key_ops: string[]; // eslint-disable-line camelcase
+ kty: string;
+ k: string;
+ ext: boolean;
+ };
+ iv: string;
+ hashes: { [alg: string]: string };
+ v: string;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/extensible_events.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/extensible_events.ts
new file mode 100644
index 0000000..db9ea18
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/extensible_events.ts
@@ -0,0 +1,151 @@
+/*
+Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EitherAnd, NamespacedValue, Optional, UnstableValue } from "matrix-events-sdk";
+
+import { isProvided } from "../extensible_events_v1/utilities";
+
+// Types and utilities for MSC1767: Extensible events (version 1) in Matrix
+
+/**
+ * Represents the stable and unstable values of a given namespace.
+ */
+export type TSNamespace<N> = N extends NamespacedValue<infer S, infer U>
+ ? TSNamespaceValue<S> | TSNamespaceValue<U>
+ : never;
+
+/**
+ * Represents a namespaced value, if the value is a string. Used to extract provided types
+ * from a TSNamespace<N> (in cases where only stable *or* unstable is provided).
+ */
+export type TSNamespaceValue<V> = V extends string ? V : never;
+
+/**
+ * Creates a type which is V when T is `never`, otherwise T.
+ */
+// See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for details on the array syntax.
+export type DefaultNever<T, V> = [T] extends [never] ? V : T;
+
+/**
+ * The namespaced value for m.message
+ */
+export const M_MESSAGE = new UnstableValue("m.message", "org.matrix.msc1767.message");
+
+/**
+ * An m.message event rendering
+ */
+export interface IMessageRendering {
+ body: string;
+ mimetype?: string;
+}
+
+/**
+ * The content for an m.message event
+ */
+export type ExtensibleMessageEventContent = EitherAnd<
+ { [M_MESSAGE.name]: IMessageRendering[] },
+ { [M_MESSAGE.altName]: IMessageRendering[] }
+>;
+
+/**
+ * The namespaced value for m.text
+ */
+export const M_TEXT = new UnstableValue("m.text", "org.matrix.msc1767.text");
+
+/**
+ * The content for an m.text event
+ */
+export type TextEventContent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>;
+
+/**
+ * The namespaced value for m.html
+ */
+export const M_HTML = new UnstableValue("m.html", "org.matrix.msc1767.html");
+
+/**
+ * The content for an m.html event
+ */
+export type HtmlEventContent = EitherAnd<{ [M_HTML.name]: string }, { [M_HTML.altName]: string }>;
+
+/**
+ * The content for an m.message, m.text, or m.html event
+ */
+export type ExtensibleAnyMessageEventContent = ExtensibleMessageEventContent | TextEventContent | HtmlEventContent;
+
+/**
+ * The namespaced value for an m.reference relation
+ */
+export const REFERENCE_RELATION = new NamespacedValue("m.reference");
+
+/**
+ * Represents any relation type
+ */
+export type AnyRelation = TSNamespace<typeof REFERENCE_RELATION> | string;
+
+/**
+ * An m.relates_to relationship
+ */
+export type RelatesToRelationship<R = never, C = never> = {
+ "m.relates_to": {
+ // See https://github.com/microsoft/TypeScript/issues/23182#issuecomment-379091887 for array syntax
+ rel_type: [R] extends [never] ? AnyRelation : TSNamespace<R>;
+ event_id: string;
+ } & DefaultNever<C, {}>;
+};
+
+/**
+ * Partial types for a Matrix Event.
+ */
+export interface IPartialEvent<TContent> {
+ type: string;
+ content: TContent;
+}
+
+/**
+ * Represents a potentially namespaced event type.
+ */
+export type ExtensibleEventType = NamespacedValue<string, string> | string;
+
+/**
+ * Determines if two event types are the same, including namespaces.
+ * @param given - The given event type. This will be compared
+ * against the expected type.
+ * @param expected - The expected event type.
+ * @returns True if the given type matches the expected type.
+ */
+export function isEventTypeSame(
+ given: Optional<ExtensibleEventType>,
+ expected: Optional<ExtensibleEventType>,
+): boolean {
+ if (typeof given === "string") {
+ if (typeof expected === "string") {
+ return expected === given;
+ } else {
+ return (expected as NamespacedValue<string, string>).matches(given as string);
+ }
+ } else {
+ if (typeof expected === "string") {
+ return (given as NamespacedValue<string, string>).matches(expected as string);
+ } else {
+ const expectedNs = expected as NamespacedValue<string, string>;
+ const givenNs = given as NamespacedValue<string, string>;
+ return (
+ expectedNs.matches(givenNs.name) ||
+ (isProvided(givenNs.altName) && expectedNs.matches(givenNs.altName!))
+ );
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/global.d.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/global.d.ts
new file mode 100644
index 0000000..749eb7f
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/global.d.ts
@@ -0,0 +1,99 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// this is needed to tell TS about global.Olm
+import "@matrix-org/olm";
+
+export {};
+
+declare global {
+ // use `number` as the return type in all cases for global.set{Interval,Timeout},
+ // so we don't accidentally use the methods on NodeJS.Timeout - they only exist in a subset of environments.
+ // The overload for clear{Interval,Timeout} is resolved as expected.
+ // We use `ReturnType<typeof setTimeout>` in the code to be agnostic of if this definition gets loaded.
+ function setInterval(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
+ function setTimeout(handler: TimerHandler, timeout: number, ...arguments: any[]): number;
+
+ namespace NodeJS {
+ interface Global {
+ localStorage: Storage;
+ // marker variable used to detect both the browser & node entrypoints being used at once
+ __js_sdk_entrypoint: unknown;
+ }
+ }
+
+ interface Window {
+ webkitAudioContext: typeof AudioContext;
+ }
+
+ interface Crypto {
+ webkitSubtle?: Window["crypto"]["subtle"];
+ }
+
+ interface MediaDevices {
+ // This is experimental and types don't know about it yet
+ // https://github.com/microsoft/TypeScript/issues/33232
+ getDisplayMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
+ getUserMedia(constraints: MediaStreamConstraints | DesktopCapturerConstraints): Promise<MediaStream>;
+ }
+
+ interface DesktopCapturerConstraints {
+ audio:
+ | boolean
+ | {
+ mandatory: {
+ chromeMediaSource: string;
+ chromeMediaSourceId: string;
+ };
+ };
+ video:
+ | boolean
+ | {
+ mandatory: {
+ chromeMediaSource: string;
+ chromeMediaSourceId: string;
+ };
+ };
+ }
+
+ interface DummyInterfaceWeShouldntBeUsingThis {}
+
+ interface Navigator {
+ // We check for the webkit-prefixed getUserMedia to detect if we're
+ // on webkit: we should check if we still need to do this
+ webkitGetUserMedia: DummyInterfaceWeShouldntBeUsingThis;
+ }
+
+ export interface ISettledFulfilled<T> {
+ status: "fulfilled";
+ value: T;
+ }
+ export interface ISettledRejected {
+ status: "rejected";
+ reason: any;
+ }
+
+ interface PromiseConstructor {
+ allSettled<T>(promises: Promise<T>[]): Promise<Array<ISettledFulfilled<T> | ISettledRejected>>;
+ }
+
+ interface RTCRtpTransceiver {
+ // This has been removed from TS
+ // (https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1029),
+ // but we still need this for MatrixCall::getRidOfRTXCodecs()
+ setCodecPreferences(codecs: RTCRtpCodecCapability[]): void;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/local_notifications.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/local_notifications.ts
new file mode 100644
index 0000000..b92d986
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/local_notifications.ts
@@ -0,0 +1,19 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export interface LocalNotificationSettings {
+ is_silenced: boolean;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/location.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/location.ts
new file mode 100644
index 0000000..d1a826f
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/location.ts
@@ -0,0 +1,92 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Types for MSC3488 - m.location: Extending events with location data
+import { EitherAnd } from "matrix-events-sdk";
+
+import { UnstableValue } from "../NamespacedValue";
+import { M_TEXT } from "./extensible_events";
+
+export enum LocationAssetType {
+ Self = "m.self",
+ Pin = "m.pin",
+}
+
+export const M_ASSET = new UnstableValue("m.asset", "org.matrix.msc3488.asset");
+export type MAssetContent = { type: LocationAssetType };
+/**
+ * The event definition for an m.asset event (in content)
+ */
+export type MAssetEvent = EitherAnd<{ [M_ASSET.name]: MAssetContent }, { [M_ASSET.altName]: MAssetContent }>;
+
+export const M_TIMESTAMP = new UnstableValue("m.ts", "org.matrix.msc3488.ts");
+/**
+ * The event definition for an m.ts event (in content)
+ */
+export type MTimestampEvent = EitherAnd<{ [M_TIMESTAMP.name]: number }, { [M_TIMESTAMP.altName]: number }>;
+
+export const M_LOCATION = new UnstableValue("m.location", "org.matrix.msc3488.location");
+
+export type MLocationContent = {
+ uri: string;
+ description?: string | null;
+};
+
+export type MLocationEvent = EitherAnd<
+ { [M_LOCATION.name]: MLocationContent },
+ { [M_LOCATION.altName]: MLocationContent }
+>;
+
+export type MTextEvent = EitherAnd<{ [M_TEXT.name]: string }, { [M_TEXT.altName]: string }>;
+
+/* From the spec at:
+ * https://github.com/matrix-org/matrix-doc/blob/matthew/location/proposals/3488-location.md
+{
+ "type": "m.room.message",
+ "content": {
+ "body": "Matthew was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021",
+ "msgtype": "m.location",
+ "geo_uri": "geo:51.5008,0.1247;u=35",
+ "m.location": {
+ "uri": "geo:51.5008,0.1247;u=35",
+ "description": "Matthew's whereabouts",
+ },
+ "m.asset": {
+ "type": "m.self"
+ },
+ "m.text": "Matthew was at geo:51.5008,0.1247;u=35 as of Sat Nov 13 18:50:58 2021",
+ "m.ts": 1636829458432,
+ }
+}
+*/
+type OptionalTimestampEvent = MTimestampEvent | undefined;
+/**
+ * The content for an m.location event
+ */
+export type MLocationEventContent = MLocationEvent & MAssetEvent & MTextEvent & OptionalTimestampEvent;
+
+export type LegacyLocationEventContent = {
+ body: string;
+ msgtype: string;
+ geo_uri: string;
+};
+
+/**
+ * Possible content for location events as sent over the wire
+ */
+export type LocationEventWireContent = Partial<LegacyLocationEventContent & MLocationEventContent>;
+
+export type ILocationContent = MLocationEventContent & LegacyLocationEventContent;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/partials.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/partials.ts
new file mode 100644
index 0000000..49f92f3
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/partials.ts
@@ -0,0 +1,89 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export interface IImageInfo {
+ size?: number;
+ mimetype?: string;
+ thumbnail_info?: {
+ // eslint-disable-line camelcase
+ w?: number;
+ h?: number;
+ size?: number;
+ mimetype?: string;
+ };
+ w?: number;
+ h?: number;
+}
+
+export enum Visibility {
+ Public = "public",
+ Private = "private",
+}
+
+export enum Preset {
+ PrivateChat = "private_chat",
+ TrustedPrivateChat = "trusted_private_chat",
+ PublicChat = "public_chat",
+}
+
+export type ResizeMethod = "crop" | "scale";
+
+export type IdServerUnbindResult = "no-support" | "success";
+
+// Knock and private are reserved keywords which are not yet implemented.
+export enum JoinRule {
+ Public = "public",
+ Invite = "invite",
+ /**
+ * @deprecated Reserved keyword. Should not be used. Not yet implemented.
+ */
+ Private = "private",
+ Knock = "knock",
+ Restricted = "restricted",
+}
+
+export enum RestrictedAllowType {
+ RoomMembership = "m.room_membership",
+}
+
+export interface IJoinRuleEventContent {
+ join_rule: JoinRule; // eslint-disable-line camelcase
+ allow?: {
+ type: RestrictedAllowType;
+ room_id: string; // eslint-disable-line camelcase
+ }[];
+}
+
+export enum GuestAccess {
+ CanJoin = "can_join",
+ Forbidden = "forbidden",
+}
+
+export enum HistoryVisibility {
+ Invited = "invited",
+ Joined = "joined",
+ Shared = "shared",
+ WorldReadable = "world_readable",
+}
+
+export interface IUsageLimit {
+ // "hs_disabled" is NOT a specced string, but is used in Synapse
+ // This is tracked over at https://github.com/matrix-org/synapse/issues/9237
+ // eslint-disable-next-line camelcase
+ limit_type: "monthly_active_user" | "hs_disabled" | string;
+ // eslint-disable-next-line camelcase
+ admin_contact?: string;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/polls.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/polls.ts
new file mode 100644
index 0000000..3b06f93
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/polls.ts
@@ -0,0 +1,119 @@
+/*
+Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EitherAnd, UnstableValue } from "matrix-events-sdk";
+
+import {
+ ExtensibleAnyMessageEventContent,
+ REFERENCE_RELATION,
+ RelatesToRelationship,
+ TSNamespace,
+} from "./extensible_events";
+
+/**
+ * Identifier for a disclosed poll.
+ */
+export const M_POLL_KIND_DISCLOSED = new UnstableValue("m.poll.disclosed", "org.matrix.msc3381.poll.disclosed");
+
+/**
+ * Identifier for an undisclosed poll.
+ */
+export const M_POLL_KIND_UNDISCLOSED = new UnstableValue("m.poll.undisclosed", "org.matrix.msc3381.poll.undisclosed");
+
+/**
+ * Any poll kind.
+ */
+export type PollKind = TSNamespace<typeof M_POLL_KIND_DISCLOSED> | TSNamespace<typeof M_POLL_KIND_UNDISCLOSED> | string;
+
+/**
+ * Known poll kind namespaces.
+ */
+export type KnownPollKind = typeof M_POLL_KIND_DISCLOSED | typeof M_POLL_KIND_UNDISCLOSED;
+
+/**
+ * The namespaced value for m.poll.start
+ */
+export const M_POLL_START = new UnstableValue("m.poll.start", "org.matrix.msc3381.poll.start");
+
+/**
+ * The m.poll.start type within event content
+ */
+export type PollStartSubtype = {
+ question: ExtensibleAnyMessageEventContent;
+ kind: PollKind;
+ max_selections?: number; // default 1, always positive
+ answers: PollAnswer[];
+};
+
+/**
+ * A poll answer.
+ */
+export type PollAnswer = ExtensibleAnyMessageEventContent & { id: string };
+
+/**
+ * The event definition for an m.poll.start event (in content)
+ */
+export type PollStartEvent = EitherAnd<
+ { [M_POLL_START.name]: PollStartSubtype },
+ { [M_POLL_START.altName]: PollStartSubtype }
+>;
+
+/**
+ * The content for an m.poll.start event
+ */
+export type PollStartEventContent = PollStartEvent & ExtensibleAnyMessageEventContent;
+
+/**
+ * The namespaced value for m.poll.response
+ */
+export const M_POLL_RESPONSE = new UnstableValue("m.poll.response", "org.matrix.msc3381.poll.response");
+
+/**
+ * The m.poll.response type within event content
+ */
+export type PollResponseSubtype = {
+ answers: string[];
+};
+
+/**
+ * The event definition for an m.poll.response event (in content)
+ */
+export type PollResponseEvent = EitherAnd<
+ { [M_POLL_RESPONSE.name]: PollResponseSubtype },
+ { [M_POLL_RESPONSE.altName]: PollResponseSubtype }
+>;
+
+/**
+ * The content for an m.poll.response event
+ */
+export type PollResponseEventContent = PollResponseEvent & RelatesToRelationship<typeof REFERENCE_RELATION>;
+
+/**
+ * The namespaced value for m.poll.end
+ */
+export const M_POLL_END = new UnstableValue("m.poll.end", "org.matrix.msc3381.poll.end");
+
+/**
+ * The event definition for an m.poll.end event (in content)
+ */
+export type PollEndEvent = EitherAnd<{ [M_POLL_END.name]: {} }, { [M_POLL_END.altName]: {} }>;
+
+/**
+ * The content for an m.poll.end event
+ */
+export type PollEndEventContent = PollEndEvent &
+ RelatesToRelationship<typeof REFERENCE_RELATION> &
+ ExtensibleAnyMessageEventContent;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/read_receipts.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/read_receipts.ts
new file mode 100644
index 0000000..7592403
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/read_receipts.ts
@@ -0,0 +1,61 @@
+/*
+Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export enum ReceiptType {
+ Read = "m.read",
+ FullyRead = "m.fully_read",
+ ReadPrivate = "m.read.private",
+}
+
+export const MAIN_ROOM_TIMELINE = "main";
+
+export interface Receipt {
+ ts: number;
+ thread_id?: string;
+}
+
+export interface WrappedReceipt {
+ eventId: string;
+ data: Receipt;
+}
+
+export interface CachedReceipt {
+ type: ReceiptType;
+ userId: string;
+ data: Receipt;
+}
+
+export type ReceiptCache = Map<string, CachedReceipt[]>;
+
+export interface ReceiptContent {
+ [eventId: string]: {
+ [key in ReceiptType | string]: {
+ [userId: string]: Receipt;
+ };
+ };
+}
+
+// We will only hold a synthetic receipt if we do not have a real receipt or the synthetic is newer.
+// map: receipt type → user Id → receipt
+export type Receipts = Map<string, Map<string, [real: WrappedReceipt | null, synthetic: WrappedReceipt | null]>>;
+
+export type CachedReceiptStructure = {
+ eventId: string;
+ receiptType: string | ReceiptType;
+ userId: string;
+ receipt: Receipt;
+ synthetic: boolean;
+};
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/requests.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/requests.ts
new file mode 100644
index 0000000..12f4d8e
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/requests.ts
@@ -0,0 +1,243 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IContent, IEvent } from "../models/event";
+import { Preset, Visibility } from "./partials";
+import { IEventWithRoomId, SearchKey } from "./search";
+import { IRoomEventFilter } from "../filter";
+import { Direction } from "../models/event-timeline";
+import { PushRuleAction } from "./PushRules";
+import { IRoomEvent } from "../sync-accumulator";
+import { EventType, RelationType, RoomType } from "./event";
+
+// allow camelcase as these are things that go onto the wire
+/* eslint-disable camelcase */
+
+export interface IJoinRoomOpts {
+ /**
+ * True to do a room initial sync on the resulting
+ * room. If false, the <strong>returned Room object will have no current state.
+ * </strong> Default: true.
+ */
+ syncRoom?: boolean;
+
+ /**
+ * If the caller has a keypair 3pid invite, the signing URL is passed in this parameter.
+ */
+ inviteSignUrl?: string;
+
+ /**
+ * The server names to try and join through in addition to those that are automatically chosen.
+ */
+ viaServers?: string[];
+}
+
+export interface IRedactOpts {
+ reason?: string;
+ /**
+ * Whether events related to the redacted event should be redacted.
+ *
+ * If specified, then any events which relate to the event being redacted with
+ * any of the relationship types listed will also be redacted.
+ *
+ * <b>Raises an Error if the server does not support it.</b>
+ * Check for server-side support before using this param with
+ * <code>client.canSupport.get(Feature.RelationBasedRedactions)</code>.
+ * {@link https://github.com/matrix-org/matrix-spec-proposals/pull/3912}
+ */
+ with_relations?: Array<RelationType | string>;
+}
+
+export interface ISendEventResponse {
+ event_id: string;
+}
+
+export interface IPresenceOpts {
+ // One of "online", "offline" or "unavailable"
+ presence: "online" | "offline" | "unavailable";
+ // The status message to attach.
+ status_msg?: string;
+}
+
+export interface IPaginateOpts {
+ // true to fill backwards, false to go forwards
+ backwards?: boolean;
+ // number of events to request
+ limit?: number;
+}
+
+export interface IGuestAccessOpts {
+ /**
+ * True to allow guests to join this room. This
+ * implicitly gives guests write access. If false or not given, guests are
+ * explicitly forbidden from joining the room.
+ */
+ allowJoin: boolean;
+ /**
+ * True to set history visibility to
+ * be world_readable. This gives guests read access *from this point forward*.
+ * If false or not given, history visibility is not modified.
+ */
+ allowRead: boolean;
+}
+
+export interface ISearchOpts {
+ keys?: SearchKey[];
+ query: string;
+}
+
+export interface IEventSearchOpts {
+ // a JSON filter object to pass in the request
+ filter?: IRoomEventFilter;
+ // the term to search for
+ term: string;
+}
+
+export interface IInvite3PID {
+ id_server: string;
+ id_access_token?: string; // this gets injected by the js-sdk
+ medium: string;
+ address: string;
+}
+
+export interface ICreateRoomStateEvent {
+ type: string;
+ state_key?: string; // defaults to an empty string
+ content: IContent;
+}
+
+export interface ICreateRoomOpts {
+ // The alias localpart to assign to this room.
+ room_alias_name?: string;
+ // Either 'public' or 'private'.
+ visibility?: Visibility;
+ // The name to give this room.
+ name?: string;
+ // The topic to give this room.
+ topic?: string;
+ preset?: Preset;
+ power_level_content_override?: {
+ ban?: number;
+ events?: Record<EventType | string, number>;
+ events_default?: number;
+ invite?: number;
+ kick?: number;
+ notifications?: Record<string, number>;
+ redact?: number;
+ state_default?: number;
+ users?: Record<string, number>;
+ users_default?: number;
+ };
+ creation_content?: object;
+ initial_state?: ICreateRoomStateEvent[];
+ // A list of user IDs to invite to this room.
+ invite?: string[];
+ invite_3pid?: IInvite3PID[];
+ is_direct?: boolean;
+ room_version?: string;
+}
+
+export interface IRoomDirectoryOptions {
+ server?: string;
+ limit?: number;
+ since?: string;
+
+ // Filter parameters
+ filter?: {
+ // String to search for
+ generic_search_term?: string;
+ room_types?: Array<RoomType | null>;
+ };
+ include_all_networks?: boolean;
+ third_party_instance_id?: string;
+}
+
+export interface IAddThreePidOnlyBody {
+ auth?: {
+ type: string;
+ session?: string;
+ };
+ client_secret: string;
+ sid: string;
+}
+
+export interface IBindThreePidBody {
+ client_secret: string;
+ id_server: string;
+ id_access_token: string;
+ sid: string;
+}
+
+export interface IRelationsRequestOpts {
+ from?: string;
+ to?: string;
+ limit?: number;
+ dir?: Direction;
+}
+
+export interface IRelationsResponse {
+ chunk: IEvent[];
+ next_batch?: string;
+ prev_batch?: string;
+}
+
+export interface IContextResponse {
+ end: string;
+ start: string;
+ state: IEventWithRoomId[];
+ events_before: IEventWithRoomId[];
+ events_after: IEventWithRoomId[];
+ event: IEventWithRoomId;
+}
+
+export interface IEventsResponse {
+ chunk: IEventWithRoomId[];
+ end: string;
+ start: string;
+}
+
+export interface INotification {
+ actions: PushRuleAction[];
+ event: IRoomEvent;
+ profile_tag?: string;
+ read: boolean;
+ room_id: string;
+ ts: number;
+}
+
+export interface INotificationsResponse {
+ next_token: string;
+ notifications: INotification[];
+}
+
+export interface IFilterResponse {
+ filter_id: string;
+}
+
+export interface ITagsResponse {
+ tags: {
+ [tagId: string]: {
+ order: number;
+ };
+ };
+}
+
+export interface IStatusResponse extends IPresenceOpts {
+ currently_active?: boolean;
+ last_active_ago?: number;
+}
+
+/* eslint-enable camelcase */
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/search.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/search.ts
new file mode 100644
index 0000000..3a6d4fd
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/search.ts
@@ -0,0 +1,119 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Types relating to the /search API
+
+import { IRoomEvent, IStateEvent } from "../sync-accumulator";
+import { IRoomEventFilter } from "../filter";
+import { SearchResult } from "../models/search-result";
+
+/* eslint-disable camelcase */
+export interface IEventWithRoomId extends IRoomEvent {
+ room_id: string;
+}
+
+export interface IStateEventWithRoomId extends IStateEvent {
+ room_id: string;
+}
+
+export interface IMatrixProfile {
+ avatar_url?: string;
+ displayname?: string;
+}
+
+export interface IResultContext {
+ events_before: IEventWithRoomId[];
+ events_after: IEventWithRoomId[];
+ profile_info: Record<string, IMatrixProfile>;
+ start?: string;
+ end?: string;
+}
+
+export interface ISearchResult {
+ rank: number;
+ result: IEventWithRoomId;
+ context: IResultContext;
+}
+
+enum GroupKey {
+ RoomId = "room_id",
+ Sender = "sender",
+}
+
+export interface IResultRoomEvents {
+ count: number;
+ highlights: string[];
+ results: ISearchResult[];
+ state?: { [roomId: string]: IStateEventWithRoomId[] };
+ groups?: {
+ [groupKey in GroupKey]: {
+ [value: string]: {
+ next_batch?: string;
+ order: number;
+ results: string[];
+ };
+ };
+ };
+ next_batch?: string;
+}
+
+interface IResultCategories {
+ room_events: IResultRoomEvents;
+}
+
+export type SearchKey = "content.body" | "content.name" | "content.topic";
+
+export enum SearchOrderBy {
+ Recent = "recent",
+ Rank = "rank",
+}
+
+export interface ISearchRequestBody {
+ search_categories: {
+ room_events: {
+ search_term: string;
+ keys?: SearchKey[];
+ filter?: IRoomEventFilter;
+ order_by?: SearchOrderBy;
+ event_context?: {
+ before_limit?: number;
+ after_limit?: number;
+ include_profile?: boolean;
+ };
+ include_state?: boolean;
+ groupings?: {
+ group_by: {
+ key: GroupKey;
+ }[];
+ };
+ };
+ };
+}
+
+export interface ISearchResponse {
+ search_categories: IResultCategories;
+}
+
+export interface ISearchResults {
+ _query?: ISearchRequestBody;
+ results: SearchResult[];
+ highlights: string[];
+ count?: number;
+ next_batch?: string;
+ pendingRequest?: Promise<ISearchResults>;
+ abortSignal?: AbortSignal;
+}
+/* eslint-enable camelcase */
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/signed.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/signed.ts
new file mode 100644
index 0000000..a209f37
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/signed.ts
@@ -0,0 +1,25 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export interface ISignatures {
+ [entity: string]: {
+ [keyId: string]: string;
+ };
+}
+
+export interface ISigned {
+ signatures?: ISignatures;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/spaces.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/spaces.ts
new file mode 100644
index 0000000..9edab27
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/spaces.ts
@@ -0,0 +1,37 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IPublicRoomsChunkRoom } from "../client";
+import { RoomType } from "./event";
+import { IStrippedState } from "../sync-accumulator";
+
+// Types relating to Rooms of type `m.space` and related APIs
+
+/* eslint-disable camelcase */
+export interface IHierarchyRelation extends IStrippedState {
+ origin_server_ts: number;
+ content: {
+ order?: string;
+ suggested?: boolean;
+ via?: string[];
+ };
+}
+
+export interface IHierarchyRoom extends IPublicRoomsChunkRoom {
+ room_type?: RoomType | string;
+ children_state: IHierarchyRelation[];
+}
+/* eslint-enable camelcase */
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/synapse.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/synapse.ts
new file mode 100644
index 0000000..1d4ce41
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/synapse.ts
@@ -0,0 +1,40 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IdServerUnbindResult } from "./partials";
+
+// Types relating to Synapse Admin APIs
+
+/* eslint-disable camelcase */
+export interface ISynapseAdminWhoisResponse {
+ user_id: string;
+ devices: {
+ [deviceId: string]: {
+ sessions: {
+ connections: {
+ ip: string;
+ last_seen: number; // millis since epoch
+ user_agent: string;
+ }[];
+ }[];
+ };
+ };
+}
+
+export interface ISynapseAdminDeactivateResponse {
+ id_server_unbind_result: IdServerUnbindResult;
+}
+/* eslint-enable camelcase */
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/sync.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/sync.ts
new file mode 100644
index 0000000..d9a2a6f
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/sync.ts
@@ -0,0 +1,27 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ServerControlledNamespacedValue } from "../NamespacedValue";
+
+/**
+ * https://github.com/matrix-org/matrix-doc/pull/3773
+ *
+ * @experimental
+ */
+export const UNREAD_THREAD_NOTIFICATIONS = new ServerControlledNamespacedValue(
+ "unread_thread_notifications",
+ "org.matrix.msc3773.unread_thread_notifications",
+);
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/threepids.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/threepids.ts
new file mode 100644
index 0000000..c28ffc3
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/threepids.ts
@@ -0,0 +1,29 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export enum ThreepidMedium {
+ Email = "email",
+ Phone = "msisdn",
+}
+
+// TODO: Are these types universal, or specific to just /account/3pid?
+export interface IThreepid {
+ medium: ThreepidMedium;
+ address: string;
+ validated_at: number; // eslint-disable-line camelcase
+ added_at: number; // eslint-disable-line camelcase
+ bound?: boolean;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/topic.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/topic.ts
new file mode 100644
index 0000000..04d1464
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/topic.ts
@@ -0,0 +1,63 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EitherAnd } from "matrix-events-sdk";
+
+import { UnstableValue } from "../NamespacedValue";
+import { IMessageRendering } from "./extensible_events";
+
+/**
+ * Extensible topic event type based on MSC3765
+ * https://github.com/matrix-org/matrix-spec-proposals/pull/3765
+ *
+ * @example
+ * ```
+ * {
+ * "type": "m.room.topic,
+ * "state_key": "",
+ * "content": {
+ * "topic": "All about **pizza**",
+ * "m.topic": [{
+ * "body": "All about **pizza**",
+ * "mimetype": "text/plain",
+ * }, {
+ * "body": "All about <b>pizza</b>",
+ * "mimetype": "text/html",
+ * }],
+ * }
+ * }
+ * ```
+ */
+
+/**
+ * The event type for an m.topic event (in content)
+ */
+export const M_TOPIC = new UnstableValue("m.topic", "org.matrix.msc3765.topic");
+
+/**
+ * The event content for an m.topic event (in content)
+ */
+export type MTopicContent = IMessageRendering[];
+
+/**
+ * The event definition for an m.topic event (in content)
+ */
+export type MTopicEvent = EitherAnd<{ [M_TOPIC.name]: MTopicContent }, { [M_TOPIC.altName]: MTopicContent }>;
+
+/**
+ * The event content for an m.room.topic event
+ */
+export type MRoomTopicEventContent = { topic: string } & MTopicEvent;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/uia.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/uia.ts
new file mode 100644
index 0000000..e611420
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/@types/uia.ts
@@ -0,0 +1,29 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IAuthData } from "../interactive-auth";
+
+/**
+ * Helper type to represent HTTP request body for a UIA enabled endpoint
+ */
+export type UIARequest<T> = T & {
+ auth?: IAuthData;
+};
+
+/**
+ * Helper type to represent HTTP response body for a UIA enabled endpoint
+ */
+export type UIAResponse<T> = T | IAuthData;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/NamespacedValue.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/NamespacedValue.ts
new file mode 100644
index 0000000..a1a7e5d
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/NamespacedValue.ts
@@ -0,0 +1,120 @@
+/*
+Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Optional } from "matrix-events-sdk/lib/types";
+
+/**
+ * Represents a simple Matrix namespaced value. This will assume that if a stable prefix
+ * is provided that the stable prefix should be used when representing the identifier.
+ */
+export class NamespacedValue<S extends string, U extends string> {
+ // Stable is optional, but one of the two parameters is required, hence the weird-looking types.
+ // Goal is to to have developers explicitly say there is no stable value (if applicable).
+ public constructor(stable: S, unstable: U);
+ public constructor(stable: S, unstable?: U);
+ public constructor(stable: null | undefined, unstable: U);
+ public constructor(public readonly stable?: S | null, public readonly unstable?: U) {
+ if (!this.unstable && !this.stable) {
+ throw new Error("One of stable or unstable values must be supplied");
+ }
+ }
+
+ public get name(): U | S {
+ if (this.stable) {
+ return this.stable;
+ }
+ return this.unstable!;
+ }
+
+ public get altName(): U | S | null | undefined {
+ if (!this.stable) {
+ return null;
+ }
+ return this.unstable;
+ }
+
+ public get names(): (U | S)[] {
+ const names = [this.name];
+ const altName = this.altName;
+ if (altName) names.push(altName);
+ return names;
+ }
+
+ public matches(val: string): boolean {
+ return this.name === val || this.altName === val;
+ }
+
+ // this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
+ // so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
+ public findIn<T>(obj: any): Optional<T> {
+ let val: T | undefined = undefined;
+ if (this.name) {
+ val = obj?.[this.name];
+ }
+ if (!val && this.altName) {
+ val = obj?.[this.altName];
+ }
+ return val;
+ }
+
+ public includedIn(arr: any[]): boolean {
+ let included = false;
+ if (this.name) {
+ included = arr.includes(this.name);
+ }
+ if (!included && this.altName) {
+ included = arr.includes(this.altName);
+ }
+ return included;
+ }
+}
+
+export class ServerControlledNamespacedValue<S extends string, U extends string> extends NamespacedValue<S, U> {
+ private preferUnstable = false;
+
+ public setPreferUnstable(preferUnstable: boolean): void {
+ this.preferUnstable = preferUnstable;
+ }
+
+ public get name(): U | S {
+ if (this.stable && !this.preferUnstable) {
+ return this.stable;
+ }
+ return this.unstable!;
+ }
+}
+
+/**
+ * Represents a namespaced value which prioritizes the unstable value over the stable
+ * value.
+ */
+export class UnstableValue<S extends string, U extends string> extends NamespacedValue<S, U> {
+ // Note: Constructor difference is that `unstable` is *required*.
+ public constructor(stable: S, unstable: U) {
+ super(stable, unstable);
+ if (!this.unstable) {
+ throw new Error("Unstable value must be supplied");
+ }
+ }
+
+ public get name(): U {
+ return this.unstable!;
+ }
+
+ public get altName(): S {
+ return this.stable!;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/ReEmitter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/ReEmitter.ts
new file mode 100644
index 0000000..565e8ea
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/ReEmitter.ts
@@ -0,0 +1,91 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2017 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// eslint-disable-next-line no-restricted-imports
+import { EventEmitter } from "events";
+
+import { ListenerMap, TypedEventEmitter } from "./models/typed-event-emitter";
+
+export class ReEmitter {
+ public constructor(private readonly target: EventEmitter) {}
+
+ // Map from emitter to event name to re-emitter
+ private reEmitters = new Map<EventEmitter, Map<string, (...args: any[]) => void>>();
+
+ public reEmit(source: EventEmitter, eventNames: string[]): void {
+ let reEmittersByEvent = this.reEmitters.get(source);
+ if (!reEmittersByEvent) {
+ reEmittersByEvent = new Map();
+ this.reEmitters.set(source, reEmittersByEvent);
+ }
+
+ for (const eventName of eventNames) {
+ // We include the source as the last argument for event handlers which may need it,
+ // such as read receipt listeners on the client class which won't have the context
+ // of the room.
+ const forSource = (...args: any[]): void => {
+ // EventEmitter special cases 'error' to make the emit function throw if no
+ // handler is attached, which sort of makes sense for making sure that something
+ // handles an error, but for re-emitting, there could be a listener on the original
+ // source object so the test doesn't really work. We *could* try to replicate the
+ // same logic and throw if there is no listener on either the source or the target,
+ // but this behaviour is fairly undesireable for us anyway: the main place we throw
+ // 'error' events is for calls, where error events are usually emitted some time
+ // later by a different part of the code where 'emit' throwing because the app hasn't
+ // added an error handler isn't terribly helpful. (A better fix in retrospect may
+ // have been to just avoid using the event name 'error', but backwards compat...)
+ if (eventName === "error" && this.target.listenerCount("error") === 0) return;
+ this.target.emit(eventName, ...args, source);
+ };
+ source.on(eventName, forSource);
+ reEmittersByEvent.set(eventName, forSource);
+ }
+ }
+
+ public stopReEmitting(source: EventEmitter, eventNames: string[]): void {
+ const reEmittersByEvent = this.reEmitters.get(source);
+ if (!reEmittersByEvent) return; // We were never re-emitting these events in the first place
+
+ for (const eventName of eventNames) {
+ source.off(eventName, reEmittersByEvent.get(eventName)!);
+ reEmittersByEvent.delete(eventName);
+ }
+
+ if (reEmittersByEvent.size === 0) this.reEmitters.delete(source);
+ }
+}
+
+export class TypedReEmitter<Events extends string, Arguments extends ListenerMap<Events>> extends ReEmitter {
+ public constructor(target: TypedEventEmitter<Events, Arguments>) {
+ super(target);
+ }
+
+ public reEmit<ReEmittedEvents extends string, T extends Events & ReEmittedEvents>(
+ source: TypedEventEmitter<ReEmittedEvents, any>,
+ eventNames: T[],
+ ): void {
+ super.reEmit(source, eventNames);
+ }
+
+ public stopReEmitting<ReEmittedEvents extends string, T extends Events & ReEmittedEvents>(
+ source: TypedEventEmitter<ReEmittedEvents, any>,
+ eventNames: T[],
+ ): void {
+ super.stopReEmitting(source, eventNames);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/ToDeviceMessageQueue.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/ToDeviceMessageQueue.ts
new file mode 100644
index 0000000..59eada4
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/ToDeviceMessageQueue.ts
@@ -0,0 +1,148 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ToDeviceMessageId } from "./@types/event";
+import { logger } from "./logger";
+import { MatrixClient, ClientEvent } from "./client";
+import { MatrixError } from "./http-api";
+import { IndexedToDeviceBatch, ToDeviceBatch, ToDeviceBatchWithTxnId, ToDevicePayload } from "./models/ToDeviceMessage";
+import { MatrixScheduler } from "./scheduler";
+import { SyncState } from "./sync";
+import { MapWithDefault } from "./utils";
+
+const MAX_BATCH_SIZE = 20;
+
+/**
+ * Maintains a queue of outgoing to-device messages, sending them
+ * as soon as the homeserver is reachable.
+ */
+export class ToDeviceMessageQueue {
+ private sending = false;
+ private running = true;
+ private retryTimeout: ReturnType<typeof setTimeout> | null = null;
+ private retryAttempts = 0;
+
+ public constructor(private client: MatrixClient) {}
+
+ public start(): void {
+ this.running = true;
+ this.sendQueue();
+ this.client.on(ClientEvent.Sync, this.onResumedSync);
+ }
+
+ public stop(): void {
+ this.running = false;
+ if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
+ this.retryTimeout = null;
+ this.client.removeListener(ClientEvent.Sync, this.onResumedSync);
+ }
+
+ public async queueBatch(batch: ToDeviceBatch): Promise<void> {
+ const batches: ToDeviceBatchWithTxnId[] = [];
+ for (let i = 0; i < batch.batch.length; i += MAX_BATCH_SIZE) {
+ const batchWithTxnId = {
+ eventType: batch.eventType,
+ batch: batch.batch.slice(i, i + MAX_BATCH_SIZE),
+ txnId: this.client.makeTxnId(),
+ };
+ batches.push(batchWithTxnId);
+ const msgmap = batchWithTxnId.batch.map(
+ (msg) => `${msg.userId}/${msg.deviceId} (msgid ${msg.payload[ToDeviceMessageId]})`,
+ );
+ logger.info(
+ `Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`,
+ msgmap,
+ );
+ }
+
+ await this.client.store.saveToDeviceBatches(batches);
+ this.sendQueue();
+ }
+
+ public sendQueue = async (): Promise<void> => {
+ if (this.retryTimeout !== null) clearTimeout(this.retryTimeout);
+ this.retryTimeout = null;
+
+ if (this.sending || !this.running) return;
+
+ logger.debug("Attempting to send queued to-device messages");
+
+ this.sending = true;
+ let headBatch: IndexedToDeviceBatch | null;
+ try {
+ while (this.running) {
+ headBatch = await this.client.store.getOldestToDeviceBatch();
+ if (headBatch === null) break;
+ await this.sendBatch(headBatch);
+ await this.client.store.removeToDeviceBatch(headBatch.id);
+ this.retryAttempts = 0;
+ }
+
+ // Make sure we're still running after the async tasks: if not, stop.
+ if (!this.running) return;
+
+ logger.debug("All queued to-device messages sent");
+ } catch (e) {
+ ++this.retryAttempts;
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ // eslint-disable-next-line new-cap
+ const retryDelay = MatrixScheduler.RETRY_BACKOFF_RATELIMIT(null, this.retryAttempts, <MatrixError>e);
+ if (retryDelay === -1) {
+ // the scheduler function doesn't differentiate between fatal errors and just getting
+ // bored and giving up for now
+ if (Math.floor((<MatrixError>e).httpStatus! / 100) === 4) {
+ logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
+ await this.client.store.removeToDeviceBatch(headBatch!.id);
+ } else {
+ logger.info("Automatic retry limit reached for to-device messages.");
+ }
+ return;
+ }
+
+ logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
+ this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
+ } finally {
+ this.sending = false;
+ }
+ };
+
+ /**
+ * Attempts to send a batch of to-device messages.
+ */
+ private async sendBatch(batch: IndexedToDeviceBatch): Promise<void> {
+ const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
+ for (const item of batch.batch) {
+ contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
+ }
+
+ logger.info(
+ `Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id} and txnId ${batch.txnId}`,
+ );
+
+ await this.client.sendToDevice(batch.eventType, contentMap, batch.txnId);
+ }
+
+ /**
+ * Listen to sync state changes and automatically resend any pending events
+ * once syncing is resumed
+ */
+ private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => {
+ if (state === SyncState.Syncing && oldState !== SyncState.Syncing) {
+ logger.info(`Resuming queue after resumed sync`);
+ this.sendQueue();
+ }
+ };
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/autodiscovery.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/autodiscovery.ts
new file mode 100644
index 0000000..f4a3415
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/autodiscovery.ts
@@ -0,0 +1,472 @@
+/*
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IClientWellKnown, IWellKnownConfig } from "./client";
+import { logger } from "./logger";
+import { MatrixError, Method, timeoutSignal } from "./http-api";
+
+// Dev note: Auto discovery is part of the spec.
+// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
+
+export enum AutoDiscoveryAction {
+ SUCCESS = "SUCCESS",
+ IGNORE = "IGNORE",
+ PROMPT = "PROMPT",
+ FAIL_PROMPT = "FAIL_PROMPT",
+ FAIL_ERROR = "FAIL_ERROR",
+}
+
+enum AutoDiscoveryError {
+ Invalid = "Invalid homeserver discovery response",
+ GenericFailure = "Failed to get autodiscovery configuration from server",
+ InvalidHsBaseUrl = "Invalid base_url for m.homeserver",
+ InvalidHomeserver = "Homeserver URL does not appear to be a valid Matrix homeserver",
+ InvalidIsBaseUrl = "Invalid base_url for m.identity_server",
+ InvalidIdentityServer = "Identity server URL does not appear to be a valid identity server",
+ InvalidIs = "Invalid identity server discovery response",
+ MissingWellknown = "No .well-known JSON file found",
+ InvalidJson = "Invalid JSON",
+}
+
+interface WellKnownConfig extends Omit<IWellKnownConfig, "error"> {
+ state: AutoDiscoveryAction;
+ error?: IWellKnownConfig["error"] | null;
+}
+
+export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
+ "m.homeserver": WellKnownConfig;
+ "m.identity_server": WellKnownConfig;
+}
+
+/**
+ * Utilities for automatically discovery resources, such as homeservers
+ * for users to log in to.
+ */
+export class AutoDiscovery {
+ // Dev note: the constants defined here are related to but not
+ // exactly the same as those in the spec. This is to hopefully
+ // translate the meaning of the states in the spec, but also
+ // support our own if needed.
+
+ public static readonly ERROR_INVALID = AutoDiscoveryError.Invalid;
+
+ public static readonly ERROR_GENERIC_FAILURE = AutoDiscoveryError.GenericFailure;
+
+ public static readonly ERROR_INVALID_HS_BASE_URL = AutoDiscoveryError.InvalidHsBaseUrl;
+
+ public static readonly ERROR_INVALID_HOMESERVER = AutoDiscoveryError.InvalidHomeserver;
+
+ public static readonly ERROR_INVALID_IS_BASE_URL = AutoDiscoveryError.InvalidIsBaseUrl;
+
+ public static readonly ERROR_INVALID_IDENTITY_SERVER = AutoDiscoveryError.InvalidIdentityServer;
+
+ public static readonly ERROR_INVALID_IS = AutoDiscoveryError.InvalidIs;
+
+ public static readonly ERROR_MISSING_WELLKNOWN = AutoDiscoveryError.MissingWellknown;
+
+ public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson;
+
+ public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError);
+
+ /**
+ * The auto discovery failed. The client is expected to communicate
+ * the error to the user and refuse logging in.
+ */
+ public static readonly FAIL_ERROR = AutoDiscoveryAction.FAIL_ERROR;
+
+ /**
+ * The auto discovery failed, however the client may still recover
+ * from the problem. The client is recommended to that the same
+ * action it would for PROMPT while also warning the user about
+ * what went wrong. The client may also treat this the same as
+ * a FAIL_ERROR state.
+ */
+ public static readonly FAIL_PROMPT = AutoDiscoveryAction.FAIL_PROMPT;
+
+ /**
+ * The auto discovery didn't fail but did not find anything of
+ * interest. The client is expected to prompt the user for more
+ * information, or fail if it prefers.
+ */
+ public static readonly PROMPT = AutoDiscoveryAction.PROMPT;
+
+ /**
+ * The auto discovery was successful.
+ */
+ public static readonly SUCCESS = AutoDiscoveryAction.SUCCESS;
+
+ /**
+ * Validates and verifies client configuration information for purposes
+ * of logging in. Such information includes the homeserver URL
+ * and identity server URL the client would want. Additional details
+ * may also be included, and will be transparently brought into the
+ * response object unaltered.
+ * @param wellknown - The configuration object itself, as returned
+ * by the .well-known auto-discovery endpoint.
+ * @returns Promise which resolves to the verified
+ * configuration, which may include error states. Rejects on unexpected
+ * failure, not when verification fails.
+ */
+ public static async fromDiscoveryConfig(wellknown: IClientWellKnown): Promise<ClientConfig> {
+ // Step 1 is to get the config, which is provided to us here.
+
+ // We default to an error state to make the first few checks easier to
+ // write. We'll update the properties of this object over the duration
+ // of this function.
+ const clientConfig: ClientConfig = {
+ "m.homeserver": {
+ state: AutoDiscovery.FAIL_ERROR,
+ error: AutoDiscovery.ERROR_INVALID,
+ base_url: null,
+ },
+ "m.identity_server": {
+ // Technically, we don't have a problem with the identity server
+ // config at this point.
+ state: AutoDiscovery.PROMPT,
+ error: null,
+ base_url: null,
+ },
+ };
+
+ if (!wellknown || !wellknown["m.homeserver"]) {
+ logger.error("No m.homeserver key in config");
+
+ clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
+ clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID;
+
+ return Promise.resolve(clientConfig);
+ }
+
+ if (!wellknown["m.homeserver"]["base_url"]) {
+ logger.error("No m.homeserver base_url in config");
+
+ clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
+ clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL;
+
+ return Promise.resolve(clientConfig);
+ }
+
+ // Step 2: Make sure the homeserver URL is valid *looking*. We'll make
+ // sure it points to a homeserver in Step 3.
+ const hsUrl = this.sanitizeWellKnownUrl(wellknown["m.homeserver"]["base_url"]);
+ if (!hsUrl) {
+ logger.error("Invalid base_url for m.homeserver");
+ clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HS_BASE_URL;
+ return Promise.resolve(clientConfig);
+ }
+
+ // Step 3: Make sure the homeserver URL points to a homeserver.
+ const hsVersions = await this.fetchWellKnownObject(`${hsUrl}/_matrix/client/versions`);
+ if (!hsVersions || !hsVersions.raw?.["versions"]) {
+ logger.error("Invalid /versions response");
+ clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID_HOMESERVER;
+
+ // Supply the base_url to the caller because they may be ignoring liveliness
+ // errors, like this one.
+ clientConfig["m.homeserver"].base_url = hsUrl;
+
+ return Promise.resolve(clientConfig);
+ }
+
+ // Step 4: Now that the homeserver looks valid, update our client config.
+ clientConfig["m.homeserver"] = {
+ state: AutoDiscovery.SUCCESS,
+ error: null,
+ base_url: hsUrl,
+ };
+
+ // Step 5: Try to pull out the identity server configuration
+ let isUrl: string | boolean = "";
+ if (wellknown["m.identity_server"]) {
+ // We prepare a failing identity server response to save lines later
+ // in this branch.
+ const failingClientConfig: ClientConfig = {
+ "m.homeserver": clientConfig["m.homeserver"],
+ "m.identity_server": {
+ state: AutoDiscovery.FAIL_PROMPT,
+ error: AutoDiscovery.ERROR_INVALID_IS,
+ base_url: null,
+ },
+ };
+
+ // Step 5a: Make sure the URL is valid *looking*. We'll make sure it
+ // points to an identity server in Step 5b.
+ isUrl = this.sanitizeWellKnownUrl(wellknown["m.identity_server"]["base_url"]);
+ if (!isUrl) {
+ logger.error("Invalid base_url for m.identity_server");
+ failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IS_BASE_URL;
+ return Promise.resolve(failingClientConfig);
+ }
+
+ // Step 5b: Verify there is an identity server listening on the provided
+ // URL.
+ const isResponse = await this.fetchWellKnownObject(`${isUrl}/_matrix/identity/v2`);
+ if (!isResponse?.raw || isResponse.action !== AutoDiscoveryAction.SUCCESS) {
+ logger.error("Invalid /v2 response");
+ failingClientConfig["m.identity_server"].error = AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER;
+
+ // Supply the base_url to the caller because they may be ignoring
+ // liveliness errors, like this one.
+ failingClientConfig["m.identity_server"].base_url = isUrl;
+
+ return Promise.resolve(failingClientConfig);
+ }
+ }
+
+ // Step 6: Now that the identity server is valid, or never existed,
+ // populate the IS section.
+ if (isUrl && isUrl.toString().length > 0) {
+ clientConfig["m.identity_server"] = {
+ state: AutoDiscovery.SUCCESS,
+ error: null,
+ base_url: isUrl,
+ };
+ }
+
+ // Step 7: Copy any other keys directly into the clientConfig. This is for
+ // things like custom configuration of services.
+ Object.keys(wellknown).forEach((k: keyof IClientWellKnown) => {
+ if (k === "m.homeserver" || k === "m.identity_server") {
+ // Only copy selected parts of the config to avoid overwriting
+ // properties computed by the validation logic above.
+ const notProps = ["error", "state", "base_url"];
+ for (const prop of Object.keys(wellknown[k]!)) {
+ if (notProps.includes(prop)) continue;
+ type Prop = Exclude<keyof IWellKnownConfig, "error" | "state" | "base_url">;
+ // @ts-ignore - ts gets unhappy as we're mixing types here
+ clientConfig[k][prop as Prop] = wellknown[k]![prop as Prop];
+ }
+ } else {
+ // Just copy the whole thing over otherwise
+ clientConfig[k] = wellknown[k];
+ }
+ });
+
+ // Step 8: Give the config to the caller (finally)
+ return Promise.resolve(clientConfig);
+ }
+
+ /**
+ * Attempts to automatically discover client configuration information
+ * prior to logging in. Such information includes the homeserver URL
+ * and identity server URL the client would want. Additional details
+ * may also be discovered, and will be transparently included in the
+ * response object unaltered.
+ * @param domain - The homeserver domain to perform discovery
+ * on. For example, "matrix.org".
+ * @returns Promise which resolves to the discovered
+ * configuration, which may include error states. Rejects on unexpected
+ * failure, not when discovery fails.
+ */
+ public static async findClientConfig(domain: string): Promise<ClientConfig> {
+ if (!domain || typeof domain !== "string" || domain.length === 0) {
+ throw new Error("'domain' must be a string of non-zero length");
+ }
+
+ // We use a .well-known lookup for all cases. According to the spec, we
+ // can do other discovery mechanisms if we want such as custom lookups
+ // however we won't bother with that here (mostly because the spec only
+ // supports .well-known right now).
+ //
+ // By using .well-known, we need to ensure we at least pull out a URL
+ // for the homeserver. We don't really need an identity server configuration
+ // but will return one anyways (with state PROMPT) to make development
+ // easier for clients. If we can't get a homeserver URL, all bets are
+ // off on the rest of the config and we'll assume it is invalid too.
+
+ // We default to an error state to make the first few checks easier to
+ // write. We'll update the properties of this object over the duration
+ // of this function.
+ const clientConfig: ClientConfig = {
+ "m.homeserver": {
+ state: AutoDiscovery.FAIL_ERROR,
+ error: AutoDiscovery.ERROR_INVALID,
+ base_url: null,
+ },
+ "m.identity_server": {
+ // Technically, we don't have a problem with the identity server
+ // config at this point.
+ state: AutoDiscovery.PROMPT,
+ error: null,
+ base_url: null,
+ },
+ };
+
+ // Step 1: Actually request the .well-known JSON file and make sure it
+ // at least has a homeserver definition.
+ const wellknown = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
+ if (!wellknown || wellknown.action !== AutoDiscoveryAction.SUCCESS) {
+ logger.error("No response or error when parsing .well-known");
+ if (wellknown.reason) logger.error(wellknown.reason);
+ if (wellknown.action === AutoDiscoveryAction.IGNORE) {
+ clientConfig["m.homeserver"] = {
+ state: AutoDiscovery.PROMPT,
+ error: null,
+ base_url: null,
+ };
+ } else {
+ // this can only ever be FAIL_PROMPT at this point.
+ clientConfig["m.homeserver"].state = AutoDiscovery.FAIL_PROMPT;
+ clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_INVALID;
+ }
+ return Promise.resolve(clientConfig);
+ }
+
+ // Step 2: Validate and parse the config
+ return AutoDiscovery.fromDiscoveryConfig(wellknown.raw!);
+ }
+
+ /**
+ * Gets the raw discovery client configuration for the given domain name.
+ * Should only be used if there's no validation to be done on the resulting
+ * object, otherwise use findClientConfig().
+ * @param domain - The domain to get the client config for.
+ * @returns Promise which resolves to the domain's client config. Can
+ * be an empty object.
+ */
+ public static async getRawClientConfig(domain?: string): Promise<IClientWellKnown> {
+ if (!domain || typeof domain !== "string" || domain.length === 0) {
+ throw new Error("'domain' must be a string of non-zero length");
+ }
+
+ const response = await this.fetchWellKnownObject(`https://${domain}/.well-known/matrix/client`);
+ if (!response) return {};
+ return response.raw || {};
+ }
+
+ /**
+ * Sanitizes a given URL to ensure it is either an HTTP or HTTP URL and
+ * is suitable for the requirements laid out by .well-known auto discovery.
+ * If valid, the URL will also be stripped of any trailing slashes.
+ * @param url - The potentially invalid URL to sanitize.
+ * @returns The sanitized URL or a falsey value if the URL is invalid.
+ * @internal
+ */
+ private static sanitizeWellKnownUrl(url?: string | null): string | false {
+ if (!url) return false;
+
+ try {
+ let parsed: URL | undefined;
+ try {
+ parsed = new URL(url);
+ } catch (e) {
+ logger.error("Could not parse url", e);
+ }
+
+ if (!parsed?.hostname) return false;
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
+
+ const port = parsed.port ? `:${parsed.port}` : "";
+ const path = parsed.pathname ? parsed.pathname : "";
+ let saferUrl = `${parsed.protocol}//${parsed.hostname}${port}${path}`;
+ if (saferUrl.endsWith("/")) {
+ saferUrl = saferUrl.substring(0, saferUrl.length - 1);
+ }
+ return saferUrl;
+ } catch (e) {
+ logger.error(e);
+ return false;
+ }
+ }
+
+ private static fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> {
+ if (this.fetchFn) {
+ return this.fetchFn(resource, options);
+ }
+ return global.fetch(resource, options);
+ }
+
+ private static fetchFn?: typeof global.fetch;
+
+ public static setFetchFn(fetchFn: typeof global.fetch): void {
+ AutoDiscovery.fetchFn = fetchFn;
+ }
+
+ /**
+ * Fetches a JSON object from a given URL, as expected by all .well-known
+ * related lookups. If the server gives a 404 then the `action` will be
+ * IGNORE. If the server returns something that isn't JSON, the `action`
+ * will be FAIL_PROMPT. For any other failure the `action` will be FAIL_PROMPT.
+ *
+ * The returned object will be a result of the call in object form with
+ * the following properties:
+ * raw: The JSON object returned by the server.
+ * action: One of SUCCESS, IGNORE, or FAIL_PROMPT.
+ * reason: Relatively human-readable description of what went wrong.
+ * error: The actual Error, if one exists.
+ * @param url - The URL to fetch a JSON object from.
+ * @returns Promise which resolves to the returned state.
+ * @internal
+ */
+ private static async fetchWellKnownObject(url: string): Promise<IWellKnownConfig> {
+ let response: Response;
+
+ try {
+ response = await AutoDiscovery.fetch(url, {
+ method: Method.Get,
+ signal: timeoutSignal(5000),
+ });
+
+ if (response.status === 404) {
+ return {
+ raw: {},
+ action: AutoDiscoveryAction.IGNORE,
+ reason: AutoDiscovery.ERROR_MISSING_WELLKNOWN,
+ };
+ }
+
+ if (!response.ok) {
+ return {
+ raw: {},
+ action: AutoDiscoveryAction.FAIL_PROMPT,
+ reason: "General failure",
+ };
+ }
+ } catch (err) {
+ const error = err as AutoDiscoveryError | string | undefined;
+ let reason = "";
+ if (typeof error === "object") {
+ reason = (<Error>error)?.message;
+ }
+
+ return {
+ error,
+ raw: {},
+ action: AutoDiscoveryAction.FAIL_PROMPT,
+ reason: reason || "General failure",
+ };
+ }
+
+ try {
+ return {
+ raw: await response.json(),
+ action: AutoDiscoveryAction.SUCCESS,
+ };
+ } catch (err) {
+ const error = err as Error;
+ return {
+ error,
+ raw: {},
+ action: AutoDiscoveryAction.FAIL_PROMPT,
+ reason:
+ (error as MatrixError)?.name === "SyntaxError"
+ ? AutoDiscovery.ERROR_INVALID_JSON
+ : AutoDiscovery.ERROR_INVALID,
+ };
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/browser-index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/browser-index.ts
new file mode 100644
index 0000000..200b2a3
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/browser-index.ts
@@ -0,0 +1,47 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import * as matrixcs from "./matrix";
+
+type BrowserMatrix = typeof matrixcs;
+declare global {
+ /* eslint-disable no-var, camelcase */
+ var __js_sdk_entrypoint: boolean;
+ var matrixcs: BrowserMatrix;
+ /* eslint-enable no-var */
+}
+
+if (global.__js_sdk_entrypoint) {
+ throw new Error("Multiple matrix-js-sdk entrypoints detected!");
+}
+global.__js_sdk_entrypoint = true;
+
+// just *accessing* indexedDB throws an exception in firefox with indexeddb disabled.
+let indexedDB: IDBFactory | undefined;
+try {
+ indexedDB = global.indexedDB;
+} catch (e) {}
+
+// if our browser (appears to) support indexeddb, use an indexeddb crypto store.
+if (indexedDB) {
+ matrixcs.setCryptoStoreFactory(() => new matrixcs.IndexedDBCryptoStore(indexedDB!, "matrix-js-sdk:crypto"));
+}
+
+// We export 3 things to make browserify happy as well as downstream projects.
+// It's awkward, but required.
+export * from "./matrix";
+export default matrixcs; // keep export for browserify package deps
+global.matrixcs = matrixcs;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts
new file mode 100644
index 0000000..0e47ff6
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/client.ts
@@ -0,0 +1,9680 @@
+/*
+Copyright 2015-2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module. See {@link MatrixClient} for the public class.
+ */
+
+import { Optional } from "matrix-events-sdk";
+
+import type { IDeviceKeys, IMegolmSessionData, IOneTimeKey } from "./@types/crypto";
+import { ISyncStateData, SyncApi, SyncApiOptions, SyncState } from "./sync";
+import {
+ EventStatus,
+ IContent,
+ IDecryptOptions,
+ IEvent,
+ MatrixEvent,
+ MatrixEventEvent,
+ MatrixEventHandlerMap,
+} from "./models/event";
+import { StubStore } from "./store/stub";
+import { CallEvent, CallEventHandlerMap, createNewMatrixCall, MatrixCall, supportsMatrixCall } from "./webrtc/call";
+import { Filter, IFilterDefinition, IRoomEventFilter } from "./filter";
+import { CallEventHandlerEvent, CallEventHandler, CallEventHandlerEventHandlerMap } from "./webrtc/callEventHandler";
+import { GroupCallEventHandlerEvent, GroupCallEventHandlerEventHandlerMap } from "./webrtc/groupCallEventHandler";
+import * as utils from "./utils";
+import { replaceParam, QueryDict, sleep, noUnsafeEventProps } from "./utils";
+import { Direction, EventTimeline } from "./models/event-timeline";
+import { IActionsObject, PushProcessor } from "./pushprocessor";
+import { AutoDiscovery, AutoDiscoveryAction } from "./autodiscovery";
+import * as olmlib from "./crypto/olmlib";
+import { decodeBase64, encodeBase64 } from "./crypto/olmlib";
+import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice";
+import { IOlmDevice } from "./crypto/algorithms/megolm";
+import { TypedReEmitter } from "./ReEmitter";
+import { IRoomEncryption, RoomList } from "./crypto/RoomList";
+import { logger } from "./logger";
+import { SERVICE_TYPES } from "./service-types";
+import {
+ HttpApiEvent,
+ HttpApiEventHandlerMap,
+ Upload,
+ UploadOpts,
+ MatrixError,
+ MatrixHttpApi,
+ Method,
+ retryNetworkOperation,
+ ClientPrefix,
+ MediaPrefix,
+ IdentityPrefix,
+ IHttpOpts,
+ FileType,
+ UploadResponse,
+ HTTPError,
+ IRequestOpts,
+} from "./http-api";
+import {
+ Crypto,
+ CryptoEvent,
+ CryptoEventHandlerMap,
+ fixBackupKey,
+ ICryptoCallbacks,
+ IBootstrapCrossSigningOpts,
+ ICheckOwnCrossSigningTrustOpts,
+ isCryptoAvailable,
+ VerificationMethod,
+ IRoomKeyRequestBody,
+} from "./crypto";
+import { DeviceInfo } from "./crypto/deviceinfo";
+import { decodeRecoveryKey } from "./crypto/recoverykey";
+import { keyFromAuthData } from "./crypto/key_passphrase";
+import { User, UserEvent, UserEventHandlerMap } from "./models/user";
+import { getHttpUriForMxc } from "./content-repo";
+import { SearchResult } from "./models/search-result";
+import { DEHYDRATION_ALGORITHM, IDehydratedDevice, IDehydratedDeviceKeyInfo } from "./crypto/dehydration";
+import {
+ IKeyBackupInfo,
+ IKeyBackupPrepareOpts,
+ IKeyBackupRestoreOpts,
+ IKeyBackupRestoreResult,
+ IKeyBackupRoomSessions,
+ IKeyBackupSession,
+} from "./crypto/keybackup";
+import { IIdentityServerProvider } from "./@types/IIdentityServerProvider";
+import { MatrixScheduler } from "./scheduler";
+import { BeaconEvent, BeaconEventHandlerMap } from "./models/beacon";
+import { IAuthData, IAuthDict } from "./interactive-auth";
+import { IMinimalEvent, IRoomEvent, IStateEvent } from "./sync-accumulator";
+import {
+ CrossSigningKey,
+ IAddSecretStorageKeyOpts,
+ ICreateSecretStorageOpts,
+ IEncryptedEventInfo,
+ IImportRoomKeysOpts,
+ IRecoveryKey,
+} from "./crypto/api";
+import { EventTimelineSet } from "./models/event-timeline-set";
+import { VerificationRequest } from "./crypto/verification/request/VerificationRequest";
+import { VerificationBase as Verification } from "./crypto/verification/Base";
+import * as ContentHelpers from "./content-helpers";
+import { CrossSigningInfo, DeviceTrustLevel, ICacheCallbacks, UserTrustLevel } from "./crypto/CrossSigning";
+import { Room, NotificationCountType, RoomEvent, RoomEventHandlerMap, RoomNameState } from "./models/room";
+import { RoomMemberEvent, RoomMemberEventHandlerMap } from "./models/room-member";
+import { RoomStateEvent, RoomStateEventHandlerMap } from "./models/room-state";
+import {
+ IAddThreePidOnlyBody,
+ IBindThreePidBody,
+ IContextResponse,
+ ICreateRoomOpts,
+ IEventSearchOpts,
+ IGuestAccessOpts,
+ IJoinRoomOpts,
+ IPaginateOpts,
+ IPresenceOpts,
+ IRedactOpts,
+ IRelationsRequestOpts,
+ IRelationsResponse,
+ IRoomDirectoryOptions,
+ ISearchOpts,
+ ISendEventResponse,
+ INotificationsResponse,
+ IFilterResponse,
+ ITagsResponse,
+ IStatusResponse,
+} from "./@types/requests";
+import {
+ EventType,
+ LOCAL_NOTIFICATION_SETTINGS_PREFIX,
+ MsgType,
+ PUSHER_ENABLED,
+ RelationType,
+ RoomCreateTypeField,
+ RoomType,
+ UNSTABLE_MSC3088_ENABLED,
+ UNSTABLE_MSC3088_PURPOSE,
+ UNSTABLE_MSC3089_TREE_SUBTYPE,
+ MSC3912_RELATION_BASED_REDACTIONS_PROP,
+} from "./@types/event";
+import { IdServerUnbindResult, IImageInfo, Preset, Visibility } from "./@types/partials";
+import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper";
+import { randomString } from "./randomstring";
+import { BackupManager, IKeyBackup, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup";
+import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace";
+import { ISignatures } from "./@types/signed";
+import { IStore } from "./store";
+import { ISecretRequest } from "./crypto/SecretStorage";
+import {
+ IEventWithRoomId,
+ ISearchRequestBody,
+ ISearchResponse,
+ ISearchResults,
+ IStateEventWithRoomId,
+ SearchOrderBy,
+} from "./@types/search";
+import { ISynapseAdminDeactivateResponse, ISynapseAdminWhoisResponse } from "./@types/synapse";
+import { IHierarchyRoom } from "./@types/spaces";
+import {
+ IPusher,
+ IPusherRequest,
+ IPushRule,
+ IPushRules,
+ PushRuleAction,
+ PushRuleActionName,
+ PushRuleKind,
+ RuleId,
+} from "./@types/PushRules";
+import { IThreepid } from "./@types/threepids";
+import { CryptoStore, OutgoingRoomKeyRequest } from "./crypto/store/base";
+import { GroupCall, IGroupCallDataChannelOptions, GroupCallIntent, GroupCallType } from "./webrtc/groupCall";
+import { MediaHandler } from "./webrtc/mediaHandler";
+import { GroupCallEventHandler } from "./webrtc/groupCallEventHandler";
+import { LoginTokenPostResponse, ILoginFlowsResponse, IRefreshTokenResponse, SSOAction } from "./@types/auth";
+import { TypedEventEmitter } from "./models/typed-event-emitter";
+import { MAIN_ROOM_TIMELINE, ReceiptType } from "./@types/read_receipts";
+import { MSC3575SlidingSyncRequest, MSC3575SlidingSyncResponse, SlidingSync } from "./sliding-sync";
+import { SlidingSyncSdk } from "./sliding-sync-sdk";
+import {
+ FeatureSupport,
+ Thread,
+ THREAD_RELATION_TYPE,
+ determineFeatureSupport,
+ ThreadFilterType,
+ threadFilterTypeToFilter,
+} from "./models/thread";
+import { MBeaconInfoEventContent, M_BEACON_INFO } from "./@types/beacon";
+import { UnstableValue } from "./NamespacedValue";
+import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue";
+import { ToDeviceBatch } from "./models/ToDeviceMessage";
+import { IgnoredInvites } from "./models/invites-ignorer";
+import { UIARequest, UIAResponse } from "./@types/uia";
+import { LocalNotificationSettings } from "./@types/local_notifications";
+import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
+import { CryptoBackend } from "./common-crypto/CryptoBackend";
+import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
+import { CryptoApi } from "./crypto-api";
+import { DeviceInfoMap } from "./crypto/DeviceList";
+import { SecretStorageKeyDescription } from "./secret-storage";
+
+export type Store = IStore;
+
+export type ResetTimelineCallback = (roomId: string) => boolean;
+
+const SCROLLBACK_DELAY_MS = 3000;
+export const CRYPTO_ENABLED: boolean = isCryptoAvailable();
+const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
+const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
+
+export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue(
+ "last_seen_user_agent",
+ "org.matrix.msc3852.last_seen_user_agent",
+);
+
+interface IExportedDevice {
+ olmDevice: IExportedOlmDevice;
+ userId: string;
+ deviceId: string;
+}
+
+export interface IKeysUploadResponse {
+ one_time_key_counts: {
+ // eslint-disable-line camelcase
+ [algorithm: string]: number;
+ };
+}
+
+export interface ICreateClientOpts {
+ baseUrl: string;
+
+ idBaseUrl?: string;
+
+ /**
+ * The data store used for sync data from the homeserver. If not specified,
+ * this client will not store any HTTP responses. The `createClient` helper
+ * will create a default store if needed.
+ */
+ store?: Store;
+
+ /**
+ * A store to be used for end-to-end crypto session data. If not specified,
+ * end-to-end crypto will be disabled. The `createClient` helper will create
+ * a default store if needed. Calls the factory supplied to
+ * {@link setCryptoStoreFactory} if unspecified; or if no factory has been
+ * specified, uses a default implementation (indexeddb in the browser,
+ * in-memory otherwise).
+ */
+ cryptoStore?: CryptoStore;
+
+ /**
+ * The scheduler to use. If not
+ * specified, this client will not retry requests on failure. This client
+ * will supply its own processing function to
+ * {@link MatrixScheduler#setProcessFunction}.
+ */
+ scheduler?: MatrixScheduler;
+
+ /**
+ * The function to invoke for HTTP requests.
+ * Most supported environments have a global `fetch` registered to which this will fall back.
+ */
+ fetchFn?: typeof global.fetch;
+
+ userId?: string;
+
+ /**
+ * A unique identifier for this device; used for tracking things like crypto
+ * keys and access tokens. If not specified, end-to-end encryption will be
+ * disabled.
+ */
+ deviceId?: string;
+
+ accessToken?: string;
+
+ /**
+ * Identity server provider to retrieve the user's access token when accessing
+ * the identity server. See also https://github.com/vector-im/element-web/issues/10615
+ * which seeks to replace the previous approach of manual access tokens params
+ * with this callback throughout the SDK.
+ */
+ identityServer?: IIdentityServerProvider;
+
+ /**
+ * The default maximum amount of
+ * time to wait before timing out HTTP requests. If not specified, there is no timeout.
+ */
+ localTimeoutMs?: number;
+
+ /**
+ * Set to true to use
+ * Authorization header instead of query param to send the access token to the server.
+ *
+ * Default false.
+ */
+ useAuthorizationHeader?: boolean;
+
+ /**
+ * Set to true to enable
+ * improved timeline support, see {@link MatrixClient#getEventTimeline}.
+ * It is disabled by default for compatibility with older clients - in particular to
+ * maintain support for back-paginating the live timeline after a '/sync'
+ * result with a gap.
+ */
+ timelineSupport?: boolean;
+
+ /**
+ * Extra query parameters to append
+ * to all requests with this client. Useful for application services which require
+ * `?user_id=`.
+ */
+ queryParams?: Record<string, string>;
+
+ /**
+ * Device data exported with
+ * "exportDevice" method that must be imported to recreate this device.
+ * Should only be useful for devices with end-to-end crypto enabled.
+ * If provided, deviceId and userId should **NOT** be provided at the top
+ * level (they are present in the exported data).
+ */
+ deviceToImport?: IExportedDevice;
+
+ /**
+ * Key used to pickle olm objects or other sensitive data.
+ */
+ pickleKey?: string;
+
+ verificationMethods?: Array<VerificationMethod>;
+
+ /**
+ * Whether relaying calls through a TURN server should be forced. Default false.
+ */
+ forceTURN?: boolean;
+
+ /**
+ * Up to this many ICE candidates will be gathered when an incoming call arrives.
+ * Gathering does not send data to the caller, but will communicate with the configured TURN
+ * server. Default 0.
+ */
+ iceCandidatePoolSize?: number;
+
+ /**
+ * True to advertise support for call transfers to other parties on Matrix calls. Default false.
+ */
+ supportsCallTransfer?: boolean;
+
+ /**
+ * Whether to allow a fallback ICE server should be used for negotiating a
+ * WebRTC connection if the homeserver doesn't provide any servers. Defaults to false.
+ */
+ fallbackICEServerAllowed?: boolean;
+
+ /**
+ * If true, to-device signalling for group calls will be encrypted
+ * with Olm. Default: true.
+ */
+ useE2eForGroupCall?: boolean;
+
+ cryptoCallbacks?: ICryptoCallbacks;
+
+ /**
+ * Method to generate room names for empty rooms and rooms names based on membership.
+ * Defaults to a built-in English handler with basic pluralisation.
+ */
+ roomNameGenerator?: (roomId: string, state: RoomNameState) => string | null;
+
+ /**
+ * If true, participant can join group call without video and audio this has to be allowed. By default, a local
+ * media stream is needed to establish a group call.
+ * Default: false.
+ */
+ isVoipWithNoMediaAllowed?: boolean;
+}
+
+export interface IMatrixClientCreateOpts extends ICreateClientOpts {
+ /**
+ * Whether to allow sending messages to encrypted rooms when encryption
+ * is not available internally within this SDK. This is useful if you are using an external
+ * E2E proxy, for example. Defaults to false.
+ */
+ usingExternalCrypto?: boolean;
+}
+
+export enum PendingEventOrdering {
+ Chronological = "chronological",
+ Detached = "detached",
+}
+
+export interface IStartClientOpts {
+ /**
+ * The event `limit=` to apply to initial sync. Default: 8.
+ */
+ initialSyncLimit?: number;
+
+ /**
+ * True to put `archived=true</code> on the <code>/initialSync` request. Default: false.
+ */
+ includeArchivedRooms?: boolean;
+
+ /**
+ * True to do /profile requests on every invite event if the displayname/avatar_url is not known for this user ID. Default: false.
+ */
+ resolveInvitesToProfiles?: boolean;
+
+ /**
+ * Controls where pending messages appear in a room's timeline. If "<b>chronological</b>", messages will
+ * appear in the timeline when the call to `sendEvent` was made. If "<b>detached</b>",
+ * pending messages will appear in a separate list, accessbile via {@link Room#getPendingEvents}.
+ * Default: "chronological".
+ */
+ pendingEventOrdering?: PendingEventOrdering;
+
+ /**
+ * The number of milliseconds to wait on /sync. Default: 30000 (30 seconds).
+ */
+ pollTimeout?: number;
+
+ /**
+ * The filter to apply to /sync calls.
+ */
+ filter?: Filter;
+
+ /**
+ * True to perform syncing without automatically updating presence.
+ */
+ disablePresence?: boolean;
+
+ /**
+ * True to not load all membership events during initial sync but fetch them when needed by calling
+ * `loadOutOfBandMembers` This will override the filter option at this moment.
+ */
+ lazyLoadMembers?: boolean;
+
+ /**
+ * The number of seconds between polls to /.well-known/matrix/client, undefined to disable.
+ * This should be in the order of hours. Default: undefined.
+ */
+ clientWellKnownPollPeriod?: number;
+
+ /**
+ * @deprecated use `threadSupport` instead
+ */
+ experimentalThreadSupport?: boolean;
+
+ /**
+ * Will organises events in threaded conversations when
+ * a thread relation is encountered
+ */
+ threadSupport?: boolean;
+
+ /**
+ * @experimental
+ */
+ slidingSync?: SlidingSync;
+
+ /**
+ * @experimental
+ */
+ intentionalMentions?: boolean;
+}
+
+export interface IStoredClientOpts extends IStartClientOpts {}
+
+export enum RoomVersionStability {
+ Stable = "stable",
+ Unstable = "unstable",
+}
+
+export interface IRoomVersionsCapability {
+ default: string;
+ available: Record<string, RoomVersionStability>;
+}
+
+export interface ICapability {
+ enabled: boolean;
+}
+
+export interface IChangePasswordCapability extends ICapability {}
+
+export interface IThreadsCapability extends ICapability {}
+
+interface ICapabilities {
+ [key: string]: any;
+ "m.change_password"?: IChangePasswordCapability;
+ "m.room_versions"?: IRoomVersionsCapability;
+ "io.element.thread"?: IThreadsCapability;
+}
+
+/* eslint-disable camelcase */
+export interface ICrossSigningKey {
+ keys: { [algorithm: string]: string };
+ signatures?: ISignatures;
+ usage: string[];
+ user_id: string;
+}
+
+enum CrossSigningKeyType {
+ MasterKey = "master_key",
+ SelfSigningKey = "self_signing_key",
+ UserSigningKey = "user_signing_key",
+}
+
+export type CrossSigningKeys = Record<CrossSigningKeyType, ICrossSigningKey>;
+
+export type SendToDeviceContentMap = Map<string, Map<string, Record<string, any>>>;
+
+export interface ISignedKey {
+ keys: Record<string, string>;
+ signatures: ISignatures;
+ user_id: string;
+ algorithms: string[];
+ device_id: string;
+}
+
+export type KeySignatures = Record<string, Record<string, ICrossSigningKey | ISignedKey>>;
+export interface IUploadKeySignaturesResponse {
+ failures: Record<
+ string,
+ Record<
+ string,
+ {
+ errcode: string;
+ error: string;
+ }
+ >
+ >;
+}
+
+export interface IPreviewUrlResponse {
+ [key: string]: undefined | string | number;
+ "og:title": string;
+ "og:type": string;
+ "og:url": string;
+ "og:image"?: string;
+ "og:image:type"?: string;
+ "og:image:height"?: number;
+ "og:image:width"?: number;
+ "og:description"?: string;
+ "matrix:image:size"?: number;
+}
+
+export interface ITurnServerResponse {
+ uris: string[];
+ username: string;
+ password: string;
+ ttl: number;
+}
+
+export interface ITurnServer {
+ urls: string[];
+ username: string;
+ credential: string;
+}
+
+export interface IServerVersions {
+ versions: string[];
+ unstable_features: Record<string, boolean>;
+}
+
+export const M_AUTHENTICATION = new UnstableValue("m.authentication", "org.matrix.msc2965.authentication");
+
+export interface IClientWellKnown {
+ [key: string]: any;
+ "m.homeserver"?: IWellKnownConfig;
+ "m.identity_server"?: IWellKnownConfig;
+ [M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
+}
+
+export interface IWellKnownConfig {
+ raw?: IClientWellKnown;
+ action?: AutoDiscoveryAction;
+ reason?: string;
+ error?: Error | string;
+ // eslint-disable-next-line
+ base_url?: string | null;
+ // XXX: this is undocumented
+ server_name?: string;
+}
+
+export interface IDelegatedAuthConfig {
+ // MSC2965
+ /** The OIDC Provider/issuer the client should use */
+ issuer: string;
+ /** The optional URL of the web UI where the user can manage their account */
+ account?: string;
+}
+
+interface IKeyBackupPath {
+ path: string;
+ queryData?: {
+ version: string;
+ };
+}
+
+interface IMediaConfig {
+ [key: string]: any; // extensible
+ "m.upload.size"?: number;
+}
+
+interface IThirdPartySigned {
+ sender: string;
+ mxid: string;
+ token: string;
+ signatures: ISignatures;
+}
+
+interface IJoinRequestBody {
+ third_party_signed?: IThirdPartySigned;
+}
+
+interface ITagMetadata {
+ [key: string]: any;
+ order: number;
+}
+
+interface IMessagesResponse {
+ start?: string;
+ end?: string;
+ chunk: IRoomEvent[];
+ state?: IStateEvent[];
+}
+
+interface IThreadedMessagesResponse {
+ prev_batch: string;
+ next_batch: string;
+ chunk: IRoomEvent[];
+ state: IStateEvent[];
+}
+
+export interface IRequestTokenResponse {
+ sid: string;
+ submit_url?: string;
+}
+
+export interface IRequestMsisdnTokenResponse extends IRequestTokenResponse {
+ msisdn: string;
+ success: boolean;
+ intl_fmt: string;
+}
+
+export interface IUploadKeysRequest {
+ "device_keys"?: Required<IDeviceKeys>;
+ "one_time_keys"?: Record<string, IOneTimeKey>;
+ "org.matrix.msc2732.fallback_keys"?: Record<string, IOneTimeKey>;
+}
+
+export interface IQueryKeysRequest {
+ device_keys: { [userId: string]: string[] };
+ timeout?: number;
+ token?: string;
+}
+
+export interface IClaimKeysRequest {
+ one_time_keys: { [userId: string]: { [deviceId: string]: string } };
+ timeout?: number;
+}
+
+export interface IOpenIDToken {
+ access_token: string;
+ token_type: "Bearer" | string;
+ matrix_server_name: string;
+ expires_in: number;
+}
+
+interface IRoomInitialSyncResponse {
+ room_id: string;
+ membership: "invite" | "join" | "leave" | "ban";
+ messages?: {
+ start?: string;
+ end?: string;
+ chunk: IEventWithRoomId[];
+ };
+ state?: IStateEventWithRoomId[];
+ visibility: Visibility;
+ account_data?: IMinimalEvent[];
+ presence: Partial<IEvent>; // legacy and undocumented, api is deprecated so this won't get attention
+}
+
+interface IJoinedRoomsResponse {
+ joined_rooms: string[];
+}
+
+interface IJoinedMembersResponse {
+ joined: {
+ [userId: string]: {
+ display_name: string;
+ avatar_url: string;
+ };
+ };
+}
+
+export interface IRegisterRequestParams {
+ auth?: IAuthData;
+ username?: string;
+ password?: string;
+ refresh_token?: boolean;
+ guest_access_token?: string;
+ x_show_msisdn?: boolean;
+ bind_msisdn?: boolean;
+ bind_email?: boolean;
+ inhibit_login?: boolean;
+ initial_device_display_name?: string;
+}
+
+export interface IPublicRoomsChunkRoom {
+ room_id: string;
+ name?: string;
+ avatar_url?: string;
+ topic?: string;
+ canonical_alias?: string;
+ aliases?: string[];
+ world_readable: boolean;
+ guest_can_join: boolean;
+ num_joined_members: number;
+ room_type?: RoomType | string; // Added by MSC3827
+}
+
+interface IPublicRoomsResponse {
+ chunk: IPublicRoomsChunkRoom[];
+ next_batch?: string;
+ prev_batch?: string;
+ total_room_count_estimate?: number;
+}
+
+interface IUserDirectoryResponse {
+ results: {
+ user_id: string;
+ display_name?: string;
+ avatar_url?: string;
+ }[];
+ limited: boolean;
+}
+
+export interface IMyDevice {
+ "device_id": string;
+ "display_name"?: string;
+ "last_seen_ip"?: string;
+ "last_seen_ts"?: number;
+ // UNSTABLE_MSC3852_LAST_SEEN_UA
+ "last_seen_user_agent"?: string;
+ "org.matrix.msc3852.last_seen_user_agent"?: string;
+}
+
+export interface Keys {
+ keys: { [keyId: string]: string };
+ usage: string[];
+ user_id: string;
+}
+
+export interface SigningKeys extends Keys {
+ signatures: ISignatures;
+}
+
+export interface DeviceKeys {
+ [deviceId: string]: IDeviceKeys & {
+ unsigned?: {
+ device_display_name: string;
+ };
+ };
+}
+
+export interface IDownloadKeyResult {
+ failures: { [serverName: string]: object };
+ device_keys: { [userId: string]: DeviceKeys };
+ // the following three fields were added in 1.1
+ master_keys?: { [userId: string]: Keys };
+ self_signing_keys?: { [userId: string]: SigningKeys };
+ user_signing_keys?: { [userId: string]: SigningKeys };
+}
+
+export interface IClaimOTKsResult {
+ failures: { [serverName: string]: object };
+ one_time_keys: {
+ [userId: string]: {
+ [deviceId: string]: {
+ [keyId: string]: {
+ key: string;
+ signatures: ISignatures;
+ };
+ };
+ };
+ };
+}
+
+export interface IFieldType {
+ regexp: string;
+ placeholder: string;
+}
+
+export interface IInstance {
+ desc: string;
+ icon?: string;
+ fields: object;
+ network_id: string;
+ // XXX: this is undocumented but we rely on it: https://github.com/matrix-org/matrix-doc/issues/3203
+ instance_id: string;
+}
+
+export interface IProtocol {
+ user_fields: string[];
+ location_fields: string[];
+ icon: string;
+ field_types: Record<string, IFieldType>;
+ instances: IInstance[];
+}
+
+interface IThirdPartyLocation {
+ alias: string;
+ protocol: string;
+ fields: object;
+}
+
+interface IThirdPartyUser {
+ userid: string;
+ protocol: string;
+ fields: object;
+}
+
+interface IRoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
+ room_type?: RoomType;
+ membership?: string;
+ is_encrypted: boolean;
+}
+
+interface IRoomKeysResponse {
+ sessions: IKeyBackupRoomSessions;
+}
+
+interface IRoomsKeysResponse {
+ rooms: Record<string, IRoomKeysResponse>;
+}
+
+interface IRoomHierarchy {
+ rooms: IHierarchyRoom[];
+ next_batch?: string;
+}
+
+export interface TimestampToEventResponse {
+ event_id: string;
+ origin_server_ts: string;
+}
+
+interface IWhoamiResponse {
+ user_id: string;
+ device_id?: string;
+}
+/* eslint-enable camelcase */
+
+// We're using this constant for methods overloading and inspect whether a variable
+// contains an eventId or not. This was required to ensure backwards compatibility
+// of methods for threads
+// Probably not the most graceful solution but does a good enough job for now
+const EVENT_ID_PREFIX = "$";
+
+export enum ClientEvent {
+ Sync = "sync",
+ Event = "event",
+ ToDeviceEvent = "toDeviceEvent",
+ AccountData = "accountData",
+ Room = "Room",
+ DeleteRoom = "deleteRoom",
+ SyncUnexpectedError = "sync.unexpectedError",
+ ClientWellKnown = "WellKnown.client",
+ ReceivedVoipEvent = "received_voip_event",
+ UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
+ TurnServers = "turnServers",
+ TurnServersError = "turnServers.error",
+}
+
+type RoomEvents =
+ | RoomEvent.Name
+ | RoomEvent.Redaction
+ | RoomEvent.RedactionCancelled
+ | RoomEvent.Receipt
+ | RoomEvent.Tags
+ | RoomEvent.LocalEchoUpdated
+ | RoomEvent.HistoryImportedWithinTimeline
+ | RoomEvent.AccountData
+ | RoomEvent.MyMembership
+ | RoomEvent.Timeline
+ | RoomEvent.TimelineReset;
+
+type RoomStateEvents =
+ | RoomStateEvent.Events
+ | RoomStateEvent.Members
+ | RoomStateEvent.NewMember
+ | RoomStateEvent.Update
+ | RoomStateEvent.Marker;
+
+type CryptoEvents =
+ | CryptoEvent.KeySignatureUploadFailure
+ | CryptoEvent.KeyBackupStatus
+ | CryptoEvent.KeyBackupFailed
+ | CryptoEvent.KeyBackupSessionsRemaining
+ | CryptoEvent.RoomKeyRequest
+ | CryptoEvent.RoomKeyRequestCancellation
+ | CryptoEvent.VerificationRequest
+ | CryptoEvent.DeviceVerificationChanged
+ | CryptoEvent.UserTrustStatusChanged
+ | CryptoEvent.KeysChanged
+ | CryptoEvent.Warning
+ | CryptoEvent.DevicesUpdated
+ | CryptoEvent.WillUpdateDevices;
+
+type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
+
+type RoomMemberEvents =
+ | RoomMemberEvent.Name
+ | RoomMemberEvent.Typing
+ | RoomMemberEvent.PowerLevel
+ | RoomMemberEvent.Membership;
+
+type UserEvents =
+ | UserEvent.AvatarUrl
+ | UserEvent.DisplayName
+ | UserEvent.Presence
+ | UserEvent.CurrentlyActive
+ | UserEvent.LastPresenceTs;
+
+export type EmittedEvents =
+ | ClientEvent
+ | RoomEvents
+ | RoomStateEvents
+ | CryptoEvents
+ | MatrixEventEvents
+ | RoomMemberEvents
+ | UserEvents
+ | CallEvent // re-emitted by call.ts using Object.values
+ | CallEventHandlerEvent.Incoming
+ | GroupCallEventHandlerEvent.Incoming
+ | GroupCallEventHandlerEvent.Outgoing
+ | GroupCallEventHandlerEvent.Ended
+ | GroupCallEventHandlerEvent.Participants
+ | HttpApiEvent.SessionLoggedOut
+ | HttpApiEvent.NoConsent
+ | BeaconEvent;
+
+export type ClientEventHandlerMap = {
+ /**
+ * Fires whenever the SDK's syncing state is updated. The state can be one of:
+ * <ul>
+ *
+ * <li>PREPARED: The client has synced with the server at least once and is
+ * ready for methods to be called on it. This will be immediately followed by
+ * a state of SYNCING. <i>This is the equivalent of "syncComplete" in the
+ * previous API.</i></li>
+ *
+ * <li>CATCHUP: The client has detected the connection to the server might be
+ * available again and will now try to do a sync again. As this sync might take
+ * a long time (depending how long ago was last synced, and general server
+ * performance) the client is put in this mode so the UI can reflect trying
+ * to catch up with the server after losing connection.</li>
+ *
+ * <li>SYNCING : The client is currently polling for new events from the server.
+ * This will be called <i>after</i> processing latest events from a sync.</li>
+ *
+ * <li>ERROR : The client has had a problem syncing with the server. If this is
+ * called <i>before</i> PREPARED then there was a problem performing the initial
+ * sync. If this is called <i>after</i> PREPARED then there was a problem polling
+ * the server for updates. This may be called multiple times even if the state is
+ * already ERROR. <i>This is the equivalent of "syncError" in the previous
+ * API.</i></li>
+ *
+ * <li>RECONNECTING: The sync connection has dropped, but not (yet) in a way that
+ * should be considered erroneous.
+ * </li>
+ *
+ * <li>STOPPED: The client has stopped syncing with server due to stopClient
+ * being called.
+ * </li>
+ * </ul>
+ * State transition diagram:
+ * ```
+ * +---->STOPPED
+ * |
+ * +----->PREPARED -------> SYNCING <--+
+ * | ^ | ^ |
+ * | CATCHUP ----------+ | | |
+ * | ^ V | |
+ * null ------+ | +------- RECONNECTING |
+ * | V V |
+ * +------->ERROR ---------------------+
+ *
+ * NB: 'null' will never be emitted by this event.
+ *
+ * ```
+ * Transitions:
+ * <ul>
+ *
+ * <li>`null -> PREPARED` : Occurs when the initial sync is completed
+ * first time. This involves setting up filters and obtaining push rules.
+ *
+ * <li>`null -> ERROR` : Occurs when the initial sync failed first time.
+ *
+ * <li>`ERROR -> PREPARED` : Occurs when the initial sync succeeds
+ * after previously failing.
+ *
+ * <li>`PREPARED -> SYNCING` : Occurs immediately after transitioning
+ * to PREPARED. Starts listening for live updates rather than catching up.
+ *
+ * <li>`SYNCING -> RECONNECTING` : Occurs when the live update fails.
+ *
+ * <li>`RECONNECTING -> RECONNECTING` : Can occur if the update calls
+ * continue to fail, but the keepalive calls (to /versions) succeed.
+ *
+ * <li>`RECONNECTING -> ERROR` : Occurs when the keepalive call also fails
+ *
+ * <li>`ERROR -> SYNCING` : Occurs when the client has performed a
+ * live update after having previously failed.
+ *
+ * <li>`ERROR -> ERROR` : Occurs when the client has failed to keepalive
+ * for a second time or more.</li>
+ *
+ * <li>`SYNCING -> SYNCING` : Occurs when the client has performed a live
+ * update. This is called <i>after</i> processing.</li>
+ *
+ * <li>`* -> STOPPED` : Occurs once the client has stopped syncing or
+ * trying to sync after stopClient has been called.</li>
+ * </ul>
+ *
+ * @param state - An enum representing the syncing state. One of "PREPARED",
+ * "SYNCING", "ERROR", "STOPPED".
+ *
+ * @param prevState - An enum representing the previous syncing state.
+ * One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>.
+ *
+ * @param data - Data about this transition.
+ *
+ * @example
+ * ```
+ * matrixClient.on("sync", function(state, prevState, data) {
+ * switch (state) {
+ * case "ERROR":
+ * // update UI to say "Connection Lost"
+ * break;
+ * case "SYNCING":
+ * // update UI to remove any "Connection Lost" message
+ * break;
+ * case "PREPARED":
+ * // the client instance is ready to be queried.
+ * var rooms = matrixClient.getRooms();
+ * break;
+ * }
+ * });
+ * ```
+ */
+ [ClientEvent.Sync]: (state: SyncState, lastState: SyncState | null, data?: ISyncStateData) => void;
+ /**
+ * Fires whenever the SDK receives a new event.
+ * <p>
+ * This is only fired for live events received via /sync - it is not fired for
+ * events received over context, search, or pagination APIs.
+ *
+ * @param event - The matrix event which caused this event to fire.
+ * @example
+ * ```
+ * matrixClient.on("event", function(event){
+ * var sender = event.getSender();
+ * });
+ * ```
+ */
+ [ClientEvent.Event]: (event: MatrixEvent) => void;
+ /**
+ * Fires whenever the SDK receives a new to-device event.
+ * @param event - The matrix event which caused this event to fire.
+ * @example
+ * ```
+ * matrixClient.on("toDeviceEvent", function(event){
+ * var sender = event.getSender();
+ * });
+ * ```
+ */
+ [ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
+ /**
+ * Fires if a to-device event is received that cannot be decrypted.
+ * Encrypted to-device events will (generally) use plain Olm encryption,
+ * in which case decryption failures are fatal: the event will never be
+ * decryptable, unlike Megolm encrypted events where the key may simply
+ * arrive later.
+ *
+ * An undecryptable to-device event is therefore likley to indicate problems.
+ *
+ * @param event - The undecyptable to-device event
+ */
+ [ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
+ /**
+ * Fires whenever new user-scoped account_data is added.
+ * @param event - The event describing the account_data just added
+ * @param event - The previous account data, if known.
+ * @example
+ * ```
+ * matrixClient.on("accountData", function(event, oldEvent){
+ * myAccountData[event.type] = event.content;
+ * });
+ * ```
+ */
+ [ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
+ /**
+ * Fires whenever a new Room is added. This will fire when you are invited to a
+ * room, as well as when you join a room. <strong>This event is experimental and
+ * may change.</strong>
+ * @param room - The newly created, fully populated room.
+ * @example
+ * ```
+ * matrixClient.on("Room", function(room){
+ * var roomId = room.roomId;
+ * });
+ * ```
+ */
+ [ClientEvent.Room]: (room: Room) => void;
+ /**
+ * Fires whenever a Room is removed. This will fire when you forget a room.
+ * <strong>This event is experimental and may change.</strong>
+ * @param roomId - The deleted room ID.
+ * @example
+ * ```
+ * matrixClient.on("deleteRoom", function(roomId){
+ * // update UI from getRooms()
+ * });
+ * ```
+ */
+ [ClientEvent.DeleteRoom]: (roomId: string) => void;
+ [ClientEvent.SyncUnexpectedError]: (error: Error) => void;
+ /**
+ * Fires when the client .well-known info is fetched.
+ *
+ * @param data - The JSON object returned by the server
+ */
+ [ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
+ [ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
+ [ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
+ [ClientEvent.TurnServersError]: (error: Error, fatal: boolean) => void;
+} & RoomEventHandlerMap &
+ RoomStateEventHandlerMap &
+ CryptoEventHandlerMap &
+ MatrixEventHandlerMap &
+ RoomMemberEventHandlerMap &
+ UserEventHandlerMap &
+ CallEventHandlerEventHandlerMap &
+ GroupCallEventHandlerEventHandlerMap &
+ CallEventHandlerMap &
+ HttpApiEventHandlerMap &
+ BeaconEventHandlerMap;
+
+const SSO_ACTION_PARAM = new UnstableValue("action", "org.matrix.msc3824.action");
+
+/**
+ * Represents a Matrix Client. Only directly construct this if you want to use
+ * custom modules. Normally, {@link createClient} should be used
+ * as it specifies 'sensible' defaults for these modules.
+ */
+export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHandlerMap> {
+ public static readonly RESTORE_BACKUP_ERROR_BAD_KEY = "RESTORE_BACKUP_ERROR_BAD_KEY";
+
+ public reEmitter = new TypedReEmitter<EmittedEvents, ClientEventHandlerMap>(this);
+ public olmVersion: [number, number, number] | null = null; // populated after initCrypto
+ public usingExternalCrypto = false;
+ public store: Store;
+ public deviceId: string | null;
+ public credentials: { userId: string | null };
+ public pickleKey?: string;
+ public scheduler?: MatrixScheduler;
+ public clientRunning = false;
+ public timelineSupport = false;
+ public urlPreviewCache: { [key: string]: Promise<IPreviewUrlResponse> } = {};
+ public identityServer?: IIdentityServerProvider;
+ public http: MatrixHttpApi<IHttpOpts & { onlyData: true }>; // XXX: Intended private, used in code.
+
+ /**
+ * The libolm crypto implementation, if it is in use.
+ *
+ * @deprecated This should not be used. Instead, use the methods exposed directly on this class or
+ * (where they are available) via {@link getCrypto}.
+ */
+ public crypto?: Crypto; // XXX: Intended private, used in code. Being replaced by cryptoBackend
+
+ private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto
+ public cryptoCallbacks: ICryptoCallbacks; // XXX: Intended private, used in code.
+ public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code.
+ public groupCallEventHandler?: GroupCallEventHandler;
+ public supportsCallTransfer = false; // XXX: Intended private, used in code.
+ public forceTURN = false; // XXX: Intended private, used in code.
+ public iceCandidatePoolSize = 0; // XXX: Intended private, used in code.
+ public idBaseUrl?: string;
+ public baseUrl: string;
+ public readonly isVoipWithNoMediaAllowed;
+
+ // Note: these are all `protected` to let downstream consumers make mistakes if they want to.
+ // We don't technically support this usage, but have reasons to do this.
+
+ protected canSupportVoip = false;
+ protected peekSync: SyncApi | null = null;
+ protected isGuestAccount = false;
+ protected ongoingScrollbacks: { [roomId: string]: { promise?: Promise<Room>; errorTs?: number } } = {};
+ protected notifTimelineSet: EventTimelineSet | null = null;
+ protected cryptoStore?: CryptoStore;
+ protected verificationMethods?: VerificationMethod[];
+ protected fallbackICEServerAllowed = false;
+ protected roomList: RoomList;
+ protected syncApi?: SlidingSyncSdk | SyncApi;
+ public roomNameGenerator?: ICreateClientOpts["roomNameGenerator"];
+ public pushRules?: IPushRules;
+ protected syncLeftRoomsPromise?: Promise<Room[]>;
+ protected syncedLeftRooms = false;
+ protected clientOpts?: IStoredClientOpts;
+ protected clientWellKnownIntervalID?: ReturnType<typeof setInterval>;
+ protected canResetTimelineCallback?: ResetTimelineCallback;
+
+ public canSupport = new Map<Feature, ServerSupport>();
+
+ // The pushprocessor caches useful things, so keep one and re-use it
+ protected pushProcessor = new PushProcessor(this);
+
+ // Promise to a response of the server's /versions response
+ // TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
+ protected serverVersionsPromise?: Promise<IServerVersions>;
+
+ public cachedCapabilities?: {
+ capabilities: ICapabilities;
+ expiration: number;
+ };
+ protected clientWellKnown?: IClientWellKnown;
+ protected clientWellKnownPromise?: Promise<IClientWellKnown>;
+ protected turnServers: ITurnServer[] = [];
+ protected turnServersExpiry = 0;
+ protected checkTurnServersIntervalID?: ReturnType<typeof setInterval>;
+ protected exportedOlmDeviceToImport?: IExportedOlmDevice;
+ protected txnCtr = 0;
+ protected mediaHandler = new MediaHandler(this);
+ protected sessionId: string;
+ protected pendingEventEncryption = new Map<string, Promise<void>>();
+
+ private useE2eForGroupCall = true;
+ private toDeviceMessageQueue: ToDeviceMessageQueue;
+
+ // A manager for determining which invites should be ignored.
+ public readonly ignoredInvites: IgnoredInvites;
+
+ public constructor(opts: IMatrixClientCreateOpts) {
+ super();
+
+ opts.baseUrl = utils.ensureNoTrailingSlash(opts.baseUrl);
+ opts.idBaseUrl = utils.ensureNoTrailingSlash(opts.idBaseUrl);
+
+ this.baseUrl = opts.baseUrl;
+ this.idBaseUrl = opts.idBaseUrl;
+ this.identityServer = opts.identityServer;
+
+ this.usingExternalCrypto = opts.usingExternalCrypto ?? false;
+ this.store = opts.store || new StubStore();
+ this.deviceId = opts.deviceId || null;
+ this.sessionId = randomString(10);
+
+ const userId = opts.userId || null;
+ this.credentials = { userId };
+
+ this.http = new MatrixHttpApi(this as ConstructorParameters<typeof MatrixHttpApi>[0], {
+ fetchFn: opts.fetchFn,
+ baseUrl: opts.baseUrl,
+ idBaseUrl: opts.idBaseUrl,
+ accessToken: opts.accessToken,
+ prefix: ClientPrefix.R0,
+ onlyData: true,
+ extraParams: opts.queryParams,
+ localTimeoutMs: opts.localTimeoutMs,
+ useAuthorizationHeader: opts.useAuthorizationHeader,
+ });
+
+ if (opts.deviceToImport) {
+ if (this.deviceId) {
+ logger.warn(
+ "not importing device because device ID is provided to " +
+ "constructor independently of exported data",
+ );
+ } else if (this.credentials.userId) {
+ logger.warn(
+ "not importing device because user ID is provided to " +
+ "constructor independently of exported data",
+ );
+ } else if (!opts.deviceToImport.deviceId) {
+ logger.warn("not importing device because no device ID in exported data");
+ } else {
+ this.deviceId = opts.deviceToImport.deviceId;
+ this.credentials.userId = opts.deviceToImport.userId;
+ // will be used during async initialization of the crypto
+ this.exportedOlmDeviceToImport = opts.deviceToImport.olmDevice;
+ }
+ } else if (opts.pickleKey) {
+ this.pickleKey = opts.pickleKey;
+ }
+
+ this.scheduler = opts.scheduler;
+ if (this.scheduler) {
+ this.scheduler.setProcessFunction(async (eventToSend: MatrixEvent) => {
+ const room = this.getRoom(eventToSend.getRoomId());
+ if (eventToSend.status !== EventStatus.SENDING) {
+ this.updatePendingEventStatus(room, eventToSend, EventStatus.SENDING);
+ }
+ const res = await this.sendEventHttpRequest(eventToSend);
+ if (room) {
+ // ensure we update pending event before the next scheduler run so that any listeners to event id
+ // updates on the synchronous event emitter get a chance to run first.
+ room.updatePendingEvent(eventToSend, EventStatus.SENT, res.event_id);
+ }
+ return res;
+ });
+ }
+
+ if (supportsMatrixCall()) {
+ this.callEventHandler = new CallEventHandler(this);
+ this.groupCallEventHandler = new GroupCallEventHandler(this);
+ this.canSupportVoip = true;
+ // Start listening for calls after the initial sync is done
+ // We do not need to backfill the call event buffer
+ // with encrypted events that might never get decrypted
+ this.on(ClientEvent.Sync, this.startCallEventHandler);
+ }
+
+ this.on(ClientEvent.Sync, this.fixupRoomNotifications);
+
+ this.timelineSupport = Boolean(opts.timelineSupport);
+
+ this.cryptoStore = opts.cryptoStore;
+ this.verificationMethods = opts.verificationMethods;
+ this.cryptoCallbacks = opts.cryptoCallbacks || {};
+
+ this.forceTURN = opts.forceTURN || false;
+ this.iceCandidatePoolSize = opts.iceCandidatePoolSize === undefined ? 0 : opts.iceCandidatePoolSize;
+ this.supportsCallTransfer = opts.supportsCallTransfer || false;
+ this.fallbackICEServerAllowed = opts.fallbackICEServerAllowed || false;
+ this.isVoipWithNoMediaAllowed = opts.isVoipWithNoMediaAllowed || false;
+
+ if (opts.useE2eForGroupCall !== undefined) this.useE2eForGroupCall = opts.useE2eForGroupCall;
+
+ // List of which rooms have encryption enabled: separate from crypto because
+ // we still want to know which rooms are encrypted even if crypto is disabled:
+ // we don't want to start sending unencrypted events to them.
+ this.roomList = new RoomList(this.cryptoStore);
+ this.roomNameGenerator = opts.roomNameGenerator;
+
+ this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
+
+ // The SDK doesn't really provide a clean way for events to recalculate the push
+ // actions for themselves, so we have to kinda help them out when they are encrypted.
+ // We do this so that push rules are correctly executed on events in their decrypted
+ // state, such as highlights when the user's name is mentioned.
+ this.on(MatrixEventEvent.Decrypted, (event) => {
+ fixNotificationCountOnDecryption(this, event);
+ });
+
+ // Like above, we have to listen for read receipts from ourselves in order to
+ // correctly handle notification counts on encrypted rooms.
+ // This fixes https://github.com/vector-im/element-web/issues/9421
+ this.on(RoomEvent.Receipt, (event, room) => {
+ if (room && this.isRoomEncrypted(room.roomId)) {
+ // Figure out if we've read something or if it's just informational
+ const content = event.getContent();
+ const isSelf =
+ Object.keys(content).filter((eid) => {
+ for (const [key, value] of Object.entries(content[eid])) {
+ if (!utils.isSupportedReceiptType(key)) continue;
+ if (!value) continue;
+
+ if (Object.keys(value).includes(this.getUserId()!)) return true;
+ }
+
+ return false;
+ }).length > 0;
+
+ if (!isSelf) return;
+
+ // Work backwards to determine how many events are unread. We also set
+ // a limit for how back we'll look to avoid spinning CPU for too long.
+ // If we hit the limit, we assume the count is unchanged.
+ const maxHistory = 20;
+ const events = room.getLiveTimeline().getEvents();
+
+ let highlightCount = 0;
+
+ for (let i = events.length - 1; i >= 0; i--) {
+ if (i === events.length - maxHistory) return; // limit reached
+
+ const event = events[i];
+
+ if (room.hasUserReadEvent(this.getUserId()!, event.getId()!)) {
+ // If the user has read the event, then the counting is done.
+ break;
+ }
+
+ const pushActions = this.getPushActionsForEvent(event);
+ highlightCount += pushActions?.tweaks?.highlight ? 1 : 0;
+ }
+
+ // Note: we don't need to handle 'total' notifications because the counts
+ // will come from the server.
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, highlightCount);
+ }
+ });
+
+ this.ignoredInvites = new IgnoredInvites(this);
+ }
+
+ /**
+ * High level helper method to begin syncing and poll for new events. To listen for these
+ * events, add a listener for {@link ClientEvent.Event}
+ * via {@link MatrixClient#on}. Alternatively, listen for specific
+ * state change events.
+ * @param opts - Options to apply when syncing.
+ */
+ public async startClient(opts?: IStartClientOpts): Promise<void> {
+ if (this.clientRunning) {
+ // client is already running.
+ return;
+ }
+ this.clientRunning = true;
+ // backwards compat for when 'opts' was 'historyLen'.
+ if (typeof opts === "number") {
+ opts = {
+ initialSyncLimit: opts,
+ };
+ }
+
+ // Create our own user object artificially (instead of waiting for sync)
+ // so it's always available, even if the user is not in any rooms etc.
+ const userId = this.getUserId();
+ if (userId) {
+ this.store.storeUser(new User(userId));
+ }
+
+ // periodically poll for turn servers if we support voip
+ if (this.canSupportVoip) {
+ this.checkTurnServersIntervalID = setInterval(() => {
+ this.checkTurnServers();
+ }, TURN_CHECK_INTERVAL);
+ // noinspection ES6MissingAwait
+ this.checkTurnServers();
+ }
+
+ if (this.syncApi) {
+ // This shouldn't happen since we thought the client was not running
+ logger.error("Still have sync object whilst not running: stopping old one");
+ this.syncApi.stop();
+ }
+
+ try {
+ await this.getVersions();
+
+ // This should be done with `canSupport`
+ // TODO: https://github.com/vector-im/element-web/issues/23643
+ const { threads, list, fwdPagination } = await this.doesServerSupportThread();
+ Thread.setServerSideSupport(threads);
+ Thread.setServerSideListSupport(list);
+ Thread.setServerSideFwdPaginationSupport(fwdPagination);
+ } catch (e) {
+ logger.error("Can't fetch server versions, continuing to initialise sync, this will be retried later", e);
+ }
+
+ this.clientOpts = opts ?? {};
+ if (this.clientOpts.slidingSync) {
+ this.syncApi = new SlidingSyncSdk(
+ this.clientOpts.slidingSync,
+ this,
+ this.clientOpts,
+ this.buildSyncApiOptions(),
+ );
+ } else {
+ this.syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ }
+
+ if (this.clientOpts.hasOwnProperty("experimentalThreadSupport")) {
+ logger.warn("`experimentalThreadSupport` has been deprecated, use `threadSupport` instead");
+ }
+
+ // If `threadSupport` is omitted and the deprecated `experimentalThreadSupport` has been passed
+ // We should fallback to that value for backwards compatibility purposes
+ if (
+ !this.clientOpts.hasOwnProperty("threadSupport") &&
+ this.clientOpts.hasOwnProperty("experimentalThreadSupport")
+ ) {
+ this.clientOpts.threadSupport = this.clientOpts.experimentalThreadSupport;
+ }
+
+ this.syncApi.sync();
+
+ if (this.clientOpts.clientWellKnownPollPeriod !== undefined) {
+ this.clientWellKnownIntervalID = setInterval(() => {
+ this.fetchClientWellKnown();
+ }, 1000 * this.clientOpts.clientWellKnownPollPeriod);
+ this.fetchClientWellKnown();
+ }
+
+ this.toDeviceMessageQueue.start();
+ }
+
+ /**
+ * Construct a SyncApiOptions for this client, suitable for passing into the SyncApi constructor
+ */
+ protected buildSyncApiOptions(): SyncApiOptions {
+ return {
+ crypto: this.crypto,
+ cryptoCallbacks: this.cryptoBackend,
+ canResetEntireTimeline: (roomId: string): boolean => {
+ if (!this.canResetTimelineCallback) {
+ return false;
+ }
+ return this.canResetTimelineCallback(roomId);
+ },
+ };
+ }
+
+ /**
+ * High level helper method to stop the client from polling and allow a
+ * clean shutdown.
+ */
+ public stopClient(): void {
+ this.cryptoBackend?.stop(); // crypto might have been initialised even if the client wasn't fully started
+
+ if (!this.clientRunning) return; // already stopped
+
+ logger.log("stopping MatrixClient");
+
+ this.clientRunning = false;
+
+ this.syncApi?.stop();
+ this.syncApi = undefined;
+
+ this.peekSync?.stopPeeking();
+
+ this.callEventHandler?.stop();
+ this.groupCallEventHandler?.stop();
+ this.callEventHandler = undefined;
+ this.groupCallEventHandler = undefined;
+
+ global.clearInterval(this.checkTurnServersIntervalID);
+ this.checkTurnServersIntervalID = undefined;
+
+ if (this.clientWellKnownIntervalID !== undefined) {
+ global.clearInterval(this.clientWellKnownIntervalID);
+ }
+
+ this.toDeviceMessageQueue.stop();
+ }
+
+ /**
+ * Try to rehydrate a device if available. The client must have been
+ * initialized with a `cryptoCallback.getDehydrationKey` option, and this
+ * function must be called before initCrypto and startClient are called.
+ *
+ * @returns Promise which resolves to undefined if a device could not be dehydrated, or
+ * to the new device ID if the dehydration was successful.
+ * @returns Rejects: with an error response.
+ */
+ public async rehydrateDevice(): Promise<string | undefined> {
+ if (this.crypto) {
+ throw new Error("Cannot rehydrate device after crypto is initialized");
+ }
+
+ if (!this.cryptoCallbacks.getDehydrationKey) {
+ return;
+ }
+
+ const getDeviceResult = await this.getDehydratedDevice();
+ if (!getDeviceResult) {
+ return;
+ }
+
+ if (!getDeviceResult.device_data || !getDeviceResult.device_id) {
+ logger.info("no dehydrated device found");
+ return;
+ }
+
+ const account = new global.Olm.Account();
+ try {
+ const deviceData = getDeviceResult.device_data;
+ if (deviceData.algorithm !== DEHYDRATION_ALGORITHM) {
+ logger.warn("Wrong algorithm for dehydrated device");
+ return;
+ }
+ logger.log("unpickling dehydrated device");
+ const key = await this.cryptoCallbacks.getDehydrationKey(deviceData, (k) => {
+ // copy the key so that it doesn't get clobbered
+ account.unpickle(new Uint8Array(k), deviceData.account);
+ });
+ account.unpickle(key, deviceData.account);
+ logger.log("unpickled device");
+
+ const rehydrateResult = await this.http.authedRequest<{ success: boolean }>(
+ Method.Post,
+ "/dehydrated_device/claim",
+ undefined,
+ {
+ device_id: getDeviceResult.device_id,
+ },
+ {
+ prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
+ },
+ );
+
+ if (rehydrateResult.success) {
+ this.deviceId = getDeviceResult.device_id;
+ logger.info("using dehydrated device");
+ const pickleKey = this.pickleKey || "DEFAULT_KEY";
+ this.exportedOlmDeviceToImport = {
+ pickledAccount: account.pickle(pickleKey),
+ sessions: [],
+ pickleKey: pickleKey,
+ };
+ account.free();
+ return this.deviceId;
+ } else {
+ account.free();
+ logger.info("not using dehydrated device");
+ return;
+ }
+ } catch (e) {
+ account.free();
+ logger.warn("could not unpickle", e);
+ }
+ }
+
+ /**
+ * Get the current dehydrated device, if any
+ * @returns A promise of an object containing the dehydrated device
+ */
+ public async getDehydratedDevice(): Promise<IDehydratedDevice | undefined> {
+ try {
+ return await this.http.authedRequest<IDehydratedDevice>(
+ Method.Get,
+ "/dehydrated_device",
+ undefined,
+ undefined,
+ {
+ prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
+ },
+ );
+ } catch (e) {
+ logger.info("could not get dehydrated device", e);
+ return;
+ }
+ }
+
+ /**
+ * Set the dehydration key. This will also periodically dehydrate devices to
+ * the server.
+ *
+ * @param key - the dehydration key
+ * @param keyInfo - Information about the key. Primarily for
+ * information about how to generate the key from a passphrase.
+ * @param deviceDisplayName - The device display name for the
+ * dehydrated device.
+ * @returns A promise that resolves when the dehydrated device is stored.
+ */
+ public async setDehydrationKey(
+ key: Uint8Array,
+ keyInfo: IDehydratedDeviceKeyInfo,
+ deviceDisplayName?: string,
+ ): Promise<void> {
+ if (!this.crypto) {
+ logger.warn("not dehydrating device if crypto is not enabled");
+ return;
+ }
+ return this.crypto.dehydrationManager.setKeyAndQueueDehydration(key, keyInfo, deviceDisplayName);
+ }
+
+ /**
+ * Creates a new dehydrated device (without queuing periodic dehydration)
+ * @param key - the dehydration key
+ * @param keyInfo - Information about the key. Primarily for
+ * information about how to generate the key from a passphrase.
+ * @param deviceDisplayName - The device display name for the
+ * dehydrated device.
+ * @returns the device id of the newly created dehydrated device
+ */
+ public async createDehydratedDevice(
+ key: Uint8Array,
+ keyInfo: IDehydratedDeviceKeyInfo,
+ deviceDisplayName?: string,
+ ): Promise<string | undefined> {
+ if (!this.crypto) {
+ logger.warn("not dehydrating device if crypto is not enabled");
+ return;
+ }
+ await this.crypto.dehydrationManager.setKey(key, keyInfo, deviceDisplayName);
+ return this.crypto.dehydrationManager.dehydrateDevice();
+ }
+
+ public async exportDevice(): Promise<IExportedDevice | undefined> {
+ if (!this.crypto) {
+ logger.warn("not exporting device if crypto is not enabled");
+ return;
+ }
+ return {
+ userId: this.credentials.userId!,
+ deviceId: this.deviceId!,
+ // XXX: Private member access.
+ olmDevice: await this.crypto.olmDevice.export(),
+ };
+ }
+
+ /**
+ * Clear any data out of the persistent stores used by the client.
+ *
+ * @returns Promise which resolves when the stores have been cleared.
+ */
+ public clearStores(): Promise<void> {
+ if (this.clientRunning) {
+ throw new Error("Cannot clear stores while client is running");
+ }
+
+ const promises: Promise<void>[] = [];
+
+ promises.push(this.store.deleteAllData());
+ if (this.cryptoStore) {
+ promises.push(this.cryptoStore.deleteAllData());
+ }
+
+ // delete the stores used by the rust matrix-sdk-crypto, in case they were used
+ const deleteRustSdkStore = async (): Promise<void> => {
+ let indexedDB: IDBFactory;
+ try {
+ indexedDB = global.indexedDB;
+ } catch (e) {
+ // No indexeddb support
+ return;
+ }
+ for (const dbname of [
+ `${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
+ `${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
+ ]) {
+ const prom = new Promise((resolve, reject) => {
+ logger.info(`Removing IndexedDB instance ${dbname}`);
+ const req = indexedDB.deleteDatabase(dbname);
+ req.onsuccess = (_): void => {
+ logger.info(`Removed IndexedDB instance ${dbname}`);
+ resolve(0);
+ };
+ req.onerror = (e): void => {
+ // In private browsing, Firefox has a global.indexedDB, but attempts to delete an indexeddb
+ // (even a non-existent one) fail with "DOMException: A mutation operation was attempted on a
+ // database that did not allow mutations."
+ //
+ // it seems like the only thing we can really do is ignore the error.
+ logger.warn(`Failed to remove IndexedDB instance ${dbname}:`, e);
+ resolve(0);
+ };
+ req.onblocked = (e): void => {
+ logger.info(`cannot yet remove IndexedDB instance ${dbname}`);
+ };
+ });
+ await prom;
+ }
+ };
+ promises.push(deleteRustSdkStore());
+
+ return Promise.all(promises).then(); // .then to fix types
+ }
+
+ /**
+ * Get the user-id of the logged-in user
+ *
+ * @returns MXID for the logged-in user, or null if not logged in
+ */
+ public getUserId(): string | null {
+ if (this.credentials && this.credentials.userId) {
+ return this.credentials.userId;
+ }
+ return null;
+ }
+
+ /**
+ * Get the user-id of the logged-in user
+ *
+ * @returns MXID for the logged-in user
+ * @throws Error if not logged in
+ */
+ public getSafeUserId(): string {
+ const userId = this.getUserId();
+ if (!userId) {
+ throw new Error("Expected logged in user but found none.");
+ }
+ return userId;
+ }
+
+ /**
+ * Get the domain for this client's MXID
+ * @returns Domain of this MXID
+ */
+ public getDomain(): string | null {
+ if (this.credentials && this.credentials.userId) {
+ return this.credentials.userId.replace(/^.*?:/, "");
+ }
+ return null;
+ }
+
+ /**
+ * Get the local part of the current user ID e.g. "foo" in "\@foo:bar".
+ * @returns The user ID localpart or null.
+ */
+ public getUserIdLocalpart(): string | null {
+ if (this.credentials && this.credentials.userId) {
+ return this.credentials.userId.split(":")[0].substring(1);
+ }
+ return null;
+ }
+
+ /**
+ * Get the device ID of this client
+ * @returns device ID
+ */
+ public getDeviceId(): string | null {
+ return this.deviceId;
+ }
+
+ /**
+ * Get the session ID of this client
+ * @returns session ID
+ */
+ public getSessionId(): string {
+ return this.sessionId;
+ }
+
+ /**
+ * Check if the runtime environment supports VoIP calling.
+ * @returns True if VoIP is supported.
+ */
+ public supportsVoip(): boolean {
+ return this.canSupportVoip;
+ }
+
+ /**
+ * @returns
+ */
+ public getMediaHandler(): MediaHandler {
+ return this.mediaHandler;
+ }
+
+ /**
+ * Set whether VoIP calls are forced to use only TURN
+ * candidates. This is the same as the forceTURN option
+ * when creating the client.
+ * @param force - True to force use of TURN servers
+ */
+ public setForceTURN(force: boolean): void {
+ this.forceTURN = force;
+ }
+
+ /**
+ * Set whether to advertise transfer support to other parties on Matrix calls.
+ * @param support - True to advertise the 'm.call.transferee' capability
+ */
+ public setSupportsCallTransfer(support: boolean): void {
+ this.supportsCallTransfer = support;
+ }
+
+ /**
+ * Returns true if to-device signalling for group calls will be encrypted with Olm.
+ * If false, it will be sent unencrypted.
+ * @returns boolean Whether group call signalling will be encrypted
+ */
+ public getUseE2eForGroupCall(): boolean {
+ return this.useE2eForGroupCall;
+ }
+
+ /**
+ * Creates a new call.
+ * The place*Call methods on the returned call can be used to actually place a call
+ *
+ * @param roomId - The room the call is to be placed in.
+ * @returns the call or null if the browser doesn't support calling.
+ */
+ public createCall(roomId: string): MatrixCall | null {
+ return createNewMatrixCall(this, roomId);
+ }
+
+ /**
+ * Creates a new group call and sends the associated state event
+ * to alert other members that the room now has a group call.
+ *
+ * @param roomId - The room the call is to be placed in.
+ */
+ public async createGroupCall(
+ roomId: string,
+ type: GroupCallType,
+ isPtt: boolean,
+ intent: GroupCallIntent,
+ dataChannelsEnabled?: boolean,
+ dataChannelOptions?: IGroupCallDataChannelOptions,
+ ): Promise<GroupCall> {
+ if (this.getGroupCallForRoom(roomId)) {
+ throw new Error(`${roomId} already has an existing group call`);
+ }
+
+ const room = this.getRoom(roomId);
+
+ if (!room) {
+ throw new Error(`Cannot find room ${roomId}`);
+ }
+
+ // Because without Media section a WebRTC connection is not possible, so need a RTCDataChannel to set up a
+ // no media WebRTC connection anyway.
+ return new GroupCall(
+ this,
+ room,
+ type,
+ isPtt,
+ intent,
+ undefined,
+ dataChannelsEnabled || this.isVoipWithNoMediaAllowed,
+ dataChannelOptions,
+ this.isVoipWithNoMediaAllowed,
+ ).create();
+ }
+
+ /**
+ * Wait until an initial state for the given room has been processed by the
+ * client and the client is aware of any ongoing group calls. Awaiting on
+ * the promise returned by this method before calling getGroupCallForRoom()
+ * avoids races where getGroupCallForRoom is called before the state for that
+ * room has been processed. It does not, however, fix other races, eg. two
+ * clients both creating a group call at the same time.
+ * @param roomId - The room ID to wait for
+ * @returns A promise that resolves once existing group calls in the room
+ * have been processed.
+ */
+ public waitUntilRoomReadyForGroupCalls(roomId: string): Promise<void> {
+ return this.groupCallEventHandler!.waitUntilRoomReadyForGroupCalls(roomId);
+ }
+
+ /**
+ * Get an existing group call for the provided room.
+ * @returns The group call or null if it doesn't already exist.
+ */
+ public getGroupCallForRoom(roomId: string): GroupCall | null {
+ return this.groupCallEventHandler!.groupCalls.get(roomId) || null;
+ }
+
+ /**
+ * Get the current sync state.
+ * @returns the sync state, which may be null.
+ * @see MatrixClient#event:"sync"
+ */
+ public getSyncState(): SyncState | null {
+ return this.syncApi?.getSyncState() ?? null;
+ }
+
+ /**
+ * Returns the additional data object associated with
+ * the current sync state, or null if there is no
+ * such data.
+ * Sync errors, if available, are put in the 'error' key of
+ * this object.
+ */
+ public getSyncStateData(): ISyncStateData | null {
+ if (!this.syncApi) {
+ return null;
+ }
+ return this.syncApi.getSyncStateData();
+ }
+
+ /**
+ * Whether the initial sync has completed.
+ * @returns True if at least one sync has happened.
+ */
+ public isInitialSyncComplete(): boolean {
+ const state = this.getSyncState();
+ if (!state) {
+ return false;
+ }
+ return state === SyncState.Prepared || state === SyncState.Syncing;
+ }
+
+ /**
+ * Return whether the client is configured for a guest account.
+ * @returns True if this is a guest access_token (or no token is supplied).
+ */
+ public isGuest(): boolean {
+ return this.isGuestAccount;
+ }
+
+ /**
+ * Set whether this client is a guest account. <b>This method is experimental
+ * and may change without warning.</b>
+ * @param guest - True if this is a guest account.
+ */
+ public setGuest(guest: boolean): void {
+ // EXPERIMENTAL:
+ // If the token is a macaroon, it should be encoded in it that it is a 'guest'
+ // access token, which means that the SDK can determine this entirely without
+ // the dev manually flipping this flag.
+ this.isGuestAccount = guest;
+ }
+
+ /**
+ * Return the provided scheduler, if any.
+ * @returns The scheduler or undefined
+ */
+ public getScheduler(): MatrixScheduler | undefined {
+ return this.scheduler;
+ }
+
+ /**
+ * Retry a backed off syncing request immediately. This should only be used when
+ * the user <b>explicitly</b> attempts to retry their lost connection.
+ * Will also retry any outbound to-device messages currently in the queue to be sent
+ * (retries of regular outgoing events are handled separately, per-event).
+ * @returns True if this resulted in a request being retried.
+ */
+ public retryImmediately(): boolean {
+ // don't await for this promise: we just want to kick it off
+ this.toDeviceMessageQueue.sendQueue();
+ return this.syncApi?.retryImmediately() ?? false;
+ }
+
+ /**
+ * Return the global notification EventTimelineSet, if any
+ *
+ * @returns the globl notification EventTimelineSet
+ */
+ public getNotifTimelineSet(): EventTimelineSet | null {
+ return this.notifTimelineSet;
+ }
+
+ /**
+ * Set the global notification EventTimelineSet
+ *
+ */
+ public setNotifTimelineSet(set: EventTimelineSet): void {
+ this.notifTimelineSet = set;
+ }
+
+ /**
+ * Gets the capabilities of the homeserver. Always returns an object of
+ * capability keys and their options, which may be empty.
+ * @param fresh - True to ignore any cached values.
+ * @returns Promise which resolves to the capabilities of the homeserver
+ * @returns Rejects: with an error response.
+ */
+ public getCapabilities(fresh = false): Promise<ICapabilities> {
+ const now = new Date().getTime();
+
+ if (this.cachedCapabilities && !fresh) {
+ if (now < this.cachedCapabilities.expiration) {
+ logger.log("Returning cached capabilities");
+ return Promise.resolve(this.cachedCapabilities.capabilities);
+ }
+ }
+
+ type Response = {
+ capabilities?: ICapabilities;
+ };
+ return this.http
+ .authedRequest<Response>(Method.Get, "/capabilities")
+ .catch((e: Error): Response => {
+ // We swallow errors because we need a default object anyhow
+ logger.error(e);
+ return {};
+ })
+ .then((r = {}) => {
+ const capabilities = r["capabilities"] || {};
+
+ // If the capabilities missed the cache, cache it for a shorter amount
+ // of time to try and refresh them later.
+ const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000;
+
+ this.cachedCapabilities = {
+ capabilities,
+ expiration: now + cacheMs,
+ };
+
+ logger.log("Caching capabilities: ", capabilities);
+ return capabilities;
+ });
+ }
+
+ /**
+ * Initialise support for end-to-end encryption in this client, using libolm.
+ *
+ * You should call this method after creating the matrixclient, but *before*
+ * calling `startClient`, if you want to support end-to-end encryption.
+ *
+ * It will return a Promise which will resolve when the crypto layer has been
+ * successfully initialised.
+ */
+ public async initCrypto(): Promise<void> {
+ if (!isCryptoAvailable()) {
+ throw new Error(
+ `End-to-end encryption not supported in this js-sdk build: did ` +
+ `you remember to load the olm library?`,
+ );
+ }
+
+ if (this.cryptoBackend) {
+ logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
+ return;
+ }
+
+ if (!this.cryptoStore) {
+ // the cryptostore is provided by sdk.createClient, so this shouldn't happen
+ throw new Error(`Cannot enable encryption: no cryptoStore provided`);
+ }
+
+ logger.log("Crypto: Starting up crypto store...");
+ await this.cryptoStore.startup();
+
+ // initialise the list of encrypted rooms (whether or not crypto is enabled)
+ logger.log("Crypto: initialising roomlist...");
+ await this.roomList.init();
+
+ const userId = this.getUserId();
+ if (userId === null) {
+ throw new Error(
+ `Cannot enable encryption on MatrixClient with unknown userId: ` +
+ `ensure userId is passed in createClient().`,
+ );
+ }
+ if (this.deviceId === null) {
+ throw new Error(
+ `Cannot enable encryption on MatrixClient with unknown deviceId: ` +
+ `ensure deviceId is passed in createClient().`,
+ );
+ }
+
+ const crypto = new Crypto(
+ this,
+ userId,
+ this.deviceId,
+ this.store,
+ this.cryptoStore,
+ this.roomList,
+ this.verificationMethods!,
+ );
+
+ this.reEmitter.reEmit(crypto, [
+ CryptoEvent.KeyBackupFailed,
+ CryptoEvent.KeyBackupSessionsRemaining,
+ CryptoEvent.RoomKeyRequest,
+ CryptoEvent.RoomKeyRequestCancellation,
+ CryptoEvent.Warning,
+ CryptoEvent.DevicesUpdated,
+ CryptoEvent.WillUpdateDevices,
+ CryptoEvent.DeviceVerificationChanged,
+ CryptoEvent.UserTrustStatusChanged,
+ CryptoEvent.KeysChanged,
+ ]);
+
+ logger.log("Crypto: initialising crypto object...");
+ await crypto.init({
+ exportedOlmDevice: this.exportedOlmDeviceToImport,
+ pickleKey: this.pickleKey,
+ });
+ delete this.exportedOlmDeviceToImport;
+
+ this.olmVersion = Crypto.getOlmVersion();
+
+ // if crypto initialisation was successful, tell it to attach its event handlers.
+ crypto.registerEventHandlers(this as Parameters<Crypto["registerEventHandlers"]>[0]);
+ this.cryptoBackend = this.crypto = crypto;
+
+ // upload our keys in the background
+ this.crypto.uploadDeviceKeys().catch((e) => {
+ // TODO: throwing away this error is a really bad idea.
+ logger.error("Error uploading device keys", e);
+ });
+ }
+
+ /**
+ * Initialise support for end-to-end encryption in this client, using the rust matrix-sdk-crypto.
+ *
+ * An alternative to {@link initCrypto}.
+ *
+ * *WARNING*: this API is very experimental, should not be used in production, and may change without notice!
+ * Eventually it will be deprecated and `initCrypto` will do the same thing.
+ *
+ * @experimental
+ *
+ * @returns a Promise which will resolve when the crypto layer has been
+ * successfully initialised.
+ */
+ public async initRustCrypto(): Promise<void> {
+ if (this.cryptoBackend) {
+ logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
+ return;
+ }
+
+ const userId = this.getUserId();
+ if (userId === null) {
+ throw new Error(
+ `Cannot enable encryption on MatrixClient with unknown userId: ` +
+ `ensure userId is passed in createClient().`,
+ );
+ }
+ const deviceId = this.getDeviceId();
+ if (deviceId === null) {
+ throw new Error(
+ `Cannot enable encryption on MatrixClient with unknown deviceId: ` +
+ `ensure deviceId is passed in createClient().`,
+ );
+ }
+
+ // importing rust-crypto will download the webassembly, so we delay it until we know it will be
+ // needed.
+ const RustCrypto = await import("./rust-crypto");
+ const rustCrypto = await RustCrypto.initRustCrypto(this.http, userId, deviceId);
+ this.cryptoBackend = rustCrypto;
+
+ // attach the event listeners needed by RustCrypto
+ this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
+ }
+
+ /**
+ * Access the crypto API for this client.
+ *
+ * If end-to-end encryption has been enabled for this client (via {@link initCrypto} or {@link initRustCrypto}),
+ * returns an object giving access to the crypto API. Otherwise, returns `undefined`.
+ */
+ public getCrypto(): CryptoApi | undefined {
+ return this.cryptoBackend;
+ }
+
+ /**
+ * Is end-to-end crypto enabled for this client.
+ * @returns True if end-to-end is enabled.
+ * @deprecated prefer {@link getCrypto}
+ */
+ public isCryptoEnabled(): boolean {
+ return !!this.cryptoBackend;
+ }
+
+ /**
+ * Get the Ed25519 key for this device
+ *
+ * @returns base64-encoded ed25519 key. Null if crypto is
+ * disabled.
+ */
+ public getDeviceEd25519Key(): string | null {
+ return this.crypto?.getDeviceEd25519Key() ?? null;
+ }
+
+ /**
+ * Get the Curve25519 key for this device
+ *
+ * @returns base64-encoded curve25519 key. Null if crypto is
+ * disabled.
+ */
+ public getDeviceCurve25519Key(): string | null {
+ return this.crypto?.getDeviceCurve25519Key() ?? null;
+ }
+
+ /**
+ * @deprecated Does nothing.
+ */
+ public async uploadKeys(): Promise<void> {
+ logger.warn("MatrixClient.uploadKeys is deprecated");
+ }
+
+ /**
+ * Download the keys for a list of users and stores the keys in the session
+ * store.
+ * @param userIds - The users to fetch.
+ * @param forceDownload - Always download the keys even if cached.
+ *
+ * @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}
+ */
+ public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> {
+ if (!this.crypto) {
+ return Promise.reject(new Error("End-to-end encryption disabled"));
+ }
+ return this.crypto.downloadKeys(userIds, forceDownload);
+ }
+
+ /**
+ * Get the stored device keys for a user id
+ *
+ * @param userId - the user to list keys for.
+ *
+ * @returns list of devices
+ */
+ public getStoredDevicesForUser(userId: string): DeviceInfo[] {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getStoredDevicesForUser(userId) || [];
+ }
+
+ /**
+ * Get the stored device key for a user id and device id
+ *
+ * @param userId - the user to list keys for.
+ * @param deviceId - unique identifier for the device
+ *
+ * @returns device or null
+ */
+ public getStoredDevice(userId: string, deviceId: string): DeviceInfo | null {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getStoredDevice(userId, deviceId) || null;
+ }
+
+ /**
+ * Mark the given device as verified
+ *
+ * @param userId - owner of the device
+ * @param deviceId - unique identifier for the device or user's
+ * cross-signing public key ID.
+ *
+ * @param verified - whether to mark the device as verified. defaults
+ * to 'true'.
+ *
+ * @returns
+ *
+ * @remarks
+ * Fires {@link CryptoEvent#DeviceVerificationChanged}
+ */
+ public setDeviceVerified(userId: string, deviceId: string, verified = true): Promise<void> {
+ const prom = this.setDeviceVerification(userId, deviceId, verified, null, null);
+
+ // if one of the user's own devices is being marked as verified / unverified,
+ // check the key backup status, since whether or not we use this depends on
+ // whether it has a signature from a verified device
+ if (userId == this.credentials.userId) {
+ this.checkKeyBackup();
+ }
+ return prom;
+ }
+
+ /**
+ * Mark the given device as blocked/unblocked
+ *
+ * @param userId - owner of the device
+ * @param deviceId - unique identifier for the device or user's
+ * cross-signing public key ID.
+ *
+ * @param blocked - whether to mark the device as blocked. defaults
+ * to 'true'.
+ *
+ * @returns
+ *
+ * @remarks
+ * Fires {@link CryptoEvent.DeviceVerificationChanged}
+ */
+ public setDeviceBlocked(userId: string, deviceId: string, blocked = true): Promise<void> {
+ return this.setDeviceVerification(userId, deviceId, null, blocked, null);
+ }
+
+ /**
+ * Mark the given device as known/unknown
+ *
+ * @param userId - owner of the device
+ * @param deviceId - unique identifier for the device or user's
+ * cross-signing public key ID.
+ *
+ * @param known - whether to mark the device as known. defaults
+ * to 'true'.
+ *
+ * @returns
+ *
+ * @remarks
+ * Fires {@link CryptoEvent#DeviceVerificationChanged}
+ */
+ public setDeviceKnown(userId: string, deviceId: string, known = true): Promise<void> {
+ return this.setDeviceVerification(userId, deviceId, null, null, known);
+ }
+
+ private async setDeviceVerification(
+ userId: string,
+ deviceId: string,
+ verified?: boolean | null,
+ blocked?: boolean | null,
+ known?: boolean | null,
+ ): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ await this.crypto.setDeviceVerification(userId, deviceId, verified, blocked, known);
+ }
+
+ /**
+ * Request a key verification from another user, using a DM.
+ *
+ * @param userId - the user to request verification with
+ * @param roomId - the room to use for verification
+ *
+ * @returns resolves to a VerificationRequest
+ * when the request has been sent to the other party.
+ */
+ public requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.requestVerificationDM(userId, roomId);
+ }
+
+ /**
+ * Finds a DM verification request that is already in progress for the given room id
+ *
+ * @param roomId - the room to use for verification
+ *
+ * @returns the VerificationRequest that is in progress, if any
+ */
+ public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.findVerificationRequestDMInProgress(roomId);
+ }
+
+ /**
+ * Returns all to-device verification requests that are already in progress for the given user id
+ *
+ * @param userId - the ID of the user to query
+ *
+ * @returns the VerificationRequests that are in progress
+ */
+ public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getVerificationRequestsToDeviceInProgress(userId);
+ }
+
+ /**
+ * Request a key verification from another user.
+ *
+ * @param userId - the user to request verification with
+ * @param devices - array of device IDs to send requests to. Defaults to
+ * all devices owned by the user
+ *
+ * @returns resolves to a VerificationRequest
+ * when the request has been sent to the other party.
+ */
+ public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.requestVerification(userId, devices);
+ }
+
+ /**
+ * Begin a key verification.
+ *
+ * @param method - the verification method to use
+ * @param userId - the user to verify keys with
+ * @param deviceId - the device to verify
+ *
+ * @returns a verification object
+ * @deprecated Use `requestVerification` instead.
+ */
+ public beginKeyVerification(method: string, userId: string, deviceId: string): Verification<any, any> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.beginKeyVerification(method, userId, deviceId);
+ }
+
+ public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkSecretStorageKey(key, info);
+ }
+
+ /**
+ * Set the global override for whether the client should ever send encrypted
+ * messages to unverified devices. This provides the default for rooms which
+ * do not specify a value.
+ *
+ * @param value - whether to blacklist all unverified devices by default
+ *
+ * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
+ *
+ * ```javascript
+ * client.getCrypto().globalBlacklistUnverifiedDevices = value;
+ * ```
+ */
+ public setGlobalBlacklistUnverifiedDevices(value: boolean): boolean {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ this.cryptoBackend.globalBlacklistUnverifiedDevices = value;
+ return value;
+ }
+
+ /**
+ * @returns whether to blacklist all unverified devices by default
+ *
+ * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
+ *
+ * ```javascript
+ * value = client.getCrypto().globalBlacklistUnverifiedDevices;
+ * ```
+ */
+ public getGlobalBlacklistUnverifiedDevices(): boolean {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.globalBlacklistUnverifiedDevices;
+ }
+
+ /**
+ * Set whether sendMessage in a room with unknown and unverified devices
+ * should throw an error and not send them message. This has 'Global' for
+ * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
+ * no room-level equivalent for this setting.
+ *
+ * This API is currently UNSTABLE and may change or be removed without notice.
+ *
+ * @param value - whether error on unknown devices
+ *
+ * @deprecated Prefer direct access to {@link CryptoApi.globalBlacklistUnverifiedDevices}:
+ *
+ * ```ts
+ * client.getCrypto().globalBlacklistUnverifiedDevices = value;
+ * ```
+ */
+ public setGlobalErrorOnUnknownDevices(value: boolean): void {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ this.cryptoBackend.globalErrorOnUnknownDevices = value;
+ }
+
+ /**
+ * @returns whether to error on unknown devices
+ *
+ * This API is currently UNSTABLE and may change or be removed without notice.
+ */
+ public getGlobalErrorOnUnknownDevices(): boolean {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.globalErrorOnUnknownDevices;
+ }
+
+ /**
+ * Get the user's cross-signing key ID.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param type - The type of key to get the ID of. One of
+ * "master", "self_signing", or "user_signing". Defaults to "master".
+ *
+ * @returns the key ID
+ */
+ public getCrossSigningId(type: CrossSigningKey | string = CrossSigningKey.Master): string | null {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getCrossSigningId(type);
+ }
+
+ /**
+ * Get the cross signing information for a given user.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param userId - the user ID to get the cross-signing info for.
+ *
+ * @returns the cross signing information for the user.
+ */
+ public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getStoredCrossSigningForUser(userId);
+ }
+
+ /**
+ * Check whether a given user is trusted.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param userId - The ID of the user to check.
+ */
+ public checkUserTrust(userId: string): UserTrustLevel {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.checkUserTrust(userId);
+ }
+
+ /**
+ * Check whether a given device is trusted.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param userId - The ID of the user whose devices is to be checked.
+ * @param deviceId - The ID of the device to check
+ */
+ public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.checkDeviceTrust(userId, deviceId);
+ }
+
+ /**
+ * Check whether one of our own devices is cross-signed by our
+ * user's stored keys, regardless of whether we trust those keys yet.
+ *
+ * @param deviceId - The ID of the device to check
+ *
+ * @returns true if the device is cross-signed
+ */
+ public checkIfOwnDeviceCrossSigned(deviceId: string): boolean {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkIfOwnDeviceCrossSigned(deviceId);
+ }
+
+ /**
+ * Check the copy of our cross-signing key that we have in the device list and
+ * see if we can get the private key. If so, mark it as trusted.
+ * @param opts - ICheckOwnCrossSigningTrustOpts object
+ */
+ public checkOwnCrossSigningTrust(opts?: ICheckOwnCrossSigningTrustOpts): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkOwnCrossSigningTrust(opts);
+ }
+
+ /**
+ * Checks that a given cross-signing private key matches a given public key.
+ * This can be used by the getCrossSigningKey callback to verify that the
+ * private key it is about to supply is the one that was requested.
+ * @param privateKey - The private key
+ * @param expectedPublicKey - The public key
+ * @returns true if the key matches, otherwise false
+ */
+ public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkCrossSigningPrivateKey(privateKey, expectedPublicKey);
+ }
+
+ // deprecated: use requestVerification instead
+ public legacyDeviceVerification(
+ userId: string,
+ deviceId: string,
+ method: VerificationMethod,
+ ): Promise<VerificationRequest> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.legacyDeviceVerification(userId, deviceId, method);
+ }
+
+ /**
+ * Perform any background tasks that can be done before a message is ready to
+ * send, in order to speed up sending of the message.
+ * @param room - the room the event is in
+ *
+ * @deprecated Prefer {@link CryptoApi.prepareToEncrypt | `CryptoApi.prepareToEncrypt`}:
+ *
+ * ```javascript
+ * client.getCrypto().prepareToEncrypt(room);
+ * ```
+ */
+ public prepareToEncrypt(room: Room): void {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ this.cryptoBackend.prepareToEncrypt(room);
+ }
+
+ /**
+ * Checks if the user has previously published cross-signing keys
+ *
+ * This means downloading the devicelist for the user and checking if the list includes
+ * the cross-signing pseudo-device.
+ *
+ * @deprecated Prefer {@link CryptoApi.userHasCrossSigningKeys | `CryptoApi.userHasCrossSigningKeys`}:
+ *
+ * ```javascript
+ * result = client.getCrypto().userHasCrossSigningKeys();
+ * ```
+ */
+ public userHasCrossSigningKeys(): Promise<boolean> {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.userHasCrossSigningKeys();
+ }
+
+ /**
+ * Checks whether cross signing:
+ * - is enabled on this account and trusted by this device
+ * - has private keys either cached locally or stored in secret storage
+ *
+ * If this function returns false, bootstrapCrossSigning() can be used
+ * to fix things such that it returns true. That is to say, after
+ * bootstrapCrossSigning() completes successfully, this function should
+ * return true.
+ * @returns True if cross-signing is ready to be used on this device
+ */
+ public isCrossSigningReady(): Promise<boolean> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.isCrossSigningReady();
+ }
+
+ /**
+ * Bootstrap cross-signing by creating keys if needed. If everything is already
+ * set up, then no changes are made, so this is safe to run to ensure
+ * cross-signing is ready for use.
+ *
+ * This function:
+ * - creates new cross-signing keys if they are not found locally cached nor in
+ * secret storage (if it has been setup)
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ */
+ public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.bootstrapCrossSigning(opts);
+ }
+
+ /**
+ * Whether to trust a others users signatures of their devices.
+ * If false, devices will only be considered 'verified' if we have
+ * verified that device individually (effectively disabling cross-signing).
+ *
+ * Default: true
+ *
+ * @returns True if trusting cross-signed devices
+ */
+ public getCryptoTrustCrossSignedDevices(): boolean {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getCryptoTrustCrossSignedDevices();
+ }
+
+ /**
+ * See getCryptoTrustCrossSignedDevices
+ *
+ * @param val - True to trust cross-signed devices
+ */
+ public setCryptoTrustCrossSignedDevices(val: boolean): void {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ this.crypto.setCryptoTrustCrossSignedDevices(val);
+ }
+
+ /**
+ * Counts the number of end to end session keys that are waiting to be backed up
+ * @returns Promise which resolves to the number of sessions requiring backup
+ */
+ public countSessionsNeedingBackup(): Promise<number> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.countSessionsNeedingBackup();
+ }
+
+ /**
+ * Get information about the encryption of an event
+ *
+ * @param event - event to be checked
+ * @returns The event information.
+ */
+ public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.cryptoBackend.getEventEncryptionInfo(event);
+ }
+
+ /**
+ * Create a recovery key from a user-supplied passphrase.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param password - Passphrase string that can be entered by the user
+ * when restoring the backup as an alternative to entering the recovery key.
+ * Optional.
+ * @returns Object with public key metadata, encoded private
+ * recovery key which should be disposed of after displaying to the user,
+ * and raw private key to avoid round tripping if needed.
+ */
+ public createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.createRecoveryKeyFromPassphrase(password);
+ }
+
+ /**
+ * Checks whether secret storage:
+ * - is enabled on this account
+ * - is storing cross-signing private keys
+ * - is storing session backup key (if enabled)
+ *
+ * If this function returns false, bootstrapSecretStorage() can be used
+ * to fix things such that it returns true. That is to say, after
+ * bootstrapSecretStorage() completes successfully, this function should
+ * return true.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @returns True if secret storage is ready to be used on this device
+ */
+ public isSecretStorageReady(): Promise<boolean> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.isSecretStorageReady();
+ }
+
+ /**
+ * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is
+ * already set up, then no changes are made, so this is safe to run to ensure secret
+ * storage is ready for use.
+ *
+ * This function
+ * - creates a new Secure Secret Storage key if no default key exists
+ * - if a key backup exists, it is migrated to store the key in the Secret
+ * Storage
+ * - creates a backup if none exists, and one is requested
+ * - migrates Secure Secret Storage to use the latest algorithm, if an outdated
+ * algorithm is found
+ *
+ */
+ public bootstrapSecretStorage(opts: ICreateSecretStorageOpts): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.bootstrapSecretStorage(opts);
+ }
+
+ /**
+ * Add a key for encrypting secrets.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param algorithm - the algorithm used by the key
+ * @param opts - the options for the algorithm. The properties used
+ * depend on the algorithm given.
+ * @param keyName - the name of the key. If not given, a random name will be generated.
+ *
+ * @returns An object with:
+ * keyId: the ID of the key
+ * keyInfo: details about the key (iv, mac, passphrase)
+ */
+ public addSecretStorageKey(
+ algorithm: string,
+ opts: IAddSecretStorageKeyOpts,
+ keyName?: string,
+ ): Promise<{ keyId: string; keyInfo: SecretStorageKeyDescription }> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.addSecretStorageKey(algorithm, opts, keyName);
+ }
+
+ /**
+ * Check whether we have a key with a given ID.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param keyId - The ID of the key to check
+ * for. Defaults to the default key ID if not provided.
+ * @returns Whether we have the key.
+ */
+ public hasSecretStorageKey(keyId?: string): Promise<boolean> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.hasSecretStorageKey(keyId);
+ }
+
+ /**
+ * Store an encrypted secret on the server.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param name - The name of the secret
+ * @param secret - The secret contents.
+ * @param keys - The IDs of the keys to use to encrypt the secret or null/undefined
+ * to use the default (will throw if no default key is set).
+ */
+ public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.storeSecret(name, secret, keys);
+ }
+
+ /**
+ * Get a secret from storage.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param name - the name of the secret
+ *
+ * @returns the contents of the secret
+ */
+ public getSecret(name: string): Promise<string | undefined> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getSecret(name);
+ }
+
+ /**
+ * Check if a secret is stored on the server.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param name - the name of the secret
+ * @returns map of key name to key info the secret is encrypted
+ * with, or null if it is not present or not encrypted with a trusted
+ * key
+ */
+ public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.isSecretStored(name);
+ }
+
+ /**
+ * Request a secret from another device.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param name - the name of the secret to request
+ * @param devices - the devices to request the secret from
+ *
+ * @returns the secret request object
+ */
+ public requestSecret(name: string, devices: string[]): ISecretRequest {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.requestSecret(name, devices);
+ }
+
+ /**
+ * Get the current default key ID for encrypting secrets.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @returns The default key ID or null if no default key ID is set
+ */
+ public getDefaultSecretStorageKeyId(): Promise<string | null> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.getDefaultSecretStorageKeyId();
+ }
+
+ /**
+ * Set the current default key ID for encrypting secrets.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param keyId - The new default key ID
+ */
+ public setDefaultSecretStorageKeyId(keyId: string): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.setDefaultSecretStorageKeyId(keyId);
+ }
+
+ /**
+ * Checks that a given secret storage private key matches a given public key.
+ * This can be used by the getSecretStorageKey callback to verify that the
+ * private key it is about to supply is the one that was requested.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param privateKey - The private key
+ * @param expectedPublicKey - The public key
+ * @returns true if the key matches, otherwise false
+ */
+ public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.checkSecretStoragePrivateKey(privateKey, expectedPublicKey);
+ }
+
+ /**
+ * Get e2e information on the device that sent an event
+ *
+ * @param event - event to be checked
+ */
+ public async getEventSenderDeviceInfo(event: MatrixEvent): Promise<DeviceInfo | null> {
+ if (!this.crypto) {
+ return null;
+ }
+ return this.crypto.getEventSenderDeviceInfo(event);
+ }
+
+ /**
+ * Check if the sender of an event is verified
+ *
+ * @param event - event to be checked
+ *
+ * @returns true if the sender of this event has been verified using
+ * {@link MatrixClient#setDeviceVerified}.
+ */
+ public async isEventSenderVerified(event: MatrixEvent): Promise<boolean> {
+ const device = await this.getEventSenderDeviceInfo(event);
+ if (!device) {
+ return false;
+ }
+ return device.isVerified();
+ }
+
+ /**
+ * Get outgoing room key request for this event if there is one.
+ * @param event - The event to check for
+ *
+ * @returns A room key request, or null if there is none
+ */
+ public getOutgoingRoomKeyRequest(event: MatrixEvent): Promise<OutgoingRoomKeyRequest | null> {
+ if (!this.crypto) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ const wireContent = event.getWireContent();
+ const requestBody: IRoomKeyRequestBody = {
+ session_id: wireContent.session_id,
+ sender_key: wireContent.sender_key,
+ algorithm: wireContent.algorithm,
+ room_id: event.getRoomId()!,
+ };
+ if (!requestBody.session_id || !requestBody.sender_key || !requestBody.algorithm || !requestBody.room_id) {
+ return Promise.resolve(null);
+ }
+ return this.crypto.cryptoStore.getOutgoingRoomKeyRequest(requestBody);
+ }
+
+ /**
+ * Cancel a room key request for this event if one is ongoing and resend the
+ * request.
+ * @param event - event of which to cancel and resend the room
+ * key request.
+ * @returns A promise that will resolve when the key request is queued
+ */
+ public cancelAndResendEventRoomKeyRequest(event: MatrixEvent): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ return event.cancelAndResendKeyRequest(this.crypto, this.getUserId()!);
+ }
+
+ /**
+ * Enable end-to-end encryption for a room. This does not modify room state.
+ * Any messages sent before the returned promise resolves will be sent unencrypted.
+ * @param roomId - The room ID to enable encryption in.
+ * @param config - The encryption config for the room.
+ * @returns A promise that will resolve when encryption is set up.
+ */
+ public setRoomEncryption(roomId: string, config: IRoomEncryption): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ return this.crypto.setRoomEncryption(roomId, config);
+ }
+
+ /**
+ * Whether encryption is enabled for a room.
+ * @param roomId - the room id to query.
+ * @returns whether encryption is enabled.
+ */
+ public isRoomEncrypted(roomId: string): boolean {
+ const room = this.getRoom(roomId);
+ if (!room) {
+ // we don't know about this room, so can't determine if it should be
+ // encrypted. Let's assume not.
+ return false;
+ }
+
+ // if there is an 'm.room.encryption' event in this room, it should be
+ // encrypted (independently of whether we actually support encryption)
+ const ev = room.currentState.getStateEvents(EventType.RoomEncryption, "");
+ if (ev) {
+ return true;
+ }
+
+ // we don't have an m.room.encrypted event, but that might be because
+ // the server is hiding it from us. Check the store to see if it was
+ // previously encrypted.
+ return this.roomList.isRoomEncrypted(roomId);
+ }
+
+ /**
+ * Encrypts and sends a given object via Olm to-device messages to a given
+ * set of devices.
+ *
+ * @param userDeviceMap - mapping from userId to deviceInfo
+ *
+ * @param payload - fields to include in the encrypted payload
+ *
+ * @returns Promise which
+ * resolves once the message has been encrypted and sent to the given
+ * userDeviceMap, and returns the `{ contentMap, deviceInfoByDeviceId }`
+ * of the successfully sent messages.
+ */
+ public encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ return this.crypto.encryptAndSendToDevices(userDeviceInfoArr, payload);
+ }
+
+ /**
+ * Forces the current outbound group session to be discarded such
+ * that another one will be created next time an event is sent.
+ *
+ * @param roomId - The ID of the room to discard the session for
+ *
+ * @deprecated Prefer {@link CryptoApi.forceDiscardSession | `CryptoApi.forceDiscardSession`}:
+ *
+ */
+ public forceDiscardSession(roomId: string): void {
+ if (!this.cryptoBackend) {
+ throw new Error("End-to-End encryption disabled");
+ }
+ this.cryptoBackend.forceDiscardSession(roomId);
+ }
+
+ /**
+ * Get a list containing all of the room keys
+ *
+ * This should be encrypted before returning it to the user.
+ *
+ * @returns a promise which resolves to a list of session export objects
+ *
+ * @deprecated Prefer {@link CryptoApi.exportRoomKeys | `CryptoApi.exportRoomKeys`}:
+ *
+ * ```javascript
+ * sessionData = await client.getCrypto().exportRoomKeys();
+ * ```
+ */
+ public exportRoomKeys(): Promise<IMegolmSessionData[]> {
+ if (!this.cryptoBackend) {
+ return Promise.reject(new Error("End-to-end encryption disabled"));
+ }
+ return this.cryptoBackend.exportRoomKeys();
+ }
+
+ /**
+ * Import a list of room keys previously exported by exportRoomKeys
+ *
+ * @param keys - a list of session export objects
+ *
+ * @returns a promise which resolves when the keys have been imported
+ */
+ public importRoomKeys(keys: IMegolmSessionData[], opts?: IImportRoomKeysOpts): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.importRoomKeys(keys, opts);
+ }
+
+ /**
+ * Force a re-check of the local key backup status against
+ * what's on the server.
+ *
+ * @returns Object with backup info (as returned by
+ * getKeyBackupVersion) in backupInfo and
+ * trust information (as returned by isKeyBackupTrusted)
+ * in trustInfo.
+ */
+ public checkKeyBackup(): Promise<IKeyBackupCheck | null> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.backupManager.checkKeyBackup();
+ }
+
+ /**
+ * Get information about the current key backup.
+ * @returns Information object from API or null
+ */
+ public async getKeyBackupVersion(): Promise<IKeyBackupInfo | null> {
+ let res: IKeyBackupInfo;
+ try {
+ res = await this.http.authedRequest<IKeyBackupInfo>(
+ Method.Get,
+ "/room_keys/version",
+ undefined,
+ undefined,
+ { prefix: ClientPrefix.V3 },
+ );
+ } catch (e) {
+ if ((<MatrixError>e).errcode === "M_NOT_FOUND") {
+ return null;
+ } else {
+ throw e;
+ }
+ }
+ BackupManager.checkBackupVersion(res);
+ return res;
+ }
+
+ /**
+ * @param info - key backup info dict from getKeyBackupVersion()
+ */
+ public isKeyBackupTrusted(info: IKeyBackupInfo): Promise<TrustInfo> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.backupManager.isKeyBackupTrusted(info);
+ }
+
+ /**
+ * @returns true if the client is configured to back up keys to
+ * the server, otherwise false. If we haven't completed a successful check
+ * of key backup status yet, returns null.
+ */
+ public getKeyBackupEnabled(): boolean | null {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ return this.crypto.backupManager.getKeyBackupEnabled();
+ }
+
+ /**
+ * Enable backing up of keys, using data previously returned from
+ * getKeyBackupVersion.
+ *
+ * @param info - Backup information object as returned by getKeyBackupVersion
+ * @returns Promise which resolves when complete.
+ */
+ public enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ return this.crypto.backupManager.enableKeyBackup(info);
+ }
+
+ /**
+ * Disable backing up of keys.
+ */
+ public disableKeyBackup(): void {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ this.crypto.backupManager.disableKeyBackup();
+ }
+
+ /**
+ * Set up the data required to create a new backup version. The backup version
+ * will not be created and enabled until createKeyBackupVersion is called.
+ *
+ * @param password - Passphrase string that can be entered by the user
+ * when restoring the backup as an alternative to entering the recovery key.
+ * Optional.
+ *
+ * @returns Object that can be passed to createKeyBackupVersion and
+ * additionally has a 'recovery_key' member with the user-facing recovery key string.
+ */
+ public async prepareKeyBackupVersion(
+ password?: string | Uint8Array | null,
+ opts: IKeyBackupPrepareOpts = { secureSecretStorage: false },
+ ): Promise<Pick<IPreparedKeyBackupVersion, "algorithm" | "auth_data" | "recovery_key">> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ // eslint-disable-next-line camelcase
+ const { algorithm, auth_data, recovery_key, privateKey } =
+ await this.crypto.backupManager.prepareKeyBackupVersion(password);
+
+ if (opts.secureSecretStorage) {
+ await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey));
+ logger.info("Key backup private key stored in secret storage");
+ }
+
+ return {
+ algorithm,
+ /* eslint-disable camelcase */
+ auth_data,
+ recovery_key,
+ /* eslint-enable camelcase */
+ };
+ }
+
+ /**
+ * Check whether the key backup private key is stored in secret storage.
+ * @returns map of key name to key info the secret is
+ * encrypted with, or null if it is not present or not encrypted with a
+ * trusted key
+ */
+ public isKeyBackupKeyStored(): Promise<Record<string, SecretStorageKeyDescription> | null> {
+ return Promise.resolve(this.isSecretStored("m.megolm_backup.v1"));
+ }
+
+ /**
+ * Create a new key backup version and enable it, using the information return
+ * from prepareKeyBackupVersion.
+ *
+ * @param info - Info object from prepareKeyBackupVersion
+ * @returns Object with 'version' param indicating the version created
+ */
+ public async createKeyBackupVersion(info: IKeyBackupInfo): Promise<IKeyBackupInfo> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ await this.crypto.backupManager.createKeyBackupVersion(info);
+
+ const data = {
+ algorithm: info.algorithm,
+ auth_data: info.auth_data,
+ };
+
+ // Sign the backup auth data with the device key for backwards compat with
+ // older devices with cross-signing. This can probably go away very soon in
+ // favour of just signing with the cross-singing master key.
+ // XXX: Private member access
+ await this.crypto.signObject(data.auth_data);
+
+ if (
+ this.cryptoCallbacks.getCrossSigningKey &&
+ // XXX: Private member access
+ this.crypto.crossSigningInfo.getId()
+ ) {
+ // now also sign the auth data with the cross-signing master key
+ // we check for the callback explicitly here because we still want to be able
+ // to create an un-cross-signed key backup if there is a cross-signing key but
+ // no callback supplied.
+ // XXX: Private member access
+ await this.crypto.crossSigningInfo.signObject(data.auth_data, "master");
+ }
+
+ const res = await this.http.authedRequest<IKeyBackupInfo>(Method.Post, "/room_keys/version", undefined, data, {
+ prefix: ClientPrefix.V3,
+ });
+
+ // We could assume everything's okay and enable directly, but this ensures
+ // we run the same signature verification that will be used for future
+ // sessions.
+ await this.checkKeyBackup();
+ if (!this.getKeyBackupEnabled()) {
+ logger.error("Key backup not usable even though we just created it");
+ }
+
+ return res;
+ }
+
+ public async deleteKeyBackupVersion(version: string): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ // If we're currently backing up to this backup... stop.
+ // (We start using it automatically in createKeyBackupVersion
+ // so this is symmetrical).
+ if (this.crypto.backupManager.version) {
+ this.crypto.backupManager.disableKeyBackup();
+ }
+
+ const path = utils.encodeUri("/room_keys/version/$version", {
+ $version: version,
+ });
+
+ await this.http.authedRequest(Method.Delete, path, undefined, undefined, { prefix: ClientPrefix.V3 });
+ }
+
+ private makeKeyBackupPath(roomId: undefined, sessionId: undefined, version?: string): IKeyBackupPath;
+ private makeKeyBackupPath(roomId: string, sessionId: undefined, version?: string): IKeyBackupPath;
+ private makeKeyBackupPath(roomId: string, sessionId: string, version?: string): IKeyBackupPath;
+ private makeKeyBackupPath(roomId?: string, sessionId?: string, version?: string): IKeyBackupPath {
+ let path: string;
+ if (sessionId !== undefined) {
+ path = utils.encodeUri("/room_keys/keys/$roomId/$sessionId", {
+ $roomId: roomId!,
+ $sessionId: sessionId,
+ });
+ } else if (roomId !== undefined) {
+ path = utils.encodeUri("/room_keys/keys/$roomId", {
+ $roomId: roomId,
+ });
+ } else {
+ path = "/room_keys/keys";
+ }
+ const queryData = version === undefined ? undefined : { version };
+ return { path, queryData };
+ }
+
+ /**
+ * Back up session keys to the homeserver.
+ * @param roomId - ID of the room that the keys are for Optional.
+ * @param sessionId - ID of the session that the keys are for Optional.
+ * @param version - backup version Optional.
+ * @param data - Object keys to send
+ * @returns a promise that will resolve when the keys
+ * are uploaded
+ */
+ public sendKeyBackup(
+ roomId: undefined,
+ sessionId: undefined,
+ version: string | undefined,
+ data: IKeyBackup,
+ ): Promise<void>;
+ public sendKeyBackup(
+ roomId: string,
+ sessionId: undefined,
+ version: string | undefined,
+ data: IKeyBackup,
+ ): Promise<void>;
+ public sendKeyBackup(
+ roomId: string,
+ sessionId: string,
+ version: string | undefined,
+ data: IKeyBackup,
+ ): Promise<void>;
+ public async sendKeyBackup(
+ roomId: string | undefined,
+ sessionId: string | undefined,
+ version: string | undefined,
+ data: IKeyBackup,
+ ): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ const path = this.makeKeyBackupPath(roomId!, sessionId!, version);
+ await this.http.authedRequest(Method.Put, path.path, path.queryData, data, { prefix: ClientPrefix.V3 });
+ }
+
+ /**
+ * Marks all group sessions as needing to be backed up and schedules them to
+ * upload in the background as soon as possible.
+ */
+ public async scheduleAllGroupSessionsForBackup(): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ await this.crypto.backupManager.scheduleAllGroupSessionsForBackup();
+ }
+
+ /**
+ * Marks all group sessions as needing to be backed up without scheduling
+ * them to upload in the background.
+ * @returns Promise which resolves to the number of sessions requiring a backup.
+ */
+ public flagAllGroupSessionsForBackup(): Promise<number> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ return this.crypto.backupManager.flagAllGroupSessionsForBackup();
+ }
+
+ public isValidRecoveryKey(recoveryKey: string): boolean {
+ try {
+ decodeRecoveryKey(recoveryKey);
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ /**
+ * Get the raw key for a key backup from the password
+ * Used when migrating key backups into SSSS
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param password - Passphrase
+ * @param backupInfo - Backup metadata from `checkKeyBackup`
+ * @returns key backup key
+ */
+ public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise<Uint8Array> {
+ return keyFromAuthData(backupInfo.auth_data, password);
+ }
+
+ /**
+ * Get the raw key for a key backup from the recovery key
+ * Used when migrating key backups into SSSS
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param recoveryKey - The recovery key
+ * @returns key backup key
+ */
+ public keyBackupKeyFromRecoveryKey(recoveryKey: string): Uint8Array {
+ return decodeRecoveryKey(recoveryKey);
+ }
+
+ /**
+ * Restore from an existing key backup via a passphrase.
+ *
+ * @param password - Passphrase
+ * @param targetRoomId - Room ID to target a specific room.
+ * Restores all rooms if omitted.
+ * @param targetSessionId - Session ID to target a specific session.
+ * Restores all sessions if omitted.
+ * @param backupInfo - Backup metadata from `checkKeyBackup`
+ * @param opts - Optional params such as callbacks
+ * @returns Status of restoration with `total` and `imported`
+ * key counts.
+ */
+ public async restoreKeyBackupWithPassword(
+ password: string,
+ targetRoomId: undefined,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithPassword(
+ password: string,
+ targetRoomId: string,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithPassword(
+ password: string,
+ targetRoomId: string,
+ targetSessionId: string,
+ backupInfo: IKeyBackupInfo,
+ opts: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithPassword(
+ password: string,
+ targetRoomId: string | undefined,
+ targetSessionId: string | undefined,
+ backupInfo: IKeyBackupInfo,
+ opts: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ const privKey = await keyFromAuthData(backupInfo.auth_data, password);
+ return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
+ }
+
+ /**
+ * Restore from an existing key backup via a private key stored in secret
+ * storage.
+ *
+ * @param backupInfo - Backup metadata from `checkKeyBackup`
+ * @param targetRoomId - Room ID to target a specific room.
+ * Restores all rooms if omitted.
+ * @param targetSessionId - Session ID to target a specific session.
+ * Restores all sessions if omitted.
+ * @param opts - Optional params such as callbacks
+ * @returns Status of restoration with `total` and `imported`
+ * key counts.
+ */
+ public async restoreKeyBackupWithSecretStorage(
+ backupInfo: IKeyBackupInfo,
+ targetRoomId?: string,
+ targetSessionId?: string,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ const storedKey = await this.getSecret("m.megolm_backup.v1");
+
+ // ensure that the key is in the right format. If not, fix the key and
+ // store the fixed version
+ const fixedKey = fixBackupKey(storedKey);
+ if (fixedKey) {
+ const keys = await this.crypto.getSecretStorageKey();
+ await this.storeSecret("m.megolm_backup.v1", fixedKey, [keys![0]]);
+ }
+
+ const privKey = decodeBase64(fixedKey || storedKey!);
+ return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
+ }
+
+ /**
+ * Restore from an existing key backup via an encoded recovery key.
+ *
+ * @param recoveryKey - Encoded recovery key
+ * @param targetRoomId - Room ID to target a specific room.
+ * Restores all rooms if omitted.
+ * @param targetSessionId - Session ID to target a specific session.
+ * Restores all sessions if omitted.
+ * @param backupInfo - Backup metadata from `checkKeyBackup`
+ * @param opts - Optional params such as callbacks
+
+ * @returns Status of restoration with `total` and `imported`
+ * key counts.
+ */
+ public restoreKeyBackupWithRecoveryKey(
+ recoveryKey: string,
+ targetRoomId: undefined,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public restoreKeyBackupWithRecoveryKey(
+ recoveryKey: string,
+ targetRoomId: string,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public restoreKeyBackupWithRecoveryKey(
+ recoveryKey: string,
+ targetRoomId: string,
+ targetSessionId: string,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public restoreKeyBackupWithRecoveryKey(
+ recoveryKey: string,
+ targetRoomId: string | undefined,
+ targetSessionId: string | undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ const privKey = decodeRecoveryKey(recoveryKey);
+ return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
+ }
+
+ public async restoreKeyBackupWithCache(
+ targetRoomId: undefined,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithCache(
+ targetRoomId: string,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithCache(
+ targetRoomId: string,
+ targetSessionId: string,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ public async restoreKeyBackupWithCache(
+ targetRoomId: string | undefined,
+ targetSessionId: string | undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+ const privKey = await this.crypto.getSessionBackupPrivateKey();
+ if (!privKey) {
+ throw new Error("Couldn't get key");
+ }
+ return this.restoreKeyBackup(privKey, targetRoomId!, targetSessionId!, backupInfo, opts);
+ }
+
+ private async restoreKeyBackup(
+ privKey: ArrayLike<number>,
+ targetRoomId: undefined,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ private async restoreKeyBackup(
+ privKey: ArrayLike<number>,
+ targetRoomId: string,
+ targetSessionId: undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ private async restoreKeyBackup(
+ privKey: ArrayLike<number>,
+ targetRoomId: string,
+ targetSessionId: string,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult>;
+ private async restoreKeyBackup(
+ privKey: ArrayLike<number>,
+ targetRoomId: string | undefined,
+ targetSessionId: string | undefined,
+ backupInfo: IKeyBackupInfo,
+ opts?: IKeyBackupRestoreOpts,
+ ): Promise<IKeyBackupRestoreResult> {
+ const cacheCompleteCallback = opts?.cacheCompleteCallback;
+ const progressCallback = opts?.progressCallback;
+
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ let totalKeyCount = 0;
+ let keys: IMegolmSessionData[] = [];
+
+ const path = this.makeKeyBackupPath(targetRoomId!, targetSessionId!, backupInfo.version);
+
+ const algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => {
+ return privKey;
+ });
+
+ const untrusted = algorithm.untrusted;
+
+ try {
+ // If the pubkey computed from the private data we've been given
+ // doesn't match the one in the auth_data, the user has entered
+ // a different recovery key / the wrong passphrase.
+ if (!(await algorithm.keyMatches(privKey))) {
+ return Promise.reject(new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY }));
+ }
+
+ // Cache the key, if possible.
+ // This is async.
+ this.crypto
+ .storeSessionBackupPrivateKey(privKey)
+ .catch((e) => {
+ logger.warn("Error caching session backup key:", e);
+ })
+ .then(cacheCompleteCallback);
+
+ if (progressCallback) {
+ progressCallback({
+ stage: "fetch",
+ });
+ }
+
+ const res = await this.http.authedRequest<IRoomsKeysResponse | IRoomKeysResponse | IKeyBackupSession>(
+ Method.Get,
+ path.path,
+ path.queryData,
+ undefined,
+ { prefix: ClientPrefix.V3 },
+ );
+
+ if ((res as IRoomsKeysResponse).rooms) {
+ const rooms = (res as IRoomsKeysResponse).rooms;
+ for (const [roomId, roomData] of Object.entries(rooms)) {
+ if (!roomData.sessions) continue;
+
+ totalKeyCount += Object.keys(roomData.sessions).length;
+ const roomKeys = await algorithm.decryptSessions(roomData.sessions);
+ for (const k of roomKeys) {
+ k.room_id = roomId;
+ keys.push(k);
+ }
+ }
+ } else if ((res as IRoomKeysResponse).sessions) {
+ const sessions = (res as IRoomKeysResponse).sessions;
+ totalKeyCount = Object.keys(sessions).length;
+ keys = await algorithm.decryptSessions(sessions);
+ for (const k of keys) {
+ k.room_id = targetRoomId!;
+ }
+ } else {
+ totalKeyCount = 1;
+ try {
+ const [key] = await algorithm.decryptSessions({
+ [targetSessionId!]: res as IKeyBackupSession,
+ });
+ key.room_id = targetRoomId!;
+ key.session_id = targetSessionId!;
+ keys.push(key);
+ } catch (e) {
+ logger.log("Failed to decrypt megolm session from backup", e);
+ }
+ }
+ } finally {
+ algorithm.free();
+ }
+
+ await this.importRoomKeys(keys, {
+ progressCallback,
+ untrusted,
+ source: "backup",
+ });
+
+ await this.checkKeyBackup();
+
+ return { total: totalKeyCount, imported: keys.length };
+ }
+
+ public deleteKeysFromBackup(roomId: undefined, sessionId: undefined, version?: string): Promise<void>;
+ public deleteKeysFromBackup(roomId: string, sessionId: undefined, version?: string): Promise<void>;
+ public deleteKeysFromBackup(roomId: string, sessionId: string, version?: string): Promise<void>;
+ public async deleteKeysFromBackup(roomId?: string, sessionId?: string, version?: string): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ const path = this.makeKeyBackupPath(roomId!, sessionId!, version);
+ await this.http.authedRequest(Method.Delete, path.path, path.queryData, undefined, { prefix: ClientPrefix.V3 });
+ }
+
+ /**
+ * Share shared-history decryption keys with the given users.
+ *
+ * @param roomId - the room for which keys should be shared.
+ * @param userIds - a list of users to share with. The keys will be sent to
+ * all of the user's current devices.
+ */
+ public async sendSharedHistoryKeys(roomId: string, userIds: string[]): Promise<void> {
+ if (!this.crypto) {
+ throw new Error("End-to-end encryption disabled");
+ }
+
+ const roomEncryption = this.roomList.getRoomEncryption(roomId);
+ if (!roomEncryption) {
+ // unknown room, or unencrypted room
+ logger.error("Unknown room. Not sharing decryption keys");
+ return;
+ }
+
+ const deviceInfos = await this.crypto.downloadKeys(userIds);
+ const devicesByUser: Map<string, DeviceInfo[]> = new Map();
+ for (const [userId, devices] of deviceInfos) {
+ devicesByUser.set(userId, Array.from(devices.values()));
+ }
+
+ // XXX: Private member access
+ const alg = this.crypto.getRoomDecryptor(roomId, roomEncryption.algorithm);
+ if (alg.sendSharedHistoryInboundSessions) {
+ await alg.sendSharedHistoryInboundSessions(devicesByUser);
+ } else {
+ logger.warn("Algorithm does not support sharing previous keys", roomEncryption.algorithm);
+ }
+ }
+
+ /**
+ * Get the config for the media repository.
+ * @returns Promise which resolves with an object containing the config.
+ */
+ public getMediaConfig(): Promise<IMediaConfig> {
+ return this.http.authedRequest(Method.Get, "/config", undefined, undefined, {
+ prefix: MediaPrefix.R0,
+ });
+ }
+
+ /**
+ * Get the room for the given room ID.
+ * This function will return a valid room for any room for which a Room event
+ * has been emitted. Note in particular that other events, eg. RoomState.members
+ * will be emitted for a room before this function will return the given room.
+ * @param roomId - The room ID
+ * @returns The Room or null if it doesn't exist or there is no data store.
+ */
+ public getRoom(roomId: string | undefined): Room | null {
+ if (!roomId) {
+ return null;
+ }
+ return this.store.getRoom(roomId);
+ }
+
+ /**
+ * Retrieve all known rooms.
+ * @returns A list of rooms, or an empty list if there is no data store.
+ */
+ public getRooms(): Room[] {
+ return this.store.getRooms();
+ }
+
+ /**
+ * Retrieve all rooms that should be displayed to the user
+ * This is essentially getRooms() with some rooms filtered out, eg. old versions
+ * of rooms that have been replaced or (in future) other rooms that have been
+ * marked at the protocol level as not to be displayed to the user.
+ *
+ * @param msc3946ProcessDynamicPredecessor - if true, look for an
+ * m.room.predecessor state event and
+ * use it if found (MSC3946).
+ * @returns A list of rooms, or an empty list if there is no data store.
+ */
+ public getVisibleRooms(msc3946ProcessDynamicPredecessor = false): Room[] {
+ const allRooms = this.store.getRooms();
+
+ const replacedRooms = new Set();
+ for (const r of allRooms) {
+ const predecessor = r.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
+ if (predecessor) {
+ replacedRooms.add(predecessor);
+ }
+ }
+
+ return allRooms.filter((r) => {
+ const tombstone = r.currentState.getStateEvents(EventType.RoomTombstone, "");
+ if (tombstone && replacedRooms.has(r.roomId)) {
+ return false;
+ }
+ return true;
+ });
+ }
+
+ /**
+ * Retrieve a user.
+ * @param userId - The user ID to retrieve.
+ * @returns A user or null if there is no data store or the user does
+ * not exist.
+ */
+ public getUser(userId: string): User | null {
+ return this.store.getUser(userId);
+ }
+
+ /**
+ * Retrieve all known users.
+ * @returns A list of users, or an empty list if there is no data store.
+ */
+ public getUsers(): User[] {
+ return this.store.getUsers();
+ }
+
+ /**
+ * Set account data event for the current user.
+ * It will retry the request up to 5 times.
+ * @param eventType - The event type
+ * @param content - the contents object for the event
+ * @returns Promise which resolves: an empty object
+ * @returns Rejects: with an error response.
+ */
+ public setAccountData(eventType: EventType | string, content: IContent): Promise<{}> {
+ const path = utils.encodeUri("/user/$userId/account_data/$type", {
+ $userId: this.credentials.userId!,
+ $type: eventType,
+ });
+ return retryNetworkOperation(5, () => {
+ return this.http.authedRequest(Method.Put, path, undefined, content);
+ });
+ }
+
+ /**
+ * Get account data event of given type for the current user.
+ * @param eventType - The event type
+ * @returns The contents of the given account data event
+ */
+ public getAccountData(eventType: string): MatrixEvent | undefined {
+ return this.store.getAccountData(eventType);
+ }
+
+ /**
+ * Get account data event of given type for the current user. This variant
+ * gets account data directly from the homeserver if the local store is not
+ * ready, which can be useful very early in startup before the initial sync.
+ * @param eventType - The event type
+ * @returns Promise which resolves: The contents of the given account data event.
+ * @returns Rejects: with an error response.
+ */
+ public async getAccountDataFromServer<T extends { [k: string]: any }>(eventType: string): Promise<T | null> {
+ if (this.isInitialSyncComplete()) {
+ const event = this.store.getAccountData(eventType);
+ if (!event) {
+ return null;
+ }
+ // The network version below returns just the content, so this branch
+ // does the same to match.
+ return event.getContent<T>();
+ }
+ const path = utils.encodeUri("/user/$userId/account_data/$type", {
+ $userId: this.credentials.userId!,
+ $type: eventType,
+ });
+ try {
+ return await this.http.authedRequest(Method.Get, path);
+ } catch (e) {
+ if ((<MatrixError>e).data?.errcode === "M_NOT_FOUND") {
+ return null;
+ }
+ throw e;
+ }
+ }
+
+ public async deleteAccountData(eventType: string): Promise<void> {
+ const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion);
+ // if deletion is not supported overwrite with empty content
+ if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) {
+ await this.setAccountData(eventType, {});
+ return;
+ }
+ const path = utils.encodeUri("/user/$userId/account_data/$type", {
+ $userId: this.getSafeUserId(),
+ $type: eventType,
+ });
+ const options =
+ msc3391DeleteAccountDataServerSupport === ServerSupport.Unstable
+ ? { prefix: "/_matrix/client/unstable/org.matrix.msc3391" }
+ : undefined;
+ return await this.http.authedRequest(Method.Delete, path, undefined, undefined, options);
+ }
+
+ /**
+ * Gets the users that are ignored by this client
+ * @returns The array of users that are ignored (empty if none)
+ */
+ public getIgnoredUsers(): string[] {
+ const event = this.getAccountData("m.ignored_user_list");
+ if (!event || !event.getContent() || !event.getContent()["ignored_users"]) return [];
+ return Object.keys(event.getContent()["ignored_users"]);
+ }
+
+ /**
+ * Sets the users that the current user should ignore.
+ * @param userIds - the user IDs to ignore
+ * @returns Promise which resolves: an empty object
+ * @returns Rejects: with an error response.
+ */
+ public setIgnoredUsers(userIds: string[]): Promise<{}> {
+ const content = { ignored_users: {} as Record<string, object> };
+ userIds.forEach((u) => {
+ content.ignored_users[u] = {};
+ });
+ return this.setAccountData("m.ignored_user_list", content);
+ }
+
+ /**
+ * Gets whether or not a specific user is being ignored by this client.
+ * @param userId - the user ID to check
+ * @returns true if the user is ignored, false otherwise
+ */
+ public isUserIgnored(userId: string): boolean {
+ return this.getIgnoredUsers().includes(userId);
+ }
+
+ /**
+ * Join a room. If you have already joined the room, this will no-op.
+ * @param roomIdOrAlias - The room ID or room alias to join.
+ * @param opts - Options when joining the room.
+ * @returns Promise which resolves: Room object.
+ * @returns Rejects: with an error response.
+ */
+ public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
+ if (opts.syncRoom === undefined) {
+ opts.syncRoom = true;
+ }
+
+ const room = this.getRoom(roomIdOrAlias);
+ if (room?.hasMembershipState(this.credentials.userId!, "join")) {
+ return Promise.resolve(room);
+ }
+
+ let signPromise: Promise<IThirdPartySigned | void> = Promise.resolve();
+
+ if (opts.inviteSignUrl) {
+ const url = new URL(opts.inviteSignUrl);
+ url.searchParams.set("mxid", this.credentials.userId!);
+ signPromise = this.http.requestOtherUrl<IThirdPartySigned>(Method.Post, url);
+ }
+
+ const queryString: Record<string, string | string[]> = {};
+ if (opts.viaServers) {
+ queryString["server_name"] = opts.viaServers;
+ }
+
+ try {
+ const data: IJoinRequestBody = {};
+ const signedInviteObj = await signPromise;
+ if (signedInviteObj) {
+ data.third_party_signed = signedInviteObj;
+ }
+
+ const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
+ const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
+
+ const roomId = res.room_id;
+ const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ const room = syncApi.createRoom(roomId);
+ if (opts.syncRoom) {
+ // v2 will do this for us
+ // return syncApi.syncRoom(room);
+ }
+ return room;
+ } catch (e) {
+ throw e; // rethrow for reject
+ }
+ }
+
+ /**
+ * Resend an event. Will also retry any to-device messages waiting to be sent.
+ * @param event - The event to resend.
+ * @param room - Optional. The room the event is in. Will update the
+ * timeline entry if provided.
+ * @returns Promise which resolves: to an ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public resendEvent(event: MatrixEvent, room: Room): Promise<ISendEventResponse> {
+ // also kick the to-device queue to retry
+ this.toDeviceMessageQueue.sendQueue();
+
+ this.updatePendingEventStatus(room, event, EventStatus.SENDING);
+ return this.encryptAndSendEvent(room, event);
+ }
+
+ /**
+ * Cancel a queued or unsent event.
+ *
+ * @param event - Event to cancel
+ * @throws Error if the event is not in QUEUED, NOT_SENT or ENCRYPTING state
+ */
+ public cancelPendingEvent(event: MatrixEvent): void {
+ if (![EventStatus.QUEUED, EventStatus.NOT_SENT, EventStatus.ENCRYPTING].includes(event.status!)) {
+ throw new Error("cannot cancel an event with status " + event.status);
+ }
+
+ // if the event is currently being encrypted then
+ if (event.status === EventStatus.ENCRYPTING) {
+ this.pendingEventEncryption.delete(event.getId()!);
+ } else if (this.scheduler && event.status === EventStatus.QUEUED) {
+ // tell the scheduler to forget about it, if it's queued
+ this.scheduler.removeEventFromQueue(event);
+ }
+
+ // then tell the room about the change of state, which will remove it
+ // from the room's list of pending events.
+ const room = this.getRoom(event.getRoomId());
+ this.updatePendingEventStatus(room, event, EventStatus.CANCELLED);
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public setRoomName(roomId: string, name: string): Promise<ISendEventResponse> {
+ return this.sendStateEvent(roomId, EventType.RoomName, { name: name });
+ }
+
+ /**
+ * @param htmlTopic - Optional.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public setRoomTopic(roomId: string, topic: string, htmlTopic?: string): Promise<ISendEventResponse> {
+ const content = ContentHelpers.makeTopicContent(topic, htmlTopic);
+ return this.sendStateEvent(roomId, EventType.RoomTopic, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to an object keyed by tagId with objects containing a numeric order field.
+ * @returns Rejects: with an error response.
+ */
+ public getRoomTags(roomId: string): Promise<ITagsResponse> {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags", {
+ $userId: this.credentials.userId!,
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @param tagName - name of room tag to be set
+ * @param metadata - associated with that tag to be stored
+ * @returns Promise which resolves: to an empty object
+ * @returns Rejects: with an error response.
+ */
+ public setRoomTag(roomId: string, tagName: string, metadata: ITagMetadata): Promise<{}> {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
+ $userId: this.credentials.userId!,
+ $roomId: roomId,
+ $tag: tagName,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, metadata);
+ }
+
+ /**
+ * @param tagName - name of room tag to be removed
+ * @returns Promise which resolves: to an empty object
+ * @returns Rejects: with an error response.
+ */
+ public deleteRoomTag(roomId: string, tagName: string): Promise<{}> {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/tags/$tag", {
+ $userId: this.credentials.userId!,
+ $roomId: roomId,
+ $tag: tagName,
+ });
+ return this.http.authedRequest(Method.Delete, path);
+ }
+
+ /**
+ * @param eventType - event type to be set
+ * @param content - event content
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setRoomAccountData(roomId: string, eventType: string, content: Record<string, any>): Promise<{}> {
+ const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
+ $userId: this.credentials.userId!,
+ $roomId: roomId,
+ $type: eventType,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, content);
+ }
+
+ /**
+ * Set a power level to one or multiple users.
+ * @returns Promise which resolves: to an ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public setPowerLevel(
+ roomId: string,
+ userId: string | string[],
+ powerLevel: number | undefined,
+ event: MatrixEvent | null,
+ ): Promise<ISendEventResponse> {
+ let content = {
+ users: {} as Record<string, number>,
+ };
+ if (event?.getType() === EventType.RoomPowerLevels) {
+ // take a copy of the content to ensure we don't corrupt
+ // existing client state with a failed power level change
+ content = utils.deepCopy(event.getContent());
+ }
+
+ const users = Array.isArray(userId) ? userId : [userId];
+ for (const user of users) {
+ if (powerLevel == null) {
+ delete content.users[user];
+ } else {
+ content.users[user] = powerLevel;
+ }
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", {
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, content);
+ }
+
+ /**
+ * Create an m.beacon_info event
+ * @returns
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ public async unstable_createLiveBeacon(
+ roomId: Room["roomId"],
+ beaconInfoContent: MBeaconInfoEventContent,
+ ): Promise<ISendEventResponse> {
+ return this.unstable_setLiveBeacon(roomId, beaconInfoContent);
+ }
+
+ /**
+ * Upsert a live beacon event
+ * using a specific m.beacon_info.* event variable type
+ * @param roomId - string
+ * @returns
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ public async unstable_setLiveBeacon(
+ roomId: string,
+ beaconInfoContent: MBeaconInfoEventContent,
+ ): Promise<ISendEventResponse> {
+ return this.sendStateEvent(roomId, M_BEACON_INFO.name, beaconInfoContent, this.getUserId()!);
+ }
+
+ public sendEvent(roomId: string, eventType: string, content: IContent, txnId?: string): Promise<ISendEventResponse>;
+ public sendEvent(
+ roomId: string,
+ threadId: string | null,
+ eventType: string,
+ content: IContent,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendEvent(
+ roomId: string,
+ threadIdOrEventType: string | null,
+ eventTypeOrContent: string | IContent,
+ contentOrTxnId?: IContent | string,
+ txnIdOrVoid?: string,
+ ): Promise<ISendEventResponse> {
+ let threadId: string | null;
+ let eventType: string;
+ let content: IContent;
+ let txnId: string | undefined;
+ if (!threadIdOrEventType?.startsWith(EVENT_ID_PREFIX) && threadIdOrEventType !== null) {
+ txnId = contentOrTxnId as string;
+ content = eventTypeOrContent as IContent;
+ eventType = threadIdOrEventType;
+ threadId = null;
+ } else {
+ txnId = txnIdOrVoid;
+ content = contentOrTxnId as IContent;
+ eventType = eventTypeOrContent as string;
+ threadId = threadIdOrEventType;
+ }
+
+ // If we expect that an event is part of a thread but is missing the relation
+ // we need to add it manually, as well as the reply fallback
+ if (threadId && !content!["m.relates_to"]?.rel_type) {
+ const isReply = !!content!["m.relates_to"]?.["m.in_reply_to"];
+ content!["m.relates_to"] = {
+ ...content!["m.relates_to"],
+ rel_type: THREAD_RELATION_TYPE.name,
+ event_id: threadId,
+ // Set is_falling_back to true unless this is actually intended to be a reply
+ is_falling_back: !isReply,
+ };
+ const thread = this.getRoom(roomId)?.getThread(threadId);
+ if (thread && !isReply) {
+ content!["m.relates_to"]["m.in_reply_to"] = {
+ event_id:
+ thread
+ .lastReply((ev: MatrixEvent) => {
+ return ev.isRelation(THREAD_RELATION_TYPE.name) && !ev.status;
+ })
+ ?.getId() ?? threadId,
+ };
+ }
+ }
+
+ return this.sendCompleteEvent(roomId, threadId, { type: eventType, content }, txnId);
+ }
+
+ /**
+ * @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 txnId - Optional.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ private sendCompleteEvent(
+ roomId: string,
+ threadId: string | null,
+ eventObject: any,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (!txnId) {
+ txnId = this.makeTxnId();
+ }
+
+ // We always construct a MatrixEvent when sending because the store and scheduler use them.
+ // We'll extract the params back out if it turns out the client has no scheduler or store.
+ const localEvent = new MatrixEvent(
+ Object.assign(eventObject, {
+ event_id: "~" + roomId + ":" + txnId,
+ user_id: this.credentials.userId,
+ sender: this.credentials.userId,
+ room_id: roomId,
+ origin_server_ts: new Date().getTime(),
+ }),
+ );
+
+ const room = this.getRoom(roomId);
+ const thread = threadId ? room?.getThread(threadId) : undefined;
+ if (thread) {
+ localEvent.setThread(thread);
+ }
+
+ // set up re-emitter for this new event - this is normally the job of EventMapper but we don't use it here
+ this.reEmitter.reEmit(localEvent, [MatrixEventEvent.Replaced, MatrixEventEvent.VisibilityChange]);
+ room?.reEmitter.reEmit(localEvent, [MatrixEventEvent.BeforeRedaction]);
+
+ // if this is a relation or redaction of an event
+ // that hasn't been sent yet (e.g. with a local id starting with a ~)
+ // then listen for the remote echo of that event so that by the time
+ // this event does get sent, we have the correct event_id
+ const targetId = localEvent.getAssociatedId();
+ if (targetId?.startsWith("~")) {
+ const target = room?.getPendingEvents().find((e) => e.getId() === targetId);
+ target?.once(MatrixEventEvent.LocalEventIdReplaced, () => {
+ localEvent.updateAssociatedId(target.getId()!);
+ });
+ }
+
+ const type = localEvent.getType();
+ logger.log(`sendEvent of type ${type} in ${roomId} with txnId ${txnId}`);
+
+ localEvent.setTxnId(txnId);
+ localEvent.setStatus(EventStatus.SENDING);
+
+ // add this event immediately to the local store as 'sending'.
+ room?.addPendingEvent(localEvent, txnId);
+
+ // addPendingEvent can change the state to NOT_SENT if it believes
+ // that there's other events that have failed. We won't bother to
+ // try sending the event if the state has changed as such.
+ if (localEvent.status === EventStatus.NOT_SENT) {
+ return Promise.reject(new Error("Event blocked by other events not yet sent"));
+ }
+
+ return this.encryptAndSendEvent(room, localEvent);
+ }
+
+ /**
+ * 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
+ */
+ protected encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> {
+ let cancelled = false;
+ // Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections,
+ // so that we can handle synchronous and asynchronous exceptions with the
+ // same code path.
+ return Promise.resolve()
+ .then(() => {
+ const encryptionPromise = this.encryptEventIfNeeded(event, room ?? undefined);
+ if (!encryptionPromise) return null; // doesn't need encryption
+
+ this.pendingEventEncryption.set(event.getId()!, encryptionPromise);
+ this.updatePendingEventStatus(room, event, EventStatus.ENCRYPTING);
+ return encryptionPromise.then(() => {
+ if (!this.pendingEventEncryption.has(event.getId()!)) {
+ // cancelled via MatrixClient::cancelPendingEvent
+ cancelled = true;
+ return;
+ }
+ this.updatePendingEventStatus(room, event, EventStatus.SENDING);
+ });
+ })
+ .then(() => {
+ if (cancelled) return {} as ISendEventResponse;
+ let promise: Promise<ISendEventResponse> | null = null;
+ if (this.scheduler) {
+ // if this returns a promise then the scheduler has control now and will
+ // resolve/reject when it is done. Internally, the scheduler will invoke
+ // processFn which is set to this._sendEventHttpRequest so the same code
+ // path is executed regardless.
+ promise = this.scheduler.queueEvent(event);
+ if (promise && this.scheduler.getQueueForEvent(event)!.length > 1) {
+ // event is processed FIFO so if the length is 2 or more we know
+ // this event is stuck behind an earlier event.
+ this.updatePendingEventStatus(room, event, EventStatus.QUEUED);
+ }
+ }
+
+ if (!promise) {
+ promise = this.sendEventHttpRequest(event);
+ if (room) {
+ promise = promise.then((res) => {
+ room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]);
+ return res;
+ });
+ }
+ }
+
+ return promise;
+ })
+ .catch((err) => {
+ logger.error("Error sending event", err.stack || err);
+ try {
+ // set the error on the event before we update the status:
+ // updating the status emits the event, so the state should be
+ // consistent at that point.
+ event.error = err;
+ this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
+ } catch (e) {
+ logger.error("Exception in error handler!", (<Error>e).stack || err);
+ }
+ if (err instanceof MatrixError) {
+ err.event = event;
+ }
+ throw err;
+ });
+ }
+
+ private encryptEventIfNeeded(event: MatrixEvent, room?: Room): Promise<void> | null {
+ if (event.isEncrypted()) {
+ // this event has already been encrypted; this happens if the
+ // encryption step succeeded, but the send step failed on the first
+ // attempt.
+ return null;
+ }
+
+ if (event.isRedaction()) {
+ // Redactions do not support encryption in the spec at this time,
+ // whilst it mostly worked in some clients, it wasn't compliant.
+ return null;
+ }
+
+ if (!room || !this.isRoomEncrypted(event.getRoomId()!)) {
+ return null;
+ }
+
+ if (!this.cryptoBackend && this.usingExternalCrypto) {
+ // The client has opted to allow sending messages to encrypted
+ // rooms even if the room is encrypted, and we haven't setup
+ // crypto. This is useful for users of matrix-org/pantalaimon
+ return null;
+ }
+
+ if (event.getType() === EventType.Reaction) {
+ // For reactions, there is a very little gained by encrypting the entire
+ // event, as relation data is already kept in the clear. Event
+ // encryption for a reaction effectively only obscures the event type,
+ // but the purpose is still obvious from the relation data, so nothing
+ // is really gained. It also causes quite a few problems, such as:
+ // * triggers notifications via default push rules
+ // * prevents server-side bundling for reactions
+ // The reaction key / content / emoji value does warrant encrypting, but
+ // this will be handled separately by encrypting just this value.
+ // See https://github.com/matrix-org/matrix-doc/pull/1849#pullrequestreview-248763642
+ return null;
+ }
+
+ if (!this.cryptoBackend) {
+ throw new Error("This room is configured to use encryption, but your client does not support encryption.");
+ }
+
+ return this.cryptoBackend.encryptEvent(event, room);
+ }
+
+ /**
+ * Returns the eventType that should be used taking encryption into account
+ * for a given eventType.
+ * @param roomId - the room for the events `eventType` relates to
+ * @param eventType - the event type
+ * @returns the event type taking encryption into account
+ */
+ private getEncryptedIfNeededEventType(
+ roomId: string,
+ eventType?: EventType | string | null,
+ ): EventType | string | null | undefined {
+ if (eventType === EventType.Reaction) return eventType;
+ return this.isRoomEncrypted(roomId) ? EventType.RoomMessageEncrypted : eventType;
+ }
+
+ protected updatePendingEventStatus(room: Room | null, event: MatrixEvent, newStatus: EventStatus): void {
+ if (room) {
+ room.updatePendingEvent(event, newStatus);
+ } else {
+ event.setStatus(newStatus);
+ }
+ }
+
+ private sendEventHttpRequest(event: MatrixEvent): Promise<ISendEventResponse> {
+ let txnId = event.getTxnId();
+ if (!txnId) {
+ txnId = this.makeTxnId();
+ event.setTxnId(txnId);
+ }
+
+ const pathParams = {
+ $roomId: event.getRoomId()!,
+ $eventType: event.getWireType(),
+ $stateKey: event.getStateKey()!,
+ $txnId: txnId,
+ };
+
+ let path: string;
+
+ if (event.isState()) {
+ let pathTemplate = "/rooms/$roomId/state/$eventType";
+ if (event.getStateKey() && event.getStateKey()!.length > 0) {
+ pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey";
+ }
+ path = utils.encodeUri(pathTemplate, pathParams);
+ } else if (event.isRedaction()) {
+ const pathTemplate = `/rooms/$roomId/redact/$redactsEventId/$txnId`;
+ path = utils.encodeUri(pathTemplate, {
+ $redactsEventId: event.event.redacts!,
+ ...pathParams,
+ });
+ } else {
+ path = utils.encodeUri("/rooms/$roomId/send/$eventType/$txnId", pathParams);
+ }
+
+ return this.http
+ .authedRequest<ISendEventResponse>(Method.Put, path, undefined, event.getWireContent())
+ .then((res) => {
+ logger.log(`Event sent to ${event.getRoomId()} with event id ${res.event_id}`);
+ return res;
+ });
+ }
+
+ /**
+ * @param txnId - transaction id. One will be made up if not supplied.
+ * @param opts - Options to pass on, may contain `reason` and `with_relations` (MSC3912)
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ * @throws Error if called with `with_relations` (MSC3912) but the server does not support it.
+ * Callers should check whether the server supports MSC3912 via `MatrixClient.canSupport`.
+ */
+ public redactEvent(
+ roomId: string,
+ eventId: string,
+ txnId?: string | undefined,
+ opts?: IRedactOpts,
+ ): Promise<ISendEventResponse>;
+ public redactEvent(
+ roomId: string,
+ threadId: string | null,
+ eventId: string,
+ txnId?: string | undefined,
+ opts?: IRedactOpts,
+ ): Promise<ISendEventResponse>;
+ public redactEvent(
+ roomId: string,
+ threadId: string | null,
+ eventId?: string,
+ txnId?: string | IRedactOpts,
+ opts?: IRedactOpts,
+ ): Promise<ISendEventResponse> {
+ if (!eventId?.startsWith(EVENT_ID_PREFIX)) {
+ opts = txnId as IRedactOpts;
+ txnId = eventId;
+ eventId = threadId!;
+ threadId = null;
+ }
+ const reason = opts?.reason;
+
+ if (
+ opts?.with_relations &&
+ this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Unsupported
+ ) {
+ throw new Error(
+ "Server does not support relation based redactions " +
+ `roomId ${roomId} eventId ${eventId} txnId: ${txnId} threadId ${threadId}`,
+ );
+ }
+
+ const withRelations = opts?.with_relations
+ ? {
+ [this.canSupport.get(Feature.RelationBasedRedactions) === ServerSupport.Stable
+ ? MSC3912_RELATION_BASED_REDACTIONS_PROP.stable!
+ : MSC3912_RELATION_BASED_REDACTIONS_PROP.unstable!]: opts?.with_relations,
+ }
+ : {};
+
+ return this.sendCompleteEvent(
+ roomId,
+ threadId,
+ {
+ type: EventType.RoomRedaction,
+ content: {
+ ...withRelations,
+ reason,
+ },
+ redacts: eventId,
+ },
+ txnId as string,
+ );
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns Promise which resolves: to an ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendMessage(roomId: string, content: IContent, txnId?: string): Promise<ISendEventResponse>;
+ public sendMessage(
+ roomId: string,
+ threadId: string | null,
+ content: IContent,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendMessage(
+ roomId: string,
+ threadId: string | null | IContent,
+ content?: IContent | string,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (typeof threadId !== "string" && threadId !== null) {
+ txnId = content as string;
+ content = threadId as IContent;
+ threadId = null;
+ }
+
+ const eventType: string = EventType.RoomMessage;
+ const sendContent: IContent = content as IContent;
+
+ return this.sendEvent(roomId, threadId as string | null, eventType, sendContent, txnId);
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns
+ * @returns Rejects: with an error response.
+ */
+ public sendTextMessage(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>;
+ public sendTextMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendTextMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ txnId = body;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeTextMessage(body);
+ return this.sendMessage(roomId, threadId, content, txnId);
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendNotice(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>;
+ public sendNotice(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendNotice(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ txnId = body;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeNotice(body);
+ return this.sendMessage(roomId, threadId, content, txnId);
+ }
+
+ /**
+ * @param txnId - Optional.
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendEmoteMessage(roomId: string, body: string, txnId?: string): Promise<ISendEventResponse>;
+ public sendEmoteMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse>;
+ public sendEmoteMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ txnId?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ txnId = body;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeEmoteMessage(body);
+ return this.sendMessage(roomId, threadId, content, txnId);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendImageMessage(roomId: string, url: string, info?: IImageInfo, text?: string): Promise<ISendEventResponse>;
+ public sendImageMessage(
+ roomId: string,
+ threadId: string | null,
+ url: string,
+ info?: IImageInfo,
+ text?: string,
+ ): Promise<ISendEventResponse>;
+ public sendImageMessage(
+ roomId: string,
+ threadId: string | null,
+ url?: string | IImageInfo,
+ info?: IImageInfo | string,
+ text = "Image",
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ text = (info as string) || "Image";
+ info = url as IImageInfo;
+ url = threadId as string;
+ threadId = null;
+ }
+ const content = {
+ msgtype: MsgType.Image,
+ url: url,
+ info: info,
+ body: text,
+ };
+ return this.sendMessage(roomId, threadId, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendStickerMessage(
+ roomId: string,
+ url: string,
+ info?: IImageInfo,
+ text?: string,
+ ): Promise<ISendEventResponse>;
+ public sendStickerMessage(
+ roomId: string,
+ threadId: string | null,
+ url: string,
+ info?: IImageInfo,
+ text?: string,
+ ): Promise<ISendEventResponse>;
+ public sendStickerMessage(
+ roomId: string,
+ threadId: string | null,
+ url?: string | IImageInfo,
+ info?: IImageInfo | string,
+ text = "Sticker",
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ text = (info as string) || "Sticker";
+ info = url as IImageInfo;
+ url = threadId as string;
+ threadId = null;
+ }
+ const content = {
+ url: url,
+ info: info,
+ body: text,
+ };
+
+ return this.sendEvent(roomId, threadId, EventType.Sticker, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendHtmlMessage(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
+ public sendHtmlMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody: string,
+ ): Promise<ISendEventResponse>;
+ public sendHtmlMessage(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ htmlBody = body as string;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeHtmlMessage(body, htmlBody!);
+ return this.sendMessage(roomId, threadId, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendHtmlNotice(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
+ public sendHtmlNotice(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody: string,
+ ): Promise<ISendEventResponse>;
+ public sendHtmlNotice(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ htmlBody = body as string;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeHtmlNotice(body, htmlBody!);
+ return this.sendMessage(roomId, threadId, content);
+ }
+
+ /**
+ * @returns Promise which resolves: to a ISendEventResponse object
+ * @returns Rejects: with an error response.
+ */
+ public sendHtmlEmote(roomId: string, body: string, htmlBody: string): Promise<ISendEventResponse>;
+ public sendHtmlEmote(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody: string,
+ ): Promise<ISendEventResponse>;
+ public sendHtmlEmote(
+ roomId: string,
+ threadId: string | null,
+ body: string,
+ htmlBody?: string,
+ ): Promise<ISendEventResponse> {
+ if (!threadId?.startsWith(EVENT_ID_PREFIX) && threadId !== null) {
+ htmlBody = body as string;
+ body = threadId;
+ threadId = null;
+ }
+ const content = ContentHelpers.makeHtmlEmote(body, htmlBody!);
+ return this.sendMessage(roomId, threadId, content);
+ }
+
+ /**
+ * Send a receipt.
+ * @param event - The event being acknowledged
+ * @param receiptType - The kind of receipt e.g. "m.read". Other than
+ * ReceiptType.Read are experimental!
+ * @param body - Additional content to send alongside the receipt.
+ * @param unthreaded - An unthreaded receipt will clear room+thread notifications
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public async sendReceipt(event: MatrixEvent, receiptType: ReceiptType, body: any, unthreaded = false): Promise<{}> {
+ if (this.isGuest()) {
+ return Promise.resolve({}); // guests cannot send receipts so don't bother.
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/receipt/$receiptType/$eventId", {
+ $roomId: event.getRoomId()!,
+ $receiptType: receiptType,
+ $eventId: event.getId()!,
+ });
+
+ if (!unthreaded) {
+ const isThread = !!event.threadRootId;
+ body = {
+ ...body,
+ thread_id: isThread ? event.threadRootId : MAIN_ROOM_TIMELINE,
+ };
+ }
+
+ const promise = this.http.authedRequest<{}>(Method.Post, path, undefined, body || {});
+
+ const room = this.getRoom(event.getRoomId());
+ if (room && this.credentials.userId) {
+ room.addLocalEchoReceipt(this.credentials.userId, event, receiptType);
+ }
+ return promise;
+ }
+
+ /**
+ * Send a read receipt.
+ * @param event - The event that has been read.
+ * @param receiptType - other than ReceiptType.Read are experimental! Optional.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public async sendReadReceipt(
+ event: MatrixEvent | null,
+ receiptType = ReceiptType.Read,
+ unthreaded = false,
+ ): Promise<{} | undefined> {
+ if (!event) return;
+ const eventId = event.getId()!;
+ const room = this.getRoom(event.getRoomId());
+ if (room?.hasPendingEvent(eventId)) {
+ throw new Error(`Cannot set read receipt to a pending event (${eventId})`);
+ }
+
+ return this.sendReceipt(event, receiptType, {}, unthreaded);
+ }
+
+ /**
+ * Set a marker to indicate the point in a room before which the user has read every
+ * event. This can be retrieved from room account data (the event type is `m.fully_read`)
+ * and displayed as a horizontal line in the timeline that is visually distinct to the
+ * position of the user's own read receipt.
+ * @param roomId - ID of the room that has been read
+ * @param rmEventId - ID of the event that has been read
+ * @param rrEvent - the event tracked by the read receipt. This is here for
+ * convenience because the RR and the RM are commonly updated at the same time as each
+ * other. The local echo of this receipt will be done if set. Optional.
+ * @param rpEvent - the m.read.private read receipt event for when we don't
+ * want other users to see the read receipts. This is experimental. Optional.
+ * @returns Promise which resolves: the empty object, `{}`.
+ */
+ public async setRoomReadMarkers(
+ roomId: string,
+ rmEventId: string,
+ rrEvent?: MatrixEvent,
+ rpEvent?: MatrixEvent,
+ ): Promise<{}> {
+ const room = this.getRoom(roomId);
+ if (room && room.hasPendingEvent(rmEventId)) {
+ throw new Error(`Cannot set read marker to a pending event (${rmEventId})`);
+ }
+
+ // Add the optional RR update, do local echo like `sendReceipt`
+ let rrEventId: string | undefined;
+ if (rrEvent) {
+ rrEventId = rrEvent.getId()!;
+ if (room?.hasPendingEvent(rrEventId)) {
+ throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`);
+ }
+ room?.addLocalEchoReceipt(this.credentials.userId!, rrEvent, ReceiptType.Read);
+ }
+
+ // Add the optional private RR update, do local echo like `sendReceipt`
+ let rpEventId: string | undefined;
+ if (rpEvent) {
+ rpEventId = rpEvent.getId()!;
+ if (room?.hasPendingEvent(rpEventId)) {
+ throw new Error(`Cannot set read receipt to a pending event (${rpEventId})`);
+ }
+ room?.addLocalEchoReceipt(this.credentials.userId!, rpEvent, ReceiptType.ReadPrivate);
+ }
+
+ return await this.setRoomReadMarkersHttpRequest(roomId, rmEventId, rrEventId, rpEventId);
+ }
+
+ /**
+ * Get a preview of the given URL as of (roughly) the given point in time,
+ * described as an object with OpenGraph keys and associated values.
+ * Attributes may be synthesized where actual OG metadata is lacking.
+ * Caches results to prevent hammering the server.
+ * @param url - The URL to get preview data for
+ * @param ts - The preferred point in time that the preview should
+ * describe (ms since epoch). The preview returned will either be the most
+ * recent one preceding this timestamp if available, or failing that the next
+ * most recent available preview.
+ * @returns Promise which resolves: Object of OG metadata.
+ * @returns Rejects: with an error response.
+ * May return synthesized attributes if the URL lacked OG meta.
+ */
+ public getUrlPreview(url: string, ts: number): Promise<IPreviewUrlResponse> {
+ // bucket the timestamp to the nearest minute to prevent excessive spam to the server
+ // Surely 60-second accuracy is enough for anyone.
+ ts = Math.floor(ts / 60000) * 60000;
+
+ const parsed = new URL(url);
+ parsed.hash = ""; // strip the hash as it won't affect the preview
+ url = parsed.toString();
+
+ const key = ts + "_" + url;
+
+ // If there's already a request in flight (or we've handled it), return that instead.
+ const cachedPreview = this.urlPreviewCache[key];
+ if (cachedPreview) {
+ return cachedPreview;
+ }
+
+ const resp = this.http.authedRequest<IPreviewUrlResponse>(
+ Method.Get,
+ "/preview_url",
+ {
+ url,
+ ts: ts.toString(),
+ },
+ undefined,
+ {
+ prefix: MediaPrefix.R0,
+ },
+ );
+ // TODO: Expire the URL preview cache sometimes
+ this.urlPreviewCache[key] = resp;
+ return resp;
+ }
+
+ /**
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public sendTyping(roomId: string, isTyping: boolean, timeoutMs: number): Promise<{}> {
+ if (this.isGuest()) {
+ return Promise.resolve({}); // guests cannot send typing notifications so don't bother.
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/typing/$userId", {
+ $roomId: roomId,
+ $userId: this.getUserId()!,
+ });
+ const data: any = {
+ typing: isTyping,
+ };
+ if (isTyping) {
+ data.timeout = timeoutMs ? timeoutMs : 20000;
+ }
+ return this.http.authedRequest(Method.Put, path, undefined, data);
+ }
+
+ /**
+ * Determines the history of room upgrades for a given room, as far as the
+ * client can see. Returns an array of Rooms where the first entry is the
+ * oldest and the last entry is the newest (likely current) room. If the
+ * provided room is not found, this returns an empty list. This works in
+ * both directions, looking for older and newer rooms of the given room.
+ * @param roomId - The room ID to search from
+ * @param verifyLinks - If true, the function will only return rooms
+ * which can be proven to be linked. For example, rooms which have a create
+ * event pointing to an old room which the client is not aware of or doesn't
+ * have a matching tombstone would not be returned.
+ * @param msc3946ProcessDynamicPredecessor - if true, look for
+ * m.room.predecessor state events as well as create events, and prefer
+ * predecessor events where they exist (MSC3946).
+ * @returns An array of rooms representing the upgrade
+ * history.
+ */
+ public getRoomUpgradeHistory(
+ roomId: string,
+ verifyLinks = false,
+ msc3946ProcessDynamicPredecessor = false,
+ ): Room[] {
+ const currentRoom = this.getRoom(roomId);
+ if (!currentRoom) return [];
+
+ const before = this.findPredecessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor);
+ const after = this.findSuccessorRooms(currentRoom, verifyLinks, msc3946ProcessDynamicPredecessor);
+
+ return [...before, currentRoom, ...after];
+ }
+
+ private findPredecessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] {
+ const ret: Room[] = [];
+
+ // Work backwards from newer to older rooms
+ let predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
+ while (predecessorRoomId !== null) {
+ const predecessorRoom = this.getRoom(predecessorRoomId);
+ if (predecessorRoom === null) {
+ break;
+ }
+ if (verifyLinks) {
+ const tombstone = predecessorRoom.currentState.getStateEvents(EventType.RoomTombstone, "");
+ if (!tombstone || tombstone.getContent()["replacement_room"] !== room.roomId) {
+ break;
+ }
+ }
+
+ // Insert at the front because we're working backwards from the currentRoom
+ ret.splice(0, 0, predecessorRoom);
+
+ room = predecessorRoom;
+ predecessorRoomId = room.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
+ }
+ return ret;
+ }
+
+ private findSuccessorRooms(room: Room, verifyLinks: boolean, msc3946ProcessDynamicPredecessor: boolean): Room[] {
+ const ret: Room[] = [];
+
+ // Work forwards, looking at tombstone events
+ let tombstoneEvent = room.currentState.getStateEvents(EventType.RoomTombstone, "");
+ while (tombstoneEvent) {
+ const successorRoom = this.getRoom(tombstoneEvent.getContent()["replacement_room"]);
+ if (!successorRoom) break; // end of the chain
+ if (successorRoom.roomId === room.roomId) break; // Tombstone is referencing its own room
+
+ if (verifyLinks) {
+ const predecessorRoomId = successorRoom.findPredecessor(msc3946ProcessDynamicPredecessor)?.roomId;
+ if (!predecessorRoomId || predecessorRoomId !== room.roomId) {
+ break;
+ }
+ }
+
+ // Push to the end because we're looking forwards
+ ret.push(successorRoom);
+ const roomIds = new Set(ret.map((ref) => ref.roomId));
+ if (roomIds.size < ret.length) {
+ // The last room added to the list introduced a previous roomId
+ // To avoid recursion, return the last rooms - 1
+ return ret.slice(0, ret.length - 1);
+ }
+
+ // Set the current room to the reference room so we know where we're at
+ room = successorRoom;
+ tombstoneEvent = room.currentState.getStateEvents(EventType.RoomTombstone, "");
+ }
+ return ret;
+ }
+
+ /**
+ * @param reason - Optional.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public invite(roomId: string, userId: string, reason?: string): Promise<{}> {
+ return this.membershipChange(roomId, userId, "invite", reason);
+ }
+
+ /**
+ * Invite a user to a room based on their email address.
+ * @param roomId - The room to invite the user to.
+ * @param email - The email address to invite.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public inviteByEmail(roomId: string, email: string): Promise<{}> {
+ return this.inviteByThreePid(roomId, "email", email);
+ }
+
+ /**
+ * Invite a user to a room based on a third-party identifier.
+ * @param roomId - The room to invite the user to.
+ * @param medium - The medium to invite the user e.g. "email".
+ * @param address - The address for the specified medium.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public async inviteByThreePid(roomId: string, medium: string, address: string): Promise<{}> {
+ const path = utils.encodeUri("/rooms/$roomId/invite", { $roomId: roomId });
+
+ const identityServerUrl = this.getIdentityServerUrl(true);
+ if (!identityServerUrl) {
+ return Promise.reject(
+ new MatrixError({
+ error: "No supplied identity server URL",
+ errcode: "ORG.MATRIX.JSSDK_MISSING_PARAM",
+ }),
+ );
+ }
+ const params: Record<string, string> = {
+ id_server: identityServerUrl,
+ medium: medium,
+ address: address,
+ };
+
+ if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
+ const identityAccessToken = await this.identityServer.getAccessToken();
+ if (identityAccessToken) {
+ params["id_access_token"] = identityAccessToken;
+ }
+ }
+
+ return this.http.authedRequest(Method.Post, path, undefined, params);
+ }
+
+ /**
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public leave(roomId: string): Promise<{}> {
+ return this.membershipChange(roomId, undefined, "leave");
+ }
+
+ /**
+ * Leaves all rooms in the chain of room upgrades based on the given room. By
+ * default, this will leave all the previous and upgraded rooms, including the
+ * given room. To only leave the given room and any previous rooms, keeping the
+ * upgraded (modern) rooms untouched supply `false` to `includeFuture`.
+ * @param roomId - The room ID to start leaving at
+ * @param includeFuture - If true, the whole chain (past and future) of
+ * upgraded rooms will be left.
+ * @returns Promise which resolves when completed with an object keyed
+ * by room ID and value of the error encountered when leaving or null.
+ */
+ public leaveRoomChain(
+ roomId: string,
+ includeFuture = true,
+ ): Promise<{ [roomId: string]: Error | MatrixError | null }> {
+ const upgradeHistory = this.getRoomUpgradeHistory(roomId);
+
+ let eligibleToLeave = upgradeHistory;
+ if (!includeFuture) {
+ eligibleToLeave = [];
+ for (const room of upgradeHistory) {
+ eligibleToLeave.push(room);
+ if (room.roomId === roomId) {
+ break;
+ }
+ }
+ }
+
+ const populationResults: { [roomId: string]: Error } = {};
+ const promises: Promise<any>[] = [];
+
+ const doLeave = (roomId: string): Promise<void> => {
+ return this.leave(roomId)
+ .then(() => {
+ delete populationResults[roomId];
+ })
+ .catch((err) => {
+ // suppress error
+ populationResults[roomId] = err;
+ });
+ };
+
+ for (const room of eligibleToLeave) {
+ promises.push(doLeave(room.roomId));
+ }
+
+ return Promise.all(promises).then(() => populationResults);
+ }
+
+ /**
+ * @param reason - Optional.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public ban(roomId: string, userId: string, reason?: string): Promise<{}> {
+ return this.membershipChange(roomId, userId, "ban", reason);
+ }
+
+ /**
+ * @param deleteRoom - True to delete the room from the store on success.
+ * Default: true.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public forget(roomId: string, deleteRoom = true): Promise<{}> {
+ const promise = this.membershipChange(roomId, undefined, "forget");
+ if (!deleteRoom) {
+ return promise;
+ }
+ return promise.then((response) => {
+ this.store.removeRoom(roomId);
+ this.emit(ClientEvent.DeleteRoom, roomId);
+ return response;
+ });
+ }
+
+ /**
+ * @returns Promise which resolves: Object (currently empty)
+ * @returns Rejects: with an error response.
+ */
+ public unban(roomId: string, userId: string): Promise<{}> {
+ // unbanning != set their state to leave: this used to be
+ // the case, but was then changed so that leaving was always
+ // a revoking of privilege, otherwise two people racing to
+ // kick / ban someone could end up banning and then un-banning
+ // them.
+ const path = utils.encodeUri("/rooms/$roomId/unban", {
+ $roomId: roomId,
+ });
+ const data = {
+ user_id: userId,
+ };
+ return this.http.authedRequest(Method.Post, path, undefined, data);
+ }
+
+ /**
+ * @param reason - Optional.
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public kick(roomId: string, userId: string, reason?: string): Promise<{}> {
+ const path = utils.encodeUri("/rooms/$roomId/kick", {
+ $roomId: roomId,
+ });
+ const data = {
+ user_id: userId,
+ reason: reason,
+ };
+ return this.http.authedRequest(Method.Post, path, undefined, data);
+ }
+
+ private membershipChange(
+ roomId: string,
+ userId: string | undefined,
+ membership: string,
+ reason?: string,
+ ): Promise<{}> {
+ // API returns an empty object
+ const path = utils.encodeUri("/rooms/$room_id/$membership", {
+ $room_id: roomId,
+ $membership: membership,
+ });
+ return this.http.authedRequest(Method.Post, path, undefined, {
+ user_id: userId, // may be undefined e.g. on leave
+ reason: reason,
+ });
+ }
+
+ /**
+ * Obtain a dict of actions which should be performed for this event according
+ * to the push rules for this user. Caches the dict on the event.
+ * @param event - The event to get push actions for.
+ * @param forceRecalculate - forces to recalculate actions for an event
+ * Useful when an event just got decrypted
+ * @returns A dict of actions to perform.
+ */
+ public getPushActionsForEvent(event: MatrixEvent, forceRecalculate = false): IActionsObject | null {
+ if (!event.getPushActions() || forceRecalculate) {
+ event.setPushActions(this.pushProcessor.actionsForEvent(event));
+ }
+ return event.getPushActions();
+ }
+
+ /**
+ * @param info - The kind of info to set (e.g. 'avatar_url')
+ * @param data - The JSON object to set.
+ * @returns
+ * @returns Rejects: with an error response.
+ */
+ // eslint-disable-next-line camelcase
+ public setProfileInfo(info: "avatar_url", data: { avatar_url: string }): Promise<{}>;
+ public setProfileInfo(info: "displayname", data: { displayname: string }): Promise<{}>;
+ public setProfileInfo(info: "avatar_url" | "displayname", data: object): Promise<{}> {
+ const path = utils.encodeUri("/profile/$userId/$info", {
+ $userId: this.credentials.userId!,
+ $info: info,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, data);
+ }
+
+ /**
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public async setDisplayName(name: string): Promise<{}> {
+ const prom = await this.setProfileInfo("displayname", { displayname: name });
+ // XXX: synthesise a profile update for ourselves because Synapse is broken and won't
+ const user = this.getUser(this.getUserId()!);
+ if (user) {
+ user.displayName = name;
+ user.emit(UserEvent.DisplayName, user.events.presence, user);
+ }
+ return prom;
+ }
+
+ /**
+ * @returns Promise which resolves: `{}` an empty object.
+ * @returns Rejects: with an error response.
+ */
+ public async setAvatarUrl(url: string): Promise<{}> {
+ const prom = await this.setProfileInfo("avatar_url", { avatar_url: url });
+ // XXX: synthesise a profile update for ourselves because Synapse is broken and won't
+ const user = this.getUser(this.getUserId()!);
+ if (user) {
+ user.avatarUrl = url;
+ user.emit(UserEvent.AvatarUrl, user.events.presence, user);
+ }
+ return prom;
+ }
+
+ /**
+ * Turn an MXC URL into an HTTP one. <strong>This method is experimental and
+ * may change.</strong>
+ * @param mxcUrl - The MXC URL
+ * @param width - The desired width of the thumbnail.
+ * @param height - The desired height of the thumbnail.
+ * @param resizeMethod - The thumbnail resize method to use, either
+ * "crop" or "scale".
+ * @param allowDirectLinks - If true, return any non-mxc URLs
+ * directly. Fetching such URLs will leak information about the user to
+ * anyone they share a room with. If false, will return null for such URLs.
+ * @returns the avatar URL or null.
+ */
+ public mxcUrlToHttp(
+ mxcUrl: string,
+ width?: number,
+ height?: number,
+ resizeMethod?: string,
+ allowDirectLinks?: boolean,
+ ): string | null {
+ return getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks);
+ }
+
+ /**
+ * @param opts - Options to apply
+ * @returns Promise which resolves
+ * @returns Rejects: with an error response.
+ * @throws If 'presence' isn't a valid presence enum value.
+ */
+ public async setPresence(opts: IPresenceOpts): Promise<void> {
+ const path = utils.encodeUri("/presence/$userId/status", {
+ $userId: this.credentials.userId!,
+ });
+
+ const validStates = ["offline", "online", "unavailable"];
+ if (validStates.indexOf(opts.presence) === -1) {
+ throw new Error("Bad presence value: " + opts.presence);
+ }
+ await this.http.authedRequest(Method.Put, path, undefined, opts);
+ }
+
+ /**
+ * @param userId - The user to get presence for
+ * @returns Promise which resolves: The presence state for this user.
+ * @returns Rejects: with an error response.
+ */
+ public getPresence(userId: string): Promise<IStatusResponse> {
+ const path = utils.encodeUri("/presence/$userId/status", {
+ $userId: userId,
+ });
+
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Retrieve older messages from the given room and put them in the timeline.
+ *
+ * If this is called multiple times whilst a request is ongoing, the <i>same</i>
+ * Promise will be returned. If there was a problem requesting scrollback, there
+ * will be a small delay before another request can be made (to prevent tight-looping
+ * when there is no connection).
+ *
+ * @param room - The room to get older messages in.
+ * @param limit - Optional. The maximum number of previous events to
+ * pull in. Default: 30.
+ * @returns Promise which resolves: Room. If you are at the beginning
+ * of the timeline, `Room.oldState.paginationToken` will be
+ * `null`.
+ * @returns Rejects: with an error response.
+ */
+ public scrollback(room: Room, limit = 30): Promise<Room> {
+ let timeToWaitMs = 0;
+
+ let info = this.ongoingScrollbacks[room.roomId] || {};
+ if (info.promise) {
+ return info.promise;
+ } else if (info.errorTs) {
+ const timeWaitedMs = Date.now() - info.errorTs;
+ timeToWaitMs = Math.max(SCROLLBACK_DELAY_MS - timeWaitedMs, 0);
+ }
+
+ if (room.oldState.paginationToken === null) {
+ return Promise.resolve(room); // already at the start.
+ }
+ // attempt to grab more events from the store first
+ const numAdded = this.store.scrollback(room, limit).length;
+ if (numAdded === limit) {
+ // store contained everything we needed.
+ return Promise.resolve(room);
+ }
+ // reduce the required number of events appropriately
+ limit = limit - numAdded;
+
+ const promise = new Promise<Room>((resolve, reject) => {
+ // wait for a time before doing this request
+ // (which may be 0 in order not to special case the code paths)
+ sleep(timeToWaitMs)
+ .then(() => {
+ return this.createMessagesRequest(
+ room.roomId,
+ room.oldState.paginationToken,
+ limit,
+ Direction.Backward,
+ );
+ })
+ .then((res: IMessagesResponse) => {
+ const matrixEvents = res.chunk.map(this.getEventMapper());
+ if (res.state) {
+ const stateEvents = res.state.map(this.getEventMapper());
+ room.currentState.setUnknownStateEvents(stateEvents);
+ }
+
+ const [timelineEvents, threadedEvents] = room.partitionThreadedEvents(matrixEvents);
+
+ this.processAggregatedTimelineEvents(room, timelineEvents);
+ room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
+ this.processThreadEvents(room, threadedEvents, true);
+
+ room.oldState.paginationToken = res.end ?? null;
+ if (res.chunk.length === 0) {
+ room.oldState.paginationToken = null;
+ }
+ this.store.storeEvents(room, matrixEvents, res.end ?? null, true);
+ delete this.ongoingScrollbacks[room.roomId];
+ resolve(room);
+ })
+ .catch((err) => {
+ this.ongoingScrollbacks[room.roomId] = {
+ errorTs: Date.now(),
+ };
+ reject(err);
+ });
+ });
+
+ info = { promise };
+
+ this.ongoingScrollbacks[room.roomId] = info;
+ return promise;
+ }
+
+ public getEventMapper(options?: MapperOpts): EventMapper {
+ return eventMapperFor(this, options || {});
+ }
+
+ /**
+ * Get an EventTimeline for the given event
+ *
+ * <p>If the EventTimelineSet object already has the given event in its store, the
+ * corresponding timeline will be returned. Otherwise, a /context request is
+ * made, and used to construct an EventTimeline.
+ * If the event does not belong to this EventTimelineSet then undefined will be returned.
+ *
+ * @param timelineSet - The timelineSet to look for the event in, must be bound to a room
+ * @param eventId - The ID of the event to look for
+ *
+ * @returns Promise which resolves:
+ * {@link EventTimeline} including the given event
+ */
+ public async getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<Optional<EventTimeline>> {
+ // don't allow any timeline support unless it's been enabled.
+ if (!this.timelineSupport) {
+ throw new Error(
+ "timeline support is disabled. Set the 'timelineSupport'" +
+ " parameter to true when creating MatrixClient to enable it.",
+ );
+ }
+
+ if (!timelineSet?.room) {
+ throw new Error("getEventTimeline only supports room timelines");
+ }
+
+ if (timelineSet.getTimelineForEvent(eventId)) {
+ return timelineSet.getTimelineForEvent(eventId);
+ }
+
+ if (timelineSet.thread && this.supportsThreads()) {
+ return this.getThreadTimeline(timelineSet, eventId);
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
+ $roomId: timelineSet.room.roomId,
+ $eventId: eventId,
+ });
+
+ let params: Record<string, string | string[]> | undefined = undefined;
+ if (this.clientOpts?.lazyLoadMembers) {
+ params = { filter: JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER) };
+ }
+
+ // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
+ const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
+ if (!res.event) {
+ throw new Error("'event' not in '/context' result - homeserver too old?");
+ }
+
+ // by the time the request completes, the event might have ended up in the timeline.
+ if (timelineSet.getTimelineForEvent(eventId)) {
+ return timelineSet.getTimelineForEvent(eventId);
+ }
+
+ const mapper = this.getEventMapper();
+ const event = mapper(res.event);
+ if (event.isRelation(THREAD_RELATION_TYPE.name)) {
+ logger.warn("Tried loading a regular timeline at the position of a thread event");
+ return undefined;
+ }
+ const events = [
+ // Order events from most recent to oldest (reverse-chronological).
+ // We start with the last event, since that's the point at which we have known state.
+ // events_after is already backwards; events_before is forwards.
+ ...res.events_after.reverse().map(mapper),
+ event,
+ ...res.events_before.map(mapper),
+ ];
+
+ // Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
+ let timeline = timelineSet.getTimelineForEvent(events[0].getId());
+ if (timeline) {
+ timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
+ } else {
+ timeline = timelineSet.addTimeline();
+ timeline.initialiseState(res.state.map(mapper));
+ timeline.getState(EventTimeline.FORWARDS)!.paginationToken = res.end;
+ }
+
+ const [timelineEvents, threadedEvents] = timelineSet.room.partitionThreadedEvents(events);
+ timelineSet.addEventsToTimeline(timelineEvents, true, timeline, res.start);
+ // The target event is not in a thread but process the contextual events, so we can show any threads around it.
+ this.processThreadEvents(timelineSet.room, threadedEvents, true);
+ this.processAggregatedTimelineEvents(timelineSet.room, timelineEvents);
+
+ // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
+ // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
+ // anywhere, if it was later redacted, so we just return the timeline we first thought of.
+ return (
+ timelineSet.getTimelineForEvent(eventId) ??
+ timelineSet.room.findThreadForEvent(event)?.liveTimeline ?? // for Threads degraded support
+ timeline
+ );
+ }
+
+ public async getThreadTimeline(timelineSet: EventTimelineSet, eventId: string): Promise<EventTimeline | undefined> {
+ if (!this.supportsThreads()) {
+ throw new Error("could not get thread timeline: no client support");
+ }
+
+ if (!timelineSet.room) {
+ throw new Error("could not get thread timeline: not a room timeline");
+ }
+
+ if (!timelineSet.thread) {
+ throw new Error("could not get thread timeline: not a thread timeline");
+ }
+
+ const path = utils.encodeUri("/rooms/$roomId/context/$eventId", {
+ $roomId: timelineSet.room.roomId,
+ $eventId: eventId,
+ });
+
+ const params: Record<string, string | string[]> = {
+ limit: "0",
+ };
+ if (this.clientOpts?.lazyLoadMembers) {
+ params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
+ }
+
+ // TODO: we should implement a backoff (as per scrollback()) to deal more nicely with HTTP errors.
+ const res = await this.http.authedRequest<IContextResponse>(Method.Get, path, params);
+ const mapper = this.getEventMapper();
+ const event = mapper(res.event);
+
+ if (!timelineSet.canContain(event)) {
+ return undefined;
+ }
+
+ if (Thread.hasServerSideSupport) {
+ if (Thread.hasServerSideFwdPaginationSupport) {
+ if (!timelineSet.thread) {
+ throw new Error("could not get thread timeline: not a thread timeline");
+ }
+
+ const thread = timelineSet.thread;
+ const resOlder: IRelationsResponse = await this.fetchRelations(
+ timelineSet.room.roomId,
+ thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Backward, from: res.start },
+ );
+ const resNewer: IRelationsResponse = await this.fetchRelations(
+ timelineSet.room.roomId,
+ thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Forward, from: res.end },
+ );
+ const events = [
+ // Order events from most recent to oldest (reverse-chronological).
+ // We start with the last event, since that's the point at which we have known state.
+ // events_after is already backwards; events_before is forwards.
+ ...resNewer.chunk.reverse().map(mapper),
+ event,
+ ...resOlder.chunk.map(mapper),
+ ];
+ for (const event of events) {
+ await timelineSet.thread?.processEvent(event);
+ }
+
+ // Here we handle non-thread timelines only, but still process any thread events to populate thread summaries.
+ let timeline = timelineSet.getTimelineForEvent(event.getId());
+ if (timeline) {
+ timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
+ } else {
+ timeline = timelineSet.addTimeline();
+ timeline.initialiseState(res.state.map(mapper));
+ }
+
+ timelineSet.addEventsToTimeline(events, true, timeline, resNewer.next_batch);
+ if (!resOlder.next_batch) {
+ const originalEvent = await this.fetchRoomEvent(timelineSet.room.roomId, thread.id);
+ timelineSet.addEventsToTimeline([mapper(originalEvent)], true, timeline, null);
+ }
+ timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
+ timeline.setPaginationToken(resNewer.next_batch ?? null, Direction.Forward);
+ this.processAggregatedTimelineEvents(timelineSet.room, events);
+
+ // There is no guarantee that the event ended up in "timeline" (we might have switched to a neighbouring
+ // timeline) - so check the room's index again. On the other hand, there's no guarantee the event ended up
+ // anywhere, if it was later redacted, so we just return the timeline we first thought of.
+ return timelineSet.getTimelineForEvent(eventId) ?? timeline;
+ } else {
+ // Where the event is a thread reply (not a root) and running in MSC-enabled mode the Thread timeline only
+ // functions contiguously, so we have to jump through some hoops to get our target event in it.
+ // XXX: workaround for https://github.com/vector-im/element-meta/issues/150
+
+ const thread = timelineSet.thread;
+
+ const resOlder = await this.fetchRelations(
+ timelineSet.room.roomId,
+ thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Backward, from: res.start },
+ );
+ const eventsNewer: IEvent[] = [];
+ let nextBatch: Optional<string> = res.end;
+ while (nextBatch) {
+ const resNewer: IRelationsResponse = await this.fetchRelations(
+ timelineSet.room.roomId,
+ thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Forward, from: nextBatch },
+ );
+ nextBatch = resNewer.next_batch ?? null;
+ eventsNewer.push(...resNewer.chunk);
+ }
+ const events = [
+ // Order events from most recent to oldest (reverse-chronological).
+ // We start with the last event, since that's the point at which we have known state.
+ // events_after is already backwards; events_before is forwards.
+ ...eventsNewer.reverse().map(mapper),
+ event,
+ ...resOlder.chunk.map(mapper),
+ ];
+ for (const event of events) {
+ await timelineSet.thread?.processEvent(event);
+ }
+
+ // Here we handle non-thread timelines only, but still process any thread events to populate thread
+ // summaries.
+ const timeline = timelineSet.getLiveTimeline();
+ timeline.getState(EventTimeline.BACKWARDS)!.setUnknownStateEvents(res.state.map(mapper));
+
+ timelineSet.addEventsToTimeline(events, true, timeline, null);
+ if (!resOlder.next_batch) {
+ const originalEvent = await this.fetchRoomEvent(timelineSet.room.roomId, thread.id);
+ timelineSet.addEventsToTimeline([mapper(originalEvent)], true, timeline, null);
+ }
+ timeline.setPaginationToken(resOlder.next_batch ?? null, Direction.Backward);
+ timeline.setPaginationToken(null, Direction.Forward);
+ this.processAggregatedTimelineEvents(timelineSet.room, events);
+
+ return timeline;
+ }
+ }
+ }
+
+ /**
+ * Get an EventTimeline for the latest events in the room. This will just
+ * call `/messages` to get the latest message in the room, then use
+ * `client.getEventTimeline(...)` to construct a new timeline from it.
+ *
+ * @param timelineSet - The timelineSet to find or add the timeline to
+ *
+ * @returns Promise which resolves:
+ * {@link EventTimeline} timeline with the latest events in the room
+ */
+ public async getLatestTimeline(timelineSet: EventTimelineSet): Promise<Optional<EventTimeline>> {
+ // don't allow any timeline support unless it's been enabled.
+ if (!this.timelineSupport) {
+ throw new Error(
+ "timeline support is disabled. Set the 'timelineSupport'" +
+ " parameter to true when creating MatrixClient to enable it.",
+ );
+ }
+
+ if (!timelineSet.room) {
+ throw new Error("getLatestTimeline only supports room timelines");
+ }
+
+ let event;
+ if (timelineSet.threadListType !== null) {
+ const res = await this.createThreadListMessagesRequest(
+ timelineSet.room.roomId,
+ null,
+ 1,
+ Direction.Backward,
+ timelineSet.threadListType,
+ timelineSet.getFilter(),
+ );
+ event = res.chunk?.[0];
+ } else if (timelineSet.thread && Thread.hasServerSideSupport) {
+ const res = await this.fetchRelations(
+ timelineSet.room.roomId,
+ timelineSet.thread.id,
+ THREAD_RELATION_TYPE.name,
+ null,
+ { dir: Direction.Backward, limit: 1 },
+ );
+ event = res.chunk?.[0];
+ } else {
+ const messagesPath = utils.encodeUri("/rooms/$roomId/messages", {
+ $roomId: timelineSet.room.roomId,
+ });
+
+ const params: Record<string, string | string[]> = {
+ dir: "b",
+ };
+ if (this.clientOpts?.lazyLoadMembers) {
+ params.filter = JSON.stringify(Filter.LAZY_LOADING_MESSAGES_FILTER);
+ }
+
+ const res = await this.http.authedRequest<IMessagesResponse>(Method.Get, messagesPath, params);
+ event = res.chunk?.[0];
+ }
+ if (!event) {
+ throw new Error("No message returned when trying to construct getLatestTimeline");
+ }
+
+ return this.getEventTimeline(timelineSet, event.event_id);
+ }
+
+ /**
+ * Makes a request to /messages with the appropriate lazy loading filter set.
+ * XXX: if we do get rid of scrollback (as it's not used at the moment),
+ * we could inline this method again in paginateEventTimeline as that would
+ * then be the only call-site
+ * @param limit - the maximum amount of events the retrieve
+ * @param dir - 'f' or 'b'
+ * @param timelineFilter - the timeline filter to pass
+ */
+ // XXX: Intended private, used in code.
+ public createMessagesRequest(
+ roomId: string,
+ fromToken: string | null,
+ limit = 30,
+ dir: Direction,
+ timelineFilter?: Filter,
+ ): Promise<IMessagesResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/messages", { $roomId: roomId });
+
+ const params: Record<string, string> = {
+ limit: limit.toString(),
+ dir: dir,
+ };
+
+ if (fromToken) {
+ params.from = fromToken;
+ }
+
+ let filter: IRoomEventFilter | null = null;
+ if (this.clientOpts?.lazyLoadMembers) {
+ // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER,
+ // so the timelineFilter doesn't get written into it below
+ filter = Object.assign({}, Filter.LAZY_LOADING_MESSAGES_FILTER);
+ }
+ if (timelineFilter) {
+ // XXX: it's horrific that /messages' filter parameter doesn't match
+ // /sync's one - see https://matrix.org/jira/browse/SPEC-451
+ filter = filter || {};
+ Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent()?.toJSON());
+ }
+ if (filter) {
+ params.filter = JSON.stringify(filter);
+ }
+ return this.http.authedRequest(Method.Get, path, params);
+ }
+
+ /**
+ * Makes a request to /messages with the appropriate lazy loading filter set.
+ * XXX: if we do get rid of scrollback (as it's not used at the moment),
+ * we could inline this method again in paginateEventTimeline as that would
+ * then be the only call-site
+ * @param limit - the maximum amount of events the retrieve
+ * @param dir - 'f' or 'b'
+ * @param timelineFilter - the timeline filter to pass
+ */
+ // XXX: Intended private, used by room.fetchRoomThreads
+ public createThreadListMessagesRequest(
+ roomId: string,
+ fromToken: string | null,
+ limit = 30,
+ dir = Direction.Backward,
+ threadListType: ThreadFilterType | null = ThreadFilterType.All,
+ timelineFilter?: Filter,
+ ): Promise<IMessagesResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/threads", { $roomId: roomId });
+
+ const params: Record<string, string> = {
+ limit: limit.toString(),
+ dir: dir,
+ include: threadFilterTypeToFilter(threadListType),
+ };
+
+ if (fromToken) {
+ params.from = fromToken;
+ }
+
+ let filter: IRoomEventFilter = {};
+ if (this.clientOpts?.lazyLoadMembers) {
+ // create a shallow copy of LAZY_LOADING_MESSAGES_FILTER,
+ // so the timelineFilter doesn't get written into it below
+ filter = {
+ ...Filter.LAZY_LOADING_MESSAGES_FILTER,
+ };
+ }
+ if (timelineFilter) {
+ // XXX: it's horrific that /messages' filter parameter doesn't match
+ // /sync's one - see https://matrix.org/jira/browse/SPEC-451
+ filter = {
+ ...filter,
+ ...timelineFilter.getRoomTimelineFilterComponent()?.toJSON(),
+ };
+ }
+ if (Object.keys(filter).length) {
+ params.filter = JSON.stringify(filter);
+ }
+
+ const opts = {
+ prefix:
+ Thread.hasServerSideListSupport === FeatureSupport.Stable
+ ? "/_matrix/client/v1"
+ : "/_matrix/client/unstable/org.matrix.msc3856",
+ };
+
+ return this.http
+ .authedRequest<IThreadedMessagesResponse>(Method.Get, path, params, undefined, opts)
+ .then((res) => ({
+ ...res,
+ chunk: res.chunk?.reverse(),
+ start: res.prev_batch,
+ end: res.next_batch,
+ }));
+ }
+
+ /**
+ * Take an EventTimeline, and back/forward-fill results.
+ *
+ * @param eventTimeline - timeline object to be updated
+ *
+ * @returns Promise which resolves to a boolean: false if there are no
+ * events and we reached either end of the timeline; else true.
+ */
+ public paginateEventTimeline(eventTimeline: EventTimeline, opts: IPaginateOpts): Promise<boolean> {
+ const isNotifTimeline = eventTimeline.getTimelineSet() === this.notifTimelineSet;
+ const room = this.getRoom(eventTimeline.getRoomId()!);
+ const threadListType = eventTimeline.getTimelineSet().threadListType;
+ const thread = eventTimeline.getTimelineSet().thread;
+
+ // TODO: we should implement a backoff (as per scrollback()) to deal more
+ // nicely with HTTP errors.
+ opts = opts || {};
+ const backwards = opts.backwards || false;
+
+ if (isNotifTimeline) {
+ if (!backwards) {
+ throw new Error("paginateNotifTimeline can only paginate backwards");
+ }
+ }
+
+ const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
+
+ const token = eventTimeline.getPaginationToken(dir);
+ const pendingRequest = eventTimeline.paginationRequests[dir];
+
+ if (pendingRequest) {
+ // already a request in progress - return the existing promise
+ return pendingRequest;
+ }
+
+ let path: string;
+ let params: Record<string, string>;
+ let promise: Promise<boolean>;
+
+ if (isNotifTimeline) {
+ path = "/notifications";
+ params = {
+ limit: (opts.limit ?? 30).toString(),
+ only: "highlight",
+ };
+
+ if (token && token !== "end") {
+ params.from = token;
+ }
+
+ promise = this.http
+ .authedRequest<INotificationsResponse>(Method.Get, path, params)
+ .then(async (res) => {
+ const token = res.next_token;
+ const matrixEvents: MatrixEvent[] = [];
+
+ res.notifications = res.notifications.filter(noUnsafeEventProps);
+
+ for (let i = 0; i < res.notifications.length; i++) {
+ const notification = res.notifications[i];
+ const event = this.getEventMapper()(notification.event);
+ event.setPushActions(PushProcessor.actionListToActionsObject(notification.actions));
+ event.event.room_id = notification.room_id; // XXX: gutwrenching
+ matrixEvents[i] = event;
+ }
+
+ // No need to partition events for threads here, everything lives
+ // in the notification timeline set
+ const timelineSet = eventTimeline.getTimelineSet();
+ timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
+ this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && !res.next_token) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return Boolean(res.next_token);
+ })
+ .finally(() => {
+ eventTimeline.paginationRequests[dir] = null;
+ });
+ eventTimeline.paginationRequests[dir] = promise;
+ } else if (threadListType !== null) {
+ if (!room) {
+ throw new Error("Unknown room " + eventTimeline.getRoomId());
+ }
+
+ if (!Thread.hasServerSideFwdPaginationSupport && dir === Direction.Forward) {
+ throw new Error("Cannot paginate threads forwards without server-side support for MSC 3715");
+ }
+
+ promise = this.createThreadListMessagesRequest(
+ eventTimeline.getRoomId()!,
+ token,
+ opts.limit,
+ dir,
+ threadListType,
+ eventTimeline.getFilter(),
+ )
+ .then((res) => {
+ if (res.state) {
+ const roomState = eventTimeline.getState(dir)!;
+ const stateEvents = res.state.filter(noUnsafeEventProps).map(this.getEventMapper());
+ roomState.setUnknownStateEvents(stateEvents);
+ }
+ const token = res.end;
+ const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
+
+ const timelineSet = eventTimeline.getTimelineSet();
+ timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, token);
+ this.processAggregatedTimelineEvents(room, matrixEvents);
+ this.processThreadRoots(room, matrixEvents, backwards);
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && res.end == res.start) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return res.end !== res.start;
+ })
+ .finally(() => {
+ eventTimeline.paginationRequests[dir] = null;
+ });
+ eventTimeline.paginationRequests[dir] = promise;
+ } else if (thread) {
+ const room = this.getRoom(eventTimeline.getRoomId() ?? undefined);
+ if (!room) {
+ throw new Error("Unknown room " + eventTimeline.getRoomId());
+ }
+
+ promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, {
+ dir,
+ limit: opts.limit,
+ from: token ?? undefined,
+ })
+ .then(async (res) => {
+ const mapper = this.getEventMapper();
+ const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(mapper);
+
+ // Process latest events first
+ for (const event of matrixEvents.slice().reverse()) {
+ await thread?.processEvent(event);
+ const sender = event.getSender()!;
+ if (!backwards || thread?.getEventReadUpTo(sender) === null) {
+ room.addLocalEchoReceipt(sender, event, ReceiptType.Read);
+ }
+ }
+
+ const newToken = res.next_batch;
+
+ const timelineSet = eventTimeline.getTimelineSet();
+ timelineSet.addEventsToTimeline(matrixEvents, backwards, eventTimeline, newToken ?? null);
+ if (!newToken && backwards) {
+ const originalEvent = await this.fetchRoomEvent(eventTimeline.getRoomId() ?? "", thread.id);
+ timelineSet.addEventsToTimeline([mapper(originalEvent)], true, eventTimeline, null);
+ }
+ this.processAggregatedTimelineEvents(timelineSet.room, matrixEvents);
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && !newToken) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return Boolean(newToken);
+ })
+ .finally(() => {
+ eventTimeline.paginationRequests[dir] = null;
+ });
+ eventTimeline.paginationRequests[dir] = promise;
+ } else {
+ if (!room) {
+ throw new Error("Unknown room " + eventTimeline.getRoomId());
+ }
+
+ promise = this.createMessagesRequest(
+ eventTimeline.getRoomId()!,
+ token,
+ opts.limit,
+ dir,
+ eventTimeline.getFilter(),
+ )
+ .then((res) => {
+ if (res.state) {
+ const roomState = eventTimeline.getState(dir)!;
+ const stateEvents = res.state.filter(noUnsafeEventProps).map(this.getEventMapper());
+ roomState.setUnknownStateEvents(stateEvents);
+ }
+ const token = res.end;
+ const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
+
+ const timelineSet = eventTimeline.getTimelineSet();
+ const [timelineEvents] = room.partitionThreadedEvents(matrixEvents);
+ timelineSet.addEventsToTimeline(timelineEvents, backwards, eventTimeline, token);
+ this.processAggregatedTimelineEvents(room, timelineEvents);
+ this.processThreadRoots(
+ room,
+ timelineEvents.filter((it) => it.getServerAggregatedRelation(THREAD_RELATION_TYPE.name)),
+ false,
+ );
+
+ const atEnd = res.end === undefined || res.end === res.start;
+
+ // if we've hit the end of the timeline, we need to stop trying to
+ // paginate. We need to keep the 'forwards' token though, to make sure
+ // we can recover from gappy syncs.
+ if (backwards && atEnd) {
+ eventTimeline.setPaginationToken(null, dir);
+ }
+ return !atEnd;
+ })
+ .finally(() => {
+ eventTimeline.paginationRequests[dir] = null;
+ });
+ eventTimeline.paginationRequests[dir] = promise;
+ }
+
+ return promise;
+ }
+
+ /**
+ * Reset the notifTimelineSet entirely, paginating in some historical notifs as
+ * a starting point for subsequent pagination.
+ */
+ public resetNotifTimelineSet(): void {
+ if (!this.notifTimelineSet) {
+ return;
+ }
+
+ // FIXME: This thing is a total hack, and results in duplicate events being
+ // added to the timeline both from /sync and /notifications, and lots of
+ // slow and wasteful processing and pagination. The correct solution is to
+ // extend /messages or /search or something to filter on notifications.
+
+ // use the fictitious token 'end'. in practice we would ideally give it
+ // the oldest backwards pagination token from /sync, but /sync doesn't
+ // know about /notifications, so we have no choice but to start paginating
+ // from the current point in time. This may well overlap with historical
+ // notifs which are then inserted into the timeline by /sync responses.
+ this.notifTimelineSet.resetLiveTimeline("end");
+
+ // we could try to paginate a single event at this point in order to get
+ // a more valid pagination token, but it just ends up with an out of order
+ // timeline. given what a mess this is and given we're going to have duplicate
+ // events anyway, just leave it with the dummy token for now.
+ /*
+ this.paginateNotifTimeline(this._notifTimelineSet.getLiveTimeline(), {
+ backwards: true,
+ limit: 1
+ });
+ */
+ }
+
+ /**
+ * Peek into a room and receive updates about the room. This only works if the
+ * history visibility for the room is world_readable.
+ * @param roomId - The room to attempt to peek into.
+ * @returns Promise which resolves: Room object
+ * @returns Rejects: with an error response.
+ */
+ public peekInRoom(roomId: string): Promise<Room> {
+ this.peekSync?.stopPeeking();
+ this.peekSync = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ return this.peekSync.peek(roomId);
+ }
+
+ /**
+ * Stop any ongoing room peeking.
+ */
+ public stopPeeking(): void {
+ if (this.peekSync) {
+ this.peekSync.stopPeeking();
+ this.peekSync = null;
+ }
+ }
+
+ /**
+ * Set r/w flags for guest access in a room.
+ * @param roomId - The room to configure guest access in.
+ * @param opts - Options
+ * @returns Promise which resolves
+ * @returns Rejects: with an error response.
+ */
+ public setGuestAccess(roomId: string, opts: IGuestAccessOpts): Promise<void> {
+ const writePromise = this.sendStateEvent(
+ roomId,
+ EventType.RoomGuestAccess,
+ {
+ guest_access: opts.allowJoin ? "can_join" : "forbidden",
+ },
+ "",
+ );
+
+ let readPromise: Promise<any> = Promise.resolve<any>(undefined);
+ if (opts.allowRead) {
+ readPromise = this.sendStateEvent(
+ roomId,
+ EventType.RoomHistoryVisibility,
+ {
+ history_visibility: "world_readable",
+ },
+ "",
+ );
+ }
+
+ return Promise.all([readPromise, writePromise]).then(); // .then() to hide results for contract
+ }
+
+ /**
+ * Requests an email verification token for the purposes of registration.
+ * This API requests a token from the homeserver.
+ * The doesServerRequireIdServerParam() method can be used to determine if
+ * the server requires the id_server parameter to be provided.
+ *
+ * Parameters and return value are as for requestEmailToken
+
+ * @param email - As requestEmailToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestRegisterEmailToken(
+ email: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestTokenResponse> {
+ return this.requestTokenFromEndpoint("/register/email/requestToken", {
+ email: email,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests a text message verification token for the purposes of registration.
+ * This API requests a token from the homeserver.
+ * The doesServerRequireIdServerParam() method can be used to determine if
+ * the server requires the id_server parameter to be provided.
+ *
+ * @param phoneCountry - The ISO 3166-1 alpha-2 code for the country in which
+ * phoneNumber should be parsed relative to.
+ * @param phoneNumber - The phone number, in national or international format
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestRegisterMsisdnToken(
+ phoneCountry: string,
+ phoneNumber: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestMsisdnTokenResponse> {
+ return this.requestTokenFromEndpoint("/register/msisdn/requestToken", {
+ country: phoneCountry,
+ phone_number: phoneNumber,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests an email verification token for the purposes of adding a
+ * third party identifier to an account.
+ * This API requests a token from the homeserver.
+ * The doesServerRequireIdServerParam() method can be used to determine if
+ * the server requires the id_server parameter to be provided.
+ * If an account with the given email address already exists and is
+ * associated with an account other than the one the user is authed as,
+ * it will either send an email to the address informing them of this
+ * or return M_THREEPID_IN_USE (which one is up to the homeserver).
+ *
+ * @param email - As requestEmailToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestAdd3pidEmailToken(
+ email: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestTokenResponse> {
+ return this.requestTokenFromEndpoint("/account/3pid/email/requestToken", {
+ email: email,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests a text message verification token for the purposes of adding a
+ * third party identifier to an account.
+ * This API proxies the identity server /validate/email/requestToken API,
+ * adding specific behaviour for the addition of phone numbers to an
+ * account, as requestAdd3pidEmailToken.
+ *
+ * @param phoneCountry - As requestRegisterMsisdnToken
+ * @param phoneNumber - As requestRegisterMsisdnToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestAdd3pidMsisdnToken(
+ phoneCountry: string,
+ phoneNumber: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestMsisdnTokenResponse> {
+ return this.requestTokenFromEndpoint("/account/3pid/msisdn/requestToken", {
+ country: phoneCountry,
+ phone_number: phoneNumber,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests an email verification token for the purposes of resetting
+ * the password on an account.
+ * This API proxies the identity server /validate/email/requestToken API,
+ * adding specific behaviour for the password resetting. Specifically,
+ * if no account with the given email address exists, it may either
+ * return M_THREEPID_NOT_FOUND or send an email
+ * to the address informing them of this (which one is up to the homeserver).
+ *
+ * requestEmailToken calls the equivalent API directly on the identity server,
+ * therefore bypassing the password reset specific logic.
+ *
+ * @param email - As requestEmailToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestPasswordEmailToken(
+ email: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ ): Promise<IRequestTokenResponse> {
+ return this.requestTokenFromEndpoint("/account/password/email/requestToken", {
+ email: email,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Requests a text message verification token for the purposes of resetting
+ * the password on an account.
+ * This API proxies the identity server /validate/email/requestToken API,
+ * adding specific behaviour for the password resetting, as requestPasswordEmailToken.
+ *
+ * @param phoneCountry - As requestRegisterMsisdnToken
+ * @param phoneNumber - As requestRegisterMsisdnToken
+ * @param clientSecret - As requestEmailToken
+ * @param sendAttempt - As requestEmailToken
+ * @param nextLink - As requestEmailToken
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ public requestPasswordMsisdnToken(
+ phoneCountry: string,
+ phoneNumber: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink: string,
+ ): Promise<IRequestMsisdnTokenResponse> {
+ return this.requestTokenFromEndpoint("/account/password/msisdn/requestToken", {
+ country: phoneCountry,
+ phone_number: phoneNumber,
+ client_secret: clientSecret,
+ send_attempt: sendAttempt,
+ next_link: nextLink,
+ });
+ }
+
+ /**
+ * Internal utility function for requesting validation tokens from usage-specific
+ * requestToken endpoints.
+ *
+ * @param endpoint - The endpoint to send the request to
+ * @param params - Parameters for the POST request
+ * @returns Promise which resolves: As requestEmailToken
+ */
+ private async requestTokenFromEndpoint<T extends IRequestTokenResponse>(
+ endpoint: string,
+ params: Record<string, any>,
+ ): Promise<T> {
+ const postParams = Object.assign({}, params);
+
+ // If the HS supports separate add and bind, then requestToken endpoints
+ // don't need an IS as they are all validated by the HS directly.
+ if (!(await this.doesServerSupportSeparateAddAndBind()) && this.idBaseUrl) {
+ const idServerUrl = new URL(this.idBaseUrl);
+ postParams.id_server = idServerUrl.host;
+
+ if (this.identityServer?.getAccessToken && (await this.doesServerAcceptIdentityAccessToken())) {
+ const identityAccessToken = await this.identityServer.getAccessToken();
+ if (identityAccessToken) {
+ postParams.id_access_token = identityAccessToken;
+ }
+ }
+ }
+
+ return this.http.request(Method.Post, endpoint, undefined, postParams);
+ }
+
+ /**
+ * Get the room-kind push rule associated with a room.
+ * @param scope - "global" or device-specific.
+ * @param roomId - the id of the room.
+ * @returns the rule or undefined.
+ */
+ public getRoomPushRule(scope: "global" | "device", roomId: string): IPushRule | undefined {
+ // There can be only room-kind push rule per room
+ // and its id is the room id.
+ if (this.pushRules) {
+ return this.pushRules[scope]?.room?.find((rule) => rule.rule_id === roomId);
+ } else {
+ throw new Error("SyncApi.sync() must be done before accessing to push rules.");
+ }
+ }
+
+ /**
+ * Set a room-kind muting push rule in a room.
+ * The operation also updates MatrixClient.pushRules at the end.
+ * @param scope - "global" or device-specific.
+ * @param roomId - the id of the room.
+ * @param mute - the mute state.
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public setRoomMutePushRule(scope: "global" | "device", roomId: string, mute: boolean): Promise<void> | undefined {
+ let promise: Promise<unknown> | undefined;
+ let hasDontNotifyRule = false;
+
+ // Get the existing room-kind push rule if any
+ const roomPushRule = this.getRoomPushRule(scope, roomId);
+ if (roomPushRule?.actions.includes(PushRuleActionName.DontNotify)) {
+ hasDontNotifyRule = true;
+ }
+
+ if (!mute) {
+ // Remove the rule only if it is a muting rule
+ if (hasDontNotifyRule) {
+ promise = this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule!.rule_id);
+ }
+ } else {
+ if (!roomPushRule) {
+ promise = this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
+ actions: [PushRuleActionName.DontNotify],
+ });
+ } else if (!hasDontNotifyRule) {
+ // Remove the existing one before setting the mute push rule
+ // This is a workaround to SYN-590 (Push rule update fails)
+ const deferred = utils.defer();
+ this.deletePushRule(scope, PushRuleKind.RoomSpecific, roomPushRule.rule_id)
+ .then(() => {
+ this.addPushRule(scope, PushRuleKind.RoomSpecific, roomId, {
+ actions: [PushRuleActionName.DontNotify],
+ })
+ .then(() => {
+ deferred.resolve();
+ })
+ .catch((err) => {
+ deferred.reject(err);
+ });
+ })
+ .catch((err) => {
+ deferred.reject(err);
+ });
+
+ promise = deferred.promise;
+ }
+ }
+
+ if (promise) {
+ return new Promise<void>((resolve, reject) => {
+ // Update this.pushRules when the operation completes
+ promise!
+ .then(() => {
+ this.getPushRules()
+ .then((result) => {
+ this.pushRules = result;
+ resolve();
+ })
+ .catch((err) => {
+ reject(err);
+ });
+ })
+ .catch((err: Error) => {
+ // Update it even if the previous operation fails. This can help the
+ // app to recover when push settings has been modified from another client
+ this.getPushRules()
+ .then((result) => {
+ this.pushRules = result;
+ reject(err);
+ })
+ .catch((err2) => {
+ reject(err);
+ });
+ });
+ });
+ }
+ }
+
+ public searchMessageText(opts: ISearchOpts): Promise<ISearchResponse> {
+ const roomEvents: ISearchRequestBody["search_categories"]["room_events"] = {
+ search_term: opts.query,
+ };
+
+ if ("keys" in opts) {
+ roomEvents.keys = opts.keys;
+ }
+
+ return this.search({
+ body: {
+ search_categories: {
+ room_events: roomEvents,
+ },
+ },
+ });
+ }
+
+ /**
+ * Perform a server-side search for room events.
+ *
+ * The returned promise resolves to an object containing the fields:
+ *
+ * * count: estimate of the number of results
+ * * next_batch: token for back-pagination; if undefined, there are no more results
+ * * highlights: a list of words to highlight from the stemming algorithm
+ * * results: a list of results
+ *
+ * Each entry in the results list is a SearchResult.
+ *
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public searchRoomEvents(opts: IEventSearchOpts): Promise<ISearchResults> {
+ // TODO: support search groups
+
+ const body = {
+ search_categories: {
+ room_events: {
+ search_term: opts.term,
+ filter: opts.filter,
+ order_by: SearchOrderBy.Recent,
+ event_context: {
+ before_limit: 1,
+ after_limit: 1,
+ include_profile: true,
+ },
+ },
+ },
+ };
+
+ const searchResults: ISearchResults = {
+ _query: body,
+ results: [],
+ highlights: [],
+ };
+
+ return this.search({ body: body }).then((res) => this.processRoomEventsSearch(searchResults, res));
+ }
+
+ /**
+ * Take a result from an earlier searchRoomEvents call, and backfill results.
+ *
+ * @param searchResults - the results object to be updated
+ * @returns Promise which resolves: updated result object
+ * @returns Rejects: with an error response.
+ */
+ public backPaginateRoomEventsSearch<T extends ISearchResults>(searchResults: T): Promise<T> {
+ // TODO: we should implement a backoff (as per scrollback()) to deal more
+ // nicely with HTTP errors.
+
+ if (!searchResults.next_batch) {
+ return Promise.reject(new Error("Cannot backpaginate event search any further"));
+ }
+
+ if (searchResults.pendingRequest) {
+ // already a request in progress - return the existing promise
+ return searchResults.pendingRequest as Promise<T>;
+ }
+
+ const searchOpts = {
+ body: searchResults._query!,
+ next_batch: searchResults.next_batch,
+ };
+
+ const promise = this.search(searchOpts, searchResults.abortSignal)
+ .then((res) => this.processRoomEventsSearch(searchResults, res))
+ .finally(() => {
+ searchResults.pendingRequest = undefined;
+ });
+ searchResults.pendingRequest = promise;
+
+ return promise;
+ }
+
+ /**
+ * helper for searchRoomEvents and backPaginateRoomEventsSearch. Processes the
+ * response from the API call and updates the searchResults
+ *
+ * @returns searchResults
+ * @internal
+ */
+ // XXX: Intended private, used in code
+ public processRoomEventsSearch<T extends ISearchResults>(searchResults: T, response: ISearchResponse): T {
+ const roomEvents = response.search_categories.room_events;
+
+ searchResults.count = roomEvents.count;
+ searchResults.next_batch = roomEvents.next_batch;
+
+ // combine the highlight list with our existing list;
+ const highlights = new Set<string>(roomEvents.highlights);
+ searchResults.highlights.forEach((hl) => {
+ highlights.add(hl);
+ });
+
+ // turn it back into a list.
+ searchResults.highlights = Array.from(highlights);
+
+ const mapper = this.getEventMapper();
+
+ // append the new results to our existing results
+ const resultsLength = roomEvents.results?.length ?? 0;
+ for (let i = 0; i < resultsLength; i++) {
+ const sr = SearchResult.fromJson(roomEvents.results[i], mapper);
+ const room = this.getRoom(sr.context.getEvent().getRoomId());
+ if (room) {
+ // Copy over a known event sender if we can
+ for (const ev of sr.context.getTimeline()) {
+ const sender = room.getMember(ev.getSender()!);
+ if (!ev.sender && sender) ev.sender = sender;
+ }
+ }
+ searchResults.results.push(sr);
+ }
+ return searchResults;
+ }
+
+ /**
+ * Populate the store with rooms the user has left.
+ * @returns Promise which resolves: TODO - Resolved when the rooms have
+ * been added to the data store.
+ * @returns Rejects: with an error response.
+ */
+ public syncLeftRooms(): Promise<Room[]> {
+ // Guard against multiple calls whilst ongoing and multiple calls post success
+ if (this.syncedLeftRooms) {
+ return Promise.resolve([]); // don't call syncRooms again if it succeeded.
+ }
+ if (this.syncLeftRoomsPromise) {
+ return this.syncLeftRoomsPromise; // return the ongoing request
+ }
+ const syncApi = new SyncApi(this, this.clientOpts, this.buildSyncApiOptions());
+ this.syncLeftRoomsPromise = syncApi.syncLeftRooms();
+
+ // cleanup locks
+ this.syncLeftRoomsPromise
+ .then(() => {
+ logger.log("Marking success of sync left room request");
+ this.syncedLeftRooms = true; // flip the bit on success
+ })
+ .finally(() => {
+ this.syncLeftRoomsPromise = undefined; // cleanup ongoing request state
+ });
+
+ return this.syncLeftRoomsPromise;
+ }
+
+ /**
+ * Create a new filter.
+ * @param content - The HTTP body for the request
+ * @returns Promise which resolves to a Filter object.
+ * @returns Rejects: with an error response.
+ */
+ public createFilter(content: IFilterDefinition): Promise<Filter> {
+ const path = utils.encodeUri("/user/$userId/filter", {
+ $userId: this.credentials.userId!,
+ });
+ return this.http.authedRequest<IFilterResponse>(Method.Post, path, undefined, content).then((response) => {
+ // persist the filter
+ const filter = Filter.fromJson(this.credentials.userId, response.filter_id, content);
+ this.store.storeFilter(filter);
+ return filter;
+ });
+ }
+
+ /**
+ * Retrieve a filter.
+ * @param userId - The user ID of the filter owner
+ * @param filterId - The filter ID to retrieve
+ * @param allowCached - True to allow cached filters to be returned.
+ * Default: True.
+ * @returns Promise which resolves: a Filter object
+ * @returns Rejects: with an error response.
+ */
+ public getFilter(userId: string, filterId: string, allowCached: boolean): Promise<Filter> {
+ if (allowCached) {
+ const filter = this.store.getFilter(userId, filterId);
+ if (filter) {
+ return Promise.resolve(filter);
+ }
+ }
+
+ const path = utils.encodeUri("/user/$userId/filter/$filterId", {
+ $userId: userId,
+ $filterId: filterId,
+ });
+
+ return this.http.authedRequest<IFilterDefinition>(Method.Get, path).then((response) => {
+ // persist the filter
+ const filter = Filter.fromJson(userId, filterId, response);
+ this.store.storeFilter(filter);
+ return filter;
+ });
+ }
+
+ /**
+ * @returns Filter ID
+ */
+ public async getOrCreateFilter(filterName: string, filter: Filter): Promise<string> {
+ const filterId = this.store.getFilterIdByName(filterName);
+ let existingId: string | undefined;
+
+ if (filterId) {
+ // check that the existing filter matches our expectations
+ try {
+ const existingFilter = await this.getFilter(this.credentials.userId!, filterId, true);
+ if (existingFilter) {
+ const oldDef = existingFilter.getDefinition();
+ const newDef = filter.getDefinition();
+
+ if (utils.deepCompare(oldDef, newDef)) {
+ // super, just use that.
+ // debuglog("Using existing filter ID %s: %s", filterId,
+ // JSON.stringify(oldDef));
+ existingId = filterId;
+ }
+ }
+ } catch (error) {
+ // Synapse currently returns the following when the filter cannot be found:
+ // {
+ // errcode: "M_UNKNOWN",
+ // name: "M_UNKNOWN",
+ // message: "No row found",
+ // }
+ if ((<MatrixError>error).errcode !== "M_UNKNOWN" && (<MatrixError>error).errcode !== "M_NOT_FOUND") {
+ throw error;
+ }
+ }
+ // if the filter doesn't exist anymore on the server, remove from store
+ if (!existingId) {
+ this.store.setFilterIdByName(filterName, undefined);
+ }
+ }
+
+ if (existingId) {
+ return existingId;
+ }
+
+ // create a new filter
+ const createdFilter = await this.createFilter(filter.getDefinition());
+
+ this.store.setFilterIdByName(filterName, createdFilter.filterId);
+ return createdFilter.filterId!;
+ }
+
+ /**
+ * Gets a bearer token from the homeserver that the user can
+ * present to a third party in order to prove their ownership
+ * of the Matrix account they are logged into.
+ * @returns Promise which resolves: Token object
+ * @returns Rejects: with an error response.
+ */
+ public getOpenIdToken(): Promise<IOpenIDToken> {
+ const path = utils.encodeUri("/user/$userId/openid/request_token", {
+ $userId: this.credentials.userId!,
+ });
+
+ return this.http.authedRequest(Method.Post, path, undefined, {});
+ }
+
+ private startCallEventHandler = (): void => {
+ if (this.isInitialSyncComplete()) {
+ this.callEventHandler!.start();
+ this.groupCallEventHandler!.start();
+ this.off(ClientEvent.Sync, this.startCallEventHandler);
+ }
+ };
+
+ /**
+ * Once the client has been initialised, we want to clear notifications we
+ * know for a fact should be here.
+ * This issue should also be addressed on synapse's side and is tracked as part
+ * of https://github.com/matrix-org/synapse/issues/14837
+ *
+ * We consider a room or a thread as fully read if the current user has sent
+ * the last event in the live timeline of that context and if the read receipt
+ * we have on record matches.
+ */
+ private fixupRoomNotifications = (): void => {
+ if (this.isInitialSyncComplete()) {
+ const unreadRooms = (this.getRooms() ?? []).filter((room) => {
+ return room.getUnreadNotificationCount(NotificationCountType.Total) > 0;
+ });
+
+ for (const room of unreadRooms) {
+ const currentUserId = this.getSafeUserId();
+ room.fixupNotifications(currentUserId);
+ }
+
+ this.off(ClientEvent.Sync, this.fixupRoomNotifications);
+ }
+ };
+
+ /**
+ * @returns Promise which resolves: ITurnServerResponse object
+ * @returns Rejects: with an error response.
+ */
+ public turnServer(): Promise<ITurnServerResponse> {
+ return this.http.authedRequest(Method.Get, "/voip/turnServer");
+ }
+
+ /**
+ * Get the TURN servers for this homeserver.
+ * @returns The servers or an empty list.
+ */
+ public getTurnServers(): ITurnServer[] {
+ return this.turnServers || [];
+ }
+
+ /**
+ * Get the unix timestamp (in milliseconds) at which the current
+ * TURN credentials (from getTurnServers) expire
+ * @returns The expiry timestamp in milliseconds
+ */
+ public getTurnServersExpiry(): number {
+ return this.turnServersExpiry;
+ }
+
+ public get pollingTurnServers(): boolean {
+ return this.checkTurnServersIntervalID !== undefined;
+ }
+
+ // XXX: Intended private, used in code.
+ public async checkTurnServers(): Promise<boolean | undefined> {
+ if (!this.canSupportVoip) {
+ return;
+ }
+
+ let credentialsGood = false;
+ const remainingTime = this.turnServersExpiry - Date.now();
+ if (remainingTime > TURN_CHECK_INTERVAL) {
+ logger.debug("TURN creds are valid for another " + remainingTime + " ms: not fetching new ones.");
+ credentialsGood = true;
+ } else {
+ logger.debug("Fetching new TURN credentials");
+ try {
+ const res = await this.turnServer();
+ if (res.uris) {
+ logger.log("Got TURN URIs: " + res.uris + " refresh in " + res.ttl + " secs");
+ // map the response to a format that can be fed to RTCPeerConnection
+ const servers: ITurnServer = {
+ urls: res.uris,
+ username: res.username,
+ credential: res.password,
+ };
+ this.turnServers = [servers];
+ // The TTL is in seconds but we work in ms
+ this.turnServersExpiry = Date.now() + res.ttl * 1000;
+ credentialsGood = true;
+ this.emit(ClientEvent.TurnServers, this.turnServers);
+ }
+ } catch (err) {
+ logger.error("Failed to get TURN URIs", err);
+ if ((<HTTPError>err).httpStatus === 403) {
+ // We got a 403, so there's no point in looping forever.
+ logger.info("TURN access unavailable for this account: stopping credentials checks");
+ if (this.checkTurnServersIntervalID !== null) global.clearInterval(this.checkTurnServersIntervalID);
+ this.checkTurnServersIntervalID = undefined;
+ this.emit(ClientEvent.TurnServersError, <HTTPError>err, true); // fatal
+ } else {
+ // otherwise, if we failed for whatever reason, try again the next time we're called.
+ this.emit(ClientEvent.TurnServersError, <Error>err, false); // non-fatal
+ }
+ }
+ }
+
+ return credentialsGood;
+ }
+
+ /**
+ * Set whether to allow a fallback ICE server should be used for negotiating a
+ * WebRTC connection if the homeserver doesn't provide any servers. Defaults to
+ * false.
+ *
+ */
+ public setFallbackICEServerAllowed(allow: boolean): void {
+ this.fallbackICEServerAllowed = allow;
+ }
+
+ /**
+ * Get whether to allow a fallback ICE server should be used for negotiating a
+ * WebRTC connection if the homeserver doesn't provide any servers. Defaults to
+ * false.
+ *
+ * @returns
+ */
+ public isFallbackICEServerAllowed(): boolean {
+ return this.fallbackICEServerAllowed;
+ }
+
+ /**
+ * Determines if the current user is an administrator of the Synapse homeserver.
+ * Returns false if untrue or the homeserver does not appear to be a Synapse
+ * homeserver. <strong>This function is implementation specific and may change
+ * as a result.</strong>
+ * @returns true if the user appears to be a Synapse administrator.
+ */
+ public isSynapseAdministrator(): Promise<boolean> {
+ const path = utils.encodeUri("/_synapse/admin/v1/users/$userId/admin", { $userId: this.getUserId()! });
+ return this.http
+ .authedRequest<{ admin: boolean }>(Method.Get, path, undefined, undefined, { prefix: "" })
+ .then((r) => r.admin); // pull out the specific boolean we want
+ }
+
+ /**
+ * Performs a whois lookup on a user using Synapse's administrator API.
+ * <strong>This function is implementation specific and may change as a
+ * result.</strong>
+ * @param userId - the User ID to look up.
+ * @returns the whois response - see Synapse docs for information.
+ */
+ public whoisSynapseUser(userId: string): Promise<ISynapseAdminWhoisResponse> {
+ const path = utils.encodeUri("/_synapse/admin/v1/whois/$userId", { $userId: userId });
+ return this.http.authedRequest(Method.Get, path, undefined, undefined, { prefix: "" });
+ }
+
+ /**
+ * Deactivates a user using Synapse's administrator API. <strong>This
+ * function is implementation specific and may change as a result.</strong>
+ * @param userId - the User ID to deactivate.
+ * @returns the deactivate response - see Synapse docs for information.
+ */
+ public deactivateSynapseUser(userId: string): Promise<ISynapseAdminDeactivateResponse> {
+ const path = utils.encodeUri("/_synapse/admin/v1/deactivate/$userId", { $userId: userId });
+ return this.http.authedRequest(Method.Post, path, undefined, undefined, { prefix: "" });
+ }
+
+ private async fetchClientWellKnown(): Promise<void> {
+ // `getRawClientConfig` does not throw or reject on network errors, instead
+ // it absorbs errors and returns `{}`.
+ this.clientWellKnownPromise = AutoDiscovery.getRawClientConfig(this.getDomain() ?? undefined);
+ this.clientWellKnown = await this.clientWellKnownPromise;
+ this.emit(ClientEvent.ClientWellKnown, this.clientWellKnown);
+ }
+
+ public getClientWellKnown(): IClientWellKnown | undefined {
+ return this.clientWellKnown;
+ }
+
+ public waitForClientWellKnown(): Promise<IClientWellKnown> {
+ if (!this.clientRunning) {
+ throw new Error("Client is not running");
+ }
+ return this.clientWellKnownPromise!;
+ }
+
+ /**
+ * store client options with boolean/string/numeric values
+ * to know in the next session what flags the sync data was
+ * created with (e.g. lazy loading)
+ * @param opts - the complete set of client options
+ * @returns for store operation
+ */
+ public storeClientOptions(): Promise<void> {
+ // XXX: Intended private, used in code
+ const primTypes = ["boolean", "string", "number"];
+ const serializableOpts = Object.entries(this.clientOpts!)
+ .filter(([key, value]) => {
+ return primTypes.includes(typeof value);
+ })
+ .reduce<Record<string, any>>((obj, [key, value]) => {
+ obj[key] = value;
+ return obj;
+ }, {});
+ return this.store.storeClientOptions(serializableOpts);
+ }
+
+ /**
+ * Gets a set of room IDs in common with another user
+ * @param userId - The userId to check.
+ * @returns Promise which resolves to a set of rooms
+ * @returns Rejects: with an error response.
+ */
+ // eslint-disable-next-line
+ public async _unstable_getSharedRooms(userId: string): Promise<string[]> {
+ const sharedRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666");
+ const mutualRoomsSupport = await this.doesServerSupportUnstableFeature("uk.half-shot.msc2666.mutual_rooms");
+
+ if (!sharedRoomsSupport && !mutualRoomsSupport) {
+ throw Error("Server does not support mutual_rooms API");
+ }
+
+ const path = utils.encodeUri(
+ `/uk.half-shot.msc2666/user/${mutualRoomsSupport ? "mutual_rooms" : "shared_rooms"}/$userId`,
+ { $userId: userId },
+ );
+
+ const res = await this.http.authedRequest<{ joined: string[] }>(Method.Get, path, undefined, undefined, {
+ prefix: ClientPrefix.Unstable,
+ });
+ return res.joined;
+ }
+
+ /**
+ * Get the API versions supported by the server, along with any
+ * unstable APIs it supports
+ * @returns The server /versions response
+ */
+ public async getVersions(): Promise<IServerVersions> {
+ if (this.serverVersionsPromise) {
+ return this.serverVersionsPromise;
+ }
+
+ this.serverVersionsPromise = this.http
+ .request<IServerVersions>(
+ Method.Get,
+ "/_matrix/client/versions",
+ undefined, // queryParams
+ undefined, // data
+ {
+ prefix: "",
+ },
+ )
+ .catch((e) => {
+ // Need to unset this if it fails, otherwise we'll never retry
+ this.serverVersionsPromise = undefined;
+ // but rethrow the exception to anything that was waiting
+ throw e;
+ });
+
+ const serverVersions = await this.serverVersionsPromise;
+ this.canSupport = await buildFeatureSupportMap(serverVersions);
+
+ return this.serverVersionsPromise;
+ }
+
+ /**
+ * Check if a particular spec version is supported by the server.
+ * @param version - The spec version (such as "r0.5.0") to check for.
+ * @returns Whether it is supported
+ */
+ public async isVersionSupported(version: string): Promise<boolean> {
+ const { versions } = await this.getVersions();
+ return versions && versions.includes(version);
+ }
+
+ /**
+ * Query the server to see if it supports members lazy loading
+ * @returns true if server supports lazy loading
+ */
+ public async doesServerSupportLazyLoading(): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+
+ const versions = response["versions"];
+ const unstableFeatures = response["unstable_features"];
+
+ return (
+ (versions && versions.includes("r0.5.0")) || (unstableFeatures && unstableFeatures["m.lazy_load_members"])
+ );
+ }
+
+ /**
+ * Query the server to see if the `id_server` parameter is required
+ * when registering with an 3pid, adding a 3pid or resetting password.
+ * @returns true if id_server parameter is required
+ */
+ public async doesServerRequireIdServerParam(): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return true;
+
+ const versions = response["versions"];
+
+ // Supporting r0.6.0 is the same as having the flag set to false
+ if (versions && versions.includes("r0.6.0")) {
+ return false;
+ }
+
+ const unstableFeatures = response["unstable_features"];
+ if (!unstableFeatures) return true;
+ if (unstableFeatures["m.require_identity_server"] === undefined) {
+ return true;
+ } else {
+ return unstableFeatures["m.require_identity_server"];
+ }
+ }
+
+ /**
+ * Query the server to see if the `id_access_token` parameter can be safely
+ * passed to the homeserver. Some homeservers may trigger errors if they are not
+ * prepared for the new parameter.
+ * @returns true if id_access_token can be sent
+ */
+ public async doesServerAcceptIdentityAccessToken(): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+
+ const versions = response["versions"];
+ const unstableFeatures = response["unstable_features"];
+ return (versions && versions.includes("r0.6.0")) || (unstableFeatures && unstableFeatures["m.id_access_token"]);
+ }
+
+ /**
+ * Query the server to see if it supports separate 3PID add and bind functions.
+ * This affects the sequence of API calls clients should use for these operations,
+ * so it's helpful to be able to check for support.
+ * @returns true if separate functions are supported
+ */
+ public async doesServerSupportSeparateAddAndBind(): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+
+ const versions = response["versions"];
+ const unstableFeatures = response["unstable_features"];
+
+ return versions?.includes("r0.6.0") || unstableFeatures?.["m.separate_add_and_bind"];
+ }
+
+ /**
+ * Query the server to see if it lists support for an unstable feature
+ * in the /versions response
+ * @param feature - the feature name
+ * @returns true if the feature is supported
+ */
+ public async doesServerSupportUnstableFeature(feature: string): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+ const unstableFeatures = response["unstable_features"];
+ return unstableFeatures && !!unstableFeatures[feature];
+ }
+
+ /**
+ * Query the server to see if it is forcing encryption to be enabled for
+ * a given room preset, based on the /versions response.
+ * @param presetName - The name of the preset to check.
+ * @returns true if the server is forcing encryption
+ * for the preset.
+ */
+ public async doesServerForceEncryptionForPreset(presetName: Preset): Promise<boolean> {
+ const response = await this.getVersions();
+ if (!response) return false;
+ const unstableFeatures = response["unstable_features"];
+
+ // The preset name in the versions response will be without the _chat suffix.
+ const versionsPresetName = presetName.includes("_chat")
+ ? presetName.substring(0, presetName.indexOf("_chat"))
+ : presetName;
+
+ return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${versionsPresetName}`];
+ }
+
+ public async doesServerSupportThread(): Promise<{
+ threads: FeatureSupport;
+ list: FeatureSupport;
+ fwdPagination: FeatureSupport;
+ }> {
+ if (await this.isVersionSupported("v1.4")) {
+ return {
+ threads: FeatureSupport.Stable,
+ list: FeatureSupport.Stable,
+ fwdPagination: FeatureSupport.Stable,
+ };
+ }
+
+ try {
+ const [threadUnstable, threadStable, listUnstable, listStable, fwdPaginationUnstable, fwdPaginationStable] =
+ await Promise.all([
+ this.doesServerSupportUnstableFeature("org.matrix.msc3440"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3440.stable"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3856"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3856.stable"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3715"),
+ this.doesServerSupportUnstableFeature("org.matrix.msc3715.stable"),
+ ]);
+
+ return {
+ threads: determineFeatureSupport(threadStable, threadUnstable),
+ list: determineFeatureSupport(listStable, listUnstable),
+ fwdPagination: determineFeatureSupport(fwdPaginationStable, fwdPaginationUnstable),
+ };
+ } catch (e) {
+ return {
+ threads: FeatureSupport.None,
+ list: FeatureSupport.None,
+ fwdPagination: FeatureSupport.None,
+ };
+ }
+ }
+
+ /**
+ * Query the server to see if it supports the MSC2457 `logout_devices` parameter when setting password
+ * @returns true if server supports the `logout_devices` parameter
+ */
+ public doesServerSupportLogoutDevices(): Promise<boolean> {
+ return this.isVersionSupported("r0.6.1");
+ }
+
+ /**
+ * Get if lazy loading members is being used.
+ * @returns Whether or not members are lazy loaded by this client
+ */
+ public hasLazyLoadMembersEnabled(): boolean {
+ return !!this.clientOpts?.lazyLoadMembers;
+ }
+
+ /**
+ * Set a function which is called when /sync returns a 'limited' response.
+ * It is called with a room ID and returns a boolean. It should return 'true' if the SDK
+ * can SAFELY remove events from this room. It may not be safe to remove events if there
+ * are other references to the timelines for this room, e.g because the client is
+ * actively viewing events in this room.
+ * Default: returns false.
+ * @param cb - The callback which will be invoked.
+ */
+ public setCanResetTimelineCallback(cb: ResetTimelineCallback): void {
+ this.canResetTimelineCallback = cb;
+ }
+
+ /**
+ * Get the callback set via `setCanResetTimelineCallback`.
+ * @returns The callback or null
+ */
+ public getCanResetTimelineCallback(): ResetTimelineCallback | undefined {
+ return this.canResetTimelineCallback;
+ }
+
+ /**
+ * Returns relations for a given event. Handles encryption transparently,
+ * with the caveat that the amount of events returned might be 0, even though you get a nextBatch.
+ * When the returned promise resolves, all messages should have finished trying to decrypt.
+ * @param roomId - the room of the event
+ * @param eventId - the id of the event
+ * @param relationType - the rel_type of the relations requested
+ * @param eventType - the event type of the relations requested
+ * @param opts - options with optional values for the request.
+ * @returns an object with `events` as `MatrixEvent[]` and optionally `nextBatch` if more relations are available.
+ */
+ public async relations(
+ roomId: string,
+ eventId: string,
+ relationType?: RelationType | string | null,
+ eventType?: EventType | string | null,
+ opts: IRelationsRequestOpts = { dir: Direction.Backward },
+ ): Promise<{
+ originalEvent?: MatrixEvent | null;
+ events: MatrixEvent[];
+ nextBatch?: string | null;
+ prevBatch?: string | null;
+ }> {
+ const fetchedEventType = eventType ? this.getEncryptedIfNeededEventType(roomId, eventType) : null;
+ const [eventResult, result] = await Promise.all([
+ this.fetchRoomEvent(roomId, eventId),
+ this.fetchRelations(roomId, eventId, relationType, fetchedEventType, opts),
+ ]);
+ const mapper = this.getEventMapper();
+
+ const originalEvent = eventResult ? mapper(eventResult) : undefined;
+ let events = result.chunk.map(mapper);
+
+ if (fetchedEventType === EventType.RoomMessageEncrypted) {
+ const allEvents = originalEvent ? events.concat(originalEvent) : events;
+ await Promise.all(allEvents.map((e) => this.decryptEventIfNeeded(e)));
+ if (eventType !== null) {
+ events = events.filter((e) => e.getType() === eventType);
+ }
+ }
+
+ if (originalEvent && relationType === RelationType.Replace) {
+ events = events.filter((e) => e.getSender() === originalEvent.getSender());
+ }
+ return {
+ originalEvent: originalEvent ?? null,
+ events,
+ nextBatch: result.next_batch ?? null,
+ prevBatch: result.prev_batch ?? null,
+ };
+ }
+
+ /**
+ * The app may wish to see if we have a key cached without
+ * triggering a user interaction.
+ */
+ public getCrossSigningCacheCallbacks(): ICacheCallbacks | undefined {
+ // XXX: Private member access
+ return this.crypto?.crossSigningInfo.getCacheCallbacks();
+ }
+
+ /**
+ * Generates a random string suitable for use as a client secret. <strong>This
+ * method is experimental and may change.</strong>
+ * @returns A new client secret
+ */
+ public generateClientSecret(): string {
+ return randomString(32);
+ }
+
+ /**
+ * Attempts to decrypt an event
+ * @param event - The event to decrypt
+ * @returns A decryption promise
+ */
+ public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise<void> {
+ if (event.shouldAttemptDecryption() && this.isCryptoEnabled()) {
+ event.attemptDecryption(this.cryptoBackend!, options);
+ }
+
+ if (event.isBeingDecrypted()) {
+ return event.getDecryptionPromise()!;
+ } else {
+ return Promise.resolve();
+ }
+ }
+
+ private termsUrlForService(serviceType: SERVICE_TYPES, baseUrl: string): URL {
+ switch (serviceType) {
+ case SERVICE_TYPES.IS:
+ return this.http.getUrl("/terms", undefined, IdentityPrefix.V2, baseUrl);
+ case SERVICE_TYPES.IM:
+ return this.http.getUrl("/terms", undefined, "/_matrix/integrations/v1", baseUrl);
+ default:
+ throw new Error("Unsupported service type");
+ }
+ }
+
+ /**
+ * Get the Homeserver URL of this client
+ * @returns Homeserver URL of this client
+ */
+ public getHomeserverUrl(): string {
+ return this.baseUrl;
+ }
+
+ /**
+ * Get the identity server URL of this client
+ * @param stripProto - whether or not to strip the protocol from the URL
+ * @returns Identity server URL of this client
+ */
+ public getIdentityServerUrl(stripProto = false): string | undefined {
+ if (stripProto && (this.idBaseUrl?.startsWith("http://") || this.idBaseUrl?.startsWith("https://"))) {
+ return this.idBaseUrl.split("://")[1];
+ }
+ return this.idBaseUrl;
+ }
+
+ /**
+ * Set the identity server URL of this client
+ * @param url - New identity server URL
+ */
+ public setIdentityServerUrl(url: string): void {
+ this.idBaseUrl = utils.ensureNoTrailingSlash(url);
+ this.http.setIdBaseUrl(this.idBaseUrl);
+ }
+
+ /**
+ * Get the access token associated with this account.
+ * @returns The access_token or null
+ */
+ public getAccessToken(): string | null {
+ return this.http.opts.accessToken || null;
+ }
+
+ /**
+ * Set the access token associated with this account.
+ * @param token - The new access token.
+ */
+ public setAccessToken(token: string): void {
+ this.http.opts.accessToken = token;
+ }
+
+ /**
+ * @returns true if there is a valid access_token for this client.
+ */
+ public isLoggedIn(): boolean {
+ return this.http.opts.accessToken !== undefined;
+ }
+
+ /**
+ * Make up a new transaction id
+ *
+ * @returns a new, unique, transaction id
+ */
+ public makeTxnId(): string {
+ return "m" + new Date().getTime() + "." + this.txnCtr++;
+ }
+
+ /**
+ * Check whether a username is available prior to registration. An error response
+ * indicates an invalid/unavailable username.
+ * @param username - The username to check the availability of.
+ * @returns Promise which resolves: to boolean of whether the username is available.
+ */
+ public isUsernameAvailable(username: string): Promise<boolean> {
+ return this.http
+ .authedRequest<{ available: true }>(Method.Get, "/register/available", { username })
+ .then((response) => {
+ return response.available;
+ })
+ .catch((response) => {
+ if (response.errcode === "M_USER_IN_USE") {
+ return false;
+ }
+ return Promise.reject(response);
+ });
+ }
+
+ /**
+ * @param bindThreepids - Set key 'email' to true to bind any email
+ * threepid uses during registration in the identity server. Set 'msisdn' to
+ * true to bind msisdn.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public register(
+ username: string,
+ password: string,
+ sessionId: string | null,
+ auth: { session?: string; type: string },
+ bindThreepids?: boolean | null | { email?: boolean; msisdn?: boolean },
+ guestAccessToken?: string,
+ inhibitLogin?: boolean,
+ ): Promise<IAuthData> {
+ // backwards compat
+ if (bindThreepids === true) {
+ bindThreepids = { email: true };
+ } else if (bindThreepids === null || bindThreepids === undefined || bindThreepids === false) {
+ bindThreepids = {};
+ }
+ if (sessionId) {
+ auth.session = sessionId;
+ }
+
+ const params: IRegisterRequestParams = {
+ auth: auth,
+ refresh_token: true, // always ask for a refresh token - does nothing if unsupported
+ };
+ if (username !== undefined && username !== null) {
+ params.username = username;
+ }
+ if (password !== undefined && password !== null) {
+ params.password = password;
+ }
+ if (bindThreepids.email) {
+ params.bind_email = true;
+ }
+ if (bindThreepids.msisdn) {
+ params.bind_msisdn = true;
+ }
+ if (guestAccessToken !== undefined && guestAccessToken !== null) {
+ params.guest_access_token = guestAccessToken;
+ }
+ if (inhibitLogin !== undefined && inhibitLogin !== null) {
+ params.inhibit_login = inhibitLogin;
+ }
+ // Temporary parameter added to make the register endpoint advertise
+ // msisdn flows. This exists because there are clients that break
+ // when given stages they don't recognise. This parameter will cease
+ // to be necessary once these old clients are gone.
+ // Only send it if we send any params at all (the password param is
+ // mandatory, so if we send any params, we'll send the password param)
+ if (password !== undefined && password !== null) {
+ params.x_show_msisdn = true;
+ }
+
+ return this.registerRequest(params);
+ }
+
+ /**
+ * Register a guest account.
+ * This method returns the auth info needed to create a new authenticated client,
+ * Remember to call `setGuest(true)` on the (guest-)authenticated client, e.g:
+ * ```javascript
+ * const tmpClient = await sdk.createClient(MATRIX_INSTANCE);
+ * const { user_id, device_id, access_token } = tmpClient.registerGuest();
+ * const client = createClient({
+ * baseUrl: MATRIX_INSTANCE,
+ * accessToken: access_token,
+ * userId: user_id,
+ * deviceId: device_id,
+ * })
+ * client.setGuest(true);
+ * ```
+ *
+ * @param body - JSON HTTP body to provide.
+ * @returns Promise which resolves: JSON object that contains:
+ * `{ user_id, device_id, access_token, home_server }`
+ * @returns Rejects: with an error response.
+ */
+ public registerGuest({ body }: { body?: any } = {}): Promise<any> {
+ // TODO: Types
+ return this.registerRequest(body || {}, "guest");
+ }
+
+ /**
+ * @param data - parameters for registration request
+ * @param kind - type of user to register. may be "guest"
+ * @returns Promise which resolves: to the /register response
+ * @returns Rejects: with an error response.
+ */
+ public registerRequest(data: IRegisterRequestParams, kind?: string): Promise<IAuthData> {
+ const params: { kind?: string } = {};
+ if (kind) {
+ params.kind = kind;
+ }
+
+ return this.http.request(Method.Post, "/register", params, data);
+ }
+
+ /**
+ * Refreshes an access token using a provided refresh token. The refresh token
+ * must be valid for the current access token known to the client instance.
+ *
+ * Note that this function will not cause a logout if the token is deemed
+ * unknown by the server - the caller is responsible for managing logout
+ * actions on error.
+ * @param refreshToken - The refresh token.
+ * @returns Promise which resolves to the new token.
+ * @returns Rejects with an error response.
+ */
+ public refreshToken(refreshToken: string): Promise<IRefreshTokenResponse> {
+ return this.http.authedRequest(
+ Method.Post,
+ "/refresh",
+ undefined,
+ { refresh_token: refreshToken },
+ {
+ prefix: ClientPrefix.V1,
+ inhibitLogoutEmit: true, // we don't want to cause logout loops
+ },
+ );
+ }
+
+ /**
+ * @returns Promise which resolves to the available login flows
+ * @returns Rejects: with an error response.
+ */
+ public loginFlows(): Promise<ILoginFlowsResponse> {
+ return this.http.request(Method.Get, "/login");
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public login(loginType: string, data: any): Promise<any> {
+ // TODO: Types
+ const loginData = {
+ type: loginType,
+ };
+
+ // merge data into loginData
+ Object.assign(loginData, data);
+
+ return this.http
+ .authedRequest<{
+ access_token?: string;
+ user_id?: string;
+ }>(Method.Post, "/login", undefined, loginData)
+ .then((response) => {
+ if (response.access_token && response.user_id) {
+ this.http.opts.accessToken = response.access_token;
+ this.credentials = {
+ userId: response.user_id,
+ };
+ }
+ return response;
+ });
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public loginWithPassword(user: string, password: string): Promise<any> {
+ // TODO: Types
+ return this.login("m.login.password", {
+ user: user,
+ password: password,
+ });
+ }
+
+ /**
+ * @param relayState - URL Callback after SAML2 Authentication
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public loginWithSAML2(relayState: string): Promise<any> {
+ // TODO: Types
+ return this.login("m.login.saml2", {
+ relay_state: relayState,
+ });
+ }
+
+ /**
+ * @param redirectUrl - The URL to redirect to after the HS
+ * authenticates with CAS.
+ * @returns The HS URL to hit to begin the CAS login process.
+ */
+ public getCasLoginUrl(redirectUrl: string): string {
+ return this.getSsoLoginUrl(redirectUrl, "cas");
+ }
+
+ /**
+ * @param redirectUrl - The URL to redirect to after the HS
+ * authenticates with the SSO.
+ * @param loginType - The type of SSO login we are doing (sso or cas).
+ * Defaults to 'sso'.
+ * @param idpId - The ID of the Identity Provider being targeted, optional.
+ * @param action - the SSO flow to indicate to the IdP, optional.
+ * @returns The HS URL to hit to begin the SSO login process.
+ */
+ public getSsoLoginUrl(redirectUrl: string, loginType = "sso", idpId?: string, action?: SSOAction): string {
+ let url = "/login/" + loginType + "/redirect";
+ if (idpId) {
+ url += "/" + idpId;
+ }
+
+ const params = {
+ redirectUrl,
+ [SSO_ACTION_PARAM.unstable!]: action,
+ };
+
+ return this.http.getUrl(url, params, ClientPrefix.R0).href;
+ }
+
+ /**
+ * @param token - Login token previously received from homeserver
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public loginWithToken(token: string): Promise<any> {
+ // TODO: Types
+ return this.login("m.login.token", {
+ token: token,
+ });
+ }
+
+ /**
+ * Logs out the current session.
+ * Obviously, further calls that require authorisation should fail after this
+ * method is called. The state of the MatrixClient object is not affected:
+ * it is up to the caller to either reset or destroy the MatrixClient after
+ * this method succeeds.
+ * @param stopClient - whether to stop the client before calling /logout to prevent invalid token errors.
+ * @returns Promise which resolves: On success, the empty object `{}`
+ */
+ public async logout(stopClient = false): Promise<{}> {
+ if (this.crypto?.backupManager?.getKeyBackupEnabled()) {
+ try {
+ while ((await this.crypto.backupManager.backupPendingKeys(200)) > 0);
+ } catch (err) {
+ logger.error("Key backup request failed when logging out. Some keys may be missing from backup", err);
+ }
+ }
+
+ if (stopClient) {
+ this.stopClient();
+ this.http.abort();
+ }
+
+ return this.http.authedRequest(Method.Post, "/logout");
+ }
+
+ /**
+ * Deactivates the logged-in account.
+ * Obviously, further calls that require authorisation should fail after this
+ * method is called. The state of the MatrixClient object is not affected:
+ * it is up to the caller to either reset or destroy the MatrixClient after
+ * this method succeeds.
+ * @param auth - Optional. Auth data to supply for User-Interactive auth.
+ * @param erase - Optional. If set, send as `erase` attribute in the
+ * JSON request body, indicating whether the account should be erased. Defaults
+ * to false.
+ * @returns Promise which resolves: On success, the empty object
+ */
+ public deactivateAccount(auth?: any, erase?: boolean): Promise<{}> {
+ const body: any = {};
+ if (auth) {
+ body.auth = auth;
+ }
+ if (erase !== undefined) {
+ body.erase = erase;
+ }
+
+ return this.http.authedRequest(Method.Post, "/account/deactivate", undefined, body);
+ }
+
+ /**
+ * Make a request for an `m.login.token` to be issued as per
+ * [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882).
+ * The server may require User-Interactive auth.
+ * Note that this is UNSTABLE and subject to breaking changes without notice.
+ * @param auth - Optional. Auth data to supply for User-Interactive auth.
+ * @returns Promise which resolves: On success, the token response
+ * or UIA auth data.
+ */
+ public requestLoginToken(auth?: IAuthData): Promise<UIAResponse<LoginTokenPostResponse>> {
+ const body: UIARequest<{}> = { auth };
+ return this.http.authedRequest(
+ Method.Post,
+ "/org.matrix.msc3882/login/token",
+ undefined, // no query params
+ body,
+ { prefix: ClientPrefix.Unstable },
+ );
+ }
+
+ /**
+ * Get the fallback URL to use for unknown interactive-auth stages.
+ *
+ * @param loginType - the type of stage being attempted
+ * @param authSessionId - the auth session ID provided by the homeserver
+ *
+ * @returns HS URL to hit to for the fallback interface
+ */
+ public getFallbackAuthUrl(loginType: string, authSessionId: string): string {
+ const path = utils.encodeUri("/auth/$loginType/fallback/web", {
+ $loginType: loginType,
+ });
+
+ return this.http.getUrl(
+ path,
+ {
+ session: authSessionId,
+ },
+ ClientPrefix.R0,
+ ).href;
+ }
+
+ /**
+ * Create a new room.
+ * @param options - a list of options to pass to the /createRoom API.
+ * @returns Promise which resolves: `{room_id: {string}}`
+ * @returns Rejects: with an error response.
+ */
+ public async createRoom(options: ICreateRoomOpts): Promise<{ room_id: string }> {
+ // eslint-disable-line camelcase
+ // some valid options include: room_alias_name, visibility, invite
+
+ // inject the id_access_token if inviting 3rd party addresses
+ const invitesNeedingToken = (options.invite_3pid || []).filter((i) => !i.id_access_token);
+ if (
+ invitesNeedingToken.length > 0 &&
+ this.identityServer?.getAccessToken &&
+ (await this.doesServerAcceptIdentityAccessToken())
+ ) {
+ const identityAccessToken = await this.identityServer.getAccessToken();
+ if (identityAccessToken) {
+ for (const invite of invitesNeedingToken) {
+ invite.id_access_token = identityAccessToken;
+ }
+ }
+ }
+
+ return this.http.authedRequest(Method.Post, "/createRoom", undefined, options);
+ }
+
+ /**
+ * Fetches relations for a given event
+ * @param roomId - the room of the event
+ * @param eventId - the id of the event
+ * @param relationType - the rel_type of the relations requested
+ * @param eventType - the event type of the relations requested
+ * @param opts - options with optional values for the request.
+ * @returns the response, with chunk, prev_batch and, next_batch.
+ */
+ public fetchRelations(
+ roomId: string,
+ eventId: string,
+ relationType?: RelationType | string | null,
+ eventType?: EventType | string | null,
+ opts: IRelationsRequestOpts = { dir: Direction.Backward },
+ ): Promise<IRelationsResponse> {
+ let params = opts as QueryDict;
+ if (Thread.hasServerSideFwdPaginationSupport === FeatureSupport.Experimental) {
+ params = replaceParam("dir", "org.matrix.msc3715.dir", params);
+ }
+ const queryString = utils.encodeParams(params);
+
+ let templatedUrl = "/rooms/$roomId/relations/$eventId";
+ if (relationType !== null) {
+ templatedUrl += "/$relationType";
+ if (eventType !== null) {
+ templatedUrl += "/$eventType";
+ }
+ } else if (eventType !== null) {
+ logger.warn(`eventType: ${eventType} ignored when fetching
+ relations as relationType is null`);
+ eventType = null;
+ }
+
+ const path = utils.encodeUri(templatedUrl + "?" + queryString, {
+ $roomId: roomId,
+ $eventId: eventId,
+ $relationType: relationType!,
+ $eventType: eventType!,
+ });
+ return this.http.authedRequest(Method.Get, path, undefined, undefined, {
+ prefix: ClientPrefix.V1,
+ });
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public roomState(roomId: string): Promise<IStateEventWithRoomId[]> {
+ const path = utils.encodeUri("/rooms/$roomId/state", { $roomId: roomId });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Get an event in a room by its event id.
+ *
+ * @returns Promise which resolves to an object containing the event.
+ * @returns Rejects: with an error response.
+ */
+ public fetchRoomEvent(roomId: string, eventId: string): Promise<Partial<IEvent>> {
+ const path = utils.encodeUri("/rooms/$roomId/event/$eventId", {
+ $roomId: roomId,
+ $eventId: eventId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @param includeMembership - the membership type to include in the response
+ * @param excludeMembership - the membership type to exclude from the response
+ * @param atEventId - the id of the event for which moment in the timeline the members should be returned for
+ * @returns Promise which resolves: dictionary of userid to profile information
+ * @returns Rejects: with an error response.
+ */
+ public members(
+ roomId: string,
+ includeMembership?: string,
+ excludeMembership?: string,
+ atEventId?: string,
+ ): Promise<{ [userId: string]: IStateEventWithRoomId[] }> {
+ const queryParams: Record<string, string> = {};
+ if (includeMembership) {
+ queryParams.membership = includeMembership;
+ }
+ if (excludeMembership) {
+ queryParams.not_membership = excludeMembership;
+ }
+ if (atEventId) {
+ queryParams.at = atEventId;
+ }
+
+ const queryString = utils.encodeParams(queryParams);
+
+ const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, { $roomId: roomId });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Upgrades a room to a new protocol version
+ * @param newVersion - The target version to upgrade to
+ * @returns Promise which resolves: Object with key 'replacement_room'
+ * @returns Rejects: with an error response.
+ */
+ public upgradeRoom(roomId: string, newVersion: string): Promise<{ replacement_room: string }> {
+ // eslint-disable-line camelcase
+ const path = utils.encodeUri("/rooms/$roomId/upgrade", { $roomId: roomId });
+ return this.http.authedRequest(Method.Post, path, undefined, { new_version: newVersion });
+ }
+
+ /**
+ * Retrieve a state event.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public getStateEvent(roomId: string, eventType: string, stateKey: string): Promise<Record<string, any>> {
+ const pathParams = {
+ $roomId: roomId,
+ $eventType: eventType,
+ $stateKey: stateKey,
+ };
+ let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
+ if (stateKey !== undefined) {
+ path = utils.encodeUri(path + "/$stateKey", pathParams);
+ }
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @param opts - Options for the request function.
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public sendStateEvent(
+ roomId: string,
+ eventType: string,
+ content: any,
+ stateKey = "",
+ opts: IRequestOpts = {},
+ ): Promise<ISendEventResponse> {
+ const pathParams = {
+ $roomId: roomId,
+ $eventType: eventType,
+ $stateKey: stateKey,
+ };
+ let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams);
+ if (stateKey !== undefined) {
+ path = utils.encodeUri(path + "/$stateKey", pathParams);
+ }
+ return this.http.authedRequest(Method.Put, path, undefined, content, opts);
+ }
+
+ /**
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public roomInitialSync(roomId: string, limit: number): Promise<IRoomInitialSyncResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/initialSync", { $roomId: roomId });
+
+ return this.http.authedRequest(Method.Get, path, { limit: limit?.toString() ?? "30" });
+ }
+
+ /**
+ * Set a marker to indicate the point in a room before which the user has read every
+ * event. This can be retrieved from room account data (the event type is `m.fully_read`)
+ * and displayed as a horizontal line in the timeline that is visually distinct to the
+ * position of the user's own read receipt.
+ * @param roomId - ID of the room that has been read
+ * @param rmEventId - ID of the event that has been read
+ * @param rrEventId - ID of the event tracked by the read receipt. This is here
+ * for convenience because the RR and the RM are commonly updated at the same time as
+ * each other. Optional.
+ * @param rpEventId - rpEvent the m.read.private read receipt event for when we
+ * don't want other users to see the read receipts. This is experimental. Optional.
+ * @returns Promise which resolves: the empty object, `{}`.
+ */
+ public async setRoomReadMarkersHttpRequest(
+ roomId: string,
+ rmEventId: string,
+ rrEventId?: string,
+ rpEventId?: string,
+ ): Promise<{}> {
+ const path = utils.encodeUri("/rooms/$roomId/read_markers", {
+ $roomId: roomId,
+ });
+
+ const content: IContent = {
+ [ReceiptType.FullyRead]: rmEventId,
+ [ReceiptType.Read]: rrEventId,
+ };
+
+ if (
+ (await this.doesServerSupportUnstableFeature("org.matrix.msc2285.stable")) ||
+ (await this.isVersionSupported("v1.4"))
+ ) {
+ content[ReceiptType.ReadPrivate] = rpEventId;
+ }
+
+ return this.http.authedRequest(Method.Post, path, undefined, content);
+ }
+
+ /**
+ * @returns Promise which resolves: A list of the user's current rooms
+ * @returns Rejects: with an error response.
+ */
+ public getJoinedRooms(): Promise<IJoinedRoomsResponse> {
+ const path = utils.encodeUri("/joined_rooms", {});
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Retrieve membership info. for a room.
+ * @param roomId - ID of the room to get membership for
+ * @returns Promise which resolves: A list of currently joined users
+ * and their profile data.
+ * @returns Rejects: with an error response.
+ */
+ public getJoinedRoomMembers(roomId: string): Promise<IJoinedMembersResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/joined_members", {
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @param options - Options for this request
+ * @param server - The remote server to query for the room list.
+ * Optional. If unspecified, get the local home
+ * server's public room list.
+ * @param limit - Maximum number of entries to return
+ * @param since - Token to paginate from
+ * @returns Promise which resolves: IPublicRoomsResponse
+ * @returns Rejects: with an error response.
+ */
+ public publicRooms({
+ server,
+ limit,
+ since,
+ ...options
+ }: IRoomDirectoryOptions = {}): Promise<IPublicRoomsResponse> {
+ const queryParams: QueryDict = { server, limit, since };
+ if (Object.keys(options).length === 0) {
+ return this.http.authedRequest(Method.Get, "/publicRooms", queryParams);
+ } else {
+ return this.http.authedRequest(Method.Post, "/publicRooms", queryParams, options);
+ }
+ }
+
+ /**
+ * Create an alias to room ID mapping.
+ * @param alias - The room alias to create.
+ * @param roomId - The room ID to link the alias to.
+ * @returns Promise which resolves: an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public createAlias(alias: string, roomId: string): Promise<{}> {
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: alias,
+ });
+ const data = {
+ room_id: roomId,
+ };
+ return this.http.authedRequest(Method.Put, path, undefined, data);
+ }
+
+ /**
+ * Delete an alias to room ID mapping. This alias must be on your local server,
+ * and you must have sufficient access to do this operation.
+ * @param alias - The room alias to delete.
+ * @returns Promise which resolves: an empty object `{}`.
+ * @returns Rejects: with an error response.
+ */
+ public deleteAlias(alias: string): Promise<{}> {
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: alias,
+ });
+ return this.http.authedRequest(Method.Delete, path);
+ }
+
+ /**
+ * Gets the local aliases for the room. Note: this includes all local aliases, unlike the
+ * curated list from the m.room.canonical_alias state event.
+ * @param roomId - The room ID to get local aliases for.
+ * @returns Promise which resolves: an object with an `aliases` property, containing an array of local aliases
+ * @returns Rejects: with an error response.
+ */
+ public getLocalAliases(roomId: string): Promise<{ aliases: string[] }> {
+ const path = utils.encodeUri("/rooms/$roomId/aliases", { $roomId: roomId });
+ const prefix = ClientPrefix.V3;
+ return this.http.authedRequest(Method.Get, path, undefined, undefined, { prefix });
+ }
+
+ /**
+ * Get room info for the given alias.
+ * @param alias - The room alias to resolve.
+ * @returns Promise which resolves: Object with room_id and servers.
+ * @returns Rejects: with an error response.
+ */
+ public getRoomIdForAlias(alias: string): Promise<{ room_id: string; servers: string[] }> {
+ // eslint-disable-line camelcase
+ // TODO: deprecate this or resolveRoomAlias
+ const path = utils.encodeUri("/directory/room/$alias", {
+ $alias: alias,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @returns Promise which resolves: Object with room_id and servers.
+ * @returns Rejects: with an error response.
+ */
+ // eslint-disable-next-line camelcase
+ public resolveRoomAlias(roomAlias: string): Promise<{ room_id: string; servers: string[] }> {
+ // TODO: deprecate this or getRoomIdForAlias
+ const path = utils.encodeUri("/directory/room/$alias", { $alias: roomAlias });
+ return this.http.request(Method.Get, path);
+ }
+
+ /**
+ * Get the visibility of a room in the current HS's room directory
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public getRoomDirectoryVisibility(roomId: string): Promise<{ visibility: Visibility }> {
+ const path = utils.encodeUri("/directory/list/room/$roomId", {
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Set the visbility of a room in the current HS's room directory
+ * @param visibility - "public" to make the room visible
+ * in the public directory, or "private" to make
+ * it invisible.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setRoomDirectoryVisibility(roomId: string, visibility: Visibility): Promise<{}> {
+ const path = utils.encodeUri("/directory/list/room/$roomId", {
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, { visibility });
+ }
+
+ /**
+ * Set the visbility of a room bridged to a 3rd party network in
+ * the current HS's room directory.
+ * @param networkId - the network ID of the 3rd party
+ * instance under which this room is published under.
+ * @param visibility - "public" to make the room visible
+ * in the public directory, or "private" to make
+ * it invisible.
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public setRoomDirectoryVisibilityAppService(
+ networkId: string,
+ roomId: string,
+ visibility: "public" | "private",
+ ): Promise<any> {
+ // TODO: Types
+ const path = utils.encodeUri("/directory/list/appservice/$networkId/$roomId", {
+ $networkId: networkId,
+ $roomId: roomId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, { visibility: visibility });
+ }
+
+ /**
+ * Query the user directory with a term matching user IDs, display names and domains.
+ * @param term - the term with which to search.
+ * @param limit - the maximum number of results to return. The server will
+ * apply a limit if unspecified.
+ * @returns Promise which resolves: an array of results.
+ */
+ public searchUserDirectory({ term, limit }: { term: string; limit?: number }): Promise<IUserDirectoryResponse> {
+ const body: any = {
+ search_term: term,
+ };
+
+ if (limit !== undefined) {
+ body.limit = limit;
+ }
+
+ return this.http.authedRequest(Method.Post, "/user_directory/search", undefined, body);
+ }
+
+ /**
+ * Upload a file to the media repository on the homeserver.
+ *
+ * @param file - The object to upload. On a browser, something that
+ * can be sent to XMLHttpRequest.send (typically a File). Under node.js,
+ * a a Buffer, String or ReadStream.
+ *
+ * @param opts - options object
+ *
+ * @returns Promise which resolves to response object, as
+ * determined by this.opts.onlyData, opts.rawResponse, and
+ * opts.onlyContentUri. Rejects with an error (usually a MatrixError).
+ */
+ public uploadContent(file: FileType, opts?: UploadOpts): Promise<UploadResponse> {
+ return this.http.uploadContent(file, opts);
+ }
+
+ /**
+ * Cancel a file upload in progress
+ * @param upload - The object returned from uploadContent
+ * @returns true if canceled, otherwise false
+ */
+ public cancelUpload(upload: Promise<UploadResponse>): boolean {
+ return this.http.cancelUpload(upload);
+ }
+
+ /**
+ * Get a list of all file uploads in progress
+ * @returns Array of objects representing current uploads.
+ * Currently in progress is element 0. Keys:
+ * - promise: The promise associated with the upload
+ * - loaded: Number of bytes uploaded
+ * - total: Total number of bytes to upload
+ */
+ public getCurrentUploads(): Upload[] {
+ return this.http.getCurrentUploads();
+ }
+
+ /**
+ * @param info - The kind of info to retrieve (e.g. 'displayname',
+ * 'avatar_url').
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ */
+ public getProfileInfo(
+ userId: string,
+ info?: string,
+ // eslint-disable-next-line camelcase
+ ): Promise<{ avatar_url?: string; displayname?: string }> {
+ const path = info
+ ? utils.encodeUri("/profile/$userId/$info", { $userId: userId, $info: info })
+ : utils.encodeUri("/profile/$userId", { $userId: userId });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * @returns Promise which resolves to a list of the user's threepids.
+ * @returns Rejects: with an error response.
+ */
+ public getThreePids(): Promise<{ threepids: IThreepid[] }> {
+ return this.http.authedRequest(Method.Get, "/account/3pid");
+ }
+
+ /**
+ * Add a 3PID to your homeserver account and optionally bind it to an identity
+ * server as well. An identity server is required as part of the `creds` object.
+ *
+ * This API is deprecated, and you should instead use `addThreePidOnly`
+ * for homeservers that support it.
+ *
+ * @returns Promise which resolves: on success
+ * @returns Rejects: with an error response.
+ */
+ public addThreePid(creds: any, bind: boolean): Promise<any> {
+ // TODO: Types
+ const path = "/account/3pid";
+ const data = {
+ threePidCreds: creds,
+ bind: bind,
+ };
+ return this.http.authedRequest(Method.Post, path, undefined, data);
+ }
+
+ /**
+ * Add a 3PID to your homeserver account. This API does not use an identity
+ * server, as the homeserver is expected to handle 3PID ownership validation.
+ *
+ * You can check whether a homeserver supports this API via
+ * `doesServerSupportSeparateAddAndBind`.
+ *
+ * @param data - A object with 3PID validation data from having called
+ * `account/3pid/<medium>/requestToken` on the homeserver.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public async addThreePidOnly(data: IAddThreePidOnlyBody): Promise<{}> {
+ const path = "/account/3pid/add";
+ const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
+ return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
+ }
+
+ /**
+ * Bind a 3PID for discovery onto an identity server via the homeserver. The
+ * identity server handles 3PID ownership validation and the homeserver records
+ * the new binding to track where all 3PIDs for the account are bound.
+ *
+ * You can check whether a homeserver supports this API via
+ * `doesServerSupportSeparateAddAndBind`.
+ *
+ * @param data - A object with 3PID validation data from having called
+ * `validate/<medium>/requestToken` on the identity server. It should also
+ * contain `id_server` and `id_access_token` fields as well.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public async bindThreePid(data: IBindThreePidBody): Promise<{}> {
+ const path = "/account/3pid/bind";
+ const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
+ return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
+ }
+
+ /**
+ * Unbind a 3PID for discovery on an identity server via the homeserver. The
+ * homeserver removes its record of the binding to keep an updated record of
+ * where all 3PIDs for the account are bound.
+ *
+ * @param medium - The threepid medium (eg. 'email')
+ * @param address - The threepid address (eg. 'bob\@example.com')
+ * this must be as returned by getThreePids.
+ * @returns Promise which resolves: on success
+ * @returns Rejects: with an error response.
+ */
+ public async unbindThreePid(
+ medium: string,
+ address: string,
+ // eslint-disable-next-line camelcase
+ ): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
+ const path = "/account/3pid/unbind";
+ const data = {
+ medium,
+ address,
+ id_server: this.getIdentityServerUrl(true),
+ };
+ const prefix = (await this.isVersionSupported("r0.6.0")) ? ClientPrefix.R0 : ClientPrefix.Unstable;
+ return this.http.authedRequest(Method.Post, path, undefined, data, { prefix });
+ }
+
+ /**
+ * @param medium - The threepid medium (eg. 'email')
+ * @param address - The threepid address (eg. 'bob\@example.com')
+ * this must be as returned by getThreePids.
+ * @returns Promise which resolves: The server response on success
+ * (generally the empty JSON object)
+ * @returns Rejects: with an error response.
+ */
+ public deleteThreePid(
+ medium: string,
+ address: string,
+ // eslint-disable-next-line camelcase
+ ): Promise<{ id_server_unbind_result: IdServerUnbindResult }> {
+ const path = "/account/3pid/delete";
+ return this.http.authedRequest(Method.Post, path, undefined, { medium, address });
+ }
+
+ /**
+ * Make a request to change your password.
+ * @param newPassword - The new desired password.
+ * @param logoutDevices - Should all sessions be logged out after the password change. Defaults to true.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setPassword(authDict: IAuthDict, newPassword: string, logoutDevices?: boolean): Promise<{}> {
+ const path = "/account/password";
+ const data = {
+ auth: authDict,
+ new_password: newPassword,
+ logout_devices: logoutDevices,
+ };
+
+ return this.http.authedRequest<{}>(Method.Post, path, undefined, data);
+ }
+
+ /**
+ * Gets all devices recorded for the logged-in user
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public getDevices(): Promise<{ devices: IMyDevice[] }> {
+ return this.http.authedRequest(Method.Get, "/devices");
+ }
+
+ /**
+ * Gets specific device details for the logged-in user
+ * @param deviceId - device to query
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public getDevice(deviceId: string): Promise<IMyDevice> {
+ const path = utils.encodeUri("/devices/$device_id", {
+ $device_id: deviceId,
+ });
+ return this.http.authedRequest(Method.Get, path);
+ }
+
+ /**
+ * Update the given device
+ *
+ * @param deviceId - device to update
+ * @param body - body of request
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ // eslint-disable-next-line camelcase
+ public setDeviceDetails(deviceId: string, body: { display_name: string }): Promise<{}> {
+ const path = utils.encodeUri("/devices/$device_id", {
+ $device_id: deviceId,
+ });
+
+ return this.http.authedRequest(Method.Put, path, undefined, body);
+ }
+
+ /**
+ * Delete the given device
+ *
+ * @param deviceId - device to delete
+ * @param auth - Optional. Auth data to supply for User-Interactive auth.
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public deleteDevice(deviceId: string, auth?: IAuthDict): Promise<IAuthData | {}> {
+ const path = utils.encodeUri("/devices/$device_id", {
+ $device_id: deviceId,
+ });
+
+ const body: any = {};
+
+ if (auth) {
+ body.auth = auth;
+ }
+
+ return this.http.authedRequest(Method.Delete, path, undefined, body);
+ }
+
+ /**
+ * Delete multiple device
+ *
+ * @param devices - IDs of the devices to delete
+ * @param auth - Optional. Auth data to supply for User-Interactive auth.
+ * @returns Promise which resolves: result object
+ * @returns Rejects: with an error response.
+ */
+ public deleteMultipleDevices(devices: string[], auth?: IAuthDict): Promise<IAuthData | {}> {
+ const body: any = { devices };
+
+ if (auth) {
+ body.auth = auth;
+ }
+
+ const path = "/delete_devices";
+ return this.http.authedRequest(Method.Post, path, undefined, body);
+ }
+
+ /**
+ * Gets all pushers registered for the logged-in user
+ *
+ * @returns Promise which resolves: Array of objects representing pushers
+ * @returns Rejects: with an error response.
+ */
+ public async getPushers(): Promise<{ pushers: IPusher[] }> {
+ const response = await this.http.authedRequest<{ pushers: IPusher[] }>(Method.Get, "/pushers");
+
+ // Migration path for clients that connect to a homeserver that does not support
+ // MSC3881 yet, see https://github.com/matrix-org/matrix-spec-proposals/blob/kerry/remote-push-toggle/proposals/3881-remote-push-notification-toggling.md#migration
+ if (!(await this.doesServerSupportUnstableFeature("org.matrix.msc3881"))) {
+ response.pushers = response.pushers.map((pusher) => {
+ if (!pusher.hasOwnProperty(PUSHER_ENABLED.name)) {
+ pusher[PUSHER_ENABLED.name] = true;
+ }
+ return pusher;
+ });
+ }
+
+ return response;
+ }
+
+ /**
+ * Adds a new pusher or updates an existing pusher
+ *
+ * @param pusher - Object representing a pusher
+ * @returns Promise which resolves: Empty json object on success
+ * @returns Rejects: with an error response.
+ */
+ public setPusher(pusher: IPusherRequest): Promise<{}> {
+ const path = "/pushers/set";
+ return this.http.authedRequest(Method.Post, path, undefined, pusher);
+ }
+
+ /**
+ * Persists local notification settings
+ * @returns Promise which resolves: an empty object
+ * @returns Rejects: with an error response.
+ */
+ public setLocalNotificationSettings(
+ deviceId: string,
+ notificationSettings: LocalNotificationSettings,
+ ): Promise<{}> {
+ const key = `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`;
+ return this.setAccountData(key, notificationSettings);
+ }
+
+ /**
+ * Get the push rules for the account from the server.
+ * @returns Promise which resolves to the push rules.
+ * @returns Rejects: with an error response.
+ */
+ public getPushRules(): Promise<IPushRules> {
+ return this.http.authedRequest<IPushRules>(Method.Get, "/pushrules/").then((rules: IPushRules) => {
+ this.setPushRules(rules);
+ return this.pushRules!;
+ });
+ }
+
+ /**
+ * Update the push rules for the account. This should be called whenever
+ * updated push rules are available.
+ */
+ public setPushRules(rules: IPushRules): void {
+ // Fix-up defaults, if applicable.
+ this.pushRules = PushProcessor.rewriteDefaultRules(rules, this.getUserId()!);
+ // Pre-calculate any necessary caches.
+ this.pushProcessor.updateCachedPushRuleKeys(this.pushRules);
+ }
+
+ /**
+ * @returns Promise which resolves: an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public addPushRule(
+ scope: string,
+ kind: PushRuleKind,
+ ruleId: Exclude<string, RuleId>,
+ body: Pick<IPushRule, "actions" | "conditions" | "pattern">,
+ ): Promise<{}> {
+ // NB. Scope not uri encoded because devices need the '/'
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
+ $kind: kind,
+ $ruleId: ruleId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, body);
+ }
+
+ /**
+ * @returns Promise which resolves: an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public deletePushRule(scope: string, kind: PushRuleKind, ruleId: Exclude<string, RuleId>): Promise<{}> {
+ // NB. Scope not uri encoded because devices need the '/'
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId", {
+ $kind: kind,
+ $ruleId: ruleId,
+ });
+ return this.http.authedRequest(Method.Delete, path);
+ }
+
+ /**
+ * Enable or disable a push notification rule.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setPushRuleEnabled(
+ scope: string,
+ kind: PushRuleKind,
+ ruleId: RuleId | string,
+ enabled: boolean,
+ ): Promise<{}> {
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/enabled", {
+ $kind: kind,
+ $ruleId: ruleId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, { enabled: enabled });
+ }
+
+ /**
+ * Set the actions for a push notification rule.
+ * @returns Promise which resolves: to an empty object `{}`
+ * @returns Rejects: with an error response.
+ */
+ public setPushRuleActions(
+ scope: string,
+ kind: PushRuleKind,
+ ruleId: RuleId | string,
+ actions: PushRuleAction[],
+ ): Promise<{}> {
+ const path = utils.encodeUri("/pushrules/" + scope + "/$kind/$ruleId/actions", {
+ $kind: kind,
+ $ruleId: ruleId,
+ });
+ return this.http.authedRequest(Method.Put, path, undefined, { actions: actions });
+ }
+
+ /**
+ * Perform a server-side search.
+ * @param next_batch - the batch token to pass in the query string
+ * @param body - the JSON object to pass to the request body.
+ * @param abortSignal - optional signal used to cancel the http request.
+ * @returns Promise which resolves to the search response object.
+ * @returns Rejects: with an error response.
+ */
+ public search(
+ { body, next_batch: nextBatch }: { body: ISearchRequestBody; next_batch?: string },
+ abortSignal?: AbortSignal,
+ ): Promise<ISearchResponse> {
+ const queryParams: any = {};
+ if (nextBatch) {
+ queryParams.next_batch = nextBatch;
+ }
+ return this.http.authedRequest(Method.Post, "/search", queryParams, body, { abortSignal });
+ }
+
+ /**
+ * Upload keys
+ *
+ * @param content - body of upload request
+ *
+ * @param opts - this method no longer takes any opts,
+ * used to take opts.device_id but this was not removed from the spec as a redundant parameter
+ *
+ * @returns Promise which resolves: result object. Rejects: with
+ * an error response ({@link MatrixError}).
+ */
+ public uploadKeysRequest(content: IUploadKeysRequest, opts?: void): Promise<IKeysUploadResponse> {
+ return this.http.authedRequest(Method.Post, "/keys/upload", undefined, content);
+ }
+
+ public uploadKeySignatures(content: KeySignatures): Promise<IUploadKeySignaturesResponse> {
+ return this.http.authedRequest(Method.Post, "/keys/signatures/upload", undefined, content, {
+ prefix: ClientPrefix.V3,
+ });
+ }
+
+ /**
+ * Download device keys
+ *
+ * @param userIds - list of users to get keys for
+ *
+ * @param token - sync token to pass in the query request, to help
+ * the HS give the most recent results
+ *
+ * @returns Promise which resolves: result object. Rejects: with
+ * an error response ({@link MatrixError}).
+ */
+ public downloadKeysForUsers(userIds: string[], { token }: { token?: string } = {}): Promise<IDownloadKeyResult> {
+ const content: IQueryKeysRequest = {
+ device_keys: {},
+ };
+ if (token !== undefined) {
+ content.token = token;
+ }
+ userIds.forEach((u) => {
+ content.device_keys[u] = [];
+ });
+
+ return this.http.authedRequest(Method.Post, "/keys/query", undefined, content);
+ }
+
+ /**
+ * Claim one-time keys
+ *
+ * @param devices - a list of [userId, deviceId] pairs
+ *
+ * @param keyAlgorithm - desired key type
+ *
+ * @param timeout - the time (in milliseconds) to wait for keys from remote
+ * servers
+ *
+ * @returns Promise which resolves: result object. Rejects: with
+ * an error response ({@link MatrixError}).
+ */
+ public claimOneTimeKeys(
+ devices: [string, string][],
+ keyAlgorithm = "signed_curve25519",
+ timeout?: number,
+ ): Promise<IClaimOTKsResult> {
+ const queries: Record<string, Record<string, string>> = {};
+
+ if (keyAlgorithm === undefined) {
+ keyAlgorithm = "signed_curve25519";
+ }
+
+ for (const [userId, deviceId] of devices) {
+ const query = queries[userId] || {};
+ queries[userId] = query;
+ query[deviceId] = keyAlgorithm;
+ }
+ const content: IClaimKeysRequest = { one_time_keys: queries };
+ if (timeout) {
+ content.timeout = timeout;
+ }
+ const path = "/keys/claim";
+ return this.http.authedRequest(Method.Post, path, undefined, content);
+ }
+
+ /**
+ * Ask the server for a list of users who have changed their device lists
+ * between a pair of sync tokens
+ *
+ *
+ * @returns Promise which resolves: result object. Rejects: with
+ * an error response ({@link MatrixError}).
+ */
+ public getKeyChanges(oldToken: string, newToken: string): Promise<{ changed: string[]; left: string[] }> {
+ const qps = {
+ from: oldToken,
+ to: newToken,
+ };
+
+ return this.http.authedRequest(Method.Get, "/keys/changes", qps);
+ }
+
+ public uploadDeviceSigningKeys(auth?: IAuthData, keys?: CrossSigningKeys): Promise<{}> {
+ // API returns empty object
+ const data = Object.assign({}, keys);
+ if (auth) Object.assign(data, { auth });
+ return this.http.authedRequest(Method.Post, "/keys/device_signing/upload", undefined, data, {
+ prefix: ClientPrefix.Unstable,
+ });
+ }
+
+ /**
+ * Register with an identity server using the OpenID token from the user's
+ * Homeserver, which can be retrieved via
+ * {@link MatrixClient#getOpenIdToken}.
+ *
+ * Note that the `/account/register` endpoint (as well as IS authentication in
+ * general) was added as part of the v2 API version.
+ *
+ * @returns Promise which resolves: with object containing an Identity
+ * Server access token.
+ * @returns Rejects: with an error response.
+ */
+ public registerWithIdentityServer(hsOpenIdToken: IOpenIDToken): Promise<{
+ access_token: string;
+ token: string;
+ }> {
+ if (!this.idBaseUrl) {
+ throw new Error("No identity server base URL set");
+ }
+
+ const uri = this.http.getUrl("/account/register", undefined, IdentityPrefix.V2, this.idBaseUrl);
+ return this.http.requestOtherUrl(Method.Post, uri, hsOpenIdToken);
+ }
+
+ /**
+ * Requests an email verification token directly from an identity server.
+ *
+ * This API is used as part of binding an email for discovery on an identity
+ * server. The validation data that results should be passed to the
+ * `bindThreePid` method to complete the binding process.
+ *
+ * @param email - The email address to request a token for
+ * @param clientSecret - A secret binary string generated by the client.
+ * It is recommended this be around 16 ASCII characters.
+ * @param sendAttempt - If an identity server sees a duplicate request
+ * with the same sendAttempt, it will not send another email.
+ * To request another email to be sent, use a larger value for
+ * the sendAttempt param as was used in the previous request.
+ * @param nextLink - Optional If specified, the client will be redirected
+ * to this link after validation.
+ * @param identityAccessToken - The `access_token` field of the identity
+ * server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: TODO
+ * @returns Rejects: with an error response.
+ * @throws Error if no identity server is set
+ */
+ public requestEmailToken(
+ email: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ identityAccessToken?: string,
+ ): Promise<IRequestTokenResponse> {
+ const params: Record<string, string> = {
+ client_secret: clientSecret,
+ email: email,
+ send_attempt: sendAttempt?.toString(),
+ };
+ if (nextLink) {
+ params.next_link = nextLink;
+ }
+
+ return this.http.idServerRequest<IRequestTokenResponse>(
+ Method.Post,
+ "/validate/email/requestToken",
+ params,
+ IdentityPrefix.V2,
+ identityAccessToken,
+ );
+ }
+
+ /**
+ * Requests a MSISDN verification token directly from an identity server.
+ *
+ * This API is used as part of binding a MSISDN for discovery on an identity
+ * server. The validation data that results should be passed to the
+ * `bindThreePid` method to complete the binding process.
+ *
+ * @param phoneCountry - The ISO 3166-1 alpha-2 code for the country in
+ * which phoneNumber should be parsed relative to.
+ * @param phoneNumber - The phone number, in national or international
+ * format
+ * @param clientSecret - A secret binary string generated by the client.
+ * It is recommended this be around 16 ASCII characters.
+ * @param sendAttempt - If an identity server sees a duplicate request
+ * with the same sendAttempt, it will not send another SMS.
+ * To request another SMS to be sent, use a larger value for
+ * the sendAttempt param as was used in the previous request.
+ * @param nextLink - Optional If specified, the client will be redirected
+ * to this link after validation.
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves to an object with a sid string
+ * @returns Rejects: with an error response.
+ * @throws Error if no identity server is set
+ */
+ public requestMsisdnToken(
+ phoneCountry: string,
+ phoneNumber: string,
+ clientSecret: string,
+ sendAttempt: number,
+ nextLink?: string,
+ identityAccessToken?: string,
+ ): Promise<IRequestMsisdnTokenResponse> {
+ const params: Record<string, string> = {
+ client_secret: clientSecret,
+ country: phoneCountry,
+ phone_number: phoneNumber,
+ send_attempt: sendAttempt?.toString(),
+ };
+ if (nextLink) {
+ params.next_link = nextLink;
+ }
+
+ return this.http.idServerRequest<IRequestMsisdnTokenResponse>(
+ Method.Post,
+ "/validate/msisdn/requestToken",
+ params,
+ IdentityPrefix.V2,
+ identityAccessToken,
+ );
+ }
+
+ /**
+ * Submits a MSISDN token to the identity server
+ *
+ * This is used when submitting the code sent by SMS to a phone number.
+ * The identity server has an equivalent API for email but the js-sdk does
+ * not expose this, since email is normally validated by the user clicking
+ * a link rather than entering a code.
+ *
+ * @param sid - The sid given in the response to requestToken
+ * @param clientSecret - A secret binary string generated by the client.
+ * This must be the same value submitted in the requestToken call.
+ * @param msisdnToken - The MSISDN token, as enetered by the user.
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: Object, currently with no parameters.
+ * @returns Rejects: with an error response.
+ * @throws Error if No identity server is set
+ */
+ public submitMsisdnToken(
+ sid: string,
+ clientSecret: string,
+ msisdnToken: string,
+ identityAccessToken: string,
+ ): Promise<any> {
+ // TODO: Types
+ const params = {
+ sid: sid,
+ client_secret: clientSecret,
+ token: msisdnToken,
+ };
+
+ return this.http.idServerRequest(
+ Method.Post,
+ "/validate/msisdn/submitToken",
+ params,
+ IdentityPrefix.V2,
+ identityAccessToken,
+ );
+ }
+
+ /**
+ * Submits a MSISDN token to an arbitrary URL.
+ *
+ * This is used when submitting the code sent by SMS to a phone number in the
+ * newer 3PID flow where the homeserver validates 3PID ownership (as part of
+ * `requestAdd3pidMsisdnToken`). The homeserver response may include a
+ * `submit_url` to specify where the token should be sent, and this helper can
+ * be used to pass the token to this URL.
+ *
+ * @param url - The URL to submit the token to
+ * @param sid - The sid given in the response to requestToken
+ * @param clientSecret - A secret binary string generated by the client.
+ * This must be the same value submitted in the requestToken call.
+ * @param msisdnToken - The MSISDN token, as enetered by the user.
+ *
+ * @returns Promise which resolves: Object, currently with no parameters.
+ * @returns Rejects: with an error response.
+ */
+ public submitMsisdnTokenOtherUrl(
+ url: string,
+ sid: string,
+ clientSecret: string,
+ msisdnToken: string,
+ ): Promise<any> {
+ // TODO: Types
+ const params = {
+ sid: sid,
+ client_secret: clientSecret,
+ token: msisdnToken,
+ };
+ return this.http.requestOtherUrl(Method.Post, url, params);
+ }
+
+ /**
+ * Gets the V2 hashing information from the identity server. Primarily useful for
+ * lookups.
+ * @param identityAccessToken - The access token for the identity server.
+ * @returns The hashing information for the identity server.
+ */
+ public getIdentityHashDetails(identityAccessToken: string): Promise<any> {
+ // TODO: Types
+ return this.http.idServerRequest(
+ Method.Get,
+ "/hash_details",
+ undefined,
+ IdentityPrefix.V2,
+ identityAccessToken,
+ );
+ }
+
+ /**
+ * Performs a hashed lookup of addresses against the identity server. This is
+ * only supported on identity servers which have at least the version 2 API.
+ * @param addressPairs - An array of 2 element arrays.
+ * The first element of each pair is the address, the second is the 3PID medium.
+ * Eg: `["email@example.org", "email"]`
+ * @param identityAccessToken - The access token for the identity server.
+ * @returns A collection of address mappings to
+ * found MXIDs. Results where no user could be found will not be listed.
+ */
+ public async identityHashedLookup(
+ addressPairs: [string, string][],
+ identityAccessToken: string,
+ ): Promise<{ address: string; mxid: string }[]> {
+ const params: Record<string, string | string[]> = {
+ // addresses: ["email@example.org", "10005550000"],
+ // algorithm: "sha256",
+ // pepper: "abc123"
+ };
+
+ // Get hash information first before trying to do a lookup
+ const hashes = await this.getIdentityHashDetails(identityAccessToken);
+ if (!hashes || !hashes["lookup_pepper"] || !hashes["algorithms"]) {
+ throw new Error("Unsupported identity server: bad response");
+ }
+
+ params["pepper"] = hashes["lookup_pepper"];
+
+ const localMapping: Record<string, string> = {
+ // hashed identifier => plain text address
+ // For use in this function's return format
+ };
+
+ // When picking an algorithm, we pick the hashed over no hashes
+ if (hashes["algorithms"].includes("sha256")) {
+ // Abuse the olm hashing
+ const olmutil = new global.Olm.Utility();
+ params["addresses"] = addressPairs.map((p) => {
+ const addr = p[0].toLowerCase(); // lowercase to get consistent hashes
+ const med = p[1].toLowerCase();
+ const hashed = olmutil
+ .sha256(`${addr} ${med} ${params["pepper"]}`)
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_"); // URL-safe base64
+ // Map the hash to a known (case-sensitive) address. We use the case
+ // sensitive version because the caller might be expecting that.
+ localMapping[hashed] = p[0];
+ return hashed;
+ });
+ params["algorithm"] = "sha256";
+ } else if (hashes["algorithms"].includes("none")) {
+ params["addresses"] = addressPairs.map((p) => {
+ const addr = p[0].toLowerCase(); // lowercase to get consistent hashes
+ const med = p[1].toLowerCase();
+ const unhashed = `${addr} ${med}`;
+ // Map the unhashed values to a known (case-sensitive) address. We use
+ // the case-sensitive version because the caller might be expecting that.
+ localMapping[unhashed] = p[0];
+ return unhashed;
+ });
+ params["algorithm"] = "none";
+ } else {
+ throw new Error("Unsupported identity server: unknown hash algorithm");
+ }
+
+ const response = await this.http.idServerRequest<{
+ mappings: { [address: string]: string };
+ }>(Method.Post, "/lookup", params, IdentityPrefix.V2, identityAccessToken);
+
+ if (!response?.["mappings"]) return []; // no results
+
+ const foundAddresses: { address: string; mxid: string }[] = [];
+ for (const hashed of Object.keys(response["mappings"])) {
+ const mxid = response["mappings"][hashed];
+ const plainAddress = localMapping[hashed];
+ if (!plainAddress) {
+ throw new Error("Identity server returned more results than expected");
+ }
+
+ foundAddresses.push({ address: plainAddress, mxid });
+ }
+ return foundAddresses;
+ }
+
+ /**
+ * Looks up the public Matrix ID mapping for a given 3rd party
+ * identifier from the identity server
+ *
+ * @param medium - The medium of the threepid, eg. 'email'
+ * @param address - The textual address of the threepid
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: A threepid mapping
+ * object or the empty object if no mapping
+ * exists
+ * @returns Rejects: with an error response.
+ */
+ public async lookupThreePid(medium: string, address: string, identityAccessToken: string): Promise<any> {
+ // TODO: Types
+ // Note: we're using the V2 API by calling this function, but our
+ // function contract requires a V1 response. We therefore have to
+ // convert it manually.
+ const response = await this.identityHashedLookup([[address, medium]], identityAccessToken);
+ const result = response.find((p) => p.address === address);
+ if (!result) {
+ return {};
+ }
+
+ const mapping = {
+ address,
+ medium,
+ mxid: result.mxid,
+
+ // We can't reasonably fill these parameters:
+ // not_before
+ // not_after
+ // ts
+ // signatures
+ };
+
+ return mapping;
+ }
+
+ /**
+ * Looks up the public Matrix ID mappings for multiple 3PIDs.
+ *
+ * @param query - Array of arrays containing
+ * [medium, address]
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: Lookup results from IS.
+ * @returns Rejects: with an error response.
+ */
+ public async bulkLookupThreePids(query: [string, string][], identityAccessToken: string): Promise<any> {
+ // TODO: Types
+ // Note: we're using the V2 API by calling this function, but our
+ // function contract requires a V1 response. We therefore have to
+ // convert it manually.
+ const response = await this.identityHashedLookup(
+ // We have to reverse the query order to get [address, medium] pairs
+ query.map((p) => [p[1], p[0]]),
+ identityAccessToken,
+ );
+
+ const v1results: [medium: string, address: string, mxid: string][] = [];
+ for (const mapping of response) {
+ const originalQuery = query.find((p) => p[1] === mapping.address);
+ if (!originalQuery) {
+ throw new Error("Identity sever returned unexpected results");
+ }
+
+ v1results.push([
+ originalQuery[0], // medium
+ mapping.address,
+ mapping.mxid,
+ ]);
+ }
+
+ return { threepids: v1results };
+ }
+
+ /**
+ * Get account info from the identity server. This is useful as a neutral check
+ * to verify that other APIs are likely to approve access by testing that the
+ * token is valid, terms have been agreed, etc.
+ *
+ * @param identityAccessToken - The `access_token` field of the Identity
+ * Server `/account/register` response (see {@link registerWithIdentityServer}).
+ *
+ * @returns Promise which resolves: an object with account info.
+ * @returns Rejects: with an error response.
+ */
+ public getIdentityAccount(identityAccessToken: string): Promise<any> {
+ // TODO: Types
+ return this.http.idServerRequest(Method.Get, "/account", undefined, IdentityPrefix.V2, identityAccessToken);
+ }
+
+ /**
+ * Send an event to a specific list of devices.
+ * This is a low-level API that simply wraps the HTTP API
+ * call to send to-device messages. We recommend using
+ * queueToDevice() which is a higher level API.
+ *
+ * @param eventType - type of event to send
+ * content to send. Map from user_id to device_id to content object.
+ * @param txnId - transaction id. One will be made up if not
+ * supplied.
+ * @returns Promise which resolves: to an empty object `{}`
+ */
+ public sendToDevice(eventType: string, contentMap: SendToDeviceContentMap, txnId?: string): Promise<{}> {
+ const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", {
+ $eventType: eventType,
+ $txnId: txnId ? txnId : this.makeTxnId(),
+ });
+
+ const body = {
+ messages: utils.recursiveMapToObject(contentMap),
+ };
+
+ const targets = new Map<string, string[]>();
+
+ for (const [userId, deviceMessages] of contentMap) {
+ targets.set(userId, Array.from(deviceMessages.keys()));
+ }
+
+ logger.log(`PUT ${path}`, targets);
+
+ return this.http.authedRequest(Method.Put, path, undefined, body);
+ }
+
+ /**
+ * Sends events directly to specific devices using Matrix's to-device
+ * messaging system. The batch will be split up into appropriately sized
+ * batches for sending and stored in the store so they can be retried
+ * later if they fail to send. Retries will happen automatically.
+ * @param batch - The to-device messages to send
+ */
+ public queueToDevice(batch: ToDeviceBatch): Promise<void> {
+ return this.toDeviceMessageQueue.queueBatch(batch);
+ }
+
+ /**
+ * Get the third party protocols that can be reached using
+ * this HS
+ * @returns Promise which resolves to the result object
+ */
+ public getThirdpartyProtocols(): Promise<{ [protocol: string]: IProtocol }> {
+ return this.http
+ .authedRequest<Record<string, IProtocol>>(Method.Get, "/thirdparty/protocols")
+ .then((response) => {
+ // sanity check
+ if (!response || typeof response !== "object") {
+ throw new Error(`/thirdparty/protocols did not return an object: ${response}`);
+ }
+ return response;
+ });
+ }
+
+ /**
+ * Get information on how a specific place on a third party protocol
+ * may be reached.
+ * @param protocol - The protocol given in getThirdpartyProtocols()
+ * @param params - Protocol-specific parameters, as given in the
+ * response to getThirdpartyProtocols()
+ * @returns Promise which resolves to the result object
+ */
+ public getThirdpartyLocation(
+ protocol: string,
+ params: { searchFields?: string[] },
+ ): Promise<IThirdPartyLocation[]> {
+ const path = utils.encodeUri("/thirdparty/location/$protocol", {
+ $protocol: protocol,
+ });
+
+ return this.http.authedRequest(Method.Get, path, params);
+ }
+
+ /**
+ * Get information on how a specific user on a third party protocol
+ * may be reached.
+ * @param protocol - The protocol given in getThirdpartyProtocols()
+ * @param params - Protocol-specific parameters, as given in the
+ * response to getThirdpartyProtocols()
+ * @returns Promise which resolves to the result object
+ */
+ public getThirdpartyUser(protocol: string, params: any): Promise<IThirdPartyUser[]> {
+ // TODO: Types
+ const path = utils.encodeUri("/thirdparty/user/$protocol", {
+ $protocol: protocol,
+ });
+
+ return this.http.authedRequest(Method.Get, path, params);
+ }
+
+ public getTerms(serviceType: SERVICE_TYPES, baseUrl: string): Promise<any> {
+ // TODO: Types
+ const url = this.termsUrlForService(serviceType, baseUrl);
+ return this.http.requestOtherUrl(Method.Get, url);
+ }
+
+ public agreeToTerms(
+ serviceType: SERVICE_TYPES,
+ baseUrl: string,
+ accessToken: string,
+ termsUrls: string[],
+ ): Promise<{}> {
+ const url = this.termsUrlForService(serviceType, baseUrl);
+ const headers = {
+ Authorization: "Bearer " + accessToken,
+ };
+ return this.http.requestOtherUrl(
+ Method.Post,
+ url,
+ {
+ user_accepts: termsUrls,
+ },
+ { headers },
+ );
+ }
+
+ /**
+ * Reports an event as inappropriate to the server, which may then notify the appropriate people.
+ * @param roomId - The room in which the event being reported is located.
+ * @param eventId - The event to report.
+ * @param score - The score to rate this content as where -100 is most offensive and 0 is inoffensive.
+ * @param reason - The reason the content is being reported. May be blank.
+ * @returns Promise which resolves to an empty object if successful
+ */
+ public reportEvent(roomId: string, eventId: string, score: number, reason: string): Promise<{}> {
+ const path = utils.encodeUri("/rooms/$roomId/report/$eventId", {
+ $roomId: roomId,
+ $eventId: eventId,
+ });
+
+ return this.http.authedRequest(Method.Post, path, undefined, { score, reason });
+ }
+
+ /**
+ * Fetches or paginates a room hierarchy as defined by MSC2946.
+ * Falls back gracefully to sourcing its data from `getSpaceSummary` if this API is not yet supported by the server.
+ * @param roomId - The ID of the space-room to use as the root of the summary.
+ * @param limit - The maximum number of rooms to return per page.
+ * @param maxDepth - The maximum depth in the tree from the root room to return.
+ * @param suggestedOnly - Whether to only return rooms with suggested=true.
+ * @param fromToken - The opaque token to paginate a previous request.
+ * @returns the response, with next_batch & rooms fields.
+ */
+ public getRoomHierarchy(
+ roomId: string,
+ limit?: number,
+ maxDepth?: number,
+ suggestedOnly = false,
+ fromToken?: string,
+ ): Promise<IRoomHierarchy> {
+ const path = utils.encodeUri("/rooms/$roomId/hierarchy", {
+ $roomId: roomId,
+ });
+
+ const queryParams: QueryDict = {
+ suggested_only: String(suggestedOnly),
+ max_depth: maxDepth?.toString(),
+ from: fromToken,
+ limit: limit?.toString(),
+ };
+
+ return this.http
+ .authedRequest<IRoomHierarchy>(Method.Get, path, queryParams, undefined, {
+ prefix: ClientPrefix.V1,
+ })
+ .catch((e) => {
+ if (e.errcode === "M_UNRECOGNIZED") {
+ // fall back to the prefixed hierarchy API.
+ return this.http.authedRequest<IRoomHierarchy>(Method.Get, path, queryParams, undefined, {
+ prefix: "/_matrix/client/unstable/org.matrix.msc2946",
+ });
+ }
+
+ throw e;
+ });
+ }
+
+ /**
+ * Creates a new file tree space with the given name. The client will pick
+ * defaults for how it expects to be able to support the remaining API offered
+ * by the returned class.
+ *
+ * Note that this is UNSTABLE and may have breaking changes without notice.
+ * @param name - The name of the tree space.
+ * @returns Promise which resolves to the created space.
+ */
+ public async unstableCreateFileTree(name: string): Promise<MSC3089TreeSpace> {
+ const { room_id: roomId } = await this.createRoom({
+ name: name,
+ preset: Preset.PrivateChat,
+ power_level_content_override: {
+ ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE,
+ users: {
+ [this.getUserId()!]: 100,
+ },
+ },
+ creation_content: {
+ [RoomCreateTypeField]: RoomType.Space,
+ },
+ initial_state: [
+ {
+ type: UNSTABLE_MSC3088_PURPOSE.name,
+ state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name,
+ content: {
+ [UNSTABLE_MSC3088_ENABLED.name]: true,
+ },
+ },
+ {
+ type: EventType.RoomEncryption,
+ state_key: "",
+ content: {
+ algorithm: olmlib.MEGOLM_ALGORITHM,
+ },
+ },
+ ],
+ });
+ return new MSC3089TreeSpace(this, roomId);
+ }
+
+ /**
+ * Gets a reference to a tree space, if the room ID given is a tree space. If the room
+ * does not appear to be a tree space then null is returned.
+ *
+ * Note that this is UNSTABLE and may have breaking changes without notice.
+ * @param roomId - The room ID to get a tree space reference for.
+ * @returns The tree space, or null if not a tree space.
+ */
+ public unstableGetFileTreeSpace(roomId: string): MSC3089TreeSpace | null {
+ const room = this.getRoom(roomId);
+ if (room?.getMyMembership() !== "join") return null;
+
+ const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
+ const purposeEvent = room.currentState.getStateEvents(
+ UNSTABLE_MSC3088_PURPOSE.name,
+ UNSTABLE_MSC3089_TREE_SUBTYPE.name,
+ );
+
+ if (!createEvent) throw new Error("Expected single room create event");
+
+ if (!purposeEvent?.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null;
+ if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null;
+
+ return new MSC3089TreeSpace(this, roomId);
+ }
+
+ /**
+ * Perform a single MSC3575 sliding sync request.
+ * @param req - The request to make.
+ * @param proxyBaseUrl - The base URL for the sliding sync proxy.
+ * @param abortSignal - Optional signal to abort request mid-flight.
+ * @returns The sliding sync response, or a standard error.
+ * @throws on non 2xx status codes with an object with a field "httpStatus":number.
+ */
+ public slidingSync(
+ req: MSC3575SlidingSyncRequest,
+ proxyBaseUrl?: string,
+ abortSignal?: AbortSignal,
+ ): Promise<MSC3575SlidingSyncResponse> {
+ const qps: Record<string, any> = {};
+ if (req.pos) {
+ qps.pos = req.pos;
+ delete req.pos;
+ }
+ if (req.timeout) {
+ qps.timeout = req.timeout;
+ delete req.timeout;
+ }
+ const clientTimeout = req.clientTimeout;
+ delete req.clientTimeout;
+ return this.http.authedRequest<MSC3575SlidingSyncResponse>(Method.Post, "/sync", qps, req, {
+ prefix: "/_matrix/client/unstable/org.matrix.msc3575",
+ baseUrl: proxyBaseUrl,
+ localTimeoutMs: clientTimeout,
+ abortSignal,
+ });
+ }
+
+ /**
+ * @deprecated use supportsThreads() instead
+ */
+ public supportsExperimentalThreads(): boolean {
+ logger.warn(`supportsExperimentalThreads() is deprecated, use supportThreads() instead`);
+ return this.clientOpts?.experimentalThreadSupport || false;
+ }
+
+ /**
+ * A helper to determine thread support
+ * @returns a boolean to determine if threads are enabled
+ */
+ public supportsThreads(): boolean {
+ return this.clientOpts?.threadSupport || false;
+ }
+
+ /**
+ * A helper to determine intentional mentions support
+ * @returns a boolean to determine if intentional mentions are enabled
+ * @experimental
+ */
+ public supportsIntentionalMentions(): boolean {
+ return this.clientOpts?.intentionalMentions || false;
+ }
+
+ /**
+ * Fetches the summary of a room as defined by an initial version of MSC3266 and implemented in Synapse
+ * Proposed at https://github.com/matrix-org/matrix-doc/pull/3266
+ * @param roomIdOrAlias - The ID or alias of the room to get the summary of.
+ * @param via - The list of servers which know about the room if only an ID was provided.
+ */
+ public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> {
+ const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
+ return this.http.authedRequest(Method.Get, path, { via }, undefined, {
+ prefix: "/_matrix/client/unstable/im.nheko.summary",
+ });
+ }
+
+ /**
+ * Processes a list of threaded events and adds them to their respective timelines
+ * @param room - the room the adds the threaded events
+ * @param threadedEvents - an array of the threaded events
+ * @param toStartOfTimeline - the direction in which we want to add the events
+ */
+ public processThreadEvents(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
+ room.processThreadedEvents(threadedEvents, toStartOfTimeline);
+ }
+
+ /**
+ * Processes a list of thread roots and creates a thread model
+ * @param room - the room to create the threads in
+ * @param threadedEvents - an array of thread roots
+ * @param toStartOfTimeline - the direction
+ */
+ public processThreadRoots(room: Room, threadedEvents: MatrixEvent[], toStartOfTimeline: boolean): void {
+ room.processThreadRoots(threadedEvents, toStartOfTimeline);
+ }
+
+ public processBeaconEvents(room?: Room, events?: MatrixEvent[]): void {
+ this.processAggregatedTimelineEvents(room, events);
+ }
+
+ /**
+ * Calls aggregation functions for event types that are aggregated
+ * Polls and location beacons
+ * @param room - room the events belong to
+ * @param events - timeline events to be processed
+ * @returns
+ */
+ public processAggregatedTimelineEvents(room?: Room, events?: MatrixEvent[]): void {
+ if (!events?.length) return;
+ if (!room) return;
+
+ room.currentState.processBeaconEvents(events, this);
+ room.processPollEvents(events);
+ }
+
+ /**
+ * Fetches information about the user for the configured access token.
+ */
+ public async whoami(): Promise<IWhoamiResponse> {
+ return this.http.authedRequest(Method.Get, "/account/whoami");
+ }
+
+ /**
+ * Find the event_id closest to the given timestamp in the given direction.
+ * @returns Resolves: A promise of an object containing the event_id and
+ * origin_server_ts of the closest event to the timestamp in the given direction
+ * @returns Rejects: when the request fails (module:http-api.MatrixError)
+ */
+ public async timestampToEvent(
+ roomId: string,
+ timestamp: number,
+ dir: Direction,
+ ): Promise<TimestampToEventResponse> {
+ const path = utils.encodeUri("/rooms/$roomId/timestamp_to_event", {
+ $roomId: roomId,
+ });
+ const queryParams = {
+ ts: timestamp.toString(),
+ dir: dir,
+ };
+
+ try {
+ return await this.http.authedRequest(Method.Get, path, queryParams, undefined, {
+ prefix: ClientPrefix.V1,
+ });
+ } catch (err) {
+ // Fallback to the prefixed unstable endpoint. Since the stable endpoint is
+ // new, we should also try the unstable endpoint before giving up. We can
+ // remove this fallback request in a year (remove after 2023-11-28).
+ if (
+ (<MatrixError>err).errcode === "M_UNRECOGNIZED" &&
+ // XXX: The 400 status code check should be removed in the future
+ // when Synapse is compliant with MSC3743.
+ ((<MatrixError>err).httpStatus === 400 ||
+ // This the correct standard status code for an unsupported
+ // endpoint according to MSC3743. Not Found and Method Not Allowed
+ // both indicate that this endpoint+verb combination is
+ // not supported.
+ (<MatrixError>err).httpStatus === 404 ||
+ (<MatrixError>err).httpStatus === 405)
+ ) {
+ return await this.http.authedRequest(Method.Get, path, queryParams, undefined, {
+ prefix: "/_matrix/client/unstable/org.matrix.msc3030",
+ });
+ }
+
+ throw err;
+ }
+ }
+}
+
+/**
+ * recalculates an accurate notifications count on event decryption.
+ * Servers do not have enough knowledge about encrypted events to calculate an
+ * accurate notification_count
+ */
+export function fixNotificationCountOnDecryption(cli: MatrixClient, event: MatrixEvent): void {
+ const ourUserId = cli.getUserId();
+ const eventId = event.getId();
+
+ const room = cli.getRoom(event.getRoomId());
+ if (!room || !ourUserId || !eventId) return;
+
+ const oldActions = event.getPushActions();
+ const actions = cli.getPushActionsForEvent(event, true);
+
+ const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
+
+ const currentHighlightCount = room.getUnreadCountForEventContext(NotificationCountType.Highlight, event);
+
+ // Ensure the unread counts are kept up to date if the event is encrypted
+ // We also want to make sure that the notification count goes up if we already
+ // have encrypted events to avoid other code from resetting 'highlight' to zero.
+ const oldHighlight = !!oldActions?.tweaks?.highlight;
+ const newHighlight = !!actions?.tweaks?.highlight;
+
+ let hasReadEvent;
+ if (isThreadEvent) {
+ const thread = room.getThread(event.threadRootId);
+ hasReadEvent = thread
+ ? thread.hasUserReadEvent(ourUserId, eventId)
+ : // If the thread object does not exist in the room yet, we don't
+ // want to calculate notification for this event yet. We have not
+ // restored the read receipts yet and can't accurately calculate
+ // notifications at this stage.
+ //
+ // This issue can likely go away when MSC3874 is implemented
+ true;
+ } else {
+ hasReadEvent = room.hasUserReadEvent(ourUserId, eventId);
+ }
+
+ if (hasReadEvent) {
+ // If the event has been read, ignore it.
+ return;
+ }
+
+ if (oldHighlight !== newHighlight || currentHighlightCount > 0) {
+ // TODO: Handle mentions received while the client is offline
+ // See also https://github.com/vector-im/element-web/issues/9069
+ let newCount = currentHighlightCount;
+ if (newHighlight && !oldHighlight) newCount++;
+ if (!newHighlight && oldHighlight) newCount--;
+
+ if (isThreadEvent) {
+ room.setThreadUnreadNotificationCount(event.threadRootId, NotificationCountType.Highlight, newCount);
+ } else {
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount);
+ }
+ }
+
+ // Total count is used to typically increment a room notification counter, but not loudly highlight it.
+ const currentTotalCount = room.getUnreadCountForEventContext(NotificationCountType.Total, event);
+
+ // `notify` is used in practice for incrementing the total count
+ const newNotify = !!actions?.notify;
+
+ // The room total count is NEVER incremented by the server for encrypted rooms. We basically ignore
+ // the server here as it's always going to tell us to increment for encrypted events.
+ if (newNotify) {
+ if (isThreadEvent) {
+ room.setThreadUnreadNotificationCount(
+ event.threadRootId,
+ NotificationCountType.Total,
+ currentTotalCount + 1,
+ );
+ } else {
+ room.setUnreadNotificationCount(NotificationCountType.Total, currentTotalCount + 1);
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/CryptoBackend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/CryptoBackend.ts
new file mode 100644
index 0000000..a0b4621
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/CryptoBackend.ts
@@ -0,0 +1,170 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import type { IToDeviceEvent } from "../sync-accumulator";
+import { MatrixEvent } from "../models/event";
+import { Room } from "../models/room";
+import { CryptoApi } from "../crypto-api";
+import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
+import { IEncryptedEventInfo } from "../crypto/api";
+import { IEventDecryptionResult } from "../@types/crypto";
+
+/**
+ * Common interface for the crypto implementations
+ */
+export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
+ /**
+ * Whether sendMessage in a room with unknown and unverified devices
+ * should throw an error and not send the message. This has 'Global' for
+ * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently
+ * no room-level equivalent for this setting.
+ *
+ * @remarks this is here, rather than in `CryptoApi`, because I don't think we're
+ * going to support it in the rust crypto implementation.
+ */
+ globalErrorOnUnknownDevices: boolean;
+
+ /**
+ * Shut down any background processes related to crypto
+ */
+ stop(): void;
+
+ /**
+ * Get the verification level for a given user
+ *
+ * TODO: define this better
+ *
+ * @param userId - user to be checked
+ */
+ checkUserTrust(userId: string): UserTrustLevel;
+
+ /**
+ * Get the verification level for a given device
+ *
+ * TODO: define this better
+ *
+ * @param userId - user to be checked
+ * @param deviceId - device to be checked
+ */
+ checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel;
+
+ /**
+ * Encrypt an event according to the configuration of the room.
+ *
+ * @param event - event to be sent
+ *
+ * @param room - destination room.
+ *
+ * @returns Promise which resolves when the event has been
+ * encrypted, or null if nothing was needed
+ */
+ encryptEvent(event: MatrixEvent, room: Room): Promise<void>;
+
+ /**
+ * Decrypt a received event
+ *
+ * @returns a promise which resolves once we have finished decrypting.
+ * Rejects with an error if there is a problem decrypting the event.
+ */
+ decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
+
+ /**
+ * Get information about the encryption of an event
+ *
+ * @param event - event to be checked
+ */
+ getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo;
+}
+
+/** The methods which crypto implementations should expose to the Sync api */
+export interface SyncCryptoCallbacks {
+ /**
+ * Called by the /sync loop whenever there are incoming to-device messages.
+ *
+ * The implementation may preprocess the received messages (eg, decrypt them) and return an
+ * updated list of messages for dispatch to the rest of the system.
+ *
+ * Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
+ * messages, rather than the results of any decryption attempts.
+ *
+ * @param events - the received to-device messages
+ * @returns A list of preprocessed to-device messages.
+ */
+ preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
+
+ /**
+ * Called by the /sync loop whenever there are incoming to-device messages.
+ *
+ * The implementation may preprocess the received messages (eg, decrypt them) and return an
+ * updated list of messages for dispatch to the rest of the system.
+ *
+ * Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
+ * messages, rather than the results of any decryption attempts.
+ *
+ * @param oneTimeKeysCounts - the received one time key counts
+ * @returns A list of preprocessed to-device messages.
+ */
+ preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void>;
+
+ /**
+ * Called by the /sync loop whenever there are incoming to-device messages.
+ *
+ * The implementation may preprocess the received messages (eg, decrypt them) and return an
+ * updated list of messages for dispatch to the rest of the system.
+ *
+ * Note that, unlike {@link ClientEvent.ToDeviceEvent} events, this is called on the raw to-device
+ * messages, rather than the results of any decryption attempts.
+ *
+ * @param unusedFallbackKeys - the received unused fallback keys
+ * @returns A list of preprocessed to-device messages.
+ */
+ preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void>;
+
+ /**
+ * Called by the /sync loop whenever an m.room.encryption event is received.
+ *
+ * This is called before RoomStateEvents are emitted for any of the events in the /sync
+ * response (even if the other events technically happened first). This works around a problem
+ * if the client uses a RoomStateEvent (typically a membership event) as a trigger to send a message
+ * in a new room (or one where encryption has been newly enabled): that would otherwise leave the
+ * crypto layer confused because it expects crypto to be set up, but it has not yet been.
+ *
+ * @param room - in which the event was received
+ * @param event - encryption event to be processed
+ */
+ onCryptoEvent(room: Room, event: MatrixEvent): Promise<void>;
+
+ /**
+ * Called by the /sync loop after each /sync response is processed.
+ *
+ * Used to complete batch processing, or to initiate background processes
+ *
+ * @param syncState - information about the completed sync.
+ */
+ onSyncCompleted(syncState: OnSyncCompletedData): void;
+}
+
+export interface OnSyncCompletedData {
+ /**
+ * The 'next_batch' result from /sync, which will become the 'since' token for the next call to /sync.
+ */
+ nextSyncToken?: string;
+
+ /**
+ * True if we are working our way through a backlog of events after connecting.
+ */
+ catchingUp?: boolean;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/README.md b/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/README.md
new file mode 100644
index 0000000..7af3298
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/common-crypto/README.md
@@ -0,0 +1,4 @@
+This directory contains functionality which is common to both the legacy (libolm-based) crypto implementation,
+and the new rust-based implementation.
+
+It is an internal module, and is _not_ directly exposed to applications.
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/content-helpers.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/content-helpers.ts
new file mode 100644
index 0000000..6790886
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/content-helpers.ts
@@ -0,0 +1,288 @@
+/*
+Copyright 2018 - 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MBeaconEventContent, MBeaconInfoContent, MBeaconInfoEventContent } from "./@types/beacon";
+import { MsgType } from "./@types/event";
+import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events";
+import { isProvided } from "./extensible_events_v1/utilities";
+import {
+ M_ASSET,
+ LocationAssetType,
+ M_LOCATION,
+ M_TIMESTAMP,
+ LocationEventWireContent,
+ MLocationEventContent,
+ MLocationContent,
+ MAssetContent,
+ LegacyLocationEventContent,
+} from "./@types/location";
+import { MRoomTopicEventContent, MTopicContent, M_TOPIC } from "./@types/topic";
+import { IContent } from "./models/event";
+
+/**
+ * Generates the content for a HTML Message event
+ * @param body - the plaintext body of the message
+ * @param htmlBody - the HTML representation of the message
+ * @returns
+ */
+export function makeHtmlMessage(body: string, htmlBody: string): IContent {
+ return {
+ msgtype: MsgType.Text,
+ format: "org.matrix.custom.html",
+ body: body,
+ formatted_body: htmlBody,
+ };
+}
+
+/**
+ * Generates the content for a HTML Notice event
+ * @param body - the plaintext body of the notice
+ * @param htmlBody - the HTML representation of the notice
+ * @returns
+ */
+export function makeHtmlNotice(body: string, htmlBody: string): IContent {
+ return {
+ msgtype: MsgType.Notice,
+ format: "org.matrix.custom.html",
+ body: body,
+ formatted_body: htmlBody,
+ };
+}
+
+/**
+ * Generates the content for a HTML Emote event
+ * @param body - the plaintext body of the emote
+ * @param htmlBody - the HTML representation of the emote
+ * @returns
+ */
+export function makeHtmlEmote(body: string, htmlBody: string): IContent {
+ return {
+ msgtype: MsgType.Emote,
+ format: "org.matrix.custom.html",
+ body: body,
+ formatted_body: htmlBody,
+ };
+}
+
+/**
+ * Generates the content for a Plaintext Message event
+ * @param body - the plaintext body of the emote
+ * @returns
+ */
+export function makeTextMessage(body: string): IContent {
+ return {
+ msgtype: MsgType.Text,
+ body: body,
+ };
+}
+
+/**
+ * Generates the content for a Plaintext Notice event
+ * @param body - the plaintext body of the notice
+ * @returns
+ */
+export function makeNotice(body: string): IContent {
+ return {
+ msgtype: MsgType.Notice,
+ body: body,
+ };
+}
+
+/**
+ * Generates the content for a Plaintext Emote event
+ * @param body - the plaintext body of the emote
+ * @returns
+ */
+export function makeEmoteMessage(body: string): IContent {
+ return {
+ msgtype: MsgType.Emote,
+ body: body,
+ };
+}
+
+/** Location content helpers */
+
+export const getTextForLocationEvent = (
+ uri: string | undefined,
+ assetType: LocationAssetType,
+ timestamp?: number,
+ description?: string | null,
+): string => {
+ const date = `at ${new Date(timestamp!).toISOString()}`;
+ const assetName = assetType === LocationAssetType.Self ? "User" : undefined;
+ const quotedDescription = description ? `"${description}"` : undefined;
+
+ return [assetName, "Location", quotedDescription, uri, date].filter(Boolean).join(" ");
+};
+
+/**
+ * Generates the content for a Location event
+ * @param uri - a geo:// uri for the location
+ * @param timestamp - the timestamp when the location was correct (milliseconds since the UNIX epoch)
+ * @param description - the (optional) label for this location on the map
+ * @param assetType - the (optional) asset type of this location e.g. "m.self"
+ * @param text - optional. A text for the location
+ */
+export const makeLocationContent = (
+ // this is first but optional
+ // to avoid a breaking change
+ text?: string,
+ uri?: string,
+ timestamp?: number,
+ description?: string | null,
+ assetType?: LocationAssetType,
+): LegacyLocationEventContent & MLocationEventContent => {
+ const defaultedText =
+ text ?? getTextForLocationEvent(uri, assetType || LocationAssetType.Self, timestamp, description);
+ const timestampEvent = timestamp ? { [M_TIMESTAMP.name]: timestamp } : {};
+ return {
+ msgtype: MsgType.Location,
+ body: defaultedText,
+ geo_uri: uri,
+ [M_LOCATION.name]: {
+ description,
+ uri,
+ },
+ [M_ASSET.name]: {
+ type: assetType || LocationAssetType.Self,
+ },
+ [M_TEXT.name]: defaultedText,
+ ...timestampEvent,
+ } as LegacyLocationEventContent & MLocationEventContent;
+};
+
+/**
+ * Parse location event content and transform to
+ * a backwards compatible modern m.location event format
+ */
+export const parseLocationEvent = (wireEventContent: LocationEventWireContent): MLocationEventContent => {
+ const location = M_LOCATION.findIn<MLocationContent>(wireEventContent);
+ const asset = M_ASSET.findIn<MAssetContent>(wireEventContent);
+ const timestamp = M_TIMESTAMP.findIn<number>(wireEventContent);
+ const text = M_TEXT.findIn<string>(wireEventContent);
+
+ const geoUri = location?.uri ?? wireEventContent?.geo_uri;
+ const description = location?.description;
+ const assetType = asset?.type ?? LocationAssetType.Self;
+ const fallbackText = text ?? wireEventContent.body;
+
+ return makeLocationContent(fallbackText, geoUri, timestamp ?? undefined, description, assetType);
+};
+
+/**
+ * Topic event helpers
+ */
+export type MakeTopicContent = (topic: string, htmlTopic?: string) => MRoomTopicEventContent;
+
+export const makeTopicContent: MakeTopicContent = (topic, htmlTopic) => {
+ const renderings = [{ body: topic, mimetype: "text/plain" }];
+ if (isProvided(htmlTopic)) {
+ renderings.push({ body: htmlTopic!, mimetype: "text/html" });
+ }
+ return { topic, [M_TOPIC.name]: renderings };
+};
+
+export type TopicState = {
+ text: string;
+ html?: string;
+};
+
+export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
+ const mtopic = M_TOPIC.findIn<MTopicContent>(content);
+ if (!Array.isArray(mtopic)) {
+ return { text: content.topic };
+ }
+ const text = mtopic?.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain")?.body ?? content.topic;
+ const html = mtopic?.find((r) => r.mimetype === "text/html")?.body;
+ return { text, html };
+};
+
+/**
+ * Beacon event helpers
+ */
+export type MakeBeaconInfoContent = (
+ timeout: number,
+ isLive?: boolean,
+ description?: string,
+ assetType?: LocationAssetType,
+ timestamp?: number,
+) => MBeaconInfoEventContent;
+
+export const makeBeaconInfoContent: MakeBeaconInfoContent = (timeout, isLive, description, assetType, timestamp) => ({
+ description,
+ timeout,
+ live: isLive,
+ [M_TIMESTAMP.name]: timestamp || Date.now(),
+ [M_ASSET.name]: {
+ type: assetType ?? LocationAssetType.Self,
+ },
+});
+
+export type BeaconInfoState = MBeaconInfoContent & {
+ assetType?: LocationAssetType;
+ timestamp?: number;
+};
+/**
+ * Flatten beacon info event content
+ */
+export const parseBeaconInfoContent = (content: MBeaconInfoEventContent): BeaconInfoState => {
+ const { description, timeout, live } = content;
+ const timestamp = M_TIMESTAMP.findIn<number>(content) ?? undefined;
+ const asset = M_ASSET.findIn<MAssetContent>(content);
+
+ return {
+ description,
+ timeout,
+ live,
+ assetType: asset?.type,
+ timestamp,
+ };
+};
+
+export type MakeBeaconContent = (
+ uri: string,
+ timestamp: number,
+ beaconInfoEventId: string,
+ description?: string,
+) => MBeaconEventContent;
+
+export const makeBeaconContent: MakeBeaconContent = (uri, timestamp, beaconInfoEventId, description) => ({
+ [M_LOCATION.name]: {
+ description,
+ uri,
+ },
+ [M_TIMESTAMP.name]: timestamp,
+ "m.relates_to": {
+ rel_type: REFERENCE_RELATION.name,
+ event_id: beaconInfoEventId,
+ },
+});
+
+export type BeaconLocationState = Omit<MLocationContent, "uri"> & {
+ uri?: string; // override from MLocationContent to allow optionals
+ timestamp?: number;
+};
+
+export const parseBeaconContent = (content: MBeaconEventContent): BeaconLocationState => {
+ const location = M_LOCATION.findIn<MLocationContent>(content);
+ const timestamp = M_TIMESTAMP.findIn<number>(content) ?? undefined;
+
+ return {
+ description: location?.description,
+ uri: location?.uri,
+ timestamp,
+ };
+};
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/content-repo.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/content-repo.ts
new file mode 100644
index 0000000..2575412
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/content-repo.ts
@@ -0,0 +1,79 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import * as utils from "./utils";
+
+/**
+ * Get the HTTP URL for an MXC URI.
+ * @param baseUrl - The base homeserver url which has a content repo.
+ * @param mxc - The mxc:// URI.
+ * @param width - The desired width of the thumbnail.
+ * @param height - The desired height of the thumbnail.
+ * @param resizeMethod - The thumbnail resize method to use, either
+ * "crop" or "scale".
+ * @param allowDirectLinks - If true, return any non-mxc URLs
+ * directly. Fetching such URLs will leak information about the user to
+ * anyone they share a room with. If false, will return the emptry string
+ * for such URLs.
+ * @returns The complete URL to the content.
+ */
+export function getHttpUriForMxc(
+ baseUrl: string,
+ mxc?: string,
+ width?: number,
+ height?: number,
+ resizeMethod?: string,
+ allowDirectLinks = false,
+): string {
+ if (typeof mxc !== "string" || !mxc) {
+ return "";
+ }
+ if (mxc.indexOf("mxc://") !== 0) {
+ if (allowDirectLinks) {
+ return mxc;
+ } else {
+ return "";
+ }
+ }
+ let serverAndMediaId = mxc.slice(6); // strips mxc://
+ let prefix = "/_matrix/media/r0/download/";
+ const params: Record<string, string> = {};
+
+ if (width) {
+ params["width"] = Math.round(width).toString();
+ }
+ if (height) {
+ params["height"] = Math.round(height).toString();
+ }
+ if (resizeMethod) {
+ params["method"] = resizeMethod;
+ }
+ if (Object.keys(params).length > 0) {
+ // these are thumbnailing params so they probably want the
+ // thumbnailing API...
+ prefix = "/_matrix/media/r0/thumbnail/";
+ }
+
+ const fragmentOffset = serverAndMediaId.indexOf("#");
+ let fragment = "";
+ if (fragmentOffset >= 0) {
+ fragment = serverAndMediaId.slice(fragmentOffset);
+ serverAndMediaId = serverAndMediaId.slice(0, fragmentOffset);
+ }
+
+ const urlParams = Object.keys(params).length === 0 ? "" : "?" + utils.encodeParams(params);
+ return baseUrl + prefix + serverAndMediaId + urlParams + fragment;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto-api.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto-api.ts
new file mode 100644
index 0000000..50617c9
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto-api.ts
@@ -0,0 +1,75 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import type { IMegolmSessionData } from "./@types/crypto";
+import { Room } from "./models/room";
+
+/**
+ * Public interface to the cryptography parts of the js-sdk
+ *
+ * @remarks Currently, this is a work-in-progress. In time, more methods will be added here.
+ */
+export interface CryptoApi {
+ /**
+ * Global override for whether the client should ever send encrypted
+ * messages to unverified devices. This provides the default for rooms which
+ * do not specify a value.
+ *
+ * If true, all unverified devices will be blacklisted by default
+ */
+ globalBlacklistUnverifiedDevices: boolean;
+
+ /**
+ * Checks if the user has previously published cross-signing keys
+ *
+ * This means downloading the devicelist for the user and checking if the list includes
+ * the cross-signing pseudo-device.
+ *
+ * @returns true if the user has previously published cross-signing keys
+ */
+ userHasCrossSigningKeys(): Promise<boolean>;
+
+ /**
+ * Perform any background tasks that can be done before a message is ready to
+ * send, in order to speed up sending of the message.
+ *
+ * @param room - the room the event is in
+ */
+ prepareToEncrypt(room: Room): void;
+
+ /**
+ * Discard any existing megolm session for the given room.
+ *
+ * This will ensure that a new session is created on the next call to {@link prepareToEncrypt},
+ * or the next time a message is sent.
+ *
+ * This should not normally be necessary: it should only be used as a debugging tool if there has been a
+ * problem with encryption.
+ *
+ * @param roomId - the room to discard sessions for
+ */
+ forceDiscardSession(roomId: string): Promise<void>;
+
+ /**
+ * Get a list containing all of the room keys
+ *
+ * This should be encrypted before returning it to the user.
+ *
+ * @returns a promise which resolves to a list of
+ * session export objects
+ */
+ exportRoomKeys(): Promise<IMegolmSessionData[]>;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts
new file mode 100644
index 0000000..31ed2d4
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/CrossSigning.ts
@@ -0,0 +1,803 @@
+/*
+Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Cross signing methods
+ */
+
+import { PkSigning } from "@matrix-org/olm";
+
+import { decodeBase64, encodeBase64, IObject, pkSign, pkVerify } from "./olmlib";
+import { logger } from "../logger";
+import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
+import { decryptAES, encryptAES } from "./aes";
+import { DeviceInfo } from "./deviceinfo";
+import { SecretStorage } from "./SecretStorage";
+import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
+import { OlmDevice } from "./OlmDevice";
+import { ICryptoCallbacks } from ".";
+import { ISignatures } from "../@types/signed";
+import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
+import { SecretStorageKeyDescription } from "../secret-storage";
+
+const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
+
+function publicKeyFromKeyInfo(keyInfo: ICrossSigningKey): string {
+ // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey }
+ // We assume only a single key, and we want the bare form without type
+ // prefix, so we select the values.
+ return Object.values(keyInfo.keys)[0];
+}
+
+export interface ICacheCallbacks {
+ getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise<Uint8Array | null>;
+ storeCrossSigningKeyCache?(type: string, key?: Uint8Array): Promise<void>;
+}
+
+export interface ICrossSigningInfo {
+ keys: Record<string, ICrossSigningKey>;
+ firstUse: boolean;
+ crossSigningVerifiedBefore: boolean;
+}
+
+export class CrossSigningInfo {
+ public keys: Record<string, ICrossSigningKey> = {};
+ public firstUse = true;
+ // This tracks whether we've ever verified this user with any identity.
+ // When you verify a user, any devices online at the time that receive
+ // the verifying signature via the homeserver will latch this to true
+ // and can use it in the future to detect cases where the user has
+ // become unverified later for any reason.
+ private crossSigningVerifiedBefore = false;
+
+ /**
+ * Information about a user's cross-signing keys
+ *
+ * @param userId - the user that the information is about
+ * @param callbacks - Callbacks used to interact with the app
+ * Requires getCrossSigningKey and saveCrossSigningKeys
+ * @param cacheCallbacks - Callbacks used to interact with the cache
+ */
+ public constructor(
+ public readonly userId: string,
+ private callbacks: ICryptoCallbacks = {},
+ private cacheCallbacks: ICacheCallbacks = {},
+ ) {}
+
+ public static fromStorage(obj: ICrossSigningInfo, userId: string): CrossSigningInfo {
+ const res = new CrossSigningInfo(userId);
+ for (const prop in obj) {
+ if (obj.hasOwnProperty(prop)) {
+ // @ts-ignore - ts doesn't like this and nor should we
+ res[prop] = obj[prop];
+ }
+ }
+ return res;
+ }
+
+ public toStorage(): ICrossSigningInfo {
+ return {
+ keys: this.keys,
+ firstUse: this.firstUse,
+ crossSigningVerifiedBefore: this.crossSigningVerifiedBefore,
+ };
+ }
+
+ /**
+ * Calls the app callback to ask for a private key
+ *
+ * @param type - The key type ("master", "self_signing", or "user_signing")
+ * @param expectedPubkey - The matching public key or undefined to use
+ * the stored public key for the given key type.
+ * @returns An array with [ public key, Olm.PkSigning ]
+ */
+ public async getCrossSigningKey(type: string, expectedPubkey?: string): Promise<[string, PkSigning]> {
+ const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0;
+
+ if (!this.callbacks.getCrossSigningKey) {
+ throw new Error("No getCrossSigningKey callback supplied");
+ }
+
+ if (expectedPubkey === undefined) {
+ expectedPubkey = this.getId(type)!;
+ }
+
+ function validateKey(key: Uint8Array | null): [string, PkSigning] | undefined {
+ if (!key) return;
+ const signing = new global.Olm.PkSigning();
+ const gotPubkey = signing.init_with_seed(key);
+ if (gotPubkey === expectedPubkey) {
+ return [gotPubkey, signing];
+ }
+ signing.free();
+ }
+
+ let privkey: Uint8Array | null = null;
+ if (this.cacheCallbacks.getCrossSigningKeyCache && shouldCache) {
+ privkey = await this.cacheCallbacks.getCrossSigningKeyCache(type, expectedPubkey);
+ }
+
+ const cacheresult = validateKey(privkey);
+ if (cacheresult) {
+ return cacheresult;
+ }
+
+ privkey = await this.callbacks.getCrossSigningKey(type, expectedPubkey);
+ const result = validateKey(privkey);
+ if (result) {
+ if (this.cacheCallbacks.storeCrossSigningKeyCache && shouldCache) {
+ await this.cacheCallbacks.storeCrossSigningKeyCache(type, privkey!);
+ }
+ return result;
+ }
+
+ /* No keysource even returned a key */
+ if (!privkey) {
+ throw new Error("getCrossSigningKey callback for " + type + " returned falsey");
+ }
+
+ /* We got some keys from the keysource, but none of them were valid */
+ throw new Error("Key type " + type + " from getCrossSigningKey callback did not match");
+ }
+
+ /**
+ * Check whether the private keys exist in secret storage.
+ * XXX: This could be static, be we often seem to have an instance when we
+ * want to know this anyway...
+ *
+ * @param secretStorage - The secret store using account data
+ * @returns map of key name to key info the secret is encrypted
+ * with, or null if it is not present or not encrypted with a trusted
+ * key
+ */
+ public async isStoredInSecretStorage(
+ secretStorage: SecretStorage<MatrixClient | undefined>,
+ ): Promise<Record<string, object> | null> {
+ // check what SSSS keys have encrypted the master key (if any)
+ const stored = (await secretStorage.isStored("m.cross_signing.master")) || {};
+ // then check which of those SSSS keys have also encrypted the SSK and USK
+ function intersect(s: Record<string, SecretStorageKeyDescription>): void {
+ for (const k of Object.keys(stored)) {
+ if (!s[k]) {
+ delete stored[k];
+ }
+ }
+ }
+ for (const type of ["self_signing", "user_signing"]) {
+ intersect((await secretStorage.isStored(`m.cross_signing.${type}`)) || {});
+ }
+ return Object.keys(stored).length ? stored : null;
+ }
+
+ /**
+ * Store private keys in secret storage for use by other devices. This is
+ * typically called in conjunction with the creation of new cross-signing
+ * keys.
+ *
+ * @param keys - The keys to store
+ * @param secretStorage - The secret store using account data
+ */
+ public static async storeInSecretStorage(
+ keys: Map<string, Uint8Array>,
+ secretStorage: SecretStorage<undefined>,
+ ): Promise<void> {
+ for (const [type, privateKey] of keys) {
+ const encodedKey = encodeBase64(privateKey);
+ await secretStorage.store(`m.cross_signing.${type}`, encodedKey);
+ }
+ }
+
+ /**
+ * Get private keys from secret storage created by some other device. This
+ * also passes the private keys to the app-specific callback.
+ *
+ * @param type - The type of key to get. One of "master",
+ * "self_signing", or "user_signing".
+ * @param secretStorage - The secret store using account data
+ * @returns The private key
+ */
+ public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise<Uint8Array | null> {
+ const encodedKey = await secretStorage.get(`m.cross_signing.${type}`);
+ if (!encodedKey) {
+ return null;
+ }
+ return decodeBase64(encodedKey);
+ }
+
+ /**
+ * Check whether the private keys exist in the local key cache.
+ *
+ * @param type - The type of key to get. One of "master",
+ * "self_signing", or "user_signing". Optional, will check all by default.
+ * @returns True if all keys are stored in the local cache.
+ */
+ public async isStoredInKeyCache(type?: string): Promise<boolean> {
+ const cacheCallbacks = this.cacheCallbacks;
+ if (!cacheCallbacks) return false;
+ const types = type ? [type] : ["master", "self_signing", "user_signing"];
+ for (const t of types) {
+ if (!(await cacheCallbacks.getCrossSigningKeyCache?.(t))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Get cross-signing private keys from the local cache.
+ *
+ * @returns A map from key type (string) to private key (Uint8Array)
+ */
+ public async getCrossSigningKeysFromCache(): Promise<Map<string, Uint8Array>> {
+ const keys = new Map();
+ const cacheCallbacks = this.cacheCallbacks;
+ if (!cacheCallbacks) return keys;
+ for (const type of ["master", "self_signing", "user_signing"]) {
+ const privKey = await cacheCallbacks.getCrossSigningKeyCache?.(type);
+ if (!privKey) {
+ continue;
+ }
+ keys.set(type, privKey);
+ }
+ return keys;
+ }
+
+ /**
+ * Get the ID used to identify the user. This can also be used to test for
+ * the existence of a given key type.
+ *
+ * @param type - The type of key to get the ID of. One of "master",
+ * "self_signing", or "user_signing". Defaults to "master".
+ *
+ * @returns the ID
+ */
+ public getId(type = "master"): string | null {
+ if (!this.keys[type]) return null;
+ const keyInfo = this.keys[type];
+ return publicKeyFromKeyInfo(keyInfo);
+ }
+
+ /**
+ * Create new cross-signing keys for the given key types. The public keys
+ * will be held in this class, while the private keys are passed off to the
+ * `saveCrossSigningKeys` application callback.
+ *
+ * @param level - The key types to reset
+ */
+ public async resetKeys(level?: CrossSigningLevel): Promise<void> {
+ if (!this.callbacks.saveCrossSigningKeys) {
+ throw new Error("No saveCrossSigningKeys callback supplied");
+ }
+
+ // If we're resetting the master key, we reset all keys
+ if (level === undefined || level & CrossSigningLevel.MASTER || !this.keys.master) {
+ level = CrossSigningLevel.MASTER | CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING;
+ } else if (level === (0 as CrossSigningLevel)) {
+ return;
+ }
+
+ const privateKeys: Record<string, Uint8Array> = {};
+ const keys: Record<string, ICrossSigningKey> = {};
+ let masterSigning;
+ let masterPub;
+
+ try {
+ if (level & CrossSigningLevel.MASTER) {
+ masterSigning = new global.Olm.PkSigning();
+ privateKeys.master = masterSigning.generate_seed();
+ masterPub = masterSigning.init_with_seed(privateKeys.master);
+ keys.master = {
+ user_id: this.userId,
+ usage: ["master"],
+ keys: {
+ ["ed25519:" + masterPub]: masterPub,
+ },
+ };
+ } else {
+ [masterPub, masterSigning] = await this.getCrossSigningKey("master");
+ }
+
+ if (level & CrossSigningLevel.SELF_SIGNING) {
+ const sskSigning = new global.Olm.PkSigning();
+ try {
+ privateKeys.self_signing = sskSigning.generate_seed();
+ const sskPub = sskSigning.init_with_seed(privateKeys.self_signing);
+ keys.self_signing = {
+ user_id: this.userId,
+ usage: ["self_signing"],
+ keys: {
+ ["ed25519:" + sskPub]: sskPub,
+ },
+ };
+ pkSign(keys.self_signing, masterSigning, this.userId, masterPub);
+ } finally {
+ sskSigning.free();
+ }
+ }
+
+ if (level & CrossSigningLevel.USER_SIGNING) {
+ const uskSigning = new global.Olm.PkSigning();
+ try {
+ privateKeys.user_signing = uskSigning.generate_seed();
+ const uskPub = uskSigning.init_with_seed(privateKeys.user_signing);
+ keys.user_signing = {
+ user_id: this.userId,
+ usage: ["user_signing"],
+ keys: {
+ ["ed25519:" + uskPub]: uskPub,
+ },
+ };
+ pkSign(keys.user_signing, masterSigning, this.userId, masterPub);
+ } finally {
+ uskSigning.free();
+ }
+ }
+
+ Object.assign(this.keys, keys);
+ this.callbacks.saveCrossSigningKeys(privateKeys);
+ } finally {
+ if (masterSigning) {
+ masterSigning.free();
+ }
+ }
+ }
+
+ /**
+ * unsets the keys, used when another session has reset the keys, to disable cross-signing
+ */
+ public clearKeys(): void {
+ this.keys = {};
+ }
+
+ public setKeys(keys: Record<string, ICrossSigningKey>): void {
+ const signingKeys: Record<string, ICrossSigningKey> = {};
+ if (keys.master) {
+ if (keys.master.user_id !== this.userId) {
+ const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId;
+ logger.error(error);
+ throw new Error(error);
+ }
+ if (!this.keys.master) {
+ // this is the first key we've seen, so first-use is true
+ this.firstUse = true;
+ } else if (publicKeyFromKeyInfo(keys.master) !== this.getId()) {
+ // this is a different key, so first-use is false
+ this.firstUse = false;
+ } // otherwise, same key, so no change
+ signingKeys.master = keys.master;
+ } else if (this.keys.master) {
+ signingKeys.master = this.keys.master;
+ } else {
+ throw new Error("Tried to set cross-signing keys without a master key");
+ }
+ const masterKey = publicKeyFromKeyInfo(signingKeys.master);
+
+ // verify signatures
+ if (keys.user_signing) {
+ if (keys.user_signing.user_id !== this.userId) {
+ const error = "Mismatched user ID " + keys.master.user_id + " in user_signing key from " + this.userId;
+ logger.error(error);
+ throw new Error(error);
+ }
+ try {
+ pkVerify(keys.user_signing, masterKey, this.userId);
+ } catch (e) {
+ logger.error("invalid signature on user-signing key");
+ // FIXME: what do we want to do here?
+ throw e;
+ }
+ }
+ if (keys.self_signing) {
+ if (keys.self_signing.user_id !== this.userId) {
+ const error = "Mismatched user ID " + keys.master.user_id + " in self_signing key from " + this.userId;
+ logger.error(error);
+ throw new Error(error);
+ }
+ try {
+ pkVerify(keys.self_signing, masterKey, this.userId);
+ } catch (e) {
+ logger.error("invalid signature on self-signing key");
+ // FIXME: what do we want to do here?
+ throw e;
+ }
+ }
+
+ // if everything checks out, then save the keys
+ if (keys.master) {
+ this.keys.master = keys.master;
+ // if the master key is set, then the old self-signing and user-signing keys are obsolete
+ delete this.keys["self_signing"];
+ delete this.keys["user_signing"];
+ }
+ if (keys.self_signing) {
+ this.keys.self_signing = keys.self_signing;
+ }
+ if (keys.user_signing) {
+ this.keys.user_signing = keys.user_signing;
+ }
+ }
+
+ public updateCrossSigningVerifiedBefore(isCrossSigningVerified: boolean): void {
+ // It is critical that this value latches forward from false to true but
+ // never back to false to avoid a downgrade attack.
+ if (!this.crossSigningVerifiedBefore && isCrossSigningVerified) {
+ this.crossSigningVerifiedBefore = true;
+ }
+ }
+
+ public async signObject<T extends object>(data: T, type: string): Promise<T & { signatures: ISignatures }> {
+ if (!this.keys[type]) {
+ throw new Error("Attempted to sign with " + type + " key but no such key present");
+ }
+ const [pubkey, signing] = await this.getCrossSigningKey(type);
+ try {
+ pkSign(data, signing, this.userId, pubkey);
+ return data as T & { signatures: ISignatures };
+ } finally {
+ signing.free();
+ }
+ }
+
+ public async signUser(key: CrossSigningInfo): Promise<ICrossSigningKey | undefined> {
+ if (!this.keys.user_signing) {
+ logger.info("No user signing key: not signing user");
+ return;
+ }
+ return this.signObject(key.keys.master, "user_signing");
+ }
+
+ public async signDevice(userId: string, device: DeviceInfo): Promise<ISignedKey | undefined> {
+ if (userId !== this.userId) {
+ throw new Error(`Trying to sign ${userId}'s device; can only sign our own device`);
+ }
+ if (!this.keys.self_signing) {
+ logger.info("No self signing key: not signing device");
+ return;
+ }
+ return this.signObject<Omit<ISignedKey, "signatures">>(
+ {
+ algorithms: device.algorithms,
+ keys: device.keys,
+ device_id: device.deviceId,
+ user_id: userId,
+ },
+ "self_signing",
+ );
+ }
+
+ /**
+ * Check whether a given user is trusted.
+ *
+ * @param userCrossSigning - Cross signing info for user
+ *
+ * @returns
+ */
+ public checkUserTrust(userCrossSigning: CrossSigningInfo): UserTrustLevel {
+ // if we're checking our own key, then it's trusted if the master key
+ // and self-signing key match
+ if (
+ this.userId === userCrossSigning.userId &&
+ this.getId() &&
+ this.getId() === userCrossSigning.getId() &&
+ this.getId("self_signing") &&
+ this.getId("self_signing") === userCrossSigning.getId("self_signing")
+ ) {
+ return new UserTrustLevel(true, true, this.firstUse);
+ }
+
+ if (!this.keys.user_signing) {
+ // If there's no user signing key, they can't possibly be verified.
+ // They may be TOFU trusted though.
+ return new UserTrustLevel(false, false, userCrossSigning.firstUse);
+ }
+
+ let userTrusted: boolean;
+ const userMaster = userCrossSigning.keys.master;
+ const uskId = this.getId("user_signing")!;
+ try {
+ pkVerify(userMaster, uskId, this.userId);
+ userTrusted = true;
+ } catch (e) {
+ userTrusted = false;
+ }
+ return new UserTrustLevel(userTrusted, userCrossSigning.crossSigningVerifiedBefore, userCrossSigning.firstUse);
+ }
+
+ /**
+ * Check whether a given device is trusted.
+ *
+ * @param userCrossSigning - Cross signing info for user
+ * @param device - The device to check
+ * @param localTrust - Whether the device is trusted locally
+ * @param trustCrossSignedDevices - Whether we trust cross signed devices
+ *
+ * @returns
+ */
+ public checkDeviceTrust(
+ userCrossSigning: CrossSigningInfo,
+ device: DeviceInfo,
+ localTrust: boolean,
+ trustCrossSignedDevices: boolean,
+ ): DeviceTrustLevel {
+ const userTrust = this.checkUserTrust(userCrossSigning);
+
+ const userSSK = userCrossSigning.keys.self_signing;
+ if (!userSSK) {
+ // if the user has no self-signing key then we cannot make any
+ // trust assertions about this device from cross-signing
+ return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices);
+ }
+
+ const deviceObj = deviceToObject(device, userCrossSigning.userId);
+ try {
+ // if we can verify the user's SSK from their master key...
+ pkVerify(userSSK, userCrossSigning.getId()!, userCrossSigning.userId);
+ // ...and this device's key from their SSK...
+ pkVerify(deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId);
+ // ...then we trust this device as much as far as we trust the user
+ return DeviceTrustLevel.fromUserTrustLevel(userTrust, localTrust, trustCrossSignedDevices);
+ } catch (e) {
+ return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices);
+ }
+ }
+
+ /**
+ * @returns Cache callbacks
+ */
+ public getCacheCallbacks(): ICacheCallbacks {
+ return this.cacheCallbacks;
+ }
+}
+
+interface DeviceObject extends IObject {
+ algorithms: string[];
+ keys: Record<string, string>;
+ device_id: string;
+ user_id: string;
+}
+
+function deviceToObject(device: DeviceInfo, userId: string): DeviceObject {
+ return {
+ algorithms: device.algorithms,
+ keys: device.keys,
+ device_id: device.deviceId,
+ user_id: userId,
+ signatures: device.signatures,
+ };
+}
+
+export enum CrossSigningLevel {
+ MASTER = 4,
+ USER_SIGNING = 2,
+ SELF_SIGNING = 1,
+}
+
+/**
+ * Represents the ways in which we trust a user
+ */
+export class UserTrustLevel {
+ public constructor(
+ private readonly crossSigningVerified: boolean,
+ private readonly crossSigningVerifiedBefore: boolean,
+ private readonly tofu: boolean,
+ ) {}
+
+ /**
+ * @returns true if this user is verified via any means
+ */
+ public isVerified(): boolean {
+ return this.isCrossSigningVerified();
+ }
+
+ /**
+ * @returns true if this user is verified via cross signing
+ */
+ public isCrossSigningVerified(): boolean {
+ return this.crossSigningVerified;
+ }
+
+ /**
+ * @returns true if we ever verified this user before (at least for
+ * the history of verifications observed by this device).
+ */
+ public wasCrossSigningVerified(): boolean {
+ return this.crossSigningVerifiedBefore;
+ }
+
+ /**
+ * @returns true if this user's key is trusted on first use
+ */
+ public isTofu(): boolean {
+ return this.tofu;
+ }
+}
+
+/**
+ * Represents the ways in which we trust a device
+ */
+export class DeviceTrustLevel {
+ public constructor(
+ public readonly crossSigningVerified: boolean,
+ public readonly tofu: boolean,
+ private readonly localVerified: boolean,
+ private readonly trustCrossSignedDevices: boolean,
+ ) {}
+
+ public static fromUserTrustLevel(
+ userTrustLevel: UserTrustLevel,
+ localVerified: boolean,
+ trustCrossSignedDevices: boolean,
+ ): DeviceTrustLevel {
+ return new DeviceTrustLevel(
+ userTrustLevel.isCrossSigningVerified(),
+ userTrustLevel.isTofu(),
+ localVerified,
+ trustCrossSignedDevices,
+ );
+ }
+
+ /**
+ * @returns true if this device is verified via any means
+ */
+ public isVerified(): boolean {
+ return Boolean(this.isLocallyVerified() || (this.trustCrossSignedDevices && this.isCrossSigningVerified()));
+ }
+
+ /**
+ * @returns true if this device is verified via cross signing
+ */
+ public isCrossSigningVerified(): boolean {
+ return this.crossSigningVerified;
+ }
+
+ /**
+ * @returns true if this device is verified locally
+ */
+ public isLocallyVerified(): boolean {
+ return this.localVerified;
+ }
+
+ /**
+ * @returns true if this device is trusted from a user's key
+ * that is trusted on first use
+ */
+ public isTofu(): boolean {
+ return this.tofu;
+ }
+}
+
+export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: OlmDevice): ICacheCallbacks {
+ return {
+ getCrossSigningKeyCache: async function (
+ type: keyof SecretStorePrivateKeys,
+ _expectedPublicKey: string,
+ ): Promise<Uint8Array> {
+ const key = await new Promise<any>((resolve) => {
+ return store.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ store.getSecretStorePrivateKey(txn, resolve, type);
+ });
+ });
+
+ if (key && key.ciphertext) {
+ const pickleKey = Buffer.from(olmDevice.pickleKey);
+ const decrypted = await decryptAES(key, pickleKey, type);
+ return decodeBase64(decrypted);
+ } else {
+ return key;
+ }
+ },
+ storeCrossSigningKeyCache: async function (
+ type: keyof SecretStorePrivateKeys,
+ key?: Uint8Array,
+ ): Promise<void> {
+ if (!(key instanceof Uint8Array)) {
+ throw new Error(`storeCrossSigningKeyCache expects Uint8Array, got ${key}`);
+ }
+ const pickleKey = Buffer.from(olmDevice.pickleKey);
+ const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, type);
+ return store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ store.storeSecretStorePrivateKey(txn, type, encryptedKey);
+ });
+ },
+ };
+}
+
+export type KeysDuringVerification = [[string, PkSigning], [string, PkSigning], [string, PkSigning], void];
+
+/**
+ * Request cross-signing keys from another device during verification.
+ *
+ * @param baseApis - base Matrix API interface
+ * @param userId - The user ID being verified
+ * @param deviceId - The device ID being verified
+ */
+export async function requestKeysDuringVerification(
+ baseApis: MatrixClient,
+ userId: string,
+ deviceId: string,
+): Promise<KeysDuringVerification | void> {
+ // If this is a self-verification, ask the other party for keys
+ if (baseApis.getUserId() !== userId) {
+ return;
+ }
+ logger.log("Cross-signing: Self-verification done; requesting keys");
+ // This happens asynchronously, and we're not concerned about waiting for
+ // it. We return here in order to test.
+ return new Promise<KeysDuringVerification | void>((resolve, reject) => {
+ const client = baseApis;
+ const original = client.crypto!.crossSigningInfo;
+
+ // We already have all of the infrastructure we need to validate and
+ // cache cross-signing keys, so instead of replicating that, here we set
+ // up callbacks that request them from the other device and call
+ // CrossSigningInfo.getCrossSigningKey() to validate/cache
+ const crossSigning = new CrossSigningInfo(
+ original.userId,
+ {
+ getCrossSigningKey: async (type): Promise<Uint8Array> => {
+ logger.debug("Cross-signing: requesting secret", type, deviceId);
+ const { promise } = client.requestSecret(`m.cross_signing.${type}`, [deviceId]);
+ const result = await promise;
+ const decoded = decodeBase64(result);
+ return Uint8Array.from(decoded);
+ },
+ },
+ original.getCacheCallbacks(),
+ );
+ crossSigning.keys = original.keys;
+
+ // XXX: get all keys out if we get one key out
+ // https://github.com/vector-im/element-web/issues/12604
+ // then change here to reject on the timeout
+ // Requests can be ignored, so don't wait around forever
+ const timeout = new Promise<void>((resolve) => {
+ setTimeout(resolve, KEY_REQUEST_TIMEOUT_MS, new Error("Timeout"));
+ });
+
+ // also request and cache the key backup key
+ const backupKeyPromise = (async (): Promise<void> => {
+ const cachedKey = await client.crypto!.getSessionBackupPrivateKey();
+ if (!cachedKey) {
+ logger.info("No cached backup key found. Requesting...");
+ const secretReq = client.requestSecret("m.megolm_backup.v1", [deviceId]);
+ const base64Key = await secretReq.promise;
+ logger.info("Got key backup key, decoding...");
+ const decodedKey = decodeBase64(base64Key);
+ logger.info("Decoded backup key, storing...");
+ await client.crypto!.storeSessionBackupPrivateKey(Uint8Array.from(decodedKey));
+ logger.info("Backup key stored. Starting backup restore...");
+ const backupInfo = await client.getKeyBackupVersion();
+ // no need to await for this - just let it go in the bg
+ client.restoreKeyBackupWithCache(undefined, undefined, backupInfo!).then(() => {
+ logger.info("Backup restored.");
+ });
+ }
+ })();
+
+ // We call getCrossSigningKey() for its side-effects
+ return Promise.race<KeysDuringVerification | void>([
+ Promise.all([
+ crossSigning.getCrossSigningKey("master"),
+ crossSigning.getCrossSigningKey("self_signing"),
+ crossSigning.getCrossSigningKey("user_signing"),
+ backupKeyPromise,
+ ]) as Promise<KeysDuringVerification>,
+ timeout,
+ ]).then(resolve, reject);
+ }).catch((e) => {
+ logger.warn("Cross-signing: failure while requesting keys:", e);
+ });
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts
new file mode 100644
index 0000000..a1ff0eb
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/DeviceList.ts
@@ -0,0 +1,989 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Manages the list of other users' devices
+ */
+
+import { logger } from "../logger";
+import { DeviceInfo, IDevice } from "./deviceinfo";
+import { CrossSigningInfo, ICrossSigningInfo } from "./CrossSigning";
+import * as olmlib from "./olmlib";
+import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
+import { chunkPromises, defer, IDeferred, sleep } from "../utils";
+import { DeviceKeys, IDownloadKeyResult, Keys, MatrixClient, SigningKeys } from "../client";
+import { OlmDevice } from "./OlmDevice";
+import { CryptoStore } from "./store/base";
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+import { CryptoEvent, CryptoEventHandlerMap } from "./index";
+
+/* State transition diagram for DeviceList.deviceTrackingStatus
+ *
+ * |
+ * stopTrackingDeviceList V
+ * +---------------------> NOT_TRACKED
+ * | |
+ * +<--------------------+ | startTrackingDeviceList
+ * | | V
+ * | +-------------> PENDING_DOWNLOAD <--------------------+-+
+ * | | ^ | | |
+ * | | restart download | | start download | | invalidateUserDeviceList
+ * | | client failed | | | |
+ * | | | V | |
+ * | +------------ DOWNLOAD_IN_PROGRESS -------------------+ |
+ * | | | |
+ * +<-------------------+ | download successful |
+ * ^ V |
+ * +----------------------- UP_TO_DATE ------------------------+
+ */
+
+// constants for DeviceList.deviceTrackingStatus
+export enum TrackingStatus {
+ NotTracked,
+ PendingDownload,
+ DownloadInProgress,
+ UpToDate,
+}
+
+// user-Id → device-Id → DeviceInfo
+export type DeviceInfoMap = Map<string, Map<string, DeviceInfo>>;
+
+type EmittedEvents = CryptoEvent.WillUpdateDevices | CryptoEvent.DevicesUpdated | CryptoEvent.UserCrossSigningUpdated;
+
+export class DeviceList extends TypedEventEmitter<EmittedEvents, CryptoEventHandlerMap> {
+ private devices: { [userId: string]: { [deviceId: string]: IDevice } } = {};
+
+ public crossSigningInfo: { [userId: string]: ICrossSigningInfo } = {};
+
+ // map of identity keys to the user who owns it
+ private userByIdentityKey: Record<string, string> = {};
+
+ // which users we are tracking device status for.
+ private deviceTrackingStatus: { [userId: string]: TrackingStatus } = {}; // loaded from storage in load()
+
+ // The 'next_batch' sync token at the point the data was written,
+ // ie. a token representing the point immediately after the
+ // moment represented by the snapshot in the db.
+ private syncToken: string | null = null;
+
+ private keyDownloadsInProgressByUser = new Map<string, Promise<void>>();
+
+ // Set whenever changes are made other than setting the sync token
+ private dirty = false;
+
+ // Promise resolved when device data is saved
+ private savePromise: Promise<boolean> | null = null;
+ // Function that resolves the save promise
+ private resolveSavePromise: ((saved: boolean) => void) | null = null;
+ // The time the save is scheduled for
+ private savePromiseTime: number | null = null;
+ // The timer used to delay the save
+ private saveTimer: ReturnType<typeof setTimeout> | null = null;
+ // True if we have fetched data from the server or loaded a non-empty
+ // set of device data from the store
+ private hasFetched: boolean | null = null;
+
+ private readonly serialiser: DeviceListUpdateSerialiser;
+
+ public constructor(
+ baseApis: MatrixClient,
+ private readonly cryptoStore: CryptoStore,
+ olmDevice: OlmDevice,
+ // Maximum number of user IDs per request to prevent server overload (#1619)
+ public readonly keyDownloadChunkSize = 250,
+ ) {
+ super();
+
+ this.serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this);
+ }
+
+ /**
+ * Load the device tracking state from storage
+ */
+ public async load(): Promise<void> {
+ await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
+ this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => {
+ this.hasFetched = Boolean(deviceData && deviceData.devices);
+ this.devices = deviceData ? deviceData.devices : {};
+ this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {};
+ this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {};
+ this.syncToken = deviceData?.syncToken ?? null;
+ this.userByIdentityKey = {};
+ for (const user of Object.keys(this.devices)) {
+ const userDevices = this.devices[user];
+ for (const device of Object.keys(userDevices)) {
+ const idKey = userDevices[device].keys["curve25519:" + device];
+ if (idKey !== undefined) {
+ this.userByIdentityKey[idKey] = user;
+ }
+ }
+ }
+ });
+ });
+
+ for (const u of Object.keys(this.deviceTrackingStatus)) {
+ // if a download was in progress when we got shut down, it isn't any more.
+ if (this.deviceTrackingStatus[u] == TrackingStatus.DownloadInProgress) {
+ this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload;
+ }
+ }
+ }
+
+ public stop(): void {
+ if (this.saveTimer !== null) {
+ clearTimeout(this.saveTimer);
+ }
+ }
+
+ /**
+ * Save the device tracking state to storage, if any changes are
+ * pending other than updating the sync token
+ *
+ * The actual save will be delayed by a short amount of time to
+ * aggregate multiple writes to the database.
+ *
+ * @param delay - Time in ms before which the save actually happens.
+ * By default, the save is delayed for a short period in order to batch
+ * multiple writes, but this behaviour can be disabled by passing 0.
+ *
+ * @returns true if the data was saved, false if
+ * it was not (eg. because no changes were pending). The promise
+ * will only resolve once the data is saved, so may take some time
+ * to resolve.
+ */
+ public async saveIfDirty(delay = 500): Promise<boolean> {
+ if (!this.dirty) return Promise.resolve(false);
+ // Delay saves for a bit so we can aggregate multiple saves that happen
+ // in quick succession (eg. when a whole room's devices are marked as known)
+
+ const targetTime = Date.now() + delay;
+ if (this.savePromiseTime && targetTime < this.savePromiseTime) {
+ // There's a save scheduled but for after we would like: cancel
+ // it & schedule one for the time we want
+ clearTimeout(this.saveTimer!);
+ this.saveTimer = null;
+ this.savePromiseTime = null;
+ // (but keep the save promise since whatever called save before
+ // will still want to know when the save is done)
+ }
+
+ let savePromise = this.savePromise;
+ if (savePromise === null) {
+ savePromise = new Promise((resolve) => {
+ this.resolveSavePromise = resolve;
+ });
+ this.savePromise = savePromise;
+ }
+
+ if (this.saveTimer === null) {
+ const resolveSavePromise = this.resolveSavePromise;
+ this.savePromiseTime = targetTime;
+ this.saveTimer = setTimeout(() => {
+ logger.log("Saving device tracking data", this.syncToken);
+
+ // null out savePromise now (after the delay but before the write),
+ // otherwise we could return the existing promise when the save has
+ // actually already happened.
+ this.savePromiseTime = null;
+ this.saveTimer = null;
+ this.savePromise = null;
+ this.resolveSavePromise = null;
+
+ this.cryptoStore
+ .doTxn("readwrite", [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => {
+ this.cryptoStore.storeEndToEndDeviceData(
+ {
+ devices: this.devices,
+ crossSigningInfo: this.crossSigningInfo,
+ trackingStatus: this.deviceTrackingStatus,
+ syncToken: this.syncToken ?? undefined,
+ },
+ txn,
+ );
+ })
+ .then(
+ () => {
+ // The device list is considered dirty until the write completes.
+ this.dirty = false;
+ resolveSavePromise?.(true);
+ },
+ (err) => {
+ logger.error("Failed to save device tracking data", this.syncToken);
+ logger.error(err);
+ },
+ );
+ }, delay);
+ }
+
+ return savePromise;
+ }
+
+ /**
+ * Gets the sync token last set with setSyncToken
+ *
+ * @returns The sync token
+ */
+ public getSyncToken(): string | null {
+ return this.syncToken;
+ }
+
+ /**
+ * Sets the sync token that the app will pass as the 'since' to the /sync
+ * endpoint next time it syncs.
+ * The sync token must always be set after any changes made as a result of
+ * data in that sync since setting the sync token to a newer one will mean
+ * those changed will not be synced from the server if a new client starts
+ * up with that data.
+ *
+ * @param st - The sync token
+ */
+ public setSyncToken(st: string | null): void {
+ this.syncToken = st;
+ }
+
+ /**
+ * Ensures up to date keys for a list of users are stored in the session store,
+ * downloading and storing them if they're not (or if forceDownload is
+ * true).
+ * @param userIds - The users to fetch.
+ * @param forceDownload - Always download the keys even if cached.
+ *
+ * @returns A promise which resolves to a map userId-\>deviceId-\>{@link DeviceInfo}.
+ */
+ public downloadKeys(userIds: string[], forceDownload: boolean): Promise<DeviceInfoMap> {
+ const usersToDownload: string[] = [];
+ const promises: Promise<unknown>[] = [];
+
+ userIds.forEach((u) => {
+ const trackingStatus = this.deviceTrackingStatus[u];
+ if (this.keyDownloadsInProgressByUser.has(u)) {
+ // already a key download in progress/queued for this user; its results
+ // will be good enough for us.
+ logger.log(`downloadKeys: already have a download in progress for ` + `${u}: awaiting its result`);
+ promises.push(this.keyDownloadsInProgressByUser.get(u)!);
+ } else if (forceDownload || trackingStatus != TrackingStatus.UpToDate) {
+ usersToDownload.push(u);
+ }
+ });
+
+ if (usersToDownload.length != 0) {
+ logger.log("downloadKeys: downloading for", usersToDownload);
+ const downloadPromise = this.doKeyDownload(usersToDownload);
+ promises.push(downloadPromise);
+ }
+
+ if (promises.length === 0) {
+ logger.log("downloadKeys: already have all necessary keys");
+ }
+
+ return Promise.all(promises).then(() => {
+ return this.getDevicesFromStore(userIds);
+ });
+ }
+
+ /**
+ * Get the stored device keys for a list of user ids
+ *
+ * @param userIds - the list of users to list keys for.
+ *
+ * @returns userId-\>deviceId-\>{@link DeviceInfo}.
+ */
+ private getDevicesFromStore(userIds: string[]): DeviceInfoMap {
+ const stored: DeviceInfoMap = new Map();
+ userIds.forEach((userId) => {
+ const deviceMap = new Map();
+ this.getStoredDevicesForUser(userId)?.forEach(function (device) {
+ deviceMap.set(device.deviceId, device);
+ });
+ stored.set(userId, deviceMap);
+ });
+ return stored;
+ }
+
+ /**
+ * Returns a list of all user IDs the DeviceList knows about
+ *
+ * @returns All known user IDs
+ */
+ public getKnownUserIds(): string[] {
+ return Object.keys(this.devices);
+ }
+
+ /**
+ * Get the stored device keys for a user id
+ *
+ * @param userId - the user to list keys for.
+ *
+ * @returns list of devices, or null if we haven't
+ * managed to get a list of devices for this user yet.
+ */
+ public getStoredDevicesForUser(userId: string): DeviceInfo[] | null {
+ const devs = this.devices[userId];
+ if (!devs) {
+ return null;
+ }
+ const res: DeviceInfo[] = [];
+ for (const deviceId in devs) {
+ if (devs.hasOwnProperty(deviceId)) {
+ res.push(DeviceInfo.fromStorage(devs[deviceId], deviceId));
+ }
+ }
+ return res;
+ }
+
+ /**
+ * Get the stored device data for a user, in raw object form
+ *
+ * @param userId - the user to get data for
+ *
+ * @returns `deviceId->{object}` devices, or undefined if
+ * there is no data for this user.
+ */
+ public getRawStoredDevicesForUser(userId: string): Record<string, IDevice> {
+ return this.devices[userId];
+ }
+
+ public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null {
+ if (!this.crossSigningInfo[userId]) return null;
+
+ return CrossSigningInfo.fromStorage(this.crossSigningInfo[userId], userId);
+ }
+
+ public storeCrossSigningForUser(userId: string, info: ICrossSigningInfo): void {
+ this.crossSigningInfo[userId] = info;
+ this.dirty = true;
+ }
+
+ /**
+ * Get the stored keys for a single device
+ *
+ *
+ * @returns device, or undefined
+ * if we don't know about this device
+ */
+ public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined {
+ const devs = this.devices[userId];
+ if (!devs?.[deviceId]) {
+ return undefined;
+ }
+ return DeviceInfo.fromStorage(devs[deviceId], deviceId);
+ }
+
+ /**
+ * Get a user ID by one of their device's curve25519 identity key
+ *
+ * @param algorithm - encryption algorithm
+ * @param senderKey - curve25519 key to match
+ *
+ * @returns user ID
+ */
+ public getUserByIdentityKey(algorithm: string, senderKey: string): string | null {
+ if (algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.MEGOLM_ALGORITHM) {
+ // we only deal in olm keys
+ return null;
+ }
+
+ return this.userByIdentityKey[senderKey];
+ }
+
+ /**
+ * Find a device by curve25519 identity key
+ *
+ * @param algorithm - encryption algorithm
+ * @param senderKey - curve25519 key to match
+ */
+ public getDeviceByIdentityKey(algorithm: string, senderKey: string): DeviceInfo | null {
+ const userId = this.getUserByIdentityKey(algorithm, senderKey);
+ if (!userId) {
+ return null;
+ }
+
+ const devices = this.devices[userId];
+ if (!devices) {
+ return null;
+ }
+
+ for (const deviceId in devices) {
+ if (!devices.hasOwnProperty(deviceId)) {
+ continue;
+ }
+
+ const device = devices[deviceId];
+ for (const keyId in device.keys) {
+ if (!device.keys.hasOwnProperty(keyId)) {
+ continue;
+ }
+ if (keyId.indexOf("curve25519:") !== 0) {
+ continue;
+ }
+ const deviceKey = device.keys[keyId];
+ if (deviceKey == senderKey) {
+ return DeviceInfo.fromStorage(device, deviceId);
+ }
+ }
+ }
+
+ // doesn't match a known device
+ return null;
+ }
+
+ /**
+ * Replaces the list of devices for a user with the given device list
+ *
+ * @param userId - The user ID
+ * @param devices - New device info for user
+ */
+ public storeDevicesForUser(userId: string, devices: Record<string, IDevice>): void {
+ this.setRawStoredDevicesForUser(userId, devices);
+ this.dirty = true;
+ }
+
+ /**
+ * flag the given user for device-list tracking, if they are not already.
+ *
+ * This will mean that a subsequent call to refreshOutdatedDeviceLists()
+ * will download the device list for the user, and that subsequent calls to
+ * invalidateUserDeviceList will trigger more updates.
+ *
+ */
+ public startTrackingDeviceList(userId: string): void {
+ // sanity-check the userId. This is mostly paranoia, but if synapse
+ // can't parse the userId we give it as an mxid, it 500s the whole
+ // request and we can never update the device lists again (because
+ // the broken userId is always 'invalid' and always included in any
+ // refresh request).
+ // By checking it is at least a string, we can eliminate a class of
+ // silly errors.
+ if (typeof userId !== "string") {
+ throw new Error("userId must be a string; was " + userId);
+ }
+ if (!this.deviceTrackingStatus[userId]) {
+ logger.log("Now tracking device list for " + userId);
+ this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload;
+ // we don't yet persist the tracking status, since there may be a lot
+ // of calls; we save all data together once the sync is done
+ this.dirty = true;
+ }
+ }
+
+ /**
+ * Mark the given user as no longer being tracked for device-list updates.
+ *
+ * This won't affect any in-progress downloads, which will still go on to
+ * complete; it will just mean that we don't think that we have an up-to-date
+ * list for future calls to downloadKeys.
+ *
+ */
+ public stopTrackingDeviceList(userId: string): void {
+ if (this.deviceTrackingStatus[userId]) {
+ logger.log("No longer tracking device list for " + userId);
+ this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked;
+
+ // we don't yet persist the tracking status, since there may be a lot
+ // of calls; we save all data together once the sync is done
+ this.dirty = true;
+ }
+ }
+
+ /**
+ * Set all users we're currently tracking to untracked
+ *
+ * This will flag each user whose devices we are tracking as in need of an
+ * update.
+ */
+ public stopTrackingAllDeviceLists(): void {
+ for (const userId of Object.keys(this.deviceTrackingStatus)) {
+ this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked;
+ }
+ this.dirty = true;
+ }
+
+ /**
+ * Mark the cached device list for the given user outdated.
+ *
+ * If we are not tracking this user's devices, we'll do nothing. Otherwise
+ * we flag the user as needing an update.
+ *
+ * This doesn't actually set off an update, so that several users can be
+ * batched together. Call refreshOutdatedDeviceLists() for that.
+ *
+ */
+ public invalidateUserDeviceList(userId: string): void {
+ if (this.deviceTrackingStatus[userId]) {
+ logger.log("Marking device list outdated for", userId);
+ this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload;
+
+ // we don't yet persist the tracking status, since there may be a lot
+ // of calls; we save all data together once the sync is done
+ this.dirty = true;
+ }
+ }
+
+ /**
+ * If we have users who have outdated device lists, start key downloads for them
+ *
+ * @returns which completes when the download completes; normally there
+ * is no need to wait for this (it's mostly for the unit tests).
+ */
+ public refreshOutdatedDeviceLists(): Promise<void> {
+ this.saveIfDirty();
+
+ const usersToDownload: string[] = [];
+ for (const userId of Object.keys(this.deviceTrackingStatus)) {
+ const stat = this.deviceTrackingStatus[userId];
+ if (stat == TrackingStatus.PendingDownload) {
+ usersToDownload.push(userId);
+ }
+ }
+
+ return this.doKeyDownload(usersToDownload);
+ }
+
+ /**
+ * Set the stored device data for a user, in raw object form
+ * Used only by internal class DeviceListUpdateSerialiser
+ *
+ * @param userId - the user to get data for
+ *
+ * @param devices - `deviceId->{object}` the new devices
+ */
+ public setRawStoredDevicesForUser(userId: string, devices: Record<string, IDevice>): void {
+ // remove old devices from userByIdentityKey
+ if (this.devices[userId] !== undefined) {
+ for (const [deviceId, dev] of Object.entries(this.devices[userId])) {
+ const identityKey = dev.keys["curve25519:" + deviceId];
+
+ delete this.userByIdentityKey[identityKey];
+ }
+ }
+
+ this.devices[userId] = devices;
+
+ // add new devices into userByIdentityKey
+ for (const [deviceId, dev] of Object.entries(devices)) {
+ const identityKey = dev.keys["curve25519:" + deviceId];
+
+ this.userByIdentityKey[identityKey] = userId;
+ }
+ }
+
+ public setRawStoredCrossSigningForUser(userId: string, info: ICrossSigningInfo): void {
+ this.crossSigningInfo[userId] = info;
+ }
+
+ /**
+ * Fire off download update requests for the given users, and update the
+ * device list tracking status for them, and the
+ * keyDownloadsInProgressByUser map for them.
+ *
+ * @param users - list of userIds
+ *
+ * @returns resolves when all the users listed have
+ * been updated. rejects if there was a problem updating any of the
+ * users.
+ */
+ private doKeyDownload(users: string[]): Promise<void> {
+ if (users.length === 0) {
+ // nothing to do
+ return Promise.resolve();
+ }
+
+ const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken!).then(
+ () => {
+ finished(true);
+ },
+ (e) => {
+ logger.error("Error downloading keys for " + users + ":", e);
+ finished(false);
+ throw e;
+ },
+ );
+
+ users.forEach((u) => {
+ this.keyDownloadsInProgressByUser.set(u, prom);
+ const stat = this.deviceTrackingStatus[u];
+ if (stat == TrackingStatus.PendingDownload) {
+ this.deviceTrackingStatus[u] = TrackingStatus.DownloadInProgress;
+ }
+ });
+
+ const finished = (success: boolean): void => {
+ this.emit(CryptoEvent.WillUpdateDevices, users, !this.hasFetched);
+ users.forEach((u) => {
+ this.dirty = true;
+
+ // we may have queued up another download request for this user
+ // since we started this request. If that happens, we should
+ // ignore the completion of the first one.
+ if (this.keyDownloadsInProgressByUser.get(u) !== prom) {
+ logger.log("Another update in the queue for", u, "- not marking up-to-date");
+ return;
+ }
+ this.keyDownloadsInProgressByUser.delete(u);
+ const stat = this.deviceTrackingStatus[u];
+ if (stat == TrackingStatus.DownloadInProgress) {
+ if (success) {
+ // we didn't get any new invalidations since this download started:
+ // this user's device list is now up to date.
+ this.deviceTrackingStatus[u] = TrackingStatus.UpToDate;
+ logger.log("Device list for", u, "now up to date");
+ } else {
+ this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload;
+ }
+ }
+ });
+ this.saveIfDirty();
+ this.emit(CryptoEvent.DevicesUpdated, users, !this.hasFetched);
+ this.hasFetched = true;
+ };
+
+ return prom;
+ }
+}
+
+/**
+ * Serialises updates to device lists
+ *
+ * Ensures that results from /keys/query are not overwritten if a second call
+ * completes *before* an earlier one.
+ *
+ * It currently does this by ensuring only one call to /keys/query happens at a
+ * time (and queuing other requests up).
+ */
+class DeviceListUpdateSerialiser {
+ private downloadInProgress = false;
+
+ // users which are queued for download
+ // userId -> true
+ private keyDownloadsQueuedByUser: Record<string, boolean> = {};
+
+ // deferred which is resolved when the queued users are downloaded.
+ // non-null indicates that we have users queued for download.
+ private queuedQueryDeferred?: IDeferred<void>;
+
+ private syncToken?: string; // The sync token we send with the requests
+
+ /*
+ * @param baseApis - Base API object
+ * @param olmDevice - The Olm Device
+ * @param deviceList - The device list object, the device list to be updated
+ */
+ public constructor(
+ private readonly baseApis: MatrixClient,
+ private readonly olmDevice: OlmDevice,
+ private readonly deviceList: DeviceList,
+ ) {}
+
+ /**
+ * Make a key query request for the given users
+ *
+ * @param users - list of user ids
+ *
+ * @param syncToken - sync token to pass in the query request, to
+ * help the HS give the most recent results
+ *
+ * @returns resolves when all the users listed have
+ * been updated. rejects if there was a problem updating any of the
+ * users.
+ */
+ public updateDevicesForUsers(users: string[], syncToken: string): Promise<void> {
+ users.forEach((u) => {
+ this.keyDownloadsQueuedByUser[u] = true;
+ });
+
+ if (!this.queuedQueryDeferred) {
+ this.queuedQueryDeferred = defer();
+ }
+
+ // We always take the new sync token and just use the latest one we've
+ // been given, since it just needs to be at least as recent as the
+ // sync response the device invalidation message arrived in
+ this.syncToken = syncToken;
+
+ if (this.downloadInProgress) {
+ // just queue up these users
+ logger.log("Queued key download for", users);
+ return this.queuedQueryDeferred.promise;
+ }
+
+ // start a new download.
+ return this.doQueuedQueries();
+ }
+
+ private doQueuedQueries(): Promise<void> {
+ if (this.downloadInProgress) {
+ throw new Error("DeviceListUpdateSerialiser.doQueuedQueries called with request active");
+ }
+
+ const downloadUsers = Object.keys(this.keyDownloadsQueuedByUser);
+ this.keyDownloadsQueuedByUser = {};
+ const deferred = this.queuedQueryDeferred;
+ this.queuedQueryDeferred = undefined;
+
+ logger.log("Starting key download for", downloadUsers);
+ this.downloadInProgress = true;
+
+ const opts: Parameters<MatrixClient["downloadKeysForUsers"]>[1] = {};
+ if (this.syncToken) {
+ opts.token = this.syncToken;
+ }
+
+ const factories: Array<() => Promise<IDownloadKeyResult>> = [];
+ for (let i = 0; i < downloadUsers.length; i += this.deviceList.keyDownloadChunkSize) {
+ const userSlice = downloadUsers.slice(i, i + this.deviceList.keyDownloadChunkSize);
+ factories.push(() => this.baseApis.downloadKeysForUsers(userSlice, opts));
+ }
+
+ chunkPromises(factories, 3)
+ .then(async (responses: IDownloadKeyResult[]) => {
+ const dk: IDownloadKeyResult["device_keys"] = Object.assign(
+ {},
+ ...responses.map((res) => res.device_keys || {}),
+ );
+ const masterKeys: IDownloadKeyResult["master_keys"] = Object.assign(
+ {},
+ ...responses.map((res) => res.master_keys || {}),
+ );
+ const ssks: IDownloadKeyResult["self_signing_keys"] = Object.assign(
+ {},
+ ...responses.map((res) => res.self_signing_keys || {}),
+ );
+ const usks: IDownloadKeyResult["user_signing_keys"] = Object.assign(
+ {},
+ ...responses.map((res) => res.user_signing_keys || {}),
+ );
+
+ // yield to other things that want to execute in between users, to
+ // avoid wedging the CPU
+ // (https://github.com/vector-im/element-web/issues/3158)
+ //
+ // of course we ought to do this in a web worker or similar, but
+ // this serves as an easy solution for now.
+ for (const userId of downloadUsers) {
+ await sleep(5);
+ try {
+ await this.processQueryResponseForUser(userId, dk[userId], {
+ master: masterKeys?.[userId],
+ self_signing: ssks?.[userId],
+ user_signing: usks?.[userId],
+ });
+ } catch (e) {
+ // log the error but continue, so that one bad key
+ // doesn't kill the whole process
+ logger.error(`Error processing keys for ${userId}:`, e);
+ }
+ }
+ })
+ .then(
+ () => {
+ logger.log("Completed key download for " + downloadUsers);
+
+ this.downloadInProgress = false;
+ deferred?.resolve();
+
+ // if we have queued users, fire off another request.
+ if (this.queuedQueryDeferred) {
+ this.doQueuedQueries();
+ }
+ },
+ (e) => {
+ logger.warn("Error downloading keys for " + downloadUsers + ":", e);
+ this.downloadInProgress = false;
+ deferred?.reject(e);
+ },
+ );
+
+ return deferred!.promise;
+ }
+
+ private async processQueryResponseForUser(
+ userId: string,
+ dkResponse: DeviceKeys,
+ crossSigningResponse: {
+ master?: Keys;
+ self_signing?: SigningKeys;
+ user_signing?: SigningKeys;
+ },
+ ): Promise<void> {
+ logger.log("got device keys for " + userId + ":", dkResponse);
+ logger.log("got cross-signing keys for " + userId + ":", crossSigningResponse);
+
+ {
+ // map from deviceid -> deviceinfo for this user
+ const userStore: Record<string, DeviceInfo> = {};
+ const devs = this.deviceList.getRawStoredDevicesForUser(userId);
+ if (devs) {
+ Object.keys(devs).forEach((deviceId) => {
+ const d = DeviceInfo.fromStorage(devs[deviceId], deviceId);
+ userStore[deviceId] = d;
+ });
+ }
+
+ await updateStoredDeviceKeysForUser(
+ this.olmDevice,
+ userId,
+ userStore,
+ dkResponse || {},
+ this.baseApis.getUserId()!,
+ this.baseApis.deviceId!,
+ );
+
+ // put the updates into the object that will be returned as our results
+ const storage: Record<string, IDevice> = {};
+ Object.keys(userStore).forEach((deviceId) => {
+ storage[deviceId] = userStore[deviceId].toStorage();
+ });
+
+ this.deviceList.setRawStoredDevicesForUser(userId, storage);
+ }
+
+ // now do the same for the cross-signing keys
+ {
+ // FIXME: should we be ignoring empty cross-signing responses, or
+ // should we be dropping the keys?
+ if (
+ crossSigningResponse &&
+ (crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing)
+ ) {
+ const crossSigning =
+ this.deviceList.getStoredCrossSigningForUser(userId) || new CrossSigningInfo(userId);
+
+ crossSigning.setKeys(crossSigningResponse);
+
+ this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage());
+
+ // NB. Unlike most events in the js-sdk, this one is internal to the
+ // js-sdk and is not re-emitted
+ this.deviceList.emit(CryptoEvent.UserCrossSigningUpdated, userId);
+ }
+ }
+ }
+}
+
+async function updateStoredDeviceKeysForUser(
+ olmDevice: OlmDevice,
+ userId: string,
+ userStore: Record<string, DeviceInfo>,
+ userResult: IDownloadKeyResult["device_keys"]["user_id"],
+ localUserId: string,
+ localDeviceId: string,
+): Promise<boolean> {
+ let updated = false;
+
+ // remove any devices in the store which aren't in the response
+ for (const deviceId in userStore) {
+ if (!userStore.hasOwnProperty(deviceId)) {
+ continue;
+ }
+
+ if (!(deviceId in userResult)) {
+ if (userId === localUserId && deviceId === localDeviceId) {
+ logger.warn(`Local device ${deviceId} missing from sync, skipping removal`);
+ continue;
+ }
+
+ logger.log("Device " + userId + ":" + deviceId + " has been removed");
+ delete userStore[deviceId];
+ updated = true;
+ }
+ }
+
+ for (const deviceId in userResult) {
+ if (!userResult.hasOwnProperty(deviceId)) {
+ continue;
+ }
+
+ const deviceResult = userResult[deviceId];
+
+ // check that the user_id and device_id in the response object are
+ // correct
+ if (deviceResult.user_id !== userId) {
+ logger.warn("Mismatched user_id " + deviceResult.user_id + " in keys from " + userId + ":" + deviceId);
+ continue;
+ }
+ if (deviceResult.device_id !== deviceId) {
+ logger.warn("Mismatched device_id " + deviceResult.device_id + " in keys from " + userId + ":" + deviceId);
+ continue;
+ }
+
+ if (await storeDeviceKeys(olmDevice, userStore, deviceResult)) {
+ updated = true;
+ }
+ }
+
+ return updated;
+}
+
+/*
+ * Process a device in a /query response, and add it to the userStore
+ *
+ * returns (a promise for) true if a change was made, else false
+ */
+async function storeDeviceKeys(
+ olmDevice: OlmDevice,
+ userStore: Record<string, DeviceInfo>,
+ deviceResult: IDownloadKeyResult["device_keys"]["user_id"]["device_id"],
+): Promise<boolean> {
+ if (!deviceResult.keys) {
+ // no keys?
+ return false;
+ }
+
+ const deviceId = deviceResult.device_id;
+ const userId = deviceResult.user_id;
+
+ const signKeyId = "ed25519:" + deviceId;
+ const signKey = deviceResult.keys[signKeyId];
+ if (!signKey) {
+ logger.warn("Device " + userId + ":" + deviceId + " has no ed25519 key");
+ return false;
+ }
+
+ const unsigned = deviceResult.unsigned || {};
+ const signatures = deviceResult.signatures || {};
+
+ try {
+ await olmlib.verifySignature(olmDevice, deviceResult, userId, deviceId, signKey);
+ } catch (e) {
+ logger.warn("Unable to verify signature on device " + userId + ":" + deviceId + ":" + e);
+ return false;
+ }
+
+ // DeviceInfo
+ let deviceStore;
+
+ if (deviceId in userStore) {
+ // already have this device.
+ deviceStore = userStore[deviceId];
+
+ if (deviceStore.getFingerprint() != signKey) {
+ // this should only happen if the list has been MITMed; we are
+ // best off sticking with the original keys.
+ //
+ // Should we warn the user about it somehow?
+ logger.warn("Ed25519 key for device " + userId + ":" + deviceId + " has changed");
+ return false;
+ }
+ } else {
+ userStore[deviceId] = deviceStore = new DeviceInfo(deviceId);
+ }
+
+ deviceStore.keys = deviceResult.keys || {};
+ deviceStore.algorithms = deviceResult.algorithms || [];
+ deviceStore.unsigned = unsigned;
+ deviceStore.signatures = signatures;
+ return true;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts
new file mode 100644
index 0000000..4efe677
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/EncryptionSetup.ts
@@ -0,0 +1,356 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "../logger";
+import { IContent, MatrixEvent } from "../models/event";
+import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning";
+import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
+import { Method, ClientPrefix } from "../http-api";
+import { Crypto, ICryptoCallbacks, IBootstrapCrossSigningOpts } from "./index";
+import {
+ ClientEvent,
+ ClientEventHandlerMap,
+ CrossSigningKeys,
+ ICrossSigningKey,
+ ISignedKey,
+ KeySignatures,
+} from "../client";
+import { IKeyBackupInfo } from "./keybackup";
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+import { IAccountDataClient } from "./SecretStorage";
+import { SecretStorageKeyDescription } from "../secret-storage";
+
+interface ICrossSigningKeys {
+ authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
+ keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>;
+}
+
+/**
+ * Builds an EncryptionSetupOperation by calling any of the add.. methods.
+ * Once done, `buildOperation()` can be called which allows to apply to operation.
+ *
+ * This is used as a helper by Crypto to keep track of all the network requests
+ * and other side-effects of bootstrapping, so it can be applied in one go (and retried in the future)
+ * Also keeps track of all the private keys created during bootstrapping, so we don't need to prompt for them
+ * more than once.
+ */
+export class EncryptionSetupBuilder {
+ public readonly accountDataClientAdapter: AccountDataClientAdapter;
+ public readonly crossSigningCallbacks: CrossSigningCallbacks;
+ public readonly ssssCryptoCallbacks: SSSSCryptoCallbacks;
+
+ private crossSigningKeys?: ICrossSigningKeys;
+ private keySignatures?: KeySignatures;
+ private keyBackupInfo?: IKeyBackupInfo;
+ private sessionBackupPrivateKey?: Uint8Array;
+
+ /**
+ * @param accountData - pre-existing account data, will only be read, not written.
+ * @param delegateCryptoCallbacks - crypto callbacks to delegate to if the key isn't in cache yet
+ */
+ public constructor(accountData: Map<string, MatrixEvent>, delegateCryptoCallbacks?: ICryptoCallbacks) {
+ this.accountDataClientAdapter = new AccountDataClientAdapter(accountData);
+ this.crossSigningCallbacks = new CrossSigningCallbacks();
+ this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks);
+ }
+
+ /**
+ * Adds new cross-signing public keys
+ *
+ * @param authUpload - Function called to await an interactive auth
+ * flow when uploading device signing keys.
+ * Args:
+ * A function that makes the request requiring auth. Receives
+ * the auth data as an object. Can be called multiple times, first with
+ * an empty authDict, to obtain the flows.
+ * @param keys - the new keys
+ */
+ public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void {
+ this.crossSigningKeys = { authUpload, keys };
+ }
+
+ /**
+ * Adds the key backup info to be updated on the server
+ *
+ * Used either to create a new key backup, or add signatures
+ * from the new MSK.
+ *
+ * @param keyBackupInfo - as received from/sent to the server
+ */
+ public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void {
+ this.keyBackupInfo = keyBackupInfo;
+ }
+
+ /**
+ * Adds the session backup private key to be updated in the local cache
+ *
+ * Used after fixing the format of the key
+ *
+ */
+ public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void {
+ this.sessionBackupPrivateKey = privateKey;
+ }
+
+ /**
+ * Add signatures from a given user and device/x-sign key
+ * Used to sign the new cross-signing key with the device key
+ *
+ */
+ public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void {
+ if (!this.keySignatures) {
+ this.keySignatures = {};
+ }
+ const userSignatures = this.keySignatures[userId] || {};
+ this.keySignatures[userId] = userSignatures;
+ userSignatures[deviceId] = signature;
+ }
+
+ public async setAccountData(type: string, content: object): Promise<void> {
+ await this.accountDataClientAdapter.setAccountData(type, content);
+ }
+
+ /**
+ * builds the operation containing all the parts that have been added to the builder
+ */
+ public buildOperation(): EncryptionSetupOperation {
+ const accountData = this.accountDataClientAdapter.values;
+ return new EncryptionSetupOperation(accountData, this.crossSigningKeys, this.keyBackupInfo, this.keySignatures);
+ }
+
+ /**
+ * Stores the created keys locally.
+ *
+ * This does not yet store the operation in a way that it can be restored,
+ * but that is the idea in the future.
+ */
+ public async persist(crypto: Crypto): Promise<void> {
+ // store private keys in cache
+ if (this.crossSigningKeys) {
+ const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice);
+ for (const type of ["master", "self_signing", "user_signing"]) {
+ logger.log(`Cache ${type} cross-signing private key locally`);
+ const privateKey = this.crossSigningCallbacks.privateKeys.get(type);
+ await cacheCallbacks.storeCrossSigningKeyCache?.(type, privateKey);
+ }
+ // store own cross-sign pubkeys as trusted
+ await crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ crypto.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningKeys!.keys);
+ });
+ }
+ // store session backup key in cache
+ if (this.sessionBackupPrivateKey) {
+ await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey);
+ }
+ }
+}
+
+/**
+ * Can be created from EncryptionSetupBuilder, or
+ * (in a follow-up PR, not implemented yet) restored from storage, to retry.
+ *
+ * It does not have knowledge of any private keys, unlike the builder.
+ */
+export class EncryptionSetupOperation {
+ /**
+ */
+ public constructor(
+ private readonly accountData: Map<string, object>,
+ private readonly crossSigningKeys?: ICrossSigningKeys,
+ private readonly keyBackupInfo?: IKeyBackupInfo,
+ private readonly keySignatures?: KeySignatures,
+ ) {}
+
+ /**
+ * Runs the (remaining part of, in the future) operation by sending requests to the server.
+ */
+ public async apply(crypto: Crypto): Promise<void> {
+ const baseApis = crypto.baseApis;
+ // upload cross-signing keys
+ if (this.crossSigningKeys) {
+ const keys: Partial<CrossSigningKeys> = {};
+ for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) {
+ keys[((name as keyof ICrossSigningKeys["keys"]) + "_key") as keyof CrossSigningKeys] = key;
+ }
+
+ // We must only call `uploadDeviceSigningKeys` from inside this auth
+ // helper to ensure we properly handle auth errors.
+ await this.crossSigningKeys.authUpload?.((authDict) => {
+ return baseApis.uploadDeviceSigningKeys(authDict, keys as CrossSigningKeys);
+ });
+
+ // pass the new keys to the main instance of our own CrossSigningInfo.
+ crypto.crossSigningInfo.setKeys(this.crossSigningKeys.keys);
+ }
+ // set account data
+ if (this.accountData) {
+ for (const [type, content] of this.accountData) {
+ await baseApis.setAccountData(type, content);
+ }
+ }
+ // upload first cross-signing signatures with the new key
+ // (e.g. signing our own device)
+ if (this.keySignatures) {
+ await baseApis.uploadKeySignatures(this.keySignatures);
+ }
+ // need to create/update key backup info
+ if (this.keyBackupInfo) {
+ if (this.keyBackupInfo.version) {
+ // session backup signature
+ // The backup is trusted because the user provided the private key.
+ // Sign the backup with the cross signing key so the key backup can
+ // be trusted via cross-signing.
+ await baseApis.http.authedRequest(
+ Method.Put,
+ "/room_keys/version/" + this.keyBackupInfo.version,
+ undefined,
+ {
+ algorithm: this.keyBackupInfo.algorithm,
+ auth_data: this.keyBackupInfo.auth_data,
+ },
+ { prefix: ClientPrefix.V3 },
+ );
+ } else {
+ // add new key backup
+ await baseApis.http.authedRequest(Method.Post, "/room_keys/version", undefined, this.keyBackupInfo, {
+ prefix: ClientPrefix.V3,
+ });
+ }
+ }
+ }
+}
+
+/**
+ * Catches account data set by SecretStorage during bootstrapping by
+ * implementing the methods related to account data in MatrixClient
+ */
+class AccountDataClientAdapter
+ extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap>
+ implements IAccountDataClient
+{
+ //
+ public readonly values = new Map<string, MatrixEvent>();
+
+ /**
+ * @param existingValues - existing account data
+ */
+ public constructor(private readonly existingValues: Map<string, MatrixEvent>) {
+ super();
+ }
+
+ /**
+ * @returns the content of the account data
+ */
+ public getAccountDataFromServer<T extends { [k: string]: any }>(type: string): Promise<T> {
+ return Promise.resolve(this.getAccountData(type) as T);
+ }
+
+ /**
+ * @returns the content of the account data
+ */
+ public getAccountData(type: string): IContent | null {
+ const modifiedValue = this.values.get(type);
+ if (modifiedValue) {
+ return modifiedValue;
+ }
+ const existingValue = this.existingValues.get(type);
+ if (existingValue) {
+ return existingValue.getContent();
+ }
+ return null;
+ }
+
+ public setAccountData(type: string, content: any): Promise<{}> {
+ const lastEvent = this.values.get(type);
+ this.values.set(type, content);
+ // ensure accountData is emitted on the next tick,
+ // as SecretStorage listens for it while calling this method
+ // and it seems to rely on this.
+ return Promise.resolve().then(() => {
+ const event = new MatrixEvent({ type, content });
+ this.emit(ClientEvent.AccountData, event, lastEvent);
+ return {};
+ });
+ }
+}
+
+/**
+ * Catches the private cross-signing keys set during bootstrapping
+ * by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks.
+ * See CrossSigningInfo constructor
+ */
+class CrossSigningCallbacks implements ICryptoCallbacks, ICacheCallbacks {
+ public readonly privateKeys = new Map<string, Uint8Array>();
+
+ // cache callbacks
+ public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise<Uint8Array | null> {
+ return this.getCrossSigningKey(type, expectedPublicKey);
+ }
+
+ public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise<void> {
+ this.privateKeys.set(type, key);
+ return Promise.resolve();
+ }
+
+ // non-cache callbacks
+ public getCrossSigningKey(type: string, expectedPubkey: string): Promise<Uint8Array | null> {
+ return Promise.resolve(this.privateKeys.get(type) ?? null);
+ }
+
+ public saveCrossSigningKeys(privateKeys: Record<string, Uint8Array>): void {
+ for (const [type, privateKey] of Object.entries(privateKeys)) {
+ this.privateKeys.set(type, privateKey);
+ }
+ }
+}
+
+/**
+ * Catches the 4S private key set during bootstrapping by implementing
+ * the SecretStorage crypto callbacks
+ */
+class SSSSCryptoCallbacks {
+ private readonly privateKeys = new Map<string, Uint8Array>();
+
+ public constructor(private readonly delegateCryptoCallbacks?: ICryptoCallbacks) {}
+
+ public async getSecretStorageKey(
+ { keys }: { keys: Record<string, SecretStorageKeyDescription> },
+ name: string,
+ ): Promise<[string, Uint8Array] | null> {
+ for (const keyId of Object.keys(keys)) {
+ const privateKey = this.privateKeys.get(keyId);
+ if (privateKey) {
+ return [keyId, privateKey];
+ }
+ }
+ // if we don't have the key cached yet, ask
+ // for it to the general crypto callbacks and cache it
+ if (this?.delegateCryptoCallbacks?.getSecretStorageKey) {
+ const result = await this.delegateCryptoCallbacks.getSecretStorageKey({ keys }, name);
+ if (result) {
+ const [keyId, privateKey] = result;
+ this.privateKeys.set(keyId, privateKey);
+ }
+ return result;
+ }
+ return null;
+ }
+
+ public addPrivateKey(keyId: string, keyInfo: SecretStorageKeyDescription, privKey: Uint8Array): void {
+ this.privateKeys.set(keyId, privKey);
+ // Also pass along to application to cache if it wishes
+ this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts
new file mode 100644
index 0000000..82a0a9a
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OlmDevice.ts
@@ -0,0 +1,1496 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Account, InboundGroupSession, OutboundGroupSession, Session, Utility } from "@matrix-org/olm";
+
+import { logger, PrefixedLogger } from "../logger";
+import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
+import * as algorithms from "./algorithms";
+import { CryptoStore, IProblem, ISessionInfo, IWithheld } from "./store/base";
+import { IOlmDevice, IOutboundGroupSessionKey } from "./algorithms/megolm";
+import { IMegolmSessionData, OlmGroupSessionExtraData } from "../@types/crypto";
+import { IMessage } from "./algorithms/olm";
+
+// The maximum size of an event is 65K, and we base64 the content, so this is a
+// reasonable approximation to the biggest plaintext we can encrypt.
+const MAX_PLAINTEXT_LENGTH = (65536 * 3) / 4;
+
+export class PayloadTooLargeError extends Error {
+ public readonly data = {
+ errcode: "M_TOO_LARGE",
+ error: "Payload too large for encrypted message",
+ };
+}
+
+function checkPayloadLength(payloadString: string): void {
+ if (payloadString === undefined) {
+ throw new Error("payloadString undefined");
+ }
+
+ if (payloadString.length > MAX_PLAINTEXT_LENGTH) {
+ // might as well fail early here rather than letting the olm library throw
+ // a cryptic memory allocation error.
+ //
+ // Note that even if we manage to do the encryption, the message send may fail,
+ // because by the time we've wrapped the ciphertext in the event object, it may
+ // exceed 65K. But at least we won't just fail with "abort()" in that case.
+ throw new PayloadTooLargeError(
+ `Message too long (${payloadString.length} bytes). ` +
+ `The maximum for an encrypted message is ${MAX_PLAINTEXT_LENGTH} bytes.`,
+ );
+ }
+}
+
+interface IInitOpts {
+ fromExportedDevice?: IExportedDevice;
+ pickleKey?: string;
+}
+
+/** data stored in the session store about an inbound group session */
+export interface InboundGroupSessionData {
+ room_id: string; // eslint-disable-line camelcase
+ /** pickled Olm.InboundGroupSession */
+ session: string;
+ keysClaimed: Record<string, string>;
+ /** Devices involved in forwarding this session to us (normally empty). */
+ forwardingCurve25519KeyChain: string[];
+ /** whether this session is untrusted. */
+ untrusted?: boolean;
+ /** whether this session exists during the room being set to shared history. */
+ sharedHistory?: boolean;
+}
+
+export interface IDecryptedGroupMessage {
+ result: string;
+ keysClaimed: Record<string, string>;
+ senderKey: string;
+ forwardingCurve25519KeyChain: string[];
+ untrusted: boolean;
+}
+
+export interface IInboundSession {
+ payload: string;
+ session_id: string;
+}
+
+export interface IExportedDevice {
+ pickleKey: string;
+ pickledAccount: string;
+ sessions: ISessionInfo[];
+}
+
+interface IUnpickledSessionInfo extends Omit<ISessionInfo, "session"> {
+ session: Session;
+}
+
+/* eslint-disable camelcase */
+interface IInboundGroupSessionKey {
+ chain_index: number;
+ key: string;
+ forwarding_curve25519_key_chain: string[];
+ sender_claimed_ed25519_key: string | null;
+ shared_history: boolean;
+ untrusted?: boolean;
+}
+/* eslint-enable camelcase */
+
+type OneTimeKeys = { curve25519: { [keyId: string]: string } };
+
+/**
+ * Manages the olm cryptography functions. Each OlmDevice has a single
+ * OlmAccount and a number of OlmSessions.
+ *
+ * Accounts and sessions are kept pickled in the cryptoStore.
+ */
+export class OlmDevice {
+ public pickleKey = "DEFAULT_KEY"; // set by consumers
+
+ /** Curve25519 key for the account, unknown until we load the account from storage in init() */
+ public deviceCurve25519Key: string | null = null;
+ /** Ed25519 key for the account, unknown until we load the account from storage in init() */
+ public deviceEd25519Key: string | null = null;
+ private maxOneTimeKeys: number | null = null;
+
+ // we don't bother stashing outboundgroupsessions in the cryptoStore -
+ // instead we keep them here.
+ private outboundGroupSessionStore: Record<string, string> = {};
+
+ // Store a set of decrypted message indexes for each group session.
+ // This partially mitigates a replay attack where a MITM resends a group
+ // message into the room.
+ //
+ // When we decrypt a message and the message index matches a previously
+ // decrypted message, one possible cause of that is that we are decrypting
+ // the same event, and may not indicate an actual replay attack. For
+ // example, this could happen if we receive events, forget about them, and
+ // then re-fetch them when we backfill. So we store the event ID and
+ // timestamp corresponding to each message index when we first decrypt it,
+ // and compare these against the event ID and timestamp every time we use
+ // that same index. If they match, then we're probably decrypting the same
+ // event and we don't consider it a replay attack.
+ //
+ // Keys are strings of form "<senderKey>|<session_id>|<message_index>"
+ // Values are objects of the form "{id: <event id>, timestamp: <ts>}"
+ private inboundGroupSessionMessageIndexes: Record<string, { id: string; timestamp: number }> = {};
+
+ // Keep track of sessions that we're starting, so that we don't start
+ // multiple sessions for the same device at the same time.
+ public sessionsInProgress: Record<string, Promise<void>> = {}; // set by consumers
+
+ // Used by olm to serialise prekey message decryptions
+ public olmPrekeyPromise: Promise<any> = Promise.resolve(); // set by consumers
+
+ public constructor(private readonly cryptoStore: CryptoStore) {}
+
+ /**
+ * @returns The version of Olm.
+ */
+ public static getOlmVersion(): [number, number, number] {
+ return global.Olm.get_library_version();
+ }
+
+ /**
+ * Initialise the OlmAccount. This must be called before any other operations
+ * on the OlmDevice.
+ *
+ * Data from an exported Olm device can be provided
+ * in order to re-create this device.
+ *
+ * Attempts to load the OlmAccount from the crypto store, or creates one if none is
+ * found.
+ *
+ * Reads the device keys from the OlmAccount object.
+ *
+ * @param fromExportedDevice - (Optional) data from exported device
+ * that must be re-created.
+ * If present, opts.pickleKey is ignored
+ * (exported data already provides a pickle key)
+ * @param pickleKey - (Optional) pickle key to set instead of default one
+ */
+ public async init({ pickleKey, fromExportedDevice }: IInitOpts = {}): Promise<void> {
+ let e2eKeys;
+ const account = new global.Olm.Account();
+
+ try {
+ if (fromExportedDevice) {
+ if (pickleKey) {
+ logger.warn("ignoring opts.pickleKey" + " because opts.fromExportedDevice is present.");
+ }
+ this.pickleKey = fromExportedDevice.pickleKey;
+ await this.initialiseFromExportedDevice(fromExportedDevice, account);
+ } else {
+ if (pickleKey) {
+ this.pickleKey = pickleKey;
+ }
+ await this.initialiseAccount(account);
+ }
+ e2eKeys = JSON.parse(account.identity_keys());
+
+ this.maxOneTimeKeys = account.max_number_of_one_time_keys();
+ } finally {
+ account.free();
+ }
+
+ this.deviceCurve25519Key = e2eKeys.curve25519;
+ this.deviceEd25519Key = e2eKeys.ed25519;
+ }
+
+ /**
+ * Populates the crypto store using data that was exported from an existing device.
+ * Note that for now only the “account” and “sessions” stores are populated;
+ * Other stores will be as with a new device.
+ *
+ * @param exportedData - Data exported from another device
+ * through the “export” method.
+ * @param account - an olm account to initialize
+ */
+ private async initialiseFromExportedDevice(exportedData: IExportedDevice, account: Account): Promise<void> {
+ await this.cryptoStore.doTxn(
+ "readwrite",
+ [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS],
+ (txn) => {
+ this.cryptoStore.storeAccount(txn, exportedData.pickledAccount);
+ exportedData.sessions.forEach((session) => {
+ const { deviceKey, sessionId } = session;
+ const sessionInfo = {
+ session: session.session,
+ lastReceivedMessageTs: session.lastReceivedMessageTs,
+ };
+ this.cryptoStore.storeEndToEndSession(deviceKey!, sessionId!, sessionInfo, txn);
+ });
+ },
+ );
+ account.unpickle(this.pickleKey, exportedData.pickledAccount);
+ }
+
+ private async initialiseAccount(account: Account): Promise<void> {
+ await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.cryptoStore.getAccount(txn, (pickledAccount) => {
+ if (pickledAccount !== null) {
+ account.unpickle(this.pickleKey, pickledAccount);
+ } else {
+ account.create();
+ pickledAccount = account.pickle(this.pickleKey);
+ this.cryptoStore.storeAccount(txn, pickledAccount);
+ }
+ });
+ });
+ }
+
+ /**
+ * extract our OlmAccount from the crypto store and call the given function
+ * with the account object
+ * The `account` object is usable only within the callback passed to this
+ * function and will be freed as soon the callback returns. It is *not*
+ * usable for the rest of the lifetime of the transaction.
+ * This function requires a live transaction object from cryptoStore.doTxn()
+ * and therefore may only be called in a doTxn() callback.
+ *
+ * @param txn - Opaque transaction object from cryptoStore.doTxn()
+ * @internal
+ */
+ private getAccount(txn: unknown, func: (account: Account) => void): void {
+ this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => {
+ const account = new global.Olm.Account();
+ try {
+ account.unpickle(this.pickleKey, pickledAccount!);
+ func(account);
+ } finally {
+ account.free();
+ }
+ });
+ }
+
+ /*
+ * Saves an account to the crypto store.
+ * This function requires a live transaction object from cryptoStore.doTxn()
+ * and therefore may only be called in a doTxn() callback.
+ *
+ * @param txn - Opaque transaction object from cryptoStore.doTxn()
+ * @param Olm.Account object
+ * @internal
+ */
+ private storeAccount(txn: unknown, account: Account): void {
+ this.cryptoStore.storeAccount(txn, account.pickle(this.pickleKey));
+ }
+
+ /**
+ * Export data for re-creating the Olm device later.
+ * TODO export data other than just account and (P2P) sessions.
+ *
+ * @returns The exported data
+ */
+ public async export(): Promise<IExportedDevice> {
+ const result: Partial<IExportedDevice> = {
+ pickleKey: this.pickleKey,
+ };
+
+ await this.cryptoStore.doTxn(
+ "readonly",
+ [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS],
+ (txn) => {
+ this.cryptoStore.getAccount(txn, (pickledAccount: string | null) => {
+ result.pickledAccount = pickledAccount!;
+ });
+ result.sessions = [];
+ // Note that the pickledSession object we get in the callback
+ // is not exactly the same thing you get in method _getSession
+ // see documentation of IndexedDBCryptoStore.getAllEndToEndSessions
+ this.cryptoStore.getAllEndToEndSessions(txn, (pickledSession) => {
+ result.sessions!.push(pickledSession!);
+ });
+ },
+ );
+ return result as IExportedDevice;
+ }
+
+ /**
+ * extract an OlmSession from the session store and call the given function
+ * The session is usable only within the callback passed to this
+ * function and will be freed as soon the callback returns. It is *not*
+ * usable for the rest of the lifetime of the transaction.
+ *
+ * @param txn - Opaque transaction object from cryptoStore.doTxn()
+ * @internal
+ */
+ private getSession(
+ deviceKey: string,
+ sessionId: string,
+ txn: unknown,
+ func: (unpickledSessionInfo: IUnpickledSessionInfo) => void,
+ ): void {
+ this.cryptoStore.getEndToEndSession(deviceKey, sessionId, txn, (sessionInfo: ISessionInfo | null) => {
+ this.unpickleSession(sessionInfo!, func);
+ });
+ }
+
+ /**
+ * Creates a session object from a session pickle and executes the given
+ * function with it. The session object is destroyed once the function
+ * returns.
+ *
+ * @internal
+ */
+ private unpickleSession(
+ sessionInfo: ISessionInfo,
+ func: (unpickledSessionInfo: IUnpickledSessionInfo) => void,
+ ): void {
+ const session = new global.Olm.Session();
+ try {
+ session.unpickle(this.pickleKey, sessionInfo.session!);
+ const unpickledSessInfo: IUnpickledSessionInfo = Object.assign({}, sessionInfo, { session });
+
+ func(unpickledSessInfo);
+ } finally {
+ session.free();
+ }
+ }
+
+ /**
+ * store our OlmSession in the session store
+ *
+ * @param sessionInfo - `{session: OlmSession, lastReceivedMessageTs: int}`
+ * @param txn - Opaque transaction object from cryptoStore.doTxn()
+ * @internal
+ */
+ private saveSession(deviceKey: string, sessionInfo: IUnpickledSessionInfo, txn: unknown): void {
+ const sessionId = sessionInfo.session.session_id();
+ logger.debug(`Saving Olm session ${sessionId} with device ${deviceKey}: ${sessionInfo.session.describe()}`);
+
+ // Why do we re-use the input object for this, overwriting the same key with a different
+ // type? Is it because we want to erase the unpickled session to enforce that it's no longer
+ // used? A comment would be great.
+ const pickledSessionInfo = Object.assign(sessionInfo, {
+ session: sessionInfo.session.pickle(this.pickleKey),
+ });
+ this.cryptoStore.storeEndToEndSession(deviceKey, sessionId, pickledSessionInfo, txn);
+ }
+
+ /**
+ * get an OlmUtility and call the given function
+ *
+ * @returns result of func
+ * @internal
+ */
+ private getUtility<T>(func: (utility: Utility) => T): T {
+ const utility = new global.Olm.Utility();
+ try {
+ return func(utility);
+ } finally {
+ utility.free();
+ }
+ }
+
+ /**
+ * Signs a message with the ed25519 key for this account.
+ *
+ * @param message - message to be signed
+ * @returns base64-encoded signature
+ */
+ public async sign(message: string): Promise<string> {
+ let result: string;
+ await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.getAccount(txn, (account: Account) => {
+ result = account.sign(message);
+ });
+ });
+ return result!;
+ }
+
+ /**
+ * Get the current (unused, unpublished) one-time keys for this account.
+ *
+ * @returns one time keys; an object with the single property
+ * <tt>curve25519</tt>, which is itself an object mapping key id to Curve25519
+ * key.
+ */
+ public async getOneTimeKeys(): Promise<OneTimeKeys> {
+ let result: OneTimeKeys;
+ await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.getAccount(txn, (account) => {
+ result = JSON.parse(account.one_time_keys());
+ });
+ });
+
+ return result!;
+ }
+
+ /**
+ * Get the maximum number of one-time keys we can store.
+ *
+ * @returns number of keys
+ */
+ public maxNumberOfOneTimeKeys(): number {
+ return this.maxOneTimeKeys ?? -1;
+ }
+
+ /**
+ * Marks all of the one-time keys as published.
+ */
+ public async markKeysAsPublished(): Promise<void> {
+ await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.getAccount(txn, (account: Account) => {
+ account.mark_keys_as_published();
+ this.storeAccount(txn, account);
+ });
+ });
+ }
+
+ /**
+ * Generate some new one-time keys
+ *
+ * @param numKeys - number of keys to generate
+ * @returns Resolved once the account is saved back having generated the keys
+ */
+ public generateOneTimeKeys(numKeys: number): Promise<void> {
+ return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.getAccount(txn, (account) => {
+ account.generate_one_time_keys(numKeys);
+ this.storeAccount(txn, account);
+ });
+ });
+ }
+
+ /**
+ * Generate a new fallback keys
+ *
+ * @returns Resolved once the account is saved back having generated the key
+ */
+ public async generateFallbackKey(): Promise<void> {
+ await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.getAccount(txn, (account) => {
+ account.generate_fallback_key();
+ this.storeAccount(txn, account);
+ });
+ });
+ }
+
+ public async getFallbackKey(): Promise<Record<string, Record<string, string>>> {
+ let result: Record<string, Record<string, string>>;
+ await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.getAccount(txn, (account: Account) => {
+ result = JSON.parse(account.unpublished_fallback_key());
+ });
+ });
+ return result!;
+ }
+
+ public async forgetOldFallbackKey(): Promise<void> {
+ await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.getAccount(txn, (account: Account) => {
+ account.forget_old_fallback_key();
+ this.storeAccount(txn, account);
+ });
+ });
+ }
+
+ /**
+ * Generate a new outbound session
+ *
+ * The new session will be stored in the cryptoStore.
+ *
+ * @param theirIdentityKey - remote user's Curve25519 identity key
+ * @param theirOneTimeKey - remote user's one-time Curve25519 key
+ * @returns sessionId for the outbound session.
+ */
+ public async createOutboundSession(theirIdentityKey: string, theirOneTimeKey: string): Promise<string> {
+ let newSessionId: string;
+ await this.cryptoStore.doTxn(
+ "readwrite",
+ [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS],
+ (txn) => {
+ this.getAccount(txn, (account: Account) => {
+ const session = new global.Olm.Session();
+ try {
+ session.create_outbound(account, theirIdentityKey, theirOneTimeKey);
+ newSessionId = session.session_id();
+ this.storeAccount(txn, account);
+ const sessionInfo: IUnpickledSessionInfo = {
+ session,
+ // Pretend we've received a message at this point, otherwise
+ // if we try to send a message to the device, it won't use
+ // this session
+ lastReceivedMessageTs: Date.now(),
+ };
+ this.saveSession(theirIdentityKey, sessionInfo, txn);
+ } finally {
+ session.free();
+ }
+ });
+ },
+ logger.withPrefix("[createOutboundSession]"),
+ );
+ return newSessionId!;
+ }
+
+ /**
+ * Generate a new inbound session, given an incoming message
+ *
+ * @param theirDeviceIdentityKey - remote user's Curve25519 identity key
+ * @param messageType - messageType field from the received message (must be 0)
+ * @param ciphertext - base64-encoded body from the received message
+ *
+ * @returns decrypted payload, and
+ * session id of new session
+ *
+ * @throws Error if the received message was not valid (for instance, it didn't use a valid one-time key).
+ */
+ public async createInboundSession(
+ theirDeviceIdentityKey: string,
+ messageType: number,
+ ciphertext: string,
+ ): Promise<IInboundSession> {
+ if (messageType !== 0) {
+ throw new Error("Need messageType == 0 to create inbound session");
+ }
+
+ let result: { payload: string; session_id: string }; // eslint-disable-line camelcase
+ await this.cryptoStore.doTxn(
+ "readwrite",
+ [IndexedDBCryptoStore.STORE_ACCOUNT, IndexedDBCryptoStore.STORE_SESSIONS],
+ (txn) => {
+ this.getAccount(txn, (account: Account) => {
+ const session = new global.Olm.Session();
+ try {
+ session.create_inbound_from(account, theirDeviceIdentityKey, ciphertext);
+ account.remove_one_time_keys(session);
+ this.storeAccount(txn, account);
+
+ const payloadString = session.decrypt(messageType, ciphertext);
+
+ const sessionInfo: IUnpickledSessionInfo = {
+ session,
+ // this counts as a received message: set last received message time
+ // to now
+ lastReceivedMessageTs: Date.now(),
+ };
+ this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
+
+ result = {
+ payload: payloadString,
+ session_id: session.session_id(),
+ };
+ } finally {
+ session.free();
+ }
+ });
+ },
+ logger.withPrefix("[createInboundSession]"),
+ );
+
+ return result!;
+ }
+
+ /**
+ * Get a list of known session IDs for the given device
+ *
+ * @param theirDeviceIdentityKey - Curve25519 identity key for the
+ * remote device
+ * @returns a list of known session ids for the device
+ */
+ public async getSessionIdsForDevice(theirDeviceIdentityKey: string): Promise<string[]> {
+ const log = logger.withPrefix("[getSessionIdsForDevice]");
+
+ if (theirDeviceIdentityKey in this.sessionsInProgress) {
+ log.debug(`Waiting for Olm session for ${theirDeviceIdentityKey} to be created`);
+ try {
+ await this.sessionsInProgress[theirDeviceIdentityKey];
+ } catch (e) {
+ // if the session failed to be created, just fall through and
+ // return an empty result
+ }
+ }
+ let sessionIds: string[];
+ await this.cryptoStore.doTxn(
+ "readonly",
+ [IndexedDBCryptoStore.STORE_SESSIONS],
+ (txn) => {
+ this.cryptoStore.getEndToEndSessions(theirDeviceIdentityKey, txn, (sessions) => {
+ sessionIds = Object.keys(sessions);
+ });
+ },
+ log,
+ );
+
+ return sessionIds!;
+ }
+
+ /**
+ * Get the right olm session id for encrypting messages to the given identity key
+ *
+ * @param theirDeviceIdentityKey - Curve25519 identity key for the
+ * remote device
+ * @param nowait - Don't wait for an in-progress session to complete.
+ * This should only be set to true of the calling function is the function
+ * that marked the session as being in-progress.
+ * @param log - A possibly customised log
+ * @returns session id, or null if no established session
+ */
+ public async getSessionIdForDevice(
+ theirDeviceIdentityKey: string,
+ nowait = false,
+ log?: PrefixedLogger,
+ ): Promise<string | null> {
+ const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey, nowait, log);
+
+ if (sessionInfos.length === 0) {
+ return null;
+ }
+ // Use the session that has most recently received a message
+ let idxOfBest = 0;
+ for (let i = 1; i < sessionInfos.length; i++) {
+ const thisSessInfo = sessionInfos[i];
+ const thisLastReceived =
+ thisSessInfo.lastReceivedMessageTs === undefined ? 0 : thisSessInfo.lastReceivedMessageTs;
+
+ const bestSessInfo = sessionInfos[idxOfBest];
+ const bestLastReceived =
+ bestSessInfo.lastReceivedMessageTs === undefined ? 0 : bestSessInfo.lastReceivedMessageTs;
+ if (
+ thisLastReceived > bestLastReceived ||
+ (thisLastReceived === bestLastReceived && thisSessInfo.sessionId < bestSessInfo.sessionId)
+ ) {
+ idxOfBest = i;
+ }
+ }
+ return sessionInfos[idxOfBest].sessionId;
+ }
+
+ /**
+ * Get information on the active Olm sessions for a device.
+ * <p>
+ * Returns an array, with an entry for each active session. The first entry in
+ * the result will be the one used for outgoing messages. Each entry contains
+ * the keys 'hasReceivedMessage' (true if the session has received an incoming
+ * message and is therefore past the pre-key stage), and 'sessionId'.
+ *
+ * @param deviceIdentityKey - Curve25519 identity key for the device
+ * @param nowait - Don't wait for an in-progress session to complete.
+ * This should only be set to true of the calling function is the function
+ * that marked the session as being in-progress.
+ * @param log - A possibly customised log
+ */
+ public async getSessionInfoForDevice(
+ deviceIdentityKey: string,
+ nowait = false,
+ log = logger,
+ ): Promise<{ sessionId: string; lastReceivedMessageTs: number; hasReceivedMessage: boolean }[]> {
+ log = log.withPrefix("[getSessionInfoForDevice]");
+
+ if (deviceIdentityKey in this.sessionsInProgress && !nowait) {
+ log.debug(`Waiting for Olm session for ${deviceIdentityKey} to be created`);
+ try {
+ await this.sessionsInProgress[deviceIdentityKey];
+ } catch (e) {
+ // if the session failed to be created, then just fall through and
+ // return an empty result
+ }
+ }
+ const info: {
+ lastReceivedMessageTs: number;
+ hasReceivedMessage: boolean;
+ sessionId: string;
+ }[] = [];
+
+ await this.cryptoStore.doTxn(
+ "readonly",
+ [IndexedDBCryptoStore.STORE_SESSIONS],
+ (txn) => {
+ this.cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => {
+ const sessionIds = Object.keys(sessions).sort();
+ for (const sessionId of sessionIds) {
+ this.unpickleSession(sessions[sessionId], (sessInfo: IUnpickledSessionInfo) => {
+ info.push({
+ lastReceivedMessageTs: sessInfo.lastReceivedMessageTs!,
+ hasReceivedMessage: sessInfo.session.has_received_message(),
+ sessionId,
+ });
+ });
+ }
+ });
+ },
+ log,
+ );
+
+ return info;
+ }
+
+ /**
+ * Encrypt an outgoing message using an existing session
+ *
+ * @param theirDeviceIdentityKey - Curve25519 identity key for the
+ * remote device
+ * @param sessionId - the id of the active session
+ * @param payloadString - payload to be encrypted and sent
+ *
+ * @returns ciphertext
+ */
+ public async encryptMessage(
+ theirDeviceIdentityKey: string,
+ sessionId: string,
+ payloadString: string,
+ ): Promise<IMessage> {
+ checkPayloadLength(payloadString);
+
+ let res: IMessage;
+ await this.cryptoStore.doTxn(
+ "readwrite",
+ [IndexedDBCryptoStore.STORE_SESSIONS],
+ (txn) => {
+ this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => {
+ const sessionDesc = sessionInfo.session.describe();
+ logger.log(
+ "encryptMessage: Olm Session ID " +
+ sessionId +
+ " to " +
+ theirDeviceIdentityKey +
+ ": " +
+ sessionDesc,
+ );
+ res = sessionInfo.session.encrypt(payloadString);
+ this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
+ });
+ },
+ logger.withPrefix("[encryptMessage]"),
+ );
+ return res!;
+ }
+
+ /**
+ * Decrypt an incoming message using an existing session
+ *
+ * @param theirDeviceIdentityKey - Curve25519 identity key for the
+ * remote device
+ * @param sessionId - the id of the active session
+ * @param messageType - messageType field from the received message
+ * @param ciphertext - base64-encoded body from the received message
+ *
+ * @returns decrypted payload.
+ */
+ public async decryptMessage(
+ theirDeviceIdentityKey: string,
+ sessionId: string,
+ messageType: number,
+ ciphertext: string,
+ ): Promise<string> {
+ let payloadString: string;
+ await this.cryptoStore.doTxn(
+ "readwrite",
+ [IndexedDBCryptoStore.STORE_SESSIONS],
+ (txn) => {
+ this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo: IUnpickledSessionInfo) => {
+ const sessionDesc = sessionInfo.session.describe();
+ logger.log(
+ "decryptMessage: Olm Session ID " +
+ sessionId +
+ " from " +
+ theirDeviceIdentityKey +
+ ": " +
+ sessionDesc,
+ );
+ payloadString = sessionInfo.session.decrypt(messageType, ciphertext);
+ sessionInfo.lastReceivedMessageTs = Date.now();
+ this.saveSession(theirDeviceIdentityKey, sessionInfo, txn);
+ });
+ },
+ logger.withPrefix("[decryptMessage]"),
+ );
+ return payloadString!;
+ }
+
+ /**
+ * Determine if an incoming messages is a prekey message matching an existing session
+ *
+ * @param theirDeviceIdentityKey - Curve25519 identity key for the
+ * remote device
+ * @param sessionId - the id of the active session
+ * @param messageType - messageType field from the received message
+ * @param ciphertext - base64-encoded body from the received message
+ *
+ * @returns true if the received message is a prekey message which matches
+ * the given session.
+ */
+ public async matchesSession(
+ theirDeviceIdentityKey: string,
+ sessionId: string,
+ messageType: number,
+ ciphertext: string,
+ ): Promise<boolean> {
+ if (messageType !== 0) {
+ return false;
+ }
+
+ let matches: boolean;
+ await this.cryptoStore.doTxn(
+ "readonly",
+ [IndexedDBCryptoStore.STORE_SESSIONS],
+ (txn) => {
+ this.getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => {
+ matches = sessionInfo.session.matches_inbound(ciphertext);
+ });
+ },
+ logger.withPrefix("[matchesSession]"),
+ );
+ return matches!;
+ }
+
+ public async recordSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
+ logger.info(`Recording problem on olm session with ${deviceKey} of type ${type}. Recreating: ${fixed}`);
+ await this.cryptoStore.storeEndToEndSessionProblem(deviceKey, type, fixed);
+ }
+
+ public sessionMayHaveProblems(deviceKey: string, timestamp: number): Promise<IProblem | null> {
+ return this.cryptoStore.getEndToEndSessionProblem(deviceKey, timestamp);
+ }
+
+ public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
+ return this.cryptoStore.filterOutNotifiedErrorDevices(devices);
+ }
+
+ // Outbound group session
+ // ======================
+
+ /**
+ * store an OutboundGroupSession in outboundGroupSessionStore
+ *
+ * @internal
+ */
+ private saveOutboundGroupSession(session: OutboundGroupSession): void {
+ this.outboundGroupSessionStore[session.session_id()] = session.pickle(this.pickleKey);
+ }
+
+ /**
+ * extract an OutboundGroupSession from outboundGroupSessionStore and call the
+ * given function
+ *
+ * @returns result of func
+ * @internal
+ */
+ private getOutboundGroupSession<T>(sessionId: string, func: (session: OutboundGroupSession) => T): T {
+ const pickled = this.outboundGroupSessionStore[sessionId];
+ if (pickled === undefined) {
+ throw new Error("Unknown outbound group session " + sessionId);
+ }
+
+ const session = new global.Olm.OutboundGroupSession();
+ try {
+ session.unpickle(this.pickleKey, pickled);
+ return func(session);
+ } finally {
+ session.free();
+ }
+ }
+
+ /**
+ * Generate a new outbound group session
+ *
+ * @returns sessionId for the outbound session.
+ */
+ public createOutboundGroupSession(): string {
+ const session = new global.Olm.OutboundGroupSession();
+ try {
+ session.create();
+ this.saveOutboundGroupSession(session);
+ return session.session_id();
+ } finally {
+ session.free();
+ }
+ }
+
+ /**
+ * Encrypt an outgoing message with an outbound group session
+ *
+ * @param sessionId - the id of the outboundgroupsession
+ * @param payloadString - payload to be encrypted and sent
+ *
+ * @returns ciphertext
+ */
+ public encryptGroupMessage(sessionId: string, payloadString: string): string {
+ logger.log(`encrypting msg with megolm session ${sessionId}`);
+
+ checkPayloadLength(payloadString);
+
+ return this.getOutboundGroupSession(sessionId, (session: OutboundGroupSession) => {
+ const res = session.encrypt(payloadString);
+ this.saveOutboundGroupSession(session);
+ return res;
+ });
+ }
+
+ /**
+ * Get the session keys for an outbound group session
+ *
+ * @param sessionId - the id of the outbound group session
+ *
+ * @returns current chain index, and
+ * base64-encoded secret key.
+ */
+ public getOutboundGroupSessionKey(sessionId: string): IOutboundGroupSessionKey {
+ return this.getOutboundGroupSession(sessionId, function (session: OutboundGroupSession) {
+ return {
+ chain_index: session.message_index(),
+ key: session.session_key(),
+ };
+ });
+ }
+
+ // Inbound group session
+ // =====================
+
+ /**
+ * Unpickle a session from a sessionData object and invoke the given function.
+ * The session is valid only until func returns.
+ *
+ * @param sessionData - Object describing the session.
+ * @param func - Invoked with the unpickled session
+ * @returns result of func
+ */
+ private unpickleInboundGroupSession<T>(
+ sessionData: InboundGroupSessionData,
+ func: (session: InboundGroupSession) => T,
+ ): T {
+ const session = new global.Olm.InboundGroupSession();
+ try {
+ session.unpickle(this.pickleKey, sessionData.session);
+ return func(session);
+ } finally {
+ session.free();
+ }
+ }
+
+ /**
+ * extract an InboundGroupSession from the crypto store and call the given function
+ *
+ * @param roomId - The room ID to extract the session for, or null to fetch
+ * sessions for any room.
+ * @param txn - Opaque transaction object from cryptoStore.doTxn()
+ * @param func - function to call.
+ *
+ * @internal
+ */
+ private getInboundGroupSession(
+ roomId: string,
+ senderKey: string,
+ sessionId: string,
+ txn: unknown,
+ func: (
+ session: InboundGroupSession | null,
+ data: InboundGroupSessionData | null,
+ withheld: IWithheld | null,
+ ) => void,
+ ): void {
+ this.cryptoStore.getEndToEndInboundGroupSession(
+ senderKey,
+ sessionId,
+ txn,
+ (sessionData: InboundGroupSessionData | null, withheld: IWithheld | null) => {
+ if (sessionData === null) {
+ func(null, null, withheld);
+ return;
+ }
+
+ // if we were given a room ID, check that the it matches the original one for the session. This stops
+ // the HS pretending a message was targeting a different room.
+ if (roomId !== null && roomId !== sessionData.room_id) {
+ throw new Error(
+ "Mismatched room_id for inbound group session (expected " +
+ sessionData.room_id +
+ ", was " +
+ roomId +
+ ")",
+ );
+ }
+
+ this.unpickleInboundGroupSession(sessionData, (session: InboundGroupSession) => {
+ func(session, sessionData, withheld);
+ });
+ },
+ );
+ }
+
+ /**
+ * Add an inbound group session to the session store
+ *
+ * @param roomId - room in which this session will be used
+ * @param senderKey - base64-encoded curve25519 key of the sender
+ * @param forwardingCurve25519KeyChain - Devices involved in forwarding
+ * this session to us.
+ * @param sessionId - session identifier
+ * @param sessionKey - base64-encoded secret key
+ * @param keysClaimed - Other keys the sender claims.
+ * @param exportFormat - true if the megolm keys are in export format
+ * (ie, they lack an ed25519 signature)
+ * @param extraSessionData - any other data to be include with the session
+ */
+ public async addInboundGroupSession(
+ roomId: string,
+ senderKey: string,
+ forwardingCurve25519KeyChain: string[],
+ sessionId: string,
+ sessionKey: string,
+ keysClaimed: Record<string, string>,
+ exportFormat: boolean,
+ extraSessionData: OlmGroupSessionExtraData = {},
+ ): Promise<void> {
+ await this.cryptoStore.doTxn(
+ "readwrite",
+ [
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD,
+ IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS,
+ ],
+ (txn) => {
+ /* if we already have this session, consider updating it */
+ this.getInboundGroupSession(
+ roomId,
+ senderKey,
+ sessionId,
+ txn,
+ (
+ existingSession: InboundGroupSession | null,
+ existingSessionData: InboundGroupSessionData | null,
+ ) => {
+ // new session.
+ const session = new global.Olm.InboundGroupSession();
+ try {
+ if (exportFormat) {
+ session.import_session(sessionKey);
+ } else {
+ session.create(sessionKey);
+ }
+ if (sessionId != session.session_id()) {
+ throw new Error("Mismatched group session ID from senderKey: " + senderKey);
+ }
+
+ if (existingSession) {
+ logger.log(`Update for megolm session ${senderKey}|${sessionId}`);
+ if (existingSession.first_known_index() <= session.first_known_index()) {
+ if (!existingSessionData!.untrusted || extraSessionData.untrusted) {
+ // existing session has less-than-or-equal index
+ // (i.e. can decrypt at least as much), and the
+ // new session's trust does not win over the old
+ // session's trust, so keep it
+ logger.log(`Keeping existing megolm session ${senderKey}|${sessionId}`);
+ return;
+ }
+ if (existingSession.first_known_index() < session.first_known_index()) {
+ // We want to upgrade the existing session's trust,
+ // but we can't just use the new session because we'll
+ // lose the lower index. Check that the sessions connect
+ // properly, and then manually set the existing session
+ // as trusted.
+ if (
+ existingSession.export_session(session.first_known_index()) ===
+ session.export_session(session.first_known_index())
+ ) {
+ logger.info(
+ "Upgrading trust of existing megolm session " +
+ `${senderKey}|${sessionId} based on newly-received trusted session`,
+ );
+ existingSessionData!.untrusted = false;
+ this.cryptoStore.storeEndToEndInboundGroupSession(
+ senderKey,
+ sessionId,
+ existingSessionData!,
+ txn,
+ );
+ } else {
+ logger.warn(
+ `Newly-received megolm session ${senderKey}|$sessionId}` +
+ " does not match existing session! Keeping existing session",
+ );
+ }
+ return;
+ }
+ // If the sessions have the same index, go ahead and store the new trusted one.
+ }
+ }
+
+ logger.info(
+ `Storing megolm session ${senderKey}|${sessionId} with first index ` +
+ session.first_known_index(),
+ );
+
+ const sessionData = Object.assign({}, extraSessionData, {
+ room_id: roomId,
+ session: session.pickle(this.pickleKey),
+ keysClaimed: keysClaimed,
+ forwardingCurve25519KeyChain: forwardingCurve25519KeyChain,
+ });
+
+ this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn);
+
+ if (!existingSession && extraSessionData.sharedHistory) {
+ this.cryptoStore.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn);
+ }
+ } finally {
+ session.free();
+ }
+ },
+ );
+ },
+ logger.withPrefix("[addInboundGroupSession]"),
+ );
+ }
+
+ /**
+ * Record in the data store why an inbound group session was withheld.
+ *
+ * @param roomId - room that the session belongs to
+ * @param senderKey - base64-encoded curve25519 key of the sender
+ * @param sessionId - session identifier
+ * @param code - reason code
+ * @param reason - human-readable version of `code`
+ */
+ public async addInboundGroupSessionWithheld(
+ roomId: string,
+ senderKey: string,
+ sessionId: string,
+ code: string,
+ reason: string,
+ ): Promise<void> {
+ await this.cryptoStore.doTxn(
+ "readwrite",
+ [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD],
+ (txn) => {
+ this.cryptoStore.storeEndToEndInboundGroupSessionWithheld(
+ senderKey,
+ sessionId,
+ {
+ room_id: roomId,
+ code: code,
+ reason: reason,
+ },
+ txn,
+ );
+ },
+ );
+ }
+
+ /**
+ * Decrypt a received message with an inbound group session
+ *
+ * @param roomId - room in which the message was received
+ * @param senderKey - base64-encoded curve25519 key of the sender
+ * @param sessionId - session identifier
+ * @param body - base64-encoded body of the encrypted message
+ * @param eventId - ID of the event being decrypted
+ * @param timestamp - timestamp of the event being decrypted
+ *
+ * @returns null if the sessionId is unknown
+ */
+ public async decryptGroupMessage(
+ roomId: string,
+ senderKey: string,
+ sessionId: string,
+ body: string,
+ eventId: string,
+ timestamp: number,
+ ): Promise<IDecryptedGroupMessage | null> {
+ let result: IDecryptedGroupMessage | null = null;
+ // when the localstorage crypto store is used as an indexeddb backend,
+ // exceptions thrown from within the inner function are not passed through
+ // to the top level, so we store exceptions in a variable and raise them at
+ // the end
+ let error: Error;
+
+ await this.cryptoStore.doTxn(
+ "readwrite",
+ [
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD,
+ ],
+ (txn) => {
+ this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => {
+ if (session === null || sessionData === null) {
+ if (withheld) {
+ error = new algorithms.DecryptionError(
+ "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
+ calculateWithheldMessage(withheld),
+ {
+ session: senderKey + "|" + sessionId,
+ },
+ );
+ }
+ result = null;
+ return;
+ }
+ let res: ReturnType<InboundGroupSession["decrypt"]>;
+ try {
+ res = session.decrypt(body);
+ } catch (e) {
+ if ((<Error>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX" && withheld) {
+ error = new algorithms.DecryptionError(
+ "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
+ calculateWithheldMessage(withheld),
+ {
+ session: senderKey + "|" + sessionId,
+ },
+ );
+ } else {
+ error = <Error>e;
+ }
+ return;
+ }
+
+ let plaintext: string = res.plaintext;
+ if (plaintext === undefined) {
+ // @ts-ignore - Compatibility for older olm versions.
+ plaintext = res as string;
+ } else {
+ // Check if we have seen this message index before to detect replay attacks.
+ // If the event ID and timestamp are specified, and the match the event ID
+ // and timestamp from the last time we used this message index, then we
+ // don't consider it a replay attack.
+ const messageIndexKey = senderKey + "|" + sessionId + "|" + res.message_index;
+ if (messageIndexKey in this.inboundGroupSessionMessageIndexes) {
+ const msgInfo = this.inboundGroupSessionMessageIndexes[messageIndexKey];
+ if (msgInfo.id !== eventId || msgInfo.timestamp !== timestamp) {
+ error = new Error(
+ "Duplicate message index, possible replay attack: " + messageIndexKey,
+ );
+ return;
+ }
+ }
+ this.inboundGroupSessionMessageIndexes[messageIndexKey] = {
+ id: eventId,
+ timestamp: timestamp,
+ };
+ }
+
+ sessionData.session = session.pickle(this.pickleKey);
+ this.cryptoStore.storeEndToEndInboundGroupSession(senderKey, sessionId, sessionData, txn);
+ result = {
+ result: plaintext,
+ keysClaimed: sessionData.keysClaimed || {},
+ senderKey: senderKey,
+ forwardingCurve25519KeyChain: sessionData.forwardingCurve25519KeyChain || [],
+ untrusted: !!sessionData.untrusted,
+ };
+ });
+ },
+ logger.withPrefix("[decryptGroupMessage]"),
+ );
+
+ if (error!) {
+ throw error;
+ }
+ return result!;
+ }
+
+ /**
+ * Determine if we have the keys for a given megolm session
+ *
+ * @param roomId - room in which the message was received
+ * @param senderKey - base64-encoded curve25519 key of the sender
+ * @param sessionId - session identifier
+ *
+ * @returns true if we have the keys to this session
+ */
+ public async hasInboundSessionKeys(roomId: string, senderKey: string, sessionId: string): Promise<boolean> {
+ let result: boolean;
+ await this.cryptoStore.doTxn(
+ "readonly",
+ [
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD,
+ ],
+ (txn) => {
+ this.cryptoStore.getEndToEndInboundGroupSession(senderKey, sessionId, txn, (sessionData) => {
+ if (sessionData === null) {
+ result = false;
+ return;
+ }
+
+ if (roomId !== sessionData.room_id) {
+ logger.warn(
+ `requested keys for inbound group session ${senderKey}|` +
+ `${sessionId}, with incorrect room_id ` +
+ `(expected ${sessionData.room_id}, ` +
+ `was ${roomId})`,
+ );
+ result = false;
+ } else {
+ result = true;
+ }
+ });
+ },
+ logger.withPrefix("[hasInboundSessionKeys]"),
+ );
+
+ return result!;
+ }
+
+ /**
+ * Extract the keys to a given megolm session, for sharing
+ *
+ * @param roomId - room in which the message was received
+ * @param senderKey - base64-encoded curve25519 key of the sender
+ * @param sessionId - session identifier
+ * @param chainIndex - The chain index at which to export the session.
+ * If omitted, export at the first index we know about.
+ *
+ * @returns
+ * details of the session key. The key is a base64-encoded megolm key in
+ * export format.
+ *
+ * @throws Error If the given chain index could not be obtained from the known
+ * index (ie. the given chain index is before the first we have).
+ */
+ public async getInboundGroupSessionKey(
+ roomId: string,
+ senderKey: string,
+ sessionId: string,
+ chainIndex?: number,
+ ): Promise<IInboundGroupSessionKey | null> {
+ let result: IInboundGroupSessionKey | null = null;
+ await this.cryptoStore.doTxn(
+ "readonly",
+ [
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD,
+ ],
+ (txn) => {
+ this.getInboundGroupSession(roomId, senderKey, sessionId, txn, (session, sessionData) => {
+ if (session === null || sessionData === null) {
+ result = null;
+ return;
+ }
+
+ if (chainIndex === undefined) {
+ chainIndex = session.first_known_index();
+ }
+
+ const exportedSession = session.export_session(chainIndex);
+
+ const claimedKeys = sessionData.keysClaimed || {};
+ const senderEd25519Key = claimedKeys.ed25519 || null;
+
+ const forwardingKeyChain = sessionData.forwardingCurve25519KeyChain || [];
+ // older forwarded keys didn't set the "untrusted"
+ // property, but can be identified by having a
+ // non-empty forwarding key chain. These keys should
+ // be marked as untrusted since we don't know that they
+ // can be trusted
+ const untrusted =
+ "untrusted" in sessionData ? sessionData.untrusted : forwardingKeyChain.length > 0;
+
+ result = {
+ chain_index: chainIndex,
+ key: exportedSession,
+ forwarding_curve25519_key_chain: forwardingKeyChain,
+ sender_claimed_ed25519_key: senderEd25519Key,
+ shared_history: sessionData.sharedHistory || false,
+ untrusted: untrusted,
+ };
+ });
+ },
+ logger.withPrefix("[getInboundGroupSessionKey]"),
+ );
+
+ return result;
+ }
+
+ /**
+ * Export an inbound group session
+ *
+ * @param senderKey - base64-encoded curve25519 key of the sender
+ * @param sessionId - session identifier
+ * @param sessionData - The session object from the store
+ * @returns exported session data
+ */
+ public exportInboundGroupSession(
+ senderKey: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ ): IMegolmSessionData {
+ return this.unpickleInboundGroupSession(sessionData, (session) => {
+ const messageIndex = session.first_known_index();
+
+ return {
+ "sender_key": senderKey,
+ "sender_claimed_keys": sessionData.keysClaimed,
+ "room_id": sessionData.room_id,
+ "session_id": sessionId,
+ "session_key": session.export_session(messageIndex),
+ "forwarding_curve25519_key_chain": sessionData.forwardingCurve25519KeyChain || [],
+ "first_known_index": session.first_known_index(),
+ "org.matrix.msc3061.shared_history": sessionData.sharedHistory || false,
+ } as IMegolmSessionData;
+ });
+ }
+
+ public async getSharedHistoryInboundGroupSessions(
+ roomId: string,
+ ): Promise<[senderKey: string, sessionId: string][]> {
+ let result: Promise<[senderKey: string, sessionId: string][]>;
+ await this.cryptoStore.doTxn(
+ "readonly",
+ [IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS],
+ (txn) => {
+ result = this.cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn);
+ },
+ logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"),
+ );
+ return result!;
+ }
+
+ // Utilities
+ // =========
+
+ /**
+ * Verify an ed25519 signature.
+ *
+ * @param key - ed25519 key
+ * @param message - message which was signed
+ * @param signature - base64-encoded signature to be checked
+ *
+ * @throws Error if there is a problem with the verification. If the key was
+ * too small then the message will be "OLM.INVALID_BASE64". If the signature
+ * was invalid then the message will be "OLM.BAD_MESSAGE_MAC".
+ */
+ public verifySignature(key: string, message: string, signature: string): void {
+ this.getUtility(function (util: Utility) {
+ util.ed25519_verify(key, message, signature);
+ });
+ }
+}
+
+export const WITHHELD_MESSAGES: Record<string, string> = {
+ "m.unverified": "The sender has disabled encrypting to unverified devices.",
+ "m.blacklisted": "The sender has blocked you.",
+ "m.unauthorised": "You are not authorised to read the message.",
+ "m.no_olm": "Unable to establish a secure channel.",
+};
+
+/**
+ * Calculate the message to use for the exception when a session key is withheld.
+ *
+ * @param withheld - An object that describes why the key was withheld.
+ *
+ * @returns the message
+ *
+ * @internal
+ */
+function calculateWithheldMessage(withheld: IWithheld): string {
+ if (withheld.code && withheld.code in WITHHELD_MESSAGES) {
+ return WITHHELD_MESSAGES[withheld.code];
+ } else if (withheld.reason) {
+ return withheld.reason;
+ } else {
+ return "decryption key withheld";
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts
new file mode 100644
index 0000000..4628b3e
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/OutgoingRoomKeyRequestManager.ts
@@ -0,0 +1,485 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { v4 as uuidv4 } from "uuid";
+
+import { logger } from "../logger";
+import { MatrixClient } from "../client";
+import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "./index";
+import { CryptoStore, OutgoingRoomKeyRequest } from "./store/base";
+import { EventType, ToDeviceMessageId } from "../@types/event";
+import { MapWithDefault } from "../utils";
+
+/**
+ * Internal module. Management of outgoing room key requests.
+ *
+ * See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ
+ * for draft documentation on what we're supposed to be implementing here.
+ */
+
+// delay between deciding we want some keys, and sending out the request, to
+// allow for (a) it turning up anyway, (b) grouping requests together
+const SEND_KEY_REQUESTS_DELAY_MS = 500;
+
+/**
+ * possible states for a room key request
+ *
+ * The state machine looks like:
+ * ```
+ *
+ * | (cancellation sent)
+ * | .-------------------------------------------------.
+ * | | |
+ * V V (cancellation requested) |
+ * UNSENT -----------------------------+ |
+ * | | |
+ * | | |
+ * | (send successful) | CANCELLATION_PENDING_AND_WILL_RESEND
+ * V | Λ
+ * SENT | |
+ * |-------------------------------- | --------------'
+ * | | (cancellation requested with intent
+ * | | to resend the original request)
+ * | |
+ * | (cancellation requested) |
+ * V |
+ * CANCELLATION_PENDING |
+ * | |
+ * | (cancellation sent) |
+ * V |
+ * (deleted) <---------------------------+
+ * ```
+ */
+export enum RoomKeyRequestState {
+ /** request not yet sent */
+ Unsent,
+ /** request sent, awaiting reply */
+ Sent,
+ /** reply received, cancellation not yet sent */
+ CancellationPending,
+ /**
+ * Cancellation not yet sent and will transition to UNSENT instead of
+ * being deleted once the cancellation has been sent.
+ */
+ CancellationPendingAndWillResend,
+}
+
+interface RequestMessageBase {
+ requesting_device_id: string;
+ request_id: string;
+}
+
+interface RequestMessageRequest extends RequestMessageBase {
+ action: "request";
+ body: IRoomKeyRequestBody;
+}
+
+interface RequestMessageCancellation extends RequestMessageBase {
+ action: "request_cancellation";
+}
+
+type RequestMessage = RequestMessageRequest | RequestMessageCancellation;
+
+export class OutgoingRoomKeyRequestManager {
+ // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null
+ // if the callback has been set, or if it is still running.
+ private sendOutgoingRoomKeyRequestsTimer?: ReturnType<typeof setTimeout>;
+
+ // sanity check to ensure that we don't end up with two concurrent runs
+ // of sendOutgoingRoomKeyRequests
+ private sendOutgoingRoomKeyRequestsRunning = false;
+
+ private clientRunning = true;
+
+ public constructor(
+ private readonly baseApis: MatrixClient,
+ private readonly deviceId: string,
+ private readonly cryptoStore: CryptoStore,
+ ) {}
+
+ /**
+ * Called when the client is stopped. Stops any running background processes.
+ */
+ public stop(): void {
+ logger.log("stopping OutgoingRoomKeyRequestManager");
+ // stop the timer on the next run
+ this.clientRunning = false;
+ }
+
+ /**
+ * Send any requests that have been queued
+ */
+ public sendQueuedRequests(): void {
+ this.startTimer();
+ }
+
+ /**
+ * Queue up a room key request, if we haven't already queued or sent one.
+ *
+ * The `requestBody` is compared (with a deep-equality check) against
+ * previous queued or sent requests and if it matches, no change is made.
+ * Otherwise, a request is added to the pending list, and a job is started
+ * in the background to send it.
+ *
+ * @param resend - whether to resend the key request if there is
+ * already one
+ *
+ * @returns resolves when the request has been added to the
+ * pending list (or we have established that a similar request already
+ * exists)
+ */
+ public async queueRoomKeyRequest(
+ requestBody: IRoomKeyRequestBody,
+ recipients: IRoomKeyRequestRecipient[],
+ resend = false,
+ ): Promise<void> {
+ const req = await this.cryptoStore.getOutgoingRoomKeyRequest(requestBody);
+ if (!req) {
+ await this.cryptoStore.getOrAddOutgoingRoomKeyRequest({
+ requestBody: requestBody,
+ recipients: recipients,
+ requestId: this.baseApis.makeTxnId(),
+ state: RoomKeyRequestState.Unsent,
+ });
+ } else {
+ switch (req.state) {
+ case RoomKeyRequestState.CancellationPendingAndWillResend:
+ case RoomKeyRequestState.Unsent:
+ // nothing to do here, since we're going to send a request anyways
+ return;
+
+ case RoomKeyRequestState.CancellationPending: {
+ // existing request is about to be cancelled. If we want to
+ // resend, then change the state so that it resends after
+ // cancelling. Otherwise, just cancel the cancellation.
+ const state = resend
+ ? RoomKeyRequestState.CancellationPendingAndWillResend
+ : RoomKeyRequestState.Sent;
+ await this.cryptoStore.updateOutgoingRoomKeyRequest(
+ req.requestId,
+ RoomKeyRequestState.CancellationPending,
+ {
+ state,
+ cancellationTxnId: this.baseApis.makeTxnId(),
+ },
+ );
+ break;
+ }
+ case RoomKeyRequestState.Sent: {
+ // a request has already been sent. If we don't want to
+ // resend, then do nothing. If we do want to, then cancel the
+ // existing request and send a new one.
+ if (resend) {
+ const state = RoomKeyRequestState.CancellationPendingAndWillResend;
+ const updatedReq = await this.cryptoStore.updateOutgoingRoomKeyRequest(
+ req.requestId,
+ RoomKeyRequestState.Sent,
+ {
+ state,
+ cancellationTxnId: this.baseApis.makeTxnId(),
+ // need to use a new transaction ID so that
+ // the request gets sent
+ requestTxnId: this.baseApis.makeTxnId(),
+ },
+ );
+ if (!updatedReq) {
+ // updateOutgoingRoomKeyRequest couldn't find the request
+ // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have
+ // raced with another tab to mark the request cancelled.
+ // Try again, to make sure the request is resent.
+ return this.queueRoomKeyRequest(requestBody, recipients, resend);
+ }
+
+ // We don't want to wait for the timer, so we send it
+ // immediately. (We might actually end up racing with the timer,
+ // but that's ok: even if we make the request twice, we'll do it
+ // with the same transaction_id, so only one message will get
+ // sent).
+ //
+ // (We also don't want to wait for the response from the server
+ // here, as it will slow down processing of received keys if we
+ // do.)
+ try {
+ await this.sendOutgoingRoomKeyRequestCancellation(updatedReq, true);
+ } catch (e) {
+ logger.error("Error sending room key request cancellation;" + " will retry later.", e);
+ }
+ // The request has transitioned from
+ // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We
+ // still need to resend the request which is now UNSENT, so
+ // start the timer if it isn't already started.
+ }
+ break;
+ }
+ default:
+ throw new Error("unhandled state: " + req.state);
+ }
+ }
+ }
+
+ /**
+ * Cancel room key requests, if any match the given requestBody
+ *
+ *
+ * @returns resolves when the request has been updated in our
+ * pending list.
+ */
+ public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<unknown> {
+ return this.cryptoStore.getOutgoingRoomKeyRequest(requestBody).then((req): unknown => {
+ if (!req) {
+ // no request was made for this key
+ return;
+ }
+ switch (req.state) {
+ case RoomKeyRequestState.CancellationPending:
+ case RoomKeyRequestState.CancellationPendingAndWillResend:
+ // nothing to do here
+ return;
+
+ case RoomKeyRequestState.Unsent:
+ // just delete it
+
+ // FIXME: ghahah we may have attempted to send it, and
+ // not yet got a successful response. So the server
+ // may have seen it, so we still need to send a cancellation
+ // in that case :/
+
+ logger.log("deleting unnecessary room key request for " + stringifyRequestBody(requestBody));
+ return this.cryptoStore.deleteOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent);
+
+ case RoomKeyRequestState.Sent: {
+ // send a cancellation.
+ return this.cryptoStore
+ .updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Sent, {
+ state: RoomKeyRequestState.CancellationPending,
+ cancellationTxnId: this.baseApis.makeTxnId(),
+ })
+ .then((updatedReq) => {
+ if (!updatedReq) {
+ // updateOutgoingRoomKeyRequest couldn't find the
+ // request in state ROOM_KEY_REQUEST_STATES.SENT,
+ // so we must have raced with another tab to mark
+ // the request cancelled. There is no point in
+ // sending another cancellation since the other tab
+ // will do it.
+ logger.log(
+ "Tried to cancel room key request for " +
+ stringifyRequestBody(requestBody) +
+ " but it was already cancelled in another tab",
+ );
+ return;
+ }
+
+ // We don't want to wait for the timer, so we send it
+ // immediately. (We might actually end up racing with the timer,
+ // but that's ok: even if we make the request twice, we'll do it
+ // with the same transaction_id, so only one message will get
+ // sent).
+ //
+ // (We also don't want to wait for the response from the server
+ // here, as it will slow down processing of received keys if we
+ // do.)
+ this.sendOutgoingRoomKeyRequestCancellation(updatedReq).catch((e) => {
+ logger.error("Error sending room key request cancellation;" + " will retry later.", e);
+ this.startTimer();
+ });
+ });
+ }
+ default:
+ throw new Error("unhandled state: " + req.state);
+ }
+ });
+ }
+
+ /**
+ * Look for room key requests by target device and state
+ *
+ * @param userId - Target user ID
+ * @param deviceId - Target device ID
+ *
+ * @returns resolves to a list of all the {@link OutgoingRoomKeyRequest}
+ */
+ public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): Promise<OutgoingRoomKeyRequest[]> {
+ return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]);
+ }
+
+ /**
+ * Find anything in `sent` state, and kick it around the loop again.
+ * This is intended for situations where something substantial has changed, and we
+ * don't really expect the other end to even care about the cancellation.
+ * For example, after initialization or self-verification.
+ * @returns An array of `queueRoomKeyRequest` outputs.
+ */
+ public async cancelAndResendAllOutgoingRequests(): Promise<void[]> {
+ const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent);
+ return Promise.all(
+ outgoings.map(({ requestBody, recipients }) => this.queueRoomKeyRequest(requestBody, recipients, true)),
+ );
+ }
+
+ // start the background timer to send queued requests, if the timer isn't
+ // already running
+ private startTimer(): void {
+ if (this.sendOutgoingRoomKeyRequestsTimer) {
+ return;
+ }
+
+ const startSendingOutgoingRoomKeyRequests = (): void => {
+ if (this.sendOutgoingRoomKeyRequestsRunning) {
+ throw new Error("RoomKeyRequestSend already in progress!");
+ }
+ this.sendOutgoingRoomKeyRequestsRunning = true;
+
+ this.sendOutgoingRoomKeyRequests()
+ .finally(() => {
+ this.sendOutgoingRoomKeyRequestsRunning = false;
+ })
+ .catch((e) => {
+ // this should only happen if there is an indexeddb error,
+ // in which case we're a bit stuffed anyway.
+ logger.warn(`error in OutgoingRoomKeyRequestManager: ${e}`);
+ });
+ };
+
+ this.sendOutgoingRoomKeyRequestsTimer = setTimeout(
+ startSendingOutgoingRoomKeyRequests,
+ SEND_KEY_REQUESTS_DELAY_MS,
+ );
+ }
+
+ // look for and send any queued requests. Runs itself recursively until
+ // there are no more requests, or there is an error (in which case, the
+ // timer will be restarted before the promise resolves).
+ private async sendOutgoingRoomKeyRequests(): Promise<void> {
+ if (!this.clientRunning) {
+ this.sendOutgoingRoomKeyRequestsTimer = undefined;
+ return;
+ }
+
+ const req = await this.cryptoStore.getOutgoingRoomKeyRequestByState([
+ RoomKeyRequestState.CancellationPending,
+ RoomKeyRequestState.CancellationPendingAndWillResend,
+ RoomKeyRequestState.Unsent,
+ ]);
+
+ if (!req) {
+ this.sendOutgoingRoomKeyRequestsTimer = undefined;
+ return;
+ }
+
+ try {
+ switch (req.state) {
+ case RoomKeyRequestState.Unsent:
+ await this.sendOutgoingRoomKeyRequest(req);
+ break;
+ case RoomKeyRequestState.CancellationPending:
+ await this.sendOutgoingRoomKeyRequestCancellation(req);
+ break;
+ case RoomKeyRequestState.CancellationPendingAndWillResend:
+ await this.sendOutgoingRoomKeyRequestCancellation(req, true);
+ break;
+ }
+
+ // go around the loop again
+ return this.sendOutgoingRoomKeyRequests();
+ } catch (e) {
+ logger.error("Error sending room key request; will retry later.", e);
+ this.sendOutgoingRoomKeyRequestsTimer = undefined;
+ }
+ }
+
+ // given a RoomKeyRequest, send it and update the request record
+ private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise<unknown> {
+ logger.log(
+ `Requesting keys for ${stringifyRequestBody(req.requestBody)}` +
+ ` from ${stringifyRecipientList(req.recipients)}` +
+ `(id ${req.requestId})`,
+ );
+
+ const requestMessage: RequestMessage = {
+ action: "request",
+ requesting_device_id: this.deviceId,
+ request_id: req.requestId,
+ body: req.requestBody,
+ };
+
+ return this.sendMessageToDevices(requestMessage, req.recipients, req.requestTxnId || req.requestId).then(() => {
+ return this.cryptoStore.updateOutgoingRoomKeyRequest(req.requestId, RoomKeyRequestState.Unsent, {
+ state: RoomKeyRequestState.Sent,
+ });
+ });
+ }
+
+ // Given a RoomKeyRequest, cancel it and delete the request record unless
+ // andResend is set, in which case transition to UNSENT.
+ private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise<unknown> {
+ logger.log(
+ `Sending cancellation for key request for ` +
+ `${stringifyRequestBody(req.requestBody)} to ` +
+ `${stringifyRecipientList(req.recipients)} ` +
+ `(cancellation id ${req.cancellationTxnId})`,
+ );
+
+ const requestMessage: RequestMessage = {
+ action: "request_cancellation",
+ requesting_device_id: this.deviceId,
+ request_id: req.requestId,
+ };
+
+ return this.sendMessageToDevices(requestMessage, req.recipients, req.cancellationTxnId).then(() => {
+ if (andResend) {
+ // We want to resend, so transition to UNSENT
+ return this.cryptoStore.updateOutgoingRoomKeyRequest(
+ req.requestId,
+ RoomKeyRequestState.CancellationPendingAndWillResend,
+ { state: RoomKeyRequestState.Unsent },
+ );
+ }
+ return this.cryptoStore.deleteOutgoingRoomKeyRequest(
+ req.requestId,
+ RoomKeyRequestState.CancellationPending,
+ );
+ });
+ }
+
+ // send a RoomKeyRequest to a list of recipients
+ private sendMessageToDevices(
+ message: RequestMessage,
+ recipients: IRoomKeyRequestRecipient[],
+ txnId?: string,
+ ): Promise<{}> {
+ const contentMap = new MapWithDefault<string, Map<string, Record<string, any>>>(() => new Map());
+ for (const recip of recipients) {
+ const userDeviceMap = contentMap.getOrCreate(recip.userId);
+ userDeviceMap.set(recip.deviceId, {
+ ...message,
+ [ToDeviceMessageId]: uuidv4(),
+ });
+ }
+
+ return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId);
+ }
+}
+
+function stringifyRequestBody(requestBody: IRoomKeyRequestBody): string {
+ // we assume that the request is for megolm keys, which are identified by
+ // room id and session id
+ return requestBody.room_id + " / " + requestBody.session_id;
+}
+
+function stringifyRecipientList(recipients: IRoomKeyRequestRecipient[]): string {
+ return `[${recipients.map((r) => `${r.userId}:${r.deviceId}`).join(",")}]`;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts
new file mode 100644
index 0000000..a73efcd
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/RoomList.ts
@@ -0,0 +1,63 @@
+/*
+Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Manages the list of encrypted rooms
+ */
+
+import { CryptoStore } from "./store/base";
+import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
+
+/* eslint-disable camelcase */
+export interface IRoomEncryption {
+ algorithm: string;
+ rotation_period_ms?: number;
+ rotation_period_msgs?: number;
+}
+/* eslint-enable camelcase */
+
+export class RoomList {
+ // Object of roomId -> room e2e info object (body of the m.room.encryption event)
+ private roomEncryption: Record<string, IRoomEncryption> = {};
+
+ public constructor(private readonly cryptoStore?: CryptoStore) {}
+
+ public async init(): Promise<void> {
+ await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {
+ this.cryptoStore!.getEndToEndRooms(txn, (result) => {
+ this.roomEncryption = result;
+ });
+ });
+ }
+
+ public getRoomEncryption(roomId: string): IRoomEncryption {
+ return this.roomEncryption[roomId] || null;
+ }
+
+ public isRoomEncrypted(roomId: string): boolean {
+ return Boolean(this.getRoomEncryption(roomId));
+ }
+
+ public async setRoomEncryption(roomId: string, roomInfo: IRoomEncryption): Promise<void> {
+ // important that this happens before calling into the store
+ // as it prevents the Crypto::setRoomEncryption from calling
+ // this twice for consecutive m.room.encryption events
+ this.roomEncryption[roomId] = roomInfo;
+ await this.cryptoStore!.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ROOMS], (txn) => {
+ this.cryptoStore!.storeEndToEndRoom(roomId, roomInfo, txn);
+ });
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts
new file mode 100644
index 0000000..5c9049f
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/SecretStorage.ts
@@ -0,0 +1,583 @@
+/*
+Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { v4 as uuidv4 } from "uuid";
+
+import { logger } from "../logger";
+import * as olmlib from "./olmlib";
+import { randomString } from "../randomstring";
+import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
+import { ICryptoCallbacks, IEncryptedContent } from ".";
+import { IContent, MatrixEvent } from "../models/event";
+import { ClientEvent, ClientEventHandlerMap, MatrixClient } from "../client";
+import { IAddSecretStorageKeyOpts } from "./api";
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+import { defer, IDeferred } from "../utils";
+import { ToDeviceMessageId } from "../@types/event";
+import { SecretStorageKeyDescription, SecretStorageKeyDescriptionAesV1 } from "../secret-storage";
+
+export const SECRET_STORAGE_ALGORITHM_V1_AES = "m.secret_storage.v1.aes-hmac-sha2";
+
+// Some of the key functions use a tuple and some use an object...
+export type SecretStorageKeyTuple = [keyId: string, keyInfo: SecretStorageKeyDescription];
+export type SecretStorageKeyObject = { keyId: string; keyInfo: SecretStorageKeyDescription };
+
+export interface ISecretRequest {
+ requestId: string;
+ promise: Promise<string>;
+ cancel: (reason: string) => void;
+}
+
+export interface IAccountDataClient extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap> {
+ // Subset of MatrixClient (which also uses any for the event content)
+ getAccountDataFromServer: <T extends { [k: string]: any }>(eventType: string) => Promise<T>;
+ getAccountData: (eventType: string) => IContent | null;
+ setAccountData: (eventType: string, content: any) => Promise<{}>;
+}
+
+interface ISecretRequestInternal {
+ name: string;
+ devices: string[];
+ deferred: IDeferred<string>;
+}
+
+interface IDecryptors {
+ encrypt: (plaintext: string) => Promise<IEncryptedPayload>;
+ decrypt: (ciphertext: IEncryptedPayload) => Promise<string>;
+}
+
+interface ISecretInfo {
+ encrypted: {
+ [keyId: string]: IEncryptedPayload;
+ };
+}
+
+/**
+ * Implements Secure Secret Storage and Sharing (MSC1946)
+ */
+export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> {
+ private requests = new Map<string, ISecretRequestInternal>();
+
+ // In it's pure javascript days, this was relying on some proper Javascript-style
+ // type-abuse where sometimes we'd pass in a fake client object with just the account
+ // data methods implemented, which is all this class needs unless you use the secret
+ // sharing code, so it was fine. As a low-touch TypeScript migration, this now has
+ // an extra, optional param for a real matrix client, so you can not pass it as long
+ // as you don't request any secrets.
+ // A better solution would probably be to split this class up into secret storage and
+ // secret sharing which are really two separate things, even though they share an MSC.
+ public constructor(
+ private readonly accountDataAdapter: IAccountDataClient,
+ private readonly cryptoCallbacks: ICryptoCallbacks,
+ private readonly baseApis: B,
+ ) {}
+
+ public async getDefaultKeyId(): Promise<string | null> {
+ const defaultKey = await this.accountDataAdapter.getAccountDataFromServer<{ key: string }>(
+ "m.secret_storage.default_key",
+ );
+ if (!defaultKey) return null;
+ return defaultKey.key;
+ }
+
+ public setDefaultKeyId(keyId: string): Promise<void> {
+ return new Promise<void>((resolve, reject) => {
+ const listener = (ev: MatrixEvent): void => {
+ if (ev.getType() === "m.secret_storage.default_key" && ev.getContent().key === keyId) {
+ this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
+ resolve();
+ }
+ };
+ this.accountDataAdapter.on(ClientEvent.AccountData, listener);
+
+ this.accountDataAdapter.setAccountData("m.secret_storage.default_key", { key: keyId }).catch((e) => {
+ this.accountDataAdapter.removeListener(ClientEvent.AccountData, listener);
+ reject(e);
+ });
+ });
+ }
+
+ /**
+ * Add a key for encrypting secrets.
+ *
+ * @param algorithm - the algorithm used by the key.
+ * @param opts - the options for the algorithm. The properties used
+ * depend on the algorithm given.
+ * @param keyId - the ID of the key. If not given, a random
+ * ID will be generated.
+ *
+ * @returns An object with:
+ * keyId: the ID of the key
+ * keyInfo: details about the key (iv, mac, passphrase)
+ */
+ public async addKey(
+ algorithm: string,
+ opts: IAddSecretStorageKeyOpts = {},
+ keyId?: string,
+ ): Promise<SecretStorageKeyObject> {
+ if (algorithm !== SECRET_STORAGE_ALGORITHM_V1_AES) {
+ throw new Error(`Unknown key algorithm ${algorithm}`);
+ }
+
+ const keyInfo = { algorithm } as SecretStorageKeyDescriptionAesV1;
+
+ if (opts.name) {
+ keyInfo.name = opts.name;
+ }
+
+ if (opts.passphrase) {
+ keyInfo.passphrase = opts.passphrase;
+ }
+ if (opts.key) {
+ const { iv, mac } = await calculateKeyCheck(opts.key);
+ keyInfo.iv = iv;
+ keyInfo.mac = mac;
+ }
+
+ if (!keyId) {
+ do {
+ keyId = randomString(32);
+ } while (
+ await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
+ `m.secret_storage.key.${keyId}`,
+ )
+ );
+ }
+
+ await this.accountDataAdapter.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo);
+
+ return {
+ keyId,
+ keyInfo,
+ };
+ }
+
+ /**
+ * Get the key information for a given ID.
+ *
+ * @param keyId - The ID of the key to check
+ * for. Defaults to the default key ID if not provided.
+ * @returns If the key was found, the return value is an array of
+ * the form [keyId, keyInfo]. Otherwise, null is returned.
+ * XXX: why is this an array when addKey returns an object?
+ */
+ public async getKey(keyId?: string | null): Promise<SecretStorageKeyTuple | null> {
+ if (!keyId) {
+ keyId = await this.getDefaultKeyId();
+ }
+ if (!keyId) {
+ return null;
+ }
+
+ const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
+ "m.secret_storage.key." + keyId,
+ );
+ return keyInfo ? [keyId, keyInfo] : null;
+ }
+
+ /**
+ * Check whether we have a key with a given ID.
+ *
+ * @param keyId - The ID of the key to check
+ * for. Defaults to the default key ID if not provided.
+ * @returns Whether we have the key.
+ */
+ public async hasKey(keyId?: string): Promise<boolean> {
+ return Boolean(await this.getKey(keyId));
+ }
+
+ /**
+ * Check whether a key matches what we expect based on the key info
+ *
+ * @param key - the key to check
+ * @param info - the key info
+ *
+ * @returns whether or not the key matches
+ */
+ public async checkKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
+ if (info.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
+ if (info.mac) {
+ const { mac } = await calculateKeyCheck(key, info.iv);
+ return info.mac.replace(/=+$/g, "") === mac.replace(/=+$/g, "");
+ } else {
+ // if we have no information, we have to assume the key is right
+ return true;
+ }
+ } else {
+ throw new Error("Unknown algorithm");
+ }
+ }
+
+ /**
+ * Store an encrypted secret on the server
+ *
+ * @param name - The name of the secret
+ * @param secret - The secret contents.
+ * @param keys - The IDs of the keys to use to encrypt the secret
+ * or null/undefined to use the default key.
+ */
+ public async store(name: string, secret: string, keys?: string[] | null): Promise<void> {
+ const encrypted: Record<string, IEncryptedPayload> = {};
+
+ if (!keys) {
+ const defaultKeyId = await this.getDefaultKeyId();
+ if (!defaultKeyId) {
+ throw new Error("No keys specified and no default key present");
+ }
+ keys = [defaultKeyId];
+ }
+
+ if (keys.length === 0) {
+ throw new Error("Zero keys given to encrypt with!");
+ }
+
+ for (const keyId of keys) {
+ // get key information from key storage
+ const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
+ "m.secret_storage.key." + keyId,
+ );
+ if (!keyInfo) {
+ throw new Error("Unknown key: " + keyId);
+ }
+
+ // encrypt secret, based on the algorithm
+ if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
+ const keys = { [keyId]: keyInfo };
+ const [, encryption] = await this.getSecretStorageKey(keys, name);
+ encrypted[keyId] = await encryption.encrypt(secret);
+ } else {
+ logger.warn("unknown algorithm for secret storage key " + keyId + ": " + keyInfo.algorithm);
+ // do nothing if we don't understand the encryption algorithm
+ }
+ }
+
+ // save encrypted secret
+ await this.accountDataAdapter.setAccountData(name, { encrypted });
+ }
+
+ /**
+ * Get a secret from storage.
+ *
+ * @param name - the name of the secret
+ *
+ * @returns the contents of the secret
+ */
+ public async get(name: string): Promise<string | undefined> {
+ const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
+ if (!secretInfo) {
+ return;
+ }
+ if (!secretInfo.encrypted) {
+ throw new Error("Content is not encrypted!");
+ }
+
+ // get possible keys to decrypt
+ const keys: Record<string, SecretStorageKeyDescription> = {};
+ for (const keyId of Object.keys(secretInfo.encrypted)) {
+ // get key information from key storage
+ const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
+ "m.secret_storage.key." + keyId,
+ );
+ const encInfo = secretInfo.encrypted[keyId];
+ // only use keys we understand the encryption algorithm of
+ if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
+ if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
+ keys[keyId] = keyInfo;
+ }
+ }
+ }
+
+ if (Object.keys(keys).length === 0) {
+ throw new Error(
+ `Could not decrypt ${name} because none of ` +
+ `the keys it is encrypted with are for a supported algorithm`,
+ );
+ }
+
+ // fetch private key from app
+ const [keyId, decryption] = await this.getSecretStorageKey(keys, name);
+ const encInfo = secretInfo.encrypted[keyId];
+
+ return decryption.decrypt(encInfo);
+ }
+
+ /**
+ * Check if a secret is stored on the server.
+ *
+ * @param name - the name of the secret
+ *
+ * @returns map of key name to key info the secret is encrypted
+ * with, or null if it is not present or not encrypted with a trusted
+ * key
+ */
+ public async isStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
+ // check if secret exists
+ const secretInfo = await this.accountDataAdapter.getAccountDataFromServer<ISecretInfo>(name);
+ if (!secretInfo?.encrypted) return null;
+
+ const ret: Record<string, SecretStorageKeyDescription> = {};
+
+ // filter secret encryption keys with supported algorithm
+ for (const keyId of Object.keys(secretInfo.encrypted)) {
+ // get key information from key storage
+ const keyInfo = await this.accountDataAdapter.getAccountDataFromServer<SecretStorageKeyDescription>(
+ "m.secret_storage.key." + keyId,
+ );
+ if (!keyInfo) continue;
+ const encInfo = secretInfo.encrypted[keyId];
+
+ // only use keys we understand the encryption algorithm of
+ if (keyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
+ if (encInfo.iv && encInfo.ciphertext && encInfo.mac) {
+ ret[keyId] = keyInfo;
+ }
+ }
+ }
+ return Object.keys(ret).length ? ret : null;
+ }
+
+ /**
+ * Request a secret from another device
+ *
+ * @param name - the name of the secret to request
+ * @param devices - the devices to request the secret from
+ */
+ public request(this: SecretStorage<MatrixClient>, name: string, devices: string[]): ISecretRequest {
+ const requestId = this.baseApis.makeTxnId();
+
+ const deferred = defer<string>();
+ this.requests.set(requestId, { name, devices, deferred });
+
+ const cancel = (reason: string): void => {
+ // send cancellation event
+ const cancelData = {
+ action: "request_cancellation",
+ requesting_device_id: this.baseApis.deviceId,
+ request_id: requestId,
+ };
+ const toDevice: Map<string, typeof cancelData> = new Map();
+ for (const device of devices) {
+ toDevice.set(device, cancelData);
+ }
+ this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
+
+ // and reject the promise so that anyone waiting on it will be
+ // notified
+ deferred.reject(new Error(reason || "Cancelled"));
+ };
+
+ // send request to devices
+ const requestData = {
+ name,
+ action: "request",
+ requesting_device_id: this.baseApis.deviceId,
+ request_id: requestId,
+ [ToDeviceMessageId]: uuidv4(),
+ };
+ const toDevice: Map<string, typeof requestData> = new Map();
+ for (const device of devices) {
+ toDevice.set(device, requestData);
+ }
+ logger.info(`Request secret ${name} from ${devices}, id ${requestId}`);
+ this.baseApis.sendToDevice("m.secret.request", new Map([[this.baseApis.getUserId()!, toDevice]]));
+
+ return {
+ requestId,
+ promise: deferred.promise,
+ cancel,
+ };
+ }
+
+ public async onRequestReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): Promise<void> {
+ const sender = event.getSender();
+ const content = event.getContent();
+ if (
+ sender !== this.baseApis.getUserId() ||
+ !(content.name && content.action && content.requesting_device_id && content.request_id)
+ ) {
+ // ignore requests from anyone else, for now
+ return;
+ }
+ const deviceId = content.requesting_device_id;
+ // check if it's a cancel
+ if (content.action === "request_cancellation") {
+ /*
+ Looks like we intended to emit events when we got cancelations, but
+ we never put anything in the _incomingRequests object, and the request
+ itself doesn't use events anyway so if we were to wire up cancellations,
+ they probably ought to use the same callback interface. I'm leaving them
+ disabled for now while converting this file to typescript.
+ if (this._incomingRequests[deviceId]
+ && this._incomingRequests[deviceId][content.request_id]) {
+ logger.info(
+ "received request cancellation for secret (" + sender +
+ ", " + deviceId + ", " + content.request_id + ")",
+ );
+ this.baseApis.emit("crypto.secrets.requestCancelled", {
+ user_id: sender,
+ device_id: deviceId,
+ request_id: content.request_id,
+ });
+ }
+ */
+ } else if (content.action === "request") {
+ if (deviceId === this.baseApis.deviceId) {
+ // no point in trying to send ourself the secret
+ return;
+ }
+
+ // check if we have the secret
+ logger.info("received request for secret (" + sender + ", " + deviceId + ", " + content.request_id + ")");
+ if (!this.cryptoCallbacks.onSecretRequested) {
+ return;
+ }
+ const secret = await this.cryptoCallbacks.onSecretRequested(
+ sender,
+ deviceId,
+ content.request_id,
+ content.name,
+ this.baseApis.checkDeviceTrust(sender, deviceId),
+ );
+ if (secret) {
+ logger.info(`Preparing ${content.name} secret for ${deviceId}`);
+ const payload = {
+ type: "m.secret.send",
+ content: {
+ request_id: content.request_id,
+ secret: secret,
+ },
+ };
+ const encryptedContent: IEncryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.baseApis.crypto!.olmDevice.deviceCurve25519Key!,
+ ciphertext: {},
+ [ToDeviceMessageId]: uuidv4(),
+ };
+ await olmlib.ensureOlmSessionsForDevices(
+ this.baseApis.crypto!.olmDevice,
+ this.baseApis,
+ new Map([[sender, [this.baseApis.getStoredDevice(sender, deviceId)!]]]),
+ );
+ await olmlib.encryptMessageForDevice(
+ encryptedContent.ciphertext,
+ this.baseApis.getUserId()!,
+ this.baseApis.deviceId!,
+ this.baseApis.crypto!.olmDevice,
+ sender,
+ this.baseApis.getStoredDevice(sender, deviceId)!,
+ payload,
+ );
+ const contentMap = new Map([[sender, new Map([[deviceId, encryptedContent]])]]);
+
+ logger.info(`Sending ${content.name} secret for ${deviceId}`);
+ this.baseApis.sendToDevice("m.room.encrypted", contentMap);
+ } else {
+ logger.info(`Request denied for ${content.name} secret for ${deviceId}`);
+ }
+ }
+ }
+
+ public onSecretReceived(this: SecretStorage<MatrixClient>, event: MatrixEvent): void {
+ if (event.getSender() !== this.baseApis.getUserId()) {
+ // we shouldn't be receiving secrets from anyone else, so ignore
+ // because someone could be trying to send us bogus data
+ return;
+ }
+
+ if (!olmlib.isOlmEncrypted(event)) {
+ logger.error("secret event not properly encrypted");
+ return;
+ }
+
+ const content = event.getContent();
+
+ const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey(
+ olmlib.OLM_ALGORITHM,
+ event.getSenderKey() || "",
+ );
+ if (senderKeyUser !== event.getSender()) {
+ logger.error("sending device does not belong to the user it claims to be from");
+ return;
+ }
+
+ logger.log("got secret share for request", content.request_id);
+ const requestControl = this.requests.get(content.request_id);
+ if (requestControl) {
+ // make sure that the device that sent it is one of the devices that
+ // we requested from
+ const deviceInfo = this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(
+ olmlib.OLM_ALGORITHM,
+ event.getSenderKey()!,
+ );
+ if (!deviceInfo) {
+ logger.log("secret share from unknown device with key", event.getSenderKey());
+ return;
+ }
+ if (!requestControl.devices.includes(deviceInfo.deviceId)) {
+ logger.log("unsolicited secret share from device", deviceInfo.deviceId);
+ return;
+ }
+ // unsure that the sender is trusted. In theory, this check is
+ // unnecessary since we only accept secret shares from devices that
+ // we requested from, but it doesn't hurt.
+ const deviceTrust = this.baseApis.crypto!.checkDeviceInfoTrust(event.getSender()!, deviceInfo);
+ if (!deviceTrust.isVerified()) {
+ logger.log("secret share from unverified device");
+ return;
+ }
+
+ logger.log(`Successfully received secret ${requestControl.name} ` + `from ${deviceInfo.deviceId}`);
+ requestControl.deferred.resolve(content.secret);
+ }
+ }
+
+ private async getSecretStorageKey(
+ keys: Record<string, SecretStorageKeyDescription>,
+ name: string,
+ ): Promise<[string, IDecryptors]> {
+ if (!this.cryptoCallbacks.getSecretStorageKey) {
+ throw new Error("No getSecretStorageKey callback supplied");
+ }
+
+ const returned = await this.cryptoCallbacks.getSecretStorageKey({ keys }, name);
+
+ if (!returned) {
+ throw new Error("getSecretStorageKey callback returned falsey");
+ }
+ if (returned.length < 2) {
+ throw new Error("getSecretStorageKey callback returned invalid data");
+ }
+
+ const [keyId, privateKey] = returned;
+ if (!keys[keyId]) {
+ throw new Error("App returned unknown key from getSecretStorageKey!");
+ }
+
+ if (keys[keyId].algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
+ const decryption = {
+ encrypt: function (secret: string): Promise<IEncryptedPayload> {
+ return encryptAES(secret, privateKey, name);
+ },
+ decrypt: function (encInfo: IEncryptedPayload): Promise<string> {
+ return decryptAES(encInfo, privateKey, name);
+ },
+ };
+ return [keyId, decryption];
+ } else {
+ throw new Error("Unknown key type: " + keys[keyId].algorithm);
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts
new file mode 100644
index 0000000..48470af
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/aes.ts
@@ -0,0 +1,157 @@
+/*
+Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { decodeBase64, encodeBase64 } from "./olmlib";
+import { subtleCrypto, crypto, TextEncoder } from "./crypto";
+
+// salt for HKDF, with 8 bytes of zeros
+const zeroSalt = new Uint8Array(8);
+
+export interface IEncryptedPayload {
+ [key: string]: any; // extensible
+ /** the initialization vector in base64 */
+ iv: string;
+ /** the ciphertext in base64 */
+ ciphertext: string;
+ /** the HMAC in base64 */
+ mac: string;
+}
+
+/**
+ * encrypt a string
+ *
+ * @param data - the plaintext to encrypt
+ * @param key - the encryption key to use
+ * @param name - the name of the secret
+ * @param ivStr - the initialization vector to use
+ */
+export async function encryptAES(
+ data: string,
+ key: Uint8Array,
+ name: string,
+ ivStr?: string,
+): Promise<IEncryptedPayload> {
+ let iv: Uint8Array;
+ if (ivStr) {
+ iv = decodeBase64(ivStr);
+ } else {
+ iv = new Uint8Array(16);
+ crypto.getRandomValues(iv);
+
+ // clear bit 63 of the IV to stop us hitting the 64-bit counter boundary
+ // (which would mean we wouldn't be able to decrypt on Android). The loss
+ // of a single bit of iv is a price we have to pay.
+ iv[8] &= 0x7f;
+ }
+
+ const [aesKey, hmacKey] = await deriveKeys(key, name);
+ const encodedData = new TextEncoder().encode(data);
+
+ const ciphertext = await subtleCrypto.encrypt(
+ {
+ name: "AES-CTR",
+ counter: iv,
+ length: 64,
+ },
+ aesKey,
+ encodedData,
+ );
+
+ const hmac = await subtleCrypto.sign({ name: "HMAC" }, hmacKey, ciphertext);
+
+ return {
+ iv: encodeBase64(iv),
+ ciphertext: encodeBase64(ciphertext),
+ mac: encodeBase64(hmac),
+ };
+}
+
+/**
+ * decrypt a string
+ *
+ * @param data - the encrypted data
+ * @param key - the encryption key to use
+ * @param name - the name of the secret
+ */
+export async function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: string): Promise<string> {
+ const [aesKey, hmacKey] = await deriveKeys(key, name);
+
+ const ciphertext = decodeBase64(data.ciphertext);
+
+ if (!(await subtleCrypto.verify({ name: "HMAC" }, hmacKey, decodeBase64(data.mac), ciphertext))) {
+ throw new Error(`Error decrypting secret ${name}: bad MAC`);
+ }
+
+ const plaintext = await subtleCrypto.decrypt(
+ {
+ name: "AES-CTR",
+ counter: decodeBase64(data.iv),
+ length: 64,
+ },
+ aesKey,
+ ciphertext,
+ );
+
+ return new TextDecoder().decode(new Uint8Array(plaintext));
+}
+
+async function deriveKeys(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> {
+ const hkdfkey = await subtleCrypto.importKey("raw", key, { name: "HKDF" }, false, ["deriveBits"]);
+ const keybits = await subtleCrypto.deriveBits(
+ {
+ name: "HKDF",
+ salt: zeroSalt,
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879
+ info: new TextEncoder().encode(name),
+ hash: "SHA-256",
+ },
+ hkdfkey,
+ 512,
+ );
+
+ const aesKey = keybits.slice(0, 32);
+ const hmacKey = keybits.slice(32);
+
+ const aesProm = subtleCrypto.importKey("raw", aesKey, { name: "AES-CTR" }, false, ["encrypt", "decrypt"]);
+
+ const hmacProm = subtleCrypto.importKey(
+ "raw",
+ hmacKey,
+ {
+ name: "HMAC",
+ hash: { name: "SHA-256" },
+ },
+ false,
+ ["sign", "verify"],
+ );
+
+ return Promise.all([aesProm, hmacProm]);
+}
+
+// string of zeroes, for calculating the key check
+const ZERO_STR = "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";
+
+/** Calculate the MAC for checking the key.
+ *
+ * @param key - the key to use
+ * @param iv - The initialization vector as a base64-encoded string.
+ * If omitted, a random initialization vector will be created.
+ * @returns An object that contains, `mac` and `iv` properties.
+ */
+export function calculateKeyCheck(key: Uint8Array, iv?: string): Promise<IEncryptedPayload> {
+ return encryptAES(ZERO_STR, key, "", iv);
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts
new file mode 100644
index 0000000..6473009
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/base.ts
@@ -0,0 +1,268 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Internal module. Defines the base classes of the encryption implementations
+ */
+
+import type { IMegolmSessionData } from "../../@types/crypto";
+import { MatrixClient } from "../../client";
+import { Room } from "../../models/room";
+import { OlmDevice } from "../OlmDevice";
+import { IContent, MatrixEvent, RoomMember } from "../../matrix";
+import { Crypto, IEncryptedContent, IEventDecryptionResult, IncomingRoomKeyRequest } from "..";
+import { DeviceInfo } from "../deviceinfo";
+import { IRoomEncryption } from "../RoomList";
+import { DeviceInfoMap } from "../DeviceList";
+
+/**
+ * Map of registered encryption algorithm classes. A map from string to {@link EncryptionAlgorithm} class
+ */
+export const ENCRYPTION_CLASSES = new Map<string, new (params: IParams) => EncryptionAlgorithm>();
+
+export type DecryptionClassParams<P extends IParams = IParams> = Omit<P, "deviceId" | "config">;
+
+/**
+ * map of registered encryption algorithm classes. Map from string to {@link DecryptionAlgorithm} class
+ */
+export const DECRYPTION_CLASSES = new Map<string, new (params: DecryptionClassParams) => DecryptionAlgorithm>();
+
+export interface IParams {
+ /** The UserID for the local user */
+ userId: string;
+ /** The identifier for this device. */
+ deviceId: string;
+ /** crypto core */
+ crypto: Crypto;
+ /** olm.js wrapper */
+ olmDevice: OlmDevice;
+ /** base matrix api interface */
+ baseApis: MatrixClient;
+ /** The ID of the room we will be sending to */
+ roomId?: string;
+ /** The body of the m.room.encryption event */
+ config: IRoomEncryption & object;
+}
+
+/**
+ * base type for encryption implementations
+ */
+export abstract class EncryptionAlgorithm {
+ protected readonly userId: string;
+ protected readonly deviceId: string;
+ protected readonly crypto: Crypto;
+ protected readonly olmDevice: OlmDevice;
+ protected readonly baseApis: MatrixClient;
+ protected readonly roomId?: string;
+
+ /**
+ * @param params - parameters
+ */
+ public constructor(params: IParams) {
+ this.userId = params.userId;
+ this.deviceId = params.deviceId;
+ this.crypto = params.crypto;
+ this.olmDevice = params.olmDevice;
+ this.baseApis = params.baseApis;
+ this.roomId = params.roomId;
+ }
+
+ /**
+ * Perform any background tasks that can be done before a message is ready to
+ * send, in order to speed up sending of the message.
+ *
+ * @param room - the room the event is in
+ */
+ public prepareToEncrypt(room: Room): void {}
+
+ /**
+ * Encrypt a message event
+ *
+ * @public
+ *
+ * @param content - event content
+ *
+ * @returns Promise which resolves to the new event body
+ */
+ public abstract encryptMessage(room: Room, eventType: string, content: IContent): Promise<IEncryptedContent>;
+
+ /**
+ * Called when the membership of a member of the room changes.
+ *
+ * @param event - event causing the change
+ * @param member - user whose membership changed
+ * @param oldMembership - previous membership
+ * @public
+ */
+ public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {}
+
+ public reshareKeyWithDevice?(
+ senderKey: string,
+ sessionId: string,
+ userId: string,
+ device: DeviceInfo,
+ ): Promise<void>;
+
+ public forceDiscardSession?(): void;
+}
+
+/**
+ * base type for decryption implementations
+ */
+export abstract class DecryptionAlgorithm {
+ protected readonly userId: string;
+ protected readonly crypto: Crypto;
+ protected readonly olmDevice: OlmDevice;
+ protected readonly baseApis: MatrixClient;
+ protected readonly roomId?: string;
+
+ public constructor(params: DecryptionClassParams) {
+ this.userId = params.userId;
+ this.crypto = params.crypto;
+ this.olmDevice = params.olmDevice;
+ this.baseApis = params.baseApis;
+ this.roomId = params.roomId;
+ }
+
+ /**
+ * Decrypt an event
+ *
+ * @param event - undecrypted event
+ *
+ * @returns promise which
+ * resolves once we have finished decrypting. Rejects with an
+ * `algorithms.DecryptionError` if there is a problem decrypting the event.
+ */
+ public abstract decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult>;
+
+ /**
+ * Handle a key event
+ *
+ * @param params - event key event
+ */
+ public async onRoomKeyEvent(params: MatrixEvent): Promise<void> {
+ // ignore by default
+ }
+
+ /**
+ * Import a room key
+ *
+ * @param opts - object
+ */
+ public async importRoomKey(session: IMegolmSessionData, opts: object): Promise<void> {
+ // ignore by default
+ }
+
+ /**
+ * Determine if we have the keys necessary to respond to a room key request
+ *
+ * @returns true if we have the keys and could (theoretically) share
+ * them; else false.
+ */
+ public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> {
+ return Promise.resolve(false);
+ }
+
+ /**
+ * Send the response to a room key request
+ *
+ */
+ public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void {
+ throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm");
+ }
+
+ /**
+ * Retry decrypting all the events from a sender that haven't been
+ * decrypted yet.
+ *
+ * @param senderKey - the sender's key
+ */
+ public async retryDecryptionFromSender(senderKey: string): Promise<boolean> {
+ // ignore by default
+ return false;
+ }
+
+ public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise<void>;
+ public sendSharedHistoryInboundSessions?(devicesByUser: Map<string, DeviceInfo[]>): Promise<void>;
+}
+
+/**
+ * Exception thrown when decryption fails
+ *
+ * @param msg - user-visible message describing the problem
+ *
+ * @param details - key/value pairs reported in the logs but not shown
+ * to the user.
+ */
+export class DecryptionError extends Error {
+ public readonly detailedString: string;
+
+ public constructor(public readonly code: string, msg: string, details?: Record<string, string | Error>) {
+ super(msg);
+ this.code = code;
+ this.name = "DecryptionError";
+ this.detailedString = detailedStringForDecryptionError(this, details);
+ }
+}
+
+function detailedStringForDecryptionError(err: DecryptionError, details?: Record<string, string | Error>): string {
+ let result = err.name + "[msg: " + err.message;
+
+ if (details) {
+ result +=
+ ", " +
+ Object.keys(details)
+ .map((k) => k + ": " + details[k])
+ .join(", ");
+ }
+
+ result += "]";
+
+ return result;
+}
+
+export class UnknownDeviceError extends Error {
+ /**
+ * Exception thrown specifically when we want to warn the user to consider
+ * the security of their conversation before continuing
+ *
+ * @param msg - message describing the problem
+ * @param devices - set of unknown devices per user we're warning about
+ */
+ public constructor(msg: string, public readonly devices: DeviceInfoMap, public event?: MatrixEvent) {
+ super(msg);
+ this.name = "UnknownDeviceError";
+ this.devices = devices;
+ }
+}
+
+/**
+ * Registers an encryption/decryption class for a particular algorithm
+ *
+ * @param algorithm - algorithm tag to register for
+ *
+ * @param encryptor - {@link EncryptionAlgorithm} implementation
+ *
+ * @param decryptor - {@link DecryptionAlgorithm} implementation
+ */
+export function registerAlgorithm<P extends IParams = IParams>(
+ algorithm: string,
+ encryptor: new (params: P) => EncryptionAlgorithm,
+ decryptor: new (params: DecryptionClassParams<P>) => DecryptionAlgorithm,
+): void {
+ ENCRYPTION_CLASSES.set(algorithm, encryptor as new (params: IParams) => EncryptionAlgorithm);
+ DECRYPTION_CLASSES.set(algorithm, decryptor as new (params: DecryptionClassParams) => DecryptionAlgorithm);
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts
new file mode 100644
index 0000000..b3c5b0e
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/index.ts
@@ -0,0 +1,20 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import "./olm";
+import "./megolm";
+
+export * from "./base";
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts
new file mode 100644
index 0000000..061e169
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/megolm.ts
@@ -0,0 +1,2208 @@
+/*
+Copyright 2015 - 2021, 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Defines m.olm encryption/decryption
+ */
+
+import { v4 as uuidv4 } from "uuid";
+
+import type { IEventDecryptionResult, IMegolmSessionData } from "../../@types/crypto";
+import { logger, PrefixedLogger } from "../../logger";
+import * as olmlib from "../olmlib";
+import {
+ DecryptionAlgorithm,
+ DecryptionClassParams,
+ DecryptionError,
+ EncryptionAlgorithm,
+ IParams,
+ registerAlgorithm,
+ UnknownDeviceError,
+} from "./base";
+import { IDecryptedGroupMessage, WITHHELD_MESSAGES } from "../OlmDevice";
+import { Room } from "../../models/room";
+import { DeviceInfo } from "../deviceinfo";
+import { IOlmSessionResult } from "../olmlib";
+import { DeviceInfoMap } from "../DeviceList";
+import { IContent, MatrixEvent } from "../../models/event";
+import { EventType, MsgType, ToDeviceMessageId } from "../../@types/event";
+import { IMegolmEncryptedContent, IncomingRoomKeyRequest, IEncryptedContent } from "../index";
+import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
+import { OlmGroupSessionExtraData } from "../../@types/crypto";
+import { MatrixError } from "../../http-api";
+import { immediate, MapWithDefault } from "../../utils";
+
+// determine whether the key can be shared with invitees
+export function isRoomSharedHistory(room: Room): boolean {
+ const visibilityEvent = room?.currentState?.getStateEvents("m.room.history_visibility", "");
+ // NOTE: if the room visibility is unset, it would normally default to
+ // "world_readable".
+ // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5)
+ // But we will be paranoid here, and treat it as a situation where the room
+ // is not shared-history
+ const visibility = visibilityEvent?.getContent()?.history_visibility;
+ return ["world_readable", "shared"].includes(visibility);
+}
+
+interface IBlockedDevice {
+ code: string;
+ reason: string;
+ deviceInfo: DeviceInfo;
+}
+
+// map user Id → device Id → IBlockedDevice
+type BlockedMap = Map<string, Map<string, IBlockedDevice>>;
+
+export interface IOlmDevice<T = DeviceInfo> {
+ userId: string;
+ deviceInfo: T;
+}
+
+/**
+ * Tests whether an encrypted content has a ciphertext.
+ * Ciphertext can be a string or object depending on the content type {@link IEncryptedContent}.
+ *
+ * @param content - Encrypted content
+ * @returns true: has ciphertext, else false
+ */
+const hasCiphertext = (content: IEncryptedContent): boolean => {
+ return typeof content.ciphertext === "string"
+ ? !!content.ciphertext.length
+ : !!Object.keys(content.ciphertext).length;
+};
+
+/** The result of parsing the an `m.room_key` or `m.forwarded_room_key` to-device event */
+interface RoomKey {
+ /**
+ * The Curve25519 key of the megolm session creator.
+ *
+ * For `m.room_key`, this is also the sender of the `m.room_key` to-device event.
+ * For `m.forwarded_room_key`, the two are different (and the key of the sender of the
+ * `m.forwarded_room_key` event is included in `forwardingKeyChain`)
+ */
+ senderKey: string;
+ sessionId: string;
+ sessionKey: string;
+ exportFormat: boolean;
+ roomId: string;
+ algorithm: string;
+ /**
+ * A list of the curve25519 keys of the users involved in forwarding this key, most recent last.
+ * For `m.room_key` events, this is empty.
+ */
+ forwardingKeyChain: string[];
+ keysClaimed: Partial<Record<"ed25519", string>>;
+ extraSessionData: OlmGroupSessionExtraData;
+}
+
+export interface IOutboundGroupSessionKey {
+ chain_index: number;
+ key: string;
+}
+
+interface IMessage {
+ type: string;
+ content: {
+ "algorithm": string;
+ "room_id": string;
+ "sender_key"?: string;
+ "sender_claimed_ed25519_key"?: string;
+ "session_id": string;
+ "session_key": string;
+ "chain_index": number;
+ "forwarding_curve25519_key_chain"?: string[];
+ "org.matrix.msc3061.shared_history": boolean;
+ };
+}
+
+interface IKeyForwardingMessage extends IMessage {
+ type: "m.forwarded_room_key";
+}
+
+interface IPayload extends Partial<IMessage> {
+ code?: string;
+ reason?: string;
+ room_id?: string;
+ session_id?: string;
+ algorithm?: string;
+ sender_key?: string;
+}
+
+interface SharedWithData {
+ // The identity key of the device we shared with
+ deviceKey: string;
+ // The message index of the ratchet we shared with that device
+ messageIndex: number;
+}
+
+/**
+ * @internal
+ */
+class OutboundSessionInfo {
+ /** number of times this session has been used */
+ public useCount = 0;
+ /** when the session was created (ms since the epoch) */
+ public creationTime: number;
+ /** devices with which we have shared the session key `userId -> {deviceId -> SharedWithData}` */
+ public sharedWithDevices: MapWithDefault<string, Map<string, SharedWithData>> = new MapWithDefault(() => new Map());
+ public blockedDevicesNotified: MapWithDefault<string, Map<string, boolean>> = new MapWithDefault(() => new Map());
+
+ /**
+ * @param sharedHistory - whether the session can be freely shared with
+ * other group members, according to the room history visibility settings
+ */
+ public constructor(public readonly sessionId: string, public readonly sharedHistory = false) {
+ this.creationTime = new Date().getTime();
+ }
+
+ /**
+ * Check if it's time to rotate the session
+ */
+ public needsRotation(rotationPeriodMsgs: number, rotationPeriodMs: number): boolean {
+ const sessionLifetime = new Date().getTime() - this.creationTime;
+
+ if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) {
+ logger.log("Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms");
+ return true;
+ }
+
+ return false;
+ }
+
+ public markSharedWithDevice(userId: string, deviceId: string, deviceKey: string, chainIndex: number): void {
+ this.sharedWithDevices.getOrCreate(userId).set(deviceId, { deviceKey, messageIndex: chainIndex });
+ }
+
+ public markNotifiedBlockedDevice(userId: string, deviceId: string): void {
+ this.blockedDevicesNotified.getOrCreate(userId).set(deviceId, true);
+ }
+
+ /**
+ * Determine if this session has been shared with devices which it shouldn't
+ * have been.
+ *
+ * @param devicesInRoom - `userId -> {deviceId -> object}`
+ * devices we should shared the session with.
+ *
+ * @returns true if we have shared the session with devices which aren't
+ * in devicesInRoom.
+ */
+ public sharedWithTooManyDevices(devicesInRoom: DeviceInfoMap): boolean {
+ for (const [userId, devices] of this.sharedWithDevices) {
+ if (!devicesInRoom.has(userId)) {
+ logger.log("Starting new megolm session because we shared with " + userId);
+ return true;
+ }
+
+ for (const [deviceId] of devices) {
+ if (!devicesInRoom.get(userId)?.get(deviceId)) {
+ logger.log("Starting new megolm session because we shared with " + userId + ":" + deviceId);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+}
+
+/**
+ * Megolm encryption implementation
+ *
+ * @param params - parameters, as per {@link EncryptionAlgorithm}
+ */
+export class MegolmEncryption extends EncryptionAlgorithm {
+ // the most recent attempt to set up a session. This is used to serialise
+ // the session setups, so that we have a race-free view of which session we
+ // are using, and which devices we have shared the keys with. It resolves
+ // with an OutboundSessionInfo (or undefined, for the first message in the
+ // room).
+ private setupPromise = Promise.resolve<OutboundSessionInfo | null>(null);
+
+ // Map of outbound sessions by sessions ID. Used if we need a particular
+ // session (the session we're currently using to send is always obtained
+ // using setupPromise).
+ private outboundSessions: Record<string, OutboundSessionInfo> = {};
+
+ private readonly sessionRotationPeriodMsgs: number;
+ private readonly sessionRotationPeriodMs: number;
+ private encryptionPreparation?: {
+ promise: Promise<void>;
+ startTime: number;
+ cancel: () => void;
+ };
+
+ protected readonly roomId: string;
+ private readonly prefixedLogger: PrefixedLogger;
+
+ public constructor(params: IParams & Required<Pick<IParams, "roomId">>) {
+ super(params);
+ this.roomId = params.roomId;
+ this.prefixedLogger = logger.withPrefix(`[${this.roomId} encryption]`);
+
+ this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100;
+ this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000;
+ }
+
+ /**
+ * @internal
+ *
+ * @param devicesInRoom - The devices in this room, indexed by user ID
+ * @param blocked - The devices that are blocked, indexed by user ID
+ * @param singleOlmCreationPhase - Only perform one round of olm
+ * session creation
+ *
+ * This method updates the setupPromise field of the class by chaining a new
+ * call on top of the existing promise, and then catching and discarding any
+ * errors that might happen while setting up the outbound group session. This
+ * is done to ensure that `setupPromise` always resolves to `null` or the
+ * `OutboundSessionInfo`.
+ *
+ * Using `>>=` to represent the promise chaining operation, it does the
+ * following:
+ *
+ * ```
+ * setupPromise = previousSetupPromise >>= setup >>= discardErrors
+ * ```
+ *
+ * The initial value for the `setupPromise` is a promise that resolves to
+ * `null`. The forceDiscardSession() resets setupPromise to this initial
+ * promise.
+ *
+ * @returns Promise which resolves to the
+ * OutboundSessionInfo when setup is complete.
+ */
+ private async ensureOutboundSession(
+ room: Room,
+ devicesInRoom: DeviceInfoMap,
+ blocked: BlockedMap,
+ singleOlmCreationPhase = false,
+ ): Promise<OutboundSessionInfo> {
+ // takes the previous OutboundSessionInfo, and considers whether to create
+ // a new one. Also shares the key with any (new) devices in the room.
+ //
+ // returns a promise which resolves once the keyshare is successful.
+ const setup = async (oldSession: OutboundSessionInfo | null): Promise<OutboundSessionInfo> => {
+ const sharedHistory = isRoomSharedHistory(room);
+ const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession);
+
+ await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session);
+
+ return session;
+ };
+
+ // first wait for the previous share to complete
+ const fallible = this.setupPromise.then(setup);
+
+ // Ensure any failures are logged for debugging and make sure that the
+ // promise chain remains unbroken
+ //
+ // setupPromise resolves to `null` or the `OutboundSessionInfo` whether
+ // or not the share succeeds
+ this.setupPromise = fallible.catch((e) => {
+ this.prefixedLogger.error(`Failed to setup outbound session`, e);
+ return null;
+ });
+
+ // but we return a promise which only resolves if the share was successful.
+ return fallible;
+ }
+
+ private async prepareSession(
+ devicesInRoom: DeviceInfoMap,
+ sharedHistory: boolean,
+ session: OutboundSessionInfo | null,
+ ): Promise<OutboundSessionInfo> {
+ // history visibility changed
+ if (session && sharedHistory !== session.sharedHistory) {
+ session = null;
+ }
+
+ // need to make a brand new session?
+ if (session?.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) {
+ this.prefixedLogger.log("Starting new megolm session because we need to rotate.");
+ session = null;
+ }
+
+ // determine if we have shared with anyone we shouldn't have
+ if (session?.sharedWithTooManyDevices(devicesInRoom)) {
+ session = null;
+ }
+
+ if (!session) {
+ this.prefixedLogger.log("Starting new megolm session");
+ session = await this.prepareNewSession(sharedHistory);
+ this.prefixedLogger.log(`Started new megolm session ${session.sessionId}`);
+ this.outboundSessions[session.sessionId] = session;
+ }
+
+ return session;
+ }
+
+ private async shareSession(
+ devicesInRoom: DeviceInfoMap,
+ sharedHistory: boolean,
+ singleOlmCreationPhase: boolean,
+ blocked: BlockedMap,
+ session: OutboundSessionInfo,
+ ): Promise<void> {
+ // now check if we need to share with any devices
+ const shareMap: Record<string, DeviceInfo[]> = {};
+
+ for (const [userId, userDevices] of devicesInRoom) {
+ for (const [deviceId, deviceInfo] of userDevices) {
+ const key = deviceInfo.getIdentityKey();
+ if (key == this.olmDevice.deviceCurve25519Key) {
+ // don't bother sending to ourself
+ continue;
+ }
+
+ if (!session.sharedWithDevices.get(userId)?.get(deviceId)) {
+ shareMap[userId] = shareMap[userId] || [];
+ shareMap[userId].push(deviceInfo);
+ }
+ }
+ }
+
+ const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId);
+ const payload: IPayload = {
+ type: "m.room_key",
+ content: {
+ "algorithm": olmlib.MEGOLM_ALGORITHM,
+ "room_id": this.roomId,
+ "session_id": session.sessionId,
+ "session_key": key.key,
+ "chain_index": key.chain_index,
+ "org.matrix.msc3061.shared_history": sharedHistory,
+ },
+ };
+ const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions(
+ this.olmDevice,
+ this.baseApis,
+ shareMap,
+ );
+
+ await Promise.all([
+ (async (): Promise<void> => {
+ // share keys with devices that we already have a session for
+ const olmSessionList = Array.from(olmSessions.entries())
+ .map(([userId, sessionsByUser]) =>
+ Array.from(sessionsByUser.entries()).map(
+ ([deviceId, session]) => `${userId}/${deviceId}: ${session.sessionId}`,
+ ),
+ )
+ .flat(1);
+ this.prefixedLogger.debug("Sharing keys with devices with existing Olm sessions:", olmSessionList);
+ await this.shareKeyWithOlmSessions(session, key, payload, olmSessions);
+ this.prefixedLogger.debug("Shared keys with existing Olm sessions");
+ })(),
+ (async (): Promise<void> => {
+ const deviceList = Array.from(devicesWithoutSession.entries())
+ .map(([userId, devicesByUser]) => devicesByUser.map((device) => `${userId}/${device.deviceId}`))
+ .flat(1);
+ this.prefixedLogger.debug(
+ "Sharing keys (start phase 1) with devices without existing Olm sessions:",
+ deviceList,
+ );
+ const errorDevices: IOlmDevice[] = [];
+
+ // meanwhile, establish olm sessions for devices that we don't
+ // already have a session for, and share keys with them. If
+ // we're doing two phases of olm session creation, use a
+ // shorter timeout when fetching one-time keys for the first
+ // phase.
+ const start = Date.now();
+ const failedServers: string[] = [];
+ await this.shareKeyWithDevices(
+ session,
+ key,
+ payload,
+ devicesWithoutSession,
+ errorDevices,
+ singleOlmCreationPhase ? 10000 : 2000,
+ failedServers,
+ );
+ this.prefixedLogger.debug("Shared keys (end phase 1) with devices without existing Olm sessions");
+
+ if (!singleOlmCreationPhase && Date.now() - start < 10000) {
+ // perform the second phase of olm session creation if requested,
+ // and if the first phase didn't take too long
+ (async (): Promise<void> => {
+ // Retry sending keys to devices that we were unable to establish
+ // an olm session for. This time, we use a longer timeout, but we
+ // do this in the background and don't block anything else while we
+ // do this. We only need to retry users from servers that didn't
+ // respond the first time.
+ const retryDevices: MapWithDefault<string, DeviceInfo[]> = new MapWithDefault(() => []);
+ const failedServerMap = new Set();
+ for (const server of failedServers) {
+ failedServerMap.add(server);
+ }
+ const failedDevices: IOlmDevice[] = [];
+ for (const { userId, deviceInfo } of errorDevices) {
+ const userHS = userId.slice(userId.indexOf(":") + 1);
+ if (failedServerMap.has(userHS)) {
+ retryDevices.getOrCreate(userId).push(deviceInfo);
+ } else {
+ // if we aren't going to retry, then handle it
+ // as a failed device
+ failedDevices.push({ userId, deviceInfo });
+ }
+ }
+
+ const retryDeviceList = Array.from(retryDevices.entries())
+ .map(([userId, devicesByUser]) =>
+ devicesByUser.map((device) => `${userId}/${device.deviceId}`),
+ )
+ .flat(1);
+
+ if (retryDeviceList.length > 0) {
+ this.prefixedLogger.debug(
+ "Sharing keys (start phase 2) with devices without existing Olm sessions:",
+ retryDeviceList,
+ );
+ await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000);
+ this.prefixedLogger.debug(
+ "Shared keys (end phase 2) with devices without existing Olm sessions",
+ );
+ }
+
+ await this.notifyFailedOlmDevices(session, key, failedDevices);
+ })();
+ } else {
+ await this.notifyFailedOlmDevices(session, key, errorDevices);
+ }
+ })(),
+ (async (): Promise<void> => {
+ this.prefixedLogger.debug(
+ `There are ${blocked.size} blocked devices:`,
+ Array.from(blocked.entries())
+ .map(([userId, blockedByUser]) =>
+ Array.from(blockedByUser.entries()).map(
+ ([deviceId, _deviceInfo]) => `${userId}/${deviceId}`,
+ ),
+ )
+ .flat(1),
+ );
+
+ // also, notify newly blocked devices that they're blocked
+ const blockedMap: MapWithDefault<string, Map<string, { device: IBlockedDevice }>> = new MapWithDefault(
+ () => new Map(),
+ );
+ let blockedCount = 0;
+ for (const [userId, userBlockedDevices] of blocked) {
+ for (const [deviceId, device] of userBlockedDevices) {
+ if (session.blockedDevicesNotified.get(userId)?.get(deviceId) === undefined) {
+ blockedMap.getOrCreate(userId).set(deviceId, { device });
+ blockedCount++;
+ }
+ }
+ }
+
+ if (blockedCount) {
+ this.prefixedLogger.debug(
+ `Notifying ${blockedCount} newly blocked devices:`,
+ Array.from(blockedMap.entries())
+ .map(([userId, blockedByUser]) =>
+ Object.entries(blockedByUser).map(([deviceId, _deviceInfo]) => `${userId}/${deviceId}`),
+ )
+ .flat(1),
+ );
+ await this.notifyBlockedDevices(session, blockedMap);
+ this.prefixedLogger.debug(`Notified ${blockedCount} newly blocked devices`);
+ }
+ })(),
+ ]);
+ }
+
+ /**
+ * @internal
+ *
+ *
+ * @returns session
+ */
+ private async prepareNewSession(sharedHistory: boolean): Promise<OutboundSessionInfo> {
+ const sessionId = this.olmDevice.createOutboundGroupSession();
+ const key = this.olmDevice.getOutboundGroupSessionKey(sessionId);
+
+ await this.olmDevice.addInboundGroupSession(
+ this.roomId,
+ this.olmDevice.deviceCurve25519Key!,
+ [],
+ sessionId,
+ key.key,
+ { ed25519: this.olmDevice.deviceEd25519Key! },
+ false,
+ { sharedHistory },
+ );
+
+ // don't wait for it to complete
+ this.crypto.backupManager.backupGroupSession(this.olmDevice.deviceCurve25519Key!, sessionId);
+
+ return new OutboundSessionInfo(sessionId, sharedHistory);
+ }
+
+ /**
+ * Determines what devices in devicesByUser don't have an olm session as given
+ * in devicemap.
+ *
+ * @internal
+ *
+ * @param deviceMap - the devices that have olm sessions, as returned by
+ * olmlib.ensureOlmSessionsForDevices.
+ * @param devicesByUser - a map of user IDs to array of deviceInfo
+ * @param noOlmDevices - an array to fill with devices that don't have
+ * olm sessions
+ *
+ * @returns an array of devices that don't have olm sessions. If
+ * noOlmDevices is specified, then noOlmDevices will be returned.
+ */
+ private getDevicesWithoutSessions(
+ deviceMap: Map<string, Map<string, IOlmSessionResult>>,
+ devicesByUser: Map<string, DeviceInfo[]>,
+ noOlmDevices: IOlmDevice[] = [],
+ ): IOlmDevice[] {
+ for (const [userId, devicesToShareWith] of devicesByUser) {
+ const sessionResults = deviceMap.get(userId);
+
+ for (const deviceInfo of devicesToShareWith) {
+ const deviceId = deviceInfo.deviceId;
+
+ const sessionResult = sessionResults?.get(deviceId);
+ if (!sessionResult?.sessionId) {
+ // no session with this device, probably because there
+ // were no one-time keys.
+
+ noOlmDevices.push({ userId, deviceInfo });
+ sessionResults?.delete(deviceId);
+
+ // ensureOlmSessionsForUsers has already done the logging,
+ // so just skip it.
+ continue;
+ }
+ }
+ }
+
+ return noOlmDevices;
+ }
+
+ /**
+ * Splits the user device map into multiple chunks to reduce the number of
+ * devices we encrypt to per API call.
+ *
+ * @internal
+ *
+ * @param devicesByUser - map from userid to list of devices
+ *
+ * @returns the blocked devices, split into chunks
+ */
+ private splitDevices<T extends DeviceInfo | IBlockedDevice>(
+ devicesByUser: Map<string, Map<string, { device: T }>>,
+ ): IOlmDevice<T>[][] {
+ const maxDevicesPerRequest = 20;
+
+ // use an array where the slices of a content map gets stored
+ let currentSlice: IOlmDevice<T>[] = [];
+ const mapSlices = [currentSlice];
+
+ for (const [userId, userDevices] of devicesByUser) {
+ for (const deviceInfo of userDevices.values()) {
+ currentSlice.push({
+ userId: userId,
+ deviceInfo: deviceInfo.device,
+ });
+ }
+
+ // We do this in the per-user loop as we prefer that all messages to the
+ // same user end up in the same API call to make it easier for the
+ // server (e.g. only have to send one EDU if a remote user, etc). This
+ // does mean that if a user has many devices we may go over the desired
+ // limit, but its not a hard limit so that is fine.
+ if (currentSlice.length > maxDevicesPerRequest) {
+ // the current slice is filled up. Start inserting into the next slice
+ currentSlice = [];
+ mapSlices.push(currentSlice);
+ }
+ }
+ if (currentSlice.length === 0) {
+ mapSlices.pop();
+ }
+ return mapSlices;
+ }
+
+ /**
+ * @internal
+ *
+ *
+ * @param chainIndex - current chain index
+ *
+ * @param userDeviceMap - mapping from userId to deviceInfo
+ *
+ * @param payload - fields to include in the encrypted payload
+ *
+ * @returns Promise which resolves once the key sharing
+ * for the given userDeviceMap is generated and has been sent.
+ */
+ private encryptAndSendKeysToDevices(
+ session: OutboundSessionInfo,
+ chainIndex: number,
+ devices: IOlmDevice[],
+ payload: IPayload,
+ ): Promise<void> {
+ return this.crypto
+ .encryptAndSendToDevices(devices, payload)
+ .then(() => {
+ // store that we successfully uploaded the keys of the current slice
+ for (const device of devices) {
+ session.markSharedWithDevice(
+ device.userId,
+ device.deviceInfo.deviceId,
+ device.deviceInfo.getIdentityKey(),
+ chainIndex,
+ );
+ }
+ })
+ .catch((error) => {
+ this.prefixedLogger.error("failed to encryptAndSendToDevices", error);
+ throw error;
+ });
+ }
+
+ /**
+ * @internal
+ *
+ *
+ * @param userDeviceMap - list of blocked devices to notify
+ *
+ * @param payload - fields to include in the notification payload
+ *
+ * @returns Promise which resolves once the notifications
+ * for the given userDeviceMap is generated and has been sent.
+ */
+ private async sendBlockedNotificationsToDevices(
+ session: OutboundSessionInfo,
+ userDeviceMap: IOlmDevice<IBlockedDevice>[],
+ payload: IPayload,
+ ): Promise<void> {
+ const contentMap: MapWithDefault<string, Map<string, IPayload>> = new MapWithDefault(() => new Map());
+
+ for (const val of userDeviceMap) {
+ const userId = val.userId;
+ const blockedInfo = val.deviceInfo;
+ const deviceInfo = blockedInfo.deviceInfo;
+ const deviceId = deviceInfo.deviceId;
+
+ const message = {
+ ...payload,
+ code: blockedInfo.code,
+ reason: blockedInfo.reason,
+ [ToDeviceMessageId]: uuidv4(),
+ };
+
+ if (message.code === "m.no_olm") {
+ delete message.room_id;
+ delete message.session_id;
+ }
+
+ contentMap.getOrCreate(userId).set(deviceId, message);
+ }
+
+ await this.baseApis.sendToDevice("m.room_key.withheld", contentMap);
+
+ // record the fact that we notified these blocked devices
+ for (const [userId, userDeviceMap] of contentMap) {
+ for (const deviceId of userDeviceMap.keys()) {
+ session.markNotifiedBlockedDevice(userId, deviceId);
+ }
+ }
+ }
+
+ /**
+ * Re-shares a megolm session key with devices if the key has already been
+ * sent to them.
+ *
+ * @param senderKey - The key of the originating device for the session
+ * @param sessionId - ID of the outbound session to share
+ * @param userId - ID of the user who owns the target device
+ * @param device - The target device
+ */
+ public async reshareKeyWithDevice(
+ senderKey: string,
+ sessionId: string,
+ userId: string,
+ device: DeviceInfo,
+ ): Promise<void> {
+ const obSessionInfo = this.outboundSessions[sessionId];
+ if (!obSessionInfo) {
+ this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} not found: not re-sharing keys`);
+ return;
+ }
+
+ // The chain index of the key we previously sent this device
+ if (!obSessionInfo.sharedWithDevices.has(userId)) {
+ this.prefixedLogger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`);
+ return;
+ }
+ const sessionSharedData = obSessionInfo.sharedWithDevices.get(userId)?.get(device.deviceId);
+ if (sessionSharedData === undefined) {
+ this.prefixedLogger.debug(
+ `megolm session ${senderKey}|${sessionId} never shared with device ${userId}:${device.deviceId}`,
+ );
+ return;
+ }
+
+ if (sessionSharedData.deviceKey !== device.getIdentityKey()) {
+ this.prefixedLogger.warn(
+ `Megolm session ${senderKey}|${sessionId} has been shared with device ${device.deviceId} but ` +
+ `with identity key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`,
+ );
+ return;
+ }
+
+ // get the key from the inbound session: the outbound one will already
+ // have been ratcheted to the next chain index.
+ const key = await this.olmDevice.getInboundGroupSessionKey(
+ this.roomId,
+ senderKey,
+ sessionId,
+ sessionSharedData.messageIndex,
+ );
+
+ if (!key) {
+ this.prefixedLogger.warn(
+ `No inbound session key found for megolm session ${senderKey}|${sessionId}: not re-sharing keys`,
+ );
+ return;
+ }
+
+ await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [device]]]));
+
+ const payload = {
+ type: "m.forwarded_room_key",
+ content: {
+ "algorithm": olmlib.MEGOLM_ALGORITHM,
+ "room_id": this.roomId,
+ "session_id": sessionId,
+ "session_key": key.key,
+ "chain_index": key.chain_index,
+ "sender_key": senderKey,
+ "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key,
+ "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain,
+ "org.matrix.msc3061.shared_history": key.shared_history || false,
+ },
+ };
+
+ const encryptedContent: IEncryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key!,
+ ciphertext: {},
+ [ToDeviceMessageId]: uuidv4(),
+ };
+ await olmlib.encryptMessageForDevice(
+ encryptedContent.ciphertext,
+ this.userId,
+ this.deviceId,
+ this.olmDevice,
+ userId,
+ device,
+ payload,
+ );
+
+ await this.baseApis.sendToDevice(
+ "m.room.encrypted",
+ new Map([[userId, new Map([[device.deviceId, encryptedContent]])]]),
+ );
+ this.prefixedLogger.debug(
+ `Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`,
+ );
+ }
+
+ /**
+ * @internal
+ *
+ *
+ * @param key - the session key as returned by
+ * OlmDevice.getOutboundGroupSessionKey
+ *
+ * @param payload - the base to-device message payload for sharing keys
+ *
+ * @param devicesByUser - map from userid to list of devices
+ *
+ * @param errorDevices - array that will be populated with the devices that we can't get an
+ * olm session for
+ *
+ * @param otkTimeout - The timeout in milliseconds when requesting
+ * one-time keys for establishing new olm sessions.
+ *
+ * @param failedServers - An array to fill with remote servers that
+ * failed to respond to one-time-key requests.
+ */
+ private async shareKeyWithDevices(
+ session: OutboundSessionInfo,
+ key: IOutboundGroupSessionKey,
+ payload: IPayload,
+ devicesByUser: Map<string, DeviceInfo[]>,
+ errorDevices: IOlmDevice[],
+ otkTimeout: number,
+ failedServers?: string[],
+ ): Promise<void> {
+ const devicemap = await olmlib.ensureOlmSessionsForDevices(
+ this.olmDevice,
+ this.baseApis,
+ devicesByUser,
+ false,
+ otkTimeout,
+ failedServers,
+ this.prefixedLogger,
+ );
+ this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices);
+ await this.shareKeyWithOlmSessions(session, key, payload, devicemap);
+ }
+
+ private async shareKeyWithOlmSessions(
+ session: OutboundSessionInfo,
+ key: IOutboundGroupSessionKey,
+ payload: IPayload,
+ deviceMap: Map<string, Map<string, IOlmSessionResult>>,
+ ): Promise<void> {
+ const userDeviceMaps = this.splitDevices(deviceMap);
+
+ for (let i = 0; i < userDeviceMaps.length; i++) {
+ const taskDetail = `megolm keys for ${session.sessionId} (slice ${i + 1}/${userDeviceMaps.length})`;
+ try {
+ this.prefixedLogger.debug(
+ `Sharing ${taskDetail}`,
+ userDeviceMaps[i].map((d) => `${d.userId}/${d.deviceInfo.deviceId}`),
+ );
+ await this.encryptAndSendKeysToDevices(session, key.chain_index, userDeviceMaps[i], payload);
+ this.prefixedLogger.debug(`Shared ${taskDetail}`);
+ } catch (e) {
+ this.prefixedLogger.error(`Failed to share ${taskDetail}`);
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * Notify devices that we weren't able to create olm sessions.
+ *
+ *
+ *
+ * @param failedDevices - the devices that we were unable to
+ * create olm sessions for, as returned by shareKeyWithDevices
+ */
+ private async notifyFailedOlmDevices(
+ session: OutboundSessionInfo,
+ key: IOutboundGroupSessionKey,
+ failedDevices: IOlmDevice[],
+ ): Promise<void> {
+ this.prefixedLogger.debug(`Notifying ${failedDevices.length} devices we failed to create Olm sessions`);
+
+ // mark the devices that failed as "handled" because we don't want to try
+ // to claim a one-time-key for dead devices on every message.
+ for (const { userId, deviceInfo } of failedDevices) {
+ const deviceId = deviceInfo.deviceId;
+
+ session.markSharedWithDevice(userId, deviceId, deviceInfo.getIdentityKey(), key.chain_index);
+ }
+
+ const unnotifiedFailedDevices = await this.olmDevice.filterOutNotifiedErrorDevices(failedDevices);
+ this.prefixedLogger.debug(
+ `Need to notify ${unnotifiedFailedDevices.length} failed devices which haven't been notified before`,
+ );
+ const blockedMap: MapWithDefault<string, Map<string, { device: IBlockedDevice }>> = new MapWithDefault(
+ () => new Map(),
+ );
+ for (const { userId, deviceInfo } of unnotifiedFailedDevices) {
+ // we use a similar format to what
+ // olmlib.ensureOlmSessionsForDevices returns, so that
+ // we can use the same function to split
+ blockedMap.getOrCreate(userId).set(deviceInfo.deviceId, {
+ device: {
+ code: "m.no_olm",
+ reason: WITHHELD_MESSAGES["m.no_olm"],
+ deviceInfo,
+ },
+ });
+ }
+
+ // send the notifications
+ await this.notifyBlockedDevices(session, blockedMap);
+ this.prefixedLogger.debug(
+ `Notified ${unnotifiedFailedDevices.length} devices we failed to create Olm sessions`,
+ );
+ }
+
+ /**
+ * Notify blocked devices that they have been blocked.
+ *
+ *
+ * @param devicesByUser - map from userid to device ID to blocked data
+ */
+ private async notifyBlockedDevices(
+ session: OutboundSessionInfo,
+ devicesByUser: Map<string, Map<string, { device: IBlockedDevice }>>,
+ ): Promise<void> {
+ const payload: IPayload = {
+ room_id: this.roomId,
+ session_id: session.sessionId,
+ algorithm: olmlib.MEGOLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key!,
+ };
+
+ const userDeviceMaps = this.splitDevices(devicesByUser);
+
+ for (let i = 0; i < userDeviceMaps.length; i++) {
+ try {
+ await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload);
+ this.prefixedLogger.log(
+ `Completed blacklist notification for ${session.sessionId} ` +
+ `(slice ${i + 1}/${userDeviceMaps.length})`,
+ );
+ } catch (e) {
+ this.prefixedLogger.log(
+ `blacklist notification for ${session.sessionId} ` +
+ `(slice ${i + 1}/${userDeviceMaps.length}) failed`,
+ );
+
+ throw e;
+ }
+ }
+ }
+
+ /**
+ * Perform any background tasks that can be done before a message is ready to
+ * send, in order to speed up sending of the message.
+ *
+ * @param room - the room the event is in
+ * @returns A function that, when called, will stop the preparation
+ */
+ public prepareToEncrypt(room: Room): () => void {
+ if (room.roomId !== this.roomId) {
+ throw new Error("MegolmEncryption.prepareToEncrypt called on unexpected room");
+ }
+
+ if (this.encryptionPreparation != null) {
+ // We're already preparing something, so don't do anything else.
+ const elapsedTime = Date.now() - this.encryptionPreparation.startTime;
+ this.prefixedLogger.debug(
+ `Already started preparing to encrypt for this room ${elapsedTime}ms ago, skipping`,
+ );
+ return this.encryptionPreparation.cancel;
+ }
+
+ this.prefixedLogger.debug("Preparing to encrypt events");
+
+ let cancelled = false;
+ const isCancelled = (): boolean => cancelled;
+
+ this.encryptionPreparation = {
+ startTime: Date.now(),
+ promise: (async (): Promise<void> => {
+ try {
+ // Attempt to enumerate the devices in room, and gracefully
+ // handle cancellation if it occurs.
+ const getDevicesResult = await this.getDevicesInRoom(room, false, isCancelled);
+ if (getDevicesResult === null) return;
+ const [devicesInRoom, blocked] = getDevicesResult;
+
+ if (this.crypto.globalErrorOnUnknownDevices) {
+ // Drop unknown devices for now. When the message gets sent, we'll
+ // throw an error, but we'll still be prepared to send to the known
+ // devices.
+ this.removeUnknownDevices(devicesInRoom);
+ }
+
+ this.prefixedLogger.debug("Ensuring outbound megolm session");
+ await this.ensureOutboundSession(room, devicesInRoom, blocked, true);
+
+ this.prefixedLogger.debug("Ready to encrypt events");
+ } catch (e) {
+ this.prefixedLogger.error("Failed to prepare to encrypt events", e);
+ } finally {
+ delete this.encryptionPreparation;
+ }
+ })(),
+
+ cancel: (): void => {
+ // The caller has indicated that the process should be cancelled,
+ // so tell the promise that we'd like to halt, and reset the preparation state.
+ cancelled = true;
+ delete this.encryptionPreparation;
+ },
+ };
+
+ return this.encryptionPreparation.cancel;
+ }
+
+ /**
+ * @param content - plaintext event content
+ *
+ * @returns Promise which resolves to the new event body
+ */
+ public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IMegolmEncryptedContent> {
+ this.prefixedLogger.log("Starting to encrypt event");
+
+ if (this.encryptionPreparation != null) {
+ // If we started sending keys, wait for it to be done.
+ // FIXME: check if we need to cancel
+ // (https://github.com/matrix-org/matrix-js-sdk/issues/1255)
+ try {
+ await this.encryptionPreparation.promise;
+ } catch (e) {
+ // ignore any errors -- if the preparation failed, we'll just
+ // restart everything here
+ }
+ }
+
+ /**
+ * When using in-room messages and the room has encryption enabled,
+ * clients should ensure that encryption does not hinder the verification.
+ */
+ const forceDistributeToUnverified = this.isVerificationEvent(eventType, content);
+ const [devicesInRoom, blocked] = await this.getDevicesInRoom(room, forceDistributeToUnverified);
+
+ // check if any of these devices are not yet known to the user.
+ // if so, warn the user so they can verify or ignore.
+ if (this.crypto.globalErrorOnUnknownDevices) {
+ this.checkForUnknownDevices(devicesInRoom);
+ }
+
+ const session = await this.ensureOutboundSession(room, devicesInRoom, blocked);
+ const payloadJson = {
+ room_id: this.roomId,
+ type: eventType,
+ content: content,
+ };
+
+ const ciphertext = this.olmDevice.encryptGroupMessage(session.sessionId, JSON.stringify(payloadJson));
+ const encryptedContent: IEncryptedContent = {
+ algorithm: olmlib.MEGOLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key!,
+ ciphertext: ciphertext,
+ session_id: session.sessionId,
+ // Include our device ID so that recipients can send us a
+ // m.new_device message if they don't have our session key.
+ // XXX: Do we still need this now that m.new_device messages
+ // no longer exist since #483?
+ device_id: this.deviceId,
+ };
+
+ session.useCount++;
+ return encryptedContent;
+ }
+
+ private isVerificationEvent(eventType: string, content: IContent): boolean {
+ switch (eventType) {
+ case EventType.KeyVerificationCancel:
+ case EventType.KeyVerificationDone:
+ case EventType.KeyVerificationMac:
+ case EventType.KeyVerificationStart:
+ case EventType.KeyVerificationKey:
+ case EventType.KeyVerificationReady:
+ case EventType.KeyVerificationAccept: {
+ return true;
+ }
+ case EventType.RoomMessage: {
+ return content["msgtype"] === MsgType.KeyVerificationRequest;
+ }
+ default: {
+ return false;
+ }
+ }
+ }
+
+ /**
+ * Forces the current outbound group session to be discarded such
+ * that another one will be created next time an event is sent.
+ *
+ * This should not normally be necessary.
+ */
+ public forceDiscardSession(): void {
+ this.setupPromise = this.setupPromise.then(() => null);
+ }
+
+ /**
+ * Checks the devices we're about to send to and see if any are entirely
+ * unknown to the user. If so, warn the user, and mark them as known to
+ * give the user a chance to go verify them before re-sending this message.
+ *
+ * @param devicesInRoom - `userId -> {deviceId -> object}`
+ * devices we should shared the session with.
+ */
+ private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void {
+ const unknownDevices: MapWithDefault<string, Map<string, DeviceInfo>> = new MapWithDefault(() => new Map());
+
+ for (const [userId, userDevices] of devicesInRoom) {
+ for (const [deviceId, device] of userDevices) {
+ if (device.isUnverified() && !device.isKnown()) {
+ unknownDevices.getOrCreate(userId).set(deviceId, device);
+ }
+ }
+ }
+
+ if (unknownDevices.size) {
+ // it'd be kind to pass unknownDevices up to the user in this error
+ throw new UnknownDeviceError(
+ "This room contains unknown devices which have not been verified. " +
+ "We strongly recommend you verify them before continuing.",
+ unknownDevices,
+ );
+ }
+ }
+
+ /**
+ * Remove unknown devices from a set of devices. The devicesInRoom parameter
+ * will be modified.
+ *
+ * @param devicesInRoom - `userId -> {deviceId -> object}`
+ * devices we should shared the session with.
+ */
+ private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void {
+ for (const [userId, userDevices] of devicesInRoom) {
+ for (const [deviceId, device] of userDevices) {
+ if (device.isUnverified() && !device.isKnown()) {
+ userDevices.delete(deviceId);
+ }
+ }
+
+ if (userDevices.size === 0) {
+ devicesInRoom.delete(userId);
+ }
+ }
+ }
+
+ /**
+ * Get the list of unblocked devices for all users in the room
+ *
+ * @param forceDistributeToUnverified - if set to true will include the unverified devices
+ * even if setting is set to block them (useful for verification)
+ * @param isCancelled - will cause the procedure to abort early if and when it starts
+ * returning `true`. If omitted, cancellation won't happen.
+ *
+ * @returns Promise which resolves to `null`, or an array whose
+ * first element is a {@link DeviceInfoMap} indicating
+ * the devices that messages should be encrypted to, and whose second
+ * element is a map from userId to deviceId to data indicating the devices
+ * that are in the room but that have been blocked.
+ * If `isCancelled` is provided and returns `true` while processing, `null`
+ * will be returned.
+ * If `isCancelled` is not provided, the Promise will never resolve to `null`.
+ */
+ private async getDevicesInRoom(
+ room: Room,
+ forceDistributeToUnverified?: boolean,
+ ): Promise<[DeviceInfoMap, BlockedMap]>;
+ private async getDevicesInRoom(
+ room: Room,
+ forceDistributeToUnverified?: boolean,
+ isCancelled?: () => boolean,
+ ): Promise<null | [DeviceInfoMap, BlockedMap]>;
+ private async getDevicesInRoom(
+ room: Room,
+ forceDistributeToUnverified = false,
+ isCancelled?: () => boolean,
+ ): Promise<null | [DeviceInfoMap, BlockedMap]> {
+ const members = await room.getEncryptionTargetMembers();
+ this.prefixedLogger.debug(
+ `Encrypting for users (shouldEncryptForInvitedMembers: ${room.shouldEncryptForInvitedMembers()}):`,
+ members.map((u) => `${u.userId} (${u.membership})`),
+ );
+
+ const roomMembers = members.map(function (u) {
+ return u.userId;
+ });
+
+ // The global value is treated as a default for when rooms don't specify a value.
+ let isBlacklisting = this.crypto.globalBlacklistUnverifiedDevices;
+ const isRoomBlacklisting = room.getBlacklistUnverifiedDevices();
+ if (typeof isRoomBlacklisting === "boolean") {
+ isBlacklisting = isRoomBlacklisting;
+ }
+
+ // We are happy to use a cached version here: we assume that if we already
+ // have a list of the user's devices, then we already share an e2e room
+ // with them, which means that they will have announced any new devices via
+ // device_lists in their /sync response. This cache should then be maintained
+ // using all the device_lists changes and left fields.
+ // See https://github.com/vector-im/element-web/issues/2305 for details.
+ const devices = await this.crypto.downloadKeys(roomMembers, false);
+
+ if (isCancelled?.() === true) {
+ return null;
+ }
+
+ const blocked = new MapWithDefault<string, Map<string, IBlockedDevice>>(() => new Map());
+ // remove any blocked devices
+ for (const [userId, userDevices] of devices) {
+ for (const [deviceId, userDevice] of userDevices) {
+ // Yield prior to checking each device so that we don't block
+ // updating/rendering for too long.
+ // See https://github.com/vector-im/element-web/issues/21612
+ if (isCancelled !== undefined) await immediate();
+ if (isCancelled?.() === true) return null;
+ const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId);
+
+ if (
+ userDevice.isBlocked() ||
+ (!deviceTrust.isVerified() && isBlacklisting && !forceDistributeToUnverified)
+ ) {
+ const blockedDevices = blocked.getOrCreate(userId);
+ const isBlocked = userDevice.isBlocked();
+ blockedDevices.set(deviceId, {
+ code: isBlocked ? "m.blacklisted" : "m.unverified",
+ reason: WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"],
+ deviceInfo: userDevice,
+ });
+ userDevices.delete(deviceId);
+ }
+ }
+ }
+
+ return [devices, blocked];
+ }
+}
+
+/**
+ * Megolm decryption implementation
+ *
+ * @param params - parameters, as per {@link DecryptionAlgorithm}
+ */
+export class MegolmDecryption extends DecryptionAlgorithm {
+ // events which we couldn't decrypt due to unknown sessions /
+ // indexes, or which we could only decrypt with untrusted keys:
+ // map from senderKey|sessionId to Set of MatrixEvents
+ private pendingEvents = new Map<string, Map<string, Set<MatrixEvent>>>();
+
+ // this gets stubbed out by the unit tests.
+ private olmlib = olmlib;
+
+ protected readonly roomId: string;
+ private readonly prefixedLogger: PrefixedLogger;
+
+ public constructor(params: DecryptionClassParams<IParams & Required<Pick<IParams, "roomId">>>) {
+ super(params);
+ this.roomId = params.roomId;
+ this.prefixedLogger = logger.withPrefix(`[${this.roomId} decryption]`);
+ }
+
+ /**
+ * returns a promise which resolves to a
+ * {@link EventDecryptionResult} once we have finished
+ * decrypting, or rejects with an `algorithms.DecryptionError` if there is a
+ * problem decrypting the event.
+ */
+ public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
+ const content = event.getWireContent();
+
+ if (!content.sender_key || !content.session_id || !content.ciphertext) {
+ throw new DecryptionError("MEGOLM_MISSING_FIELDS", "Missing fields in input");
+ }
+
+ // we add the event to the pending list *before* we start decryption.
+ //
+ // then, if the key turns up while decryption is in progress (and
+ // decryption fails), we will schedule a retry.
+ // (fixes https://github.com/vector-im/element-web/issues/5001)
+ this.addEventToPendingList(event);
+
+ let res: IDecryptedGroupMessage | null;
+ try {
+ res = await this.olmDevice.decryptGroupMessage(
+ event.getRoomId()!,
+ content.sender_key,
+ content.session_id,
+ content.ciphertext,
+ event.getId()!,
+ event.getTs(),
+ );
+ } catch (e) {
+ if ((<Error>e).name === "DecryptionError") {
+ // re-throw decryption errors as-is
+ throw e;
+ }
+
+ let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR";
+
+ if ((<MatrixError>e)?.message === "OLM.UNKNOWN_MESSAGE_INDEX") {
+ this.requestKeysForEvent(event);
+
+ errorCode = "OLM_UNKNOWN_MESSAGE_INDEX";
+ }
+
+ throw new DecryptionError(errorCode, e instanceof Error ? e.message : "Unknown Error: Error is undefined", {
+ session: content.sender_key + "|" + content.session_id,
+ });
+ }
+
+ if (res === null) {
+ // We've got a message for a session we don't have.
+ // try and get the missing key from the backup first
+ this.crypto.backupManager.queryKeyBackupRateLimited(event.getRoomId(), content.session_id).catch(() => {});
+
+ // (XXX: We might actually have received this key since we started
+ // decrypting, in which case we'll have scheduled a retry, and this
+ // request will be redundant. We could probably check to see if the
+ // event is still in the pending list; if not, a retry will have been
+ // scheduled, so we needn't send out the request here.)
+ this.requestKeysForEvent(event);
+
+ // See if there was a problem with the olm session at the time the
+ // event was sent. Use a fuzz factor of 2 minutes.
+ const problem = await this.olmDevice.sessionMayHaveProblems(content.sender_key, event.getTs() - 120000);
+ if (problem) {
+ this.prefixedLogger.info(
+ `When handling UISI from ${event.getSender()} (sender key ${content.sender_key}): ` +
+ `recent session problem with that sender:`,
+ problem,
+ );
+ let problemDescription = PROBLEM_DESCRIPTIONS[problem.type as "no_olm"] || PROBLEM_DESCRIPTIONS.unknown;
+ if (problem.fixed) {
+ problemDescription += " Trying to create a new secure channel and re-requesting the keys.";
+ }
+ throw new DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, {
+ session: content.sender_key + "|" + content.session_id,
+ });
+ }
+
+ throw new DecryptionError(
+ "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
+ "The sender's device has not sent us the keys for this message.",
+ {
+ session: content.sender_key + "|" + content.session_id,
+ },
+ );
+ }
+
+ // Success. We can remove the event from the pending list, if
+ // that hasn't already happened. However, if the event was
+ // decrypted with an untrusted key, leave it on the pending
+ // list so it will be retried if we find a trusted key later.
+ if (!res.untrusted) {
+ this.removeEventFromPendingList(event);
+ }
+
+ const payload = JSON.parse(res.result);
+
+ // belt-and-braces check that the room id matches that indicated by the HS
+ // (this is somewhat redundant, since the megolm session is scoped to the
+ // room, so neither the sender nor a MITM can lie about the room_id).
+ if (payload.room_id !== event.getRoomId()) {
+ throw new DecryptionError("MEGOLM_BAD_ROOM", "Message intended for room " + payload.room_id);
+ }
+
+ return {
+ clearEvent: payload,
+ senderCurve25519Key: res.senderKey,
+ claimedEd25519Key: res.keysClaimed.ed25519,
+ forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
+ untrusted: res.untrusted,
+ };
+ }
+
+ private requestKeysForEvent(event: MatrixEvent): void {
+ const wireContent = event.getWireContent();
+
+ const recipients = event.getKeyRequestRecipients(this.userId);
+
+ this.crypto.requestRoomKey(
+ {
+ room_id: event.getRoomId()!,
+ algorithm: wireContent.algorithm,
+ sender_key: wireContent.sender_key,
+ session_id: wireContent.session_id,
+ },
+ recipients,
+ );
+ }
+
+ /**
+ * Add an event to the list of those awaiting their session keys.
+ *
+ * @internal
+ *
+ */
+ private addEventToPendingList(event: MatrixEvent): void {
+ const content = event.getWireContent();
+ const senderKey = content.sender_key;
+ const sessionId = content.session_id;
+ if (!this.pendingEvents.has(senderKey)) {
+ this.pendingEvents.set(senderKey, new Map<string, Set<MatrixEvent>>());
+ }
+ const senderPendingEvents = this.pendingEvents.get(senderKey)!;
+ if (!senderPendingEvents.has(sessionId)) {
+ senderPendingEvents.set(sessionId, new Set());
+ }
+ senderPendingEvents.get(sessionId)?.add(event);
+ }
+
+ /**
+ * Remove an event from the list of those awaiting their session keys.
+ *
+ * @internal
+ *
+ */
+ private removeEventFromPendingList(event: MatrixEvent): void {
+ const content = event.getWireContent();
+ const senderKey = content.sender_key;
+ const sessionId = content.session_id;
+ const senderPendingEvents = this.pendingEvents.get(senderKey);
+ const pendingEvents = senderPendingEvents?.get(sessionId);
+ if (!pendingEvents) {
+ return;
+ }
+
+ pendingEvents.delete(event);
+ if (pendingEvents.size === 0) {
+ senderPendingEvents!.delete(sessionId);
+ }
+ if (senderPendingEvents!.size === 0) {
+ this.pendingEvents.delete(senderKey);
+ }
+ }
+
+ /**
+ * Parse a RoomKey out of an `m.room_key` event.
+ *
+ * @param event - the event containing the room key.
+ *
+ * @returns The `RoomKey` if it could be successfully parsed out of the
+ * event.
+ *
+ * @internal
+ *
+ */
+ private roomKeyFromEvent(event: MatrixEvent): RoomKey | undefined {
+ const senderKey = event.getSenderKey()!;
+ const content = event.getContent<Partial<IMessage["content"]>>();
+ const extraSessionData: OlmGroupSessionExtraData = {};
+
+ if (!content.room_id || !content.session_key || !content.session_id || !content.algorithm) {
+ this.prefixedLogger.error("key event is missing fields");
+ return;
+ }
+
+ if (!olmlib.isOlmEncrypted(event)) {
+ this.prefixedLogger.error("key event not properly encrypted");
+ return;
+ }
+
+ if (content["org.matrix.msc3061.shared_history"]) {
+ extraSessionData.sharedHistory = true;
+ }
+
+ const roomKey: RoomKey = {
+ senderKey: senderKey,
+ sessionId: content.session_id,
+ sessionKey: content.session_key,
+ extraSessionData,
+ exportFormat: false,
+ roomId: content.room_id,
+ algorithm: content.algorithm,
+ forwardingKeyChain: [],
+ keysClaimed: event.getKeysClaimed(),
+ };
+
+ return roomKey;
+ }
+
+ /**
+ * Parse a RoomKey out of an `m.forwarded_room_key` event.
+ *
+ * @param event - the event containing the forwarded room key.
+ *
+ * @returns The `RoomKey` if it could be successfully parsed out of the
+ * event.
+ *
+ * @internal
+ *
+ */
+ private forwardedRoomKeyFromEvent(event: MatrixEvent): RoomKey | undefined {
+ // the properties in m.forwarded_room_key are a superset of those in m.room_key, so
+ // start by parsing the m.room_key fields.
+ const roomKey = this.roomKeyFromEvent(event);
+
+ if (!roomKey) {
+ return;
+ }
+
+ const senderKey = event.getSenderKey()!;
+ const content = event.getContent<Partial<IMessage["content"]>>();
+
+ const senderKeyUser = this.baseApis.crypto!.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, senderKey);
+
+ // We received this to-device event from event.getSenderKey(), but the original
+ // creator of the room key is claimed in the content.
+ const claimedCurve25519Key = content.sender_key;
+ const claimedEd25519Key = content.sender_claimed_ed25519_key;
+
+ let forwardingKeyChain = Array.isArray(content.forwarding_curve25519_key_chain)
+ ? content.forwarding_curve25519_key_chain
+ : [];
+
+ // copy content before we modify it
+ forwardingKeyChain = forwardingKeyChain.slice();
+ forwardingKeyChain.push(senderKey);
+
+ // Check if we have all the fields we need.
+ if (senderKeyUser !== event.getSender()) {
+ this.prefixedLogger.error("sending device does not belong to the user it claims to be from");
+ return;
+ }
+
+ if (!claimedCurve25519Key) {
+ this.prefixedLogger.error("forwarded_room_key event is missing sender_key field");
+ return;
+ }
+
+ if (!claimedEd25519Key) {
+ this.prefixedLogger.error(`forwarded_room_key_event is missing sender_claimed_ed25519_key field`);
+ return;
+ }
+
+ const keysClaimed = {
+ ed25519: claimedEd25519Key,
+ };
+
+ // FIXME: We're reusing the same field to track both:
+ //
+ // 1. The Olm identity we've received this room key from.
+ // 2. The Olm identity deduced (in the trusted case) or claiming (in the
+ // untrusted case) to be the original creator of this room key.
+ //
+ // We now overwrite the value tracking usage 1 with the value tracking usage 2.
+ roomKey.senderKey = claimedCurve25519Key;
+ // Replace our keysClaimed as well.
+ roomKey.keysClaimed = keysClaimed;
+ roomKey.exportFormat = true;
+ roomKey.forwardingKeyChain = forwardingKeyChain;
+ // forwarded keys are always untrusted
+ roomKey.extraSessionData.untrusted = true;
+
+ return roomKey;
+ }
+
+ /**
+ * Determine if we should accept the forwarded room key that was found in the given
+ * event.
+ *
+ * @param event - An `m.forwarded_room_key` event.
+ * @param roomKey - The room key that was found in the event.
+ *
+ * @returns promise that will resolve to a boolean telling us if it's ok to
+ * accept the given forwarded room key.
+ *
+ * @internal
+ *
+ */
+ private async shouldAcceptForwardedKey(event: MatrixEvent, roomKey: RoomKey): Promise<boolean> {
+ const senderKey = event.getSenderKey()!;
+
+ const sendingDevice =
+ this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey) ?? undefined;
+ const deviceTrust = this.crypto.checkDeviceInfoTrust(event.getSender()!, sendingDevice);
+
+ // Using the plaintext sender here is fine since we checked that the
+ // sender matches to the user id in the device keys when this event was
+ // originally decrypted. This can obviously only happen if the device
+ // keys have been downloaded, but if they haven't the
+ // `deviceTrust.isVerified()` flag would be false as well.
+ //
+ // It would still be far nicer if the `sendingDevice` had a user ID
+ // attached to it that went through signature checks.
+ const fromUs = event.getSender() === this.baseApis.getUserId();
+ const keyFromOurVerifiedDevice = deviceTrust.isVerified() && fromUs;
+ const weRequested = await this.wasRoomKeyRequested(event, roomKey);
+ const fromInviter = this.wasRoomKeyForwardedByInviter(event, roomKey);
+ const sharedAsHistory = this.wasRoomKeyForwardedAsHistory(roomKey);
+
+ return (weRequested && keyFromOurVerifiedDevice) || (fromInviter && sharedAsHistory);
+ }
+
+ /**
+ * Did we ever request the given room key from the event sender and its
+ * accompanying device.
+ *
+ * @param event - An `m.forwarded_room_key` event.
+ * @param roomKey - The room key that was found in the event.
+ *
+ * @internal
+ *
+ */
+ private async wasRoomKeyRequested(event: MatrixEvent, roomKey: RoomKey): Promise<boolean> {
+ // We send the `m.room_key_request` out as a wildcard to-device request,
+ // otherwise we would have to duplicate the same content for each
+ // device. This is why we need to pass in "*" as the device id here.
+ const outgoingRequests = await this.crypto.cryptoStore.getOutgoingRoomKeyRequestsByTarget(
+ event.getSender()!,
+ "*",
+ [RoomKeyRequestState.Sent],
+ );
+
+ return outgoingRequests.some(
+ (req) => req.requestBody.room_id === roomKey.roomId && req.requestBody.session_id === roomKey.sessionId,
+ );
+ }
+
+ private wasRoomKeyForwardedByInviter(event: MatrixEvent, roomKey: RoomKey): boolean {
+ // TODO: This is supposed to have a time limit. We should only accept
+ // such keys if we happen to receive them for a recently joined room.
+ const room = this.baseApis.getRoom(roomKey.roomId);
+ const senderKey = event.getSenderKey();
+
+ if (!senderKey) {
+ return false;
+ }
+
+ const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, senderKey);
+
+ if (!senderKeyUser) {
+ return false;
+ }
+
+ const memberEvent = room?.getMember(this.userId)?.events.member;
+ const fromInviter =
+ memberEvent?.getSender() === senderKeyUser ||
+ (memberEvent?.getUnsigned()?.prev_sender === senderKeyUser &&
+ memberEvent?.getPrevContent()?.membership === "invite");
+
+ if (room && fromInviter) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ private wasRoomKeyForwardedAsHistory(roomKey: RoomKey): boolean {
+ const room = this.baseApis.getRoom(roomKey.roomId);
+
+ // If the key is not for a known room, then something fishy is going on,
+ // so we reject the key out of caution. In practice, this is a bit moot
+ // because we'll only accept shared_history forwarded by the inviter, and
+ // we won't know who was the inviter for an unknown room, so we'll reject
+ // it anyway.
+ if (room && roomKey.extraSessionData.sharedHistory) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Check if a forwarded room key should be parked.
+ *
+ * A forwarded room key should be parked if it's a key for a room we're not
+ * in. We park the forwarded room key in case *this sender* invites us to
+ * that room later.
+ */
+ private shouldParkForwardedKey(roomKey: RoomKey): boolean {
+ const room = this.baseApis.getRoom(roomKey.roomId);
+
+ if (!room && roomKey.extraSessionData.sharedHistory) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Park the given room key to our store.
+ *
+ * @param event - An `m.forwarded_room_key` event.
+ * @param roomKey - The room key that was found in the event.
+ *
+ * @internal
+ *
+ */
+ private async parkForwardedKey(event: MatrixEvent, roomKey: RoomKey): Promise<void> {
+ const parkedData = {
+ senderId: event.getSender()!,
+ senderKey: roomKey.senderKey,
+ sessionId: roomKey.sessionId,
+ sessionKey: roomKey.sessionKey,
+ keysClaimed: roomKey.keysClaimed,
+ forwardingCurve25519KeyChain: roomKey.forwardingKeyChain,
+ };
+ await this.crypto.cryptoStore.doTxn(
+ "readwrite",
+ ["parked_shared_history"],
+ (txn) => this.crypto.cryptoStore.addParkedSharedHistory(roomKey.roomId, parkedData, txn),
+ logger.withPrefix("[addParkedSharedHistory]"),
+ );
+ }
+
+ /**
+ * Add the given room key to our store.
+ *
+ * @param roomKey - The room key that should be added to the store.
+ *
+ * @internal
+ *
+ */
+ private async addRoomKey(roomKey: RoomKey): Promise<void> {
+ try {
+ await this.olmDevice.addInboundGroupSession(
+ roomKey.roomId,
+ roomKey.senderKey,
+ roomKey.forwardingKeyChain,
+ roomKey.sessionId,
+ roomKey.sessionKey,
+ roomKey.keysClaimed,
+ roomKey.exportFormat,
+ roomKey.extraSessionData,
+ );
+
+ // have another go at decrypting events sent with this session.
+ if (await this.retryDecryption(roomKey.senderKey, roomKey.sessionId, !roomKey.extraSessionData.untrusted)) {
+ // cancel any outstanding room key requests for this session.
+ // Only do this if we managed to decrypt every message in the
+ // session, because if we didn't, we leave the other key
+ // requests in the hopes that someone sends us a key that
+ // includes an earlier index.
+ this.crypto.cancelRoomKeyRequest({
+ algorithm: roomKey.algorithm,
+ room_id: roomKey.roomId,
+ session_id: roomKey.sessionId,
+ sender_key: roomKey.senderKey,
+ });
+ }
+
+ // don't wait for the keys to be backed up for the server
+ await this.crypto.backupManager.backupGroupSession(roomKey.senderKey, roomKey.sessionId);
+ } catch (e) {
+ this.prefixedLogger.error(`Error handling m.room_key_event: ${e}`);
+ }
+ }
+
+ /**
+ * Handle room keys that have been forwarded to us as an
+ * `m.forwarded_room_key` event.
+ *
+ * Forwarded room keys need special handling since we have no way of knowing
+ * who the original creator of the room key was. This naturally means that
+ * forwarded room keys are always untrusted and should only be accepted in
+ * some cases.
+ *
+ * @param event - An `m.forwarded_room_key` event.
+ *
+ * @internal
+ *
+ */
+ private async onForwardedRoomKey(event: MatrixEvent): Promise<void> {
+ const roomKey = this.forwardedRoomKeyFromEvent(event);
+
+ if (!roomKey) {
+ return;
+ }
+
+ if (await this.shouldAcceptForwardedKey(event, roomKey)) {
+ await this.addRoomKey(roomKey);
+ } else if (this.shouldParkForwardedKey(roomKey)) {
+ await this.parkForwardedKey(event, roomKey);
+ }
+ }
+
+ public async onRoomKeyEvent(event: MatrixEvent): Promise<void> {
+ if (event.getType() == "m.forwarded_room_key") {
+ await this.onForwardedRoomKey(event);
+ } else {
+ const roomKey = this.roomKeyFromEvent(event);
+
+ if (!roomKey) {
+ return;
+ }
+
+ await this.addRoomKey(roomKey);
+ }
+ }
+
+ /**
+ * @param event - key event
+ */
+ public async onRoomKeyWithheldEvent(event: MatrixEvent): Promise<void> {
+ const content = event.getContent();
+ const senderKey = content.sender_key;
+
+ if (content.code === "m.no_olm") {
+ await this.onNoOlmWithheldEvent(event);
+ } else if (content.code === "m.unavailable") {
+ // this simply means that the other device didn't have the key, which isn't very useful information. Don't
+ // record it in the storage
+ } else {
+ await this.olmDevice.addInboundGroupSessionWithheld(
+ content.room_id,
+ senderKey,
+ content.session_id,
+ content.code,
+ content.reason,
+ );
+ }
+
+ // Having recorded the problem, retry decryption on any affected messages.
+ // It's unlikely we'll be able to decrypt sucessfully now, but this will
+ // update the error message.
+ //
+ if (content.session_id) {
+ await this.retryDecryption(senderKey, content.session_id);
+ } else {
+ // no_olm messages aren't specific to a given megolm session, so
+ // we trigger retrying decryption for all the messages from the sender's
+ // key, so that we can update the error message to indicate the olm
+ // session problem.
+ await this.retryDecryptionFromSender(senderKey);
+ }
+ }
+
+ private async onNoOlmWithheldEvent(event: MatrixEvent): Promise<void> {
+ const content = event.getContent();
+ const senderKey = content.sender_key;
+ const sender = event.getSender()!;
+ this.prefixedLogger.warn(`${sender}:${senderKey} was unable to establish an olm session with us`);
+ // if the sender says that they haven't been able to establish an olm
+ // session, let's proactively establish one
+
+ if (await this.olmDevice.getSessionIdForDevice(senderKey)) {
+ // a session has already been established, so we don't need to
+ // create a new one.
+ this.prefixedLogger.debug("New session already created. Not creating a new one.");
+ await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true);
+ return;
+ }
+ let device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey);
+ if (!device) {
+ // if we don't know about the device, fetch the user's devices again
+ // and retry before giving up
+ await this.crypto.downloadKeys([sender], false);
+ device = this.crypto.deviceList.getDeviceByIdentityKey(content.algorithm, senderKey);
+ if (!device) {
+ this.prefixedLogger.info(
+ "Couldn't find device for identity key " + senderKey + ": not establishing session",
+ );
+ await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false);
+ return;
+ }
+ }
+
+ // XXX: switch this to use encryptAndSendToDevices() rather than duplicating it?
+
+ await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[sender, [device]]]), false);
+ const encryptedContent: IEncryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key!,
+ ciphertext: {},
+ [ToDeviceMessageId]: uuidv4(),
+ };
+ await olmlib.encryptMessageForDevice(
+ encryptedContent.ciphertext,
+ this.userId,
+ undefined,
+ this.olmDevice,
+ sender,
+ device,
+ { type: "m.dummy" },
+ );
+
+ await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true);
+
+ await this.baseApis.sendToDevice(
+ "m.room.encrypted",
+ new Map([[sender, new Map([[device.deviceId, encryptedContent]])]]),
+ );
+ }
+
+ public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise<boolean> {
+ const body = keyRequest.requestBody;
+
+ return this.olmDevice.hasInboundSessionKeys(
+ body.room_id,
+ body.sender_key,
+ body.session_id,
+ // TODO: ratchet index
+ );
+ }
+
+ public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void {
+ const userId = keyRequest.userId;
+ const deviceId = keyRequest.deviceId;
+ const deviceInfo = this.crypto.getStoredDevice(userId, deviceId)!;
+ const body = keyRequest.requestBody;
+
+ // XXX: switch this to use encryptAndSendToDevices()?
+
+ this.olmlib
+ .ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, new Map([[userId, [deviceInfo]]]))
+ .then((devicemap) => {
+ const olmSessionResult = devicemap.get(userId)?.get(deviceId);
+ if (!olmSessionResult?.sessionId) {
+ // no session with this device, probably because there
+ // were no one-time keys.
+ //
+ // ensureOlmSessionsForUsers has already done the logging,
+ // so just skip it.
+ return null;
+ }
+
+ this.prefixedLogger.log(
+ "sharing keys for session " +
+ body.sender_key +
+ "|" +
+ body.session_id +
+ " with device " +
+ userId +
+ ":" +
+ deviceId,
+ );
+
+ return this.buildKeyForwardingMessage(body.room_id, body.sender_key, body.session_id);
+ })
+ .then((payload) => {
+ const encryptedContent: IEncryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key!,
+ ciphertext: {},
+ [ToDeviceMessageId]: uuidv4(),
+ };
+
+ return this.olmlib
+ .encryptMessageForDevice(
+ encryptedContent.ciphertext,
+ this.userId,
+ undefined,
+ this.olmDevice,
+ userId,
+ deviceInfo,
+ payload!,
+ )
+ .then(() => {
+ // TODO: retries
+ return this.baseApis.sendToDevice(
+ "m.room.encrypted",
+ new Map([[userId, new Map([[deviceId, encryptedContent]])]]),
+ );
+ });
+ });
+ }
+
+ private async buildKeyForwardingMessage(
+ roomId: string,
+ senderKey: string,
+ sessionId: string,
+ ): Promise<IKeyForwardingMessage> {
+ const key = await this.olmDevice.getInboundGroupSessionKey(roomId, senderKey, sessionId);
+
+ return {
+ type: "m.forwarded_room_key",
+ content: {
+ "algorithm": olmlib.MEGOLM_ALGORITHM,
+ "room_id": roomId,
+ "sender_key": senderKey,
+ "sender_claimed_ed25519_key": key!.sender_claimed_ed25519_key!,
+ "session_id": sessionId,
+ "session_key": key!.key,
+ "chain_index": key!.chain_index,
+ "forwarding_curve25519_key_chain": key!.forwarding_curve25519_key_chain,
+ "org.matrix.msc3061.shared_history": key!.shared_history || false,
+ },
+ };
+ }
+
+ /**
+ * @param untrusted - whether the key should be considered as untrusted
+ * @param source - where the key came from
+ */
+ public importRoomKey(
+ session: IMegolmSessionData,
+ { untrusted, source }: { untrusted?: boolean; source?: string } = {},
+ ): Promise<void> {
+ const extraSessionData: OlmGroupSessionExtraData = {};
+ if (untrusted || session.untrusted) {
+ extraSessionData.untrusted = true;
+ }
+ if (session["org.matrix.msc3061.shared_history"]) {
+ extraSessionData.sharedHistory = true;
+ }
+ return this.olmDevice
+ .addInboundGroupSession(
+ session.room_id,
+ session.sender_key,
+ session.forwarding_curve25519_key_chain,
+ session.session_id,
+ session.session_key,
+ session.sender_claimed_keys,
+ true,
+ extraSessionData,
+ )
+ .then(() => {
+ if (source !== "backup") {
+ // don't wait for it to complete
+ this.crypto.backupManager.backupGroupSession(session.sender_key, session.session_id).catch((e) => {
+ // This throws if the upload failed, but this is fine
+ // since it will have written it to the db and will retry.
+ this.prefixedLogger.log("Failed to back up megolm session", e);
+ });
+ }
+ // have another go at decrypting events sent with this session.
+ this.retryDecryption(session.sender_key, session.session_id, !extraSessionData.untrusted);
+ });
+ }
+
+ /**
+ * Have another go at decrypting events after we receive a key. Resolves once
+ * decryption has been re-attempted on all events.
+ *
+ * @internal
+ * @param forceRedecryptIfUntrusted - whether messages that were already
+ * successfully decrypted using untrusted keys should be re-decrypted
+ *
+ * @returns whether all messages were successfully
+ * decrypted with trusted keys
+ */
+ private async retryDecryption(
+ senderKey: string,
+ sessionId: string,
+ forceRedecryptIfUntrusted?: boolean,
+ ): Promise<boolean> {
+ const senderPendingEvents = this.pendingEvents.get(senderKey);
+ if (!senderPendingEvents) {
+ return true;
+ }
+
+ const pending = senderPendingEvents.get(sessionId);
+ if (!pending) {
+ return true;
+ }
+
+ const pendingList = [...pending];
+ this.prefixedLogger.debug(
+ "Retrying decryption on events:",
+ pendingList.map((e) => `${e.getId()}`),
+ );
+
+ await Promise.all(
+ pendingList.map(async (ev) => {
+ try {
+ await ev.attemptDecryption(this.crypto, { isRetry: true, forceRedecryptIfUntrusted });
+ } catch (e) {
+ // don't die if something goes wrong
+ }
+ }),
+ );
+
+ // If decrypted successfully with trusted keys, they'll have
+ // been removed from pendingEvents
+ return !this.pendingEvents.get(senderKey)?.has(sessionId);
+ }
+
+ public async retryDecryptionFromSender(senderKey: string): Promise<boolean> {
+ const senderPendingEvents = this.pendingEvents.get(senderKey);
+ if (!senderPendingEvents) {
+ return true;
+ }
+
+ this.pendingEvents.delete(senderKey);
+
+ await Promise.all(
+ [...senderPendingEvents].map(async ([_sessionId, pending]) => {
+ await Promise.all(
+ [...pending].map(async (ev) => {
+ try {
+ await ev.attemptDecryption(this.crypto);
+ } catch (e) {
+ // don't die if something goes wrong
+ }
+ }),
+ );
+ }),
+ );
+
+ return !this.pendingEvents.has(senderKey);
+ }
+
+ public async sendSharedHistoryInboundSessions(devicesByUser: Map<string, DeviceInfo[]>): Promise<void> {
+ await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser);
+
+ const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId);
+ this.prefixedLogger.log(
+ `Sharing history in with users ${Array.from(devicesByUser.keys())}`,
+ sharedHistorySessions.map(([senderKey, sessionId]) => `${senderKey}|${sessionId}`),
+ );
+ for (const [senderKey, sessionId] of sharedHistorySessions) {
+ const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId);
+
+ // FIXME: use encryptAndSendToDevices() rather than duplicating it here.
+ const promises: Promise<unknown>[] = [];
+ const contentMap: Map<string, Map<string, IEncryptedContent>> = new Map();
+ for (const [userId, devices] of devicesByUser) {
+ const deviceMessages = new Map();
+ contentMap.set(userId, deviceMessages);
+ for (const deviceInfo of devices) {
+ const encryptedContent: IEncryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key!,
+ ciphertext: {},
+ [ToDeviceMessageId]: uuidv4(),
+ };
+ deviceMessages.set(deviceInfo.deviceId, encryptedContent);
+ promises.push(
+ olmlib.encryptMessageForDevice(
+ encryptedContent.ciphertext,
+ this.userId,
+ undefined,
+ this.olmDevice,
+ userId,
+ deviceInfo,
+ payload,
+ ),
+ );
+ }
+ }
+ await Promise.all(promises);
+
+ // prune out any devices that encryptMessageForDevice could not encrypt for,
+ // in which case it will have just not added anything to the ciphertext object.
+ // There's no point sending messages to devices if we couldn't encrypt to them,
+ // since that's effectively a blank message.
+ for (const [userId, deviceMessages] of contentMap) {
+ for (const [deviceId, content] of deviceMessages) {
+ if (!hasCiphertext(content)) {
+ this.prefixedLogger.log("No ciphertext for device " + userId + ":" + deviceId + ": pruning");
+ deviceMessages.delete(deviceId);
+ }
+ }
+ // No devices left for that user? Strip that too.
+ if (deviceMessages.size === 0) {
+ this.prefixedLogger.log("Pruned all devices for user " + userId);
+ contentMap.delete(userId);
+ }
+ }
+
+ // Is there anything left?
+ if (contentMap.size === 0) {
+ this.prefixedLogger.log("No users left to send to: aborting");
+ return;
+ }
+
+ await this.baseApis.sendToDevice("m.room.encrypted", contentMap);
+ }
+ }
+}
+
+const PROBLEM_DESCRIPTIONS = {
+ no_olm: "The sender was unable to establish a secure channel.",
+ unknown: "The secure channel with the sender was corrupted.",
+};
+
+registerAlgorithm(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption);
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts
new file mode 100644
index 0000000..1a79554
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/algorithms/olm.ts
@@ -0,0 +1,329 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Defines m.olm encryption/decryption
+ */
+
+import type { IEventDecryptionResult } from "../../@types/crypto";
+import { logger } from "../../logger";
+import * as olmlib from "../olmlib";
+import { DeviceInfo } from "../deviceinfo";
+import { DecryptionAlgorithm, DecryptionError, EncryptionAlgorithm, registerAlgorithm } from "./base";
+import { Room } from "../../models/room";
+import { IContent, MatrixEvent } from "../../models/event";
+import { IEncryptedContent, IOlmEncryptedContent } from "../index";
+import { IInboundSession } from "../OlmDevice";
+
+const DeviceVerification = DeviceInfo.DeviceVerification;
+
+export interface IMessage {
+ type: number;
+ body: string;
+}
+
+/**
+ * Olm encryption implementation
+ *
+ * @param params - parameters, as per {@link EncryptionAlgorithm}
+ */
+class OlmEncryption extends EncryptionAlgorithm {
+ private sessionPrepared = false;
+ private prepPromise: Promise<void> | null = null;
+
+ /**
+ * @internal
+
+ * @param roomMembers - list of currently-joined users in the room
+ * @returns Promise which resolves when setup is complete
+ */
+ private ensureSession(roomMembers: string[]): Promise<void> {
+ if (this.prepPromise) {
+ // prep already in progress
+ return this.prepPromise;
+ }
+
+ if (this.sessionPrepared) {
+ // prep already done
+ return Promise.resolve();
+ }
+
+ this.prepPromise = this.crypto
+ .downloadKeys(roomMembers)
+ .then(() => {
+ return this.crypto.ensureOlmSessionsForUsers(roomMembers);
+ })
+ .then(() => {
+ this.sessionPrepared = true;
+ })
+ .finally(() => {
+ this.prepPromise = null;
+ });
+
+ return this.prepPromise;
+ }
+
+ /**
+ * @param content - plaintext event content
+ *
+ * @returns Promise which resolves to the new event body
+ */
+ public async encryptMessage(room: Room, eventType: string, content: IContent): Promise<IOlmEncryptedContent> {
+ // pick the list of recipients based on the membership list.
+ //
+ // TODO: there is a race condition here! What if a new user turns up
+ // just as you are sending a secret message?
+
+ const members = await room.getEncryptionTargetMembers();
+
+ const users = members.map(function (u) {
+ return u.userId;
+ });
+
+ await this.ensureSession(users);
+
+ const payloadFields = {
+ room_id: room.roomId,
+ type: eventType,
+ content: content,
+ };
+
+ const encryptedContent: IEncryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key!,
+ ciphertext: {},
+ };
+
+ const promises: Promise<void>[] = [];
+
+ for (const userId of users) {
+ const devices = this.crypto.getStoredDevicesForUser(userId) || [];
+
+ for (const deviceInfo of devices) {
+ const key = deviceInfo.getIdentityKey();
+ if (key == this.olmDevice.deviceCurve25519Key) {
+ // don't bother sending to ourself
+ continue;
+ }
+ if (deviceInfo.verified == DeviceVerification.BLOCKED) {
+ // don't bother setting up sessions with blocked users
+ continue;
+ }
+
+ promises.push(
+ olmlib.encryptMessageForDevice(
+ encryptedContent.ciphertext,
+ this.userId,
+ this.deviceId,
+ this.olmDevice,
+ userId,
+ deviceInfo,
+ payloadFields,
+ ),
+ );
+ }
+ }
+
+ return Promise.all(promises).then(() => encryptedContent);
+ }
+}
+
+/**
+ * Olm decryption implementation
+ *
+ * @param params - parameters, as per {@link DecryptionAlgorithm}
+ */
+class OlmDecryption extends DecryptionAlgorithm {
+ /**
+ * returns a promise which resolves to a
+ * {@link EventDecryptionResult} once we have finished
+ * decrypting. Rejects with an `algorithms.DecryptionError` if there is a
+ * problem decrypting the event.
+ */
+ public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
+ const content = event.getWireContent();
+ const deviceKey = content.sender_key;
+ const ciphertext = content.ciphertext;
+
+ if (!ciphertext) {
+ throw new DecryptionError("OLM_MISSING_CIPHERTEXT", "Missing ciphertext");
+ }
+
+ if (!(this.olmDevice.deviceCurve25519Key! in ciphertext)) {
+ throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", "Not included in recipients");
+ }
+ const message = ciphertext[this.olmDevice.deviceCurve25519Key!];
+ let payloadString: string;
+
+ try {
+ payloadString = await this.decryptMessage(deviceKey, message);
+ } catch (e) {
+ throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", "Bad Encrypted Message", {
+ sender: deviceKey,
+ err: e as Error,
+ });
+ }
+
+ const payload = JSON.parse(payloadString);
+
+ // check that we were the intended recipient, to avoid unknown-key attack
+ // https://github.com/vector-im/vector-web/issues/2483
+ if (payload.recipient != this.userId) {
+ throw new DecryptionError("OLM_BAD_RECIPIENT", "Message was intented for " + payload.recipient);
+ }
+
+ if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) {
+ throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", "Message not intended for this device", {
+ intended: payload.recipient_keys.ed25519,
+ our_key: this.olmDevice.deviceEd25519Key!,
+ });
+ }
+
+ // check that the device that encrypted the event belongs to the user
+ // that the event claims it's from. We need to make sure that our
+ // device list is up-to-date. If the device is unknown, we can only
+ // assume that the device logged out. Some event handlers, such as
+ // secret sharing, may be more strict and reject events that come from
+ // unknown devices.
+ await this.crypto.deviceList.downloadKeys([event.getSender()!], false);
+ const senderKeyUser = this.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, deviceKey);
+ if (senderKeyUser !== event.getSender() && senderKeyUser != undefined) {
+ throw new DecryptionError("OLM_BAD_SENDER", "Message claimed to be from " + event.getSender(), {
+ real_sender: senderKeyUser,
+ });
+ }
+
+ // check that the original sender matches what the homeserver told us, to
+ // avoid people masquerading as others.
+ // (this check is also provided via the sender's embedded ed25519 key,
+ // which is checked elsewhere).
+ if (payload.sender != event.getSender()) {
+ throw new DecryptionError("OLM_FORWARDED_MESSAGE", "Message forwarded from " + payload.sender, {
+ reported_sender: event.getSender()!,
+ });
+ }
+
+ // Olm events intended for a room have a room_id.
+ if (payload.room_id !== event.getRoomId()) {
+ throw new DecryptionError("OLM_BAD_ROOM", "Message intended for room " + payload.room_id, {
+ reported_room: event.getRoomId() || "ROOM_ID_UNDEFINED",
+ });
+ }
+
+ const claimedKeys = payload.keys || {};
+
+ return {
+ clearEvent: payload,
+ senderCurve25519Key: deviceKey,
+ claimedEd25519Key: claimedKeys.ed25519 || null,
+ };
+ }
+
+ /**
+ * Attempt to decrypt an Olm message
+ *
+ * @param theirDeviceIdentityKey - Curve25519 identity key of the sender
+ * @param message - message object, with 'type' and 'body' fields
+ *
+ * @returns payload, if decrypted successfully.
+ */
+ private decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> {
+ // This is a wrapper that serialises decryptions of prekey messages, because
+ // otherwise we race between deciding we have no active sessions for the message
+ // and creating a new one, which we can only do once because it removes the OTK.
+ if (message.type !== 0) {
+ // not a prekey message: we can safely just try & decrypt it
+ return this.reallyDecryptMessage(theirDeviceIdentityKey, message);
+ } else {
+ const myPromise = this.olmDevice.olmPrekeyPromise.then(() => {
+ return this.reallyDecryptMessage(theirDeviceIdentityKey, message);
+ });
+ // we want the error, but don't propagate it to the next decryption
+ this.olmDevice.olmPrekeyPromise = myPromise.catch(() => {});
+ return myPromise;
+ }
+ }
+
+ private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise<string> {
+ const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey);
+
+ // try each session in turn.
+ const decryptionErrors: Record<string, string> = {};
+ for (const sessionId of sessionIds) {
+ try {
+ const payload = await this.olmDevice.decryptMessage(
+ theirDeviceIdentityKey,
+ sessionId,
+ message.type,
+ message.body,
+ );
+ logger.log("Decrypted Olm message from " + theirDeviceIdentityKey + " with session " + sessionId);
+ return payload;
+ } catch (e) {
+ const foundSession = await this.olmDevice.matchesSession(
+ theirDeviceIdentityKey,
+ sessionId,
+ message.type,
+ message.body,
+ );
+
+ if (foundSession) {
+ // decryption failed, but it was a prekey message matching this
+ // session, so it should have worked.
+ throw new Error(
+ "Error decrypting prekey message with existing session id " +
+ sessionId +
+ ": " +
+ (<Error>e).message,
+ );
+ }
+
+ // otherwise it's probably a message for another session; carry on, but
+ // keep a record of the error
+ decryptionErrors[sessionId] = (<Error>e).message;
+ }
+ }
+
+ if (message.type !== 0) {
+ // not a prekey message, so it should have matched an existing session, but it
+ // didn't work.
+
+ if (sessionIds.length === 0) {
+ throw new Error("No existing sessions");
+ }
+
+ throw new Error(
+ "Error decrypting non-prekey message with existing sessions: " + JSON.stringify(decryptionErrors),
+ );
+ }
+
+ // prekey message which doesn't match any existing sessions: make a new
+ // session.
+
+ let res: IInboundSession;
+ try {
+ res = await this.olmDevice.createInboundSession(theirDeviceIdentityKey, message.type, message.body);
+ } catch (e) {
+ decryptionErrors["(new)"] = (<Error>e).message;
+ throw new Error("Error decrypting prekey message: " + JSON.stringify(decryptionErrors));
+ }
+
+ logger.log("created new inbound Olm session ID " + res.session_id + " with " + theirDeviceIdentityKey);
+ return res.payload;
+ }
+}
+
+registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption);
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts
new file mode 100644
index 0000000..9e9ba52
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/api.ts
@@ -0,0 +1,127 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { DeviceInfo } from "./deviceinfo";
+import { IKeyBackupInfo } from "./keybackup";
+import { PassphraseInfo } from "../secret-storage";
+
+/* re-exports for backwards compatibility. */
+export {
+ PassphraseInfo as IPassphraseInfo,
+ SecretStorageKeyDescription as ISecretStorageKeyInfo,
+} from "../secret-storage";
+
+// TODO: Merge this with crypto.js once converted
+
+export enum CrossSigningKey {
+ Master = "master",
+ SelfSigning = "self_signing",
+ UserSigning = "user_signing",
+}
+
+export interface IEncryptedEventInfo {
+ /**
+ * whether the event is encrypted (if not encrypted, some of the other properties may not be set)
+ */
+ encrypted: boolean;
+
+ /**
+ * the sender's key
+ */
+ senderKey: string;
+
+ /**
+ * the algorithm used to encrypt the event
+ */
+ algorithm: string;
+
+ /**
+ * whether we can be sure that the owner of the senderKey sent the event
+ */
+ authenticated: boolean;
+
+ /**
+ * the sender's device information, if available
+ */
+ sender?: DeviceInfo;
+
+ /**
+ * if the event's ed25519 and curve25519 keys don't match (only meaningful if `sender` is set)
+ */
+ mismatchedSender: boolean;
+}
+
+export interface IRecoveryKey {
+ keyInfo?: IAddSecretStorageKeyOpts;
+ privateKey: Uint8Array;
+ encodedPrivateKey?: string;
+}
+
+export interface ICreateSecretStorageOpts {
+ /**
+ * Function called to await a secret storage key creation flow.
+ * @returns Promise resolving to an object with public key metadata, encoded private
+ * recovery key which should be disposed of after displaying to the user,
+ * and raw private key to avoid round tripping if needed.
+ */
+ createSecretStorageKey?: () => Promise<IRecoveryKey>;
+
+ /**
+ * The current key backup object. If passed,
+ * the passphrase and recovery key from this backup will be used.
+ */
+ keyBackupInfo?: IKeyBackupInfo;
+
+ /**
+ * If true, a new key backup version will be
+ * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
+ * is supplied.
+ */
+ setupNewKeyBackup?: boolean;
+
+ /**
+ * Reset even if keys already exist.
+ */
+ setupNewSecretStorage?: boolean;
+
+ /**
+ * Function called to get the user's
+ * current key backup passphrase. Should return a promise that resolves with a Uint8Array
+ * containing the key, or rejects if the key cannot be obtained.
+ */
+ getKeyBackupPassphrase?: () => Promise<Uint8Array>;
+}
+
+export interface IAddSecretStorageKeyOpts {
+ pubkey?: string;
+ passphrase?: PassphraseInfo;
+ name?: string;
+ key?: Uint8Array;
+}
+
+export interface IImportOpts {
+ stage: string; // TODO: Enum
+ successes: number;
+ failures: number;
+ total: number;
+}
+
+export interface IImportRoomKeysOpts {
+ /** called with an object that has a "stage" param */
+ progressCallback?: (stage: IImportOpts) => void;
+ untrusted?: boolean;
+ source?: string; // TODO: Enum
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts
new file mode 100644
index 0000000..d240bda
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/backup.ts
@@ -0,0 +1,813 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Classes for dealing with key backup.
+ */
+
+import type { IMegolmSessionData } from "../@types/crypto";
+import { MatrixClient } from "../client";
+import { logger } from "../logger";
+import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib";
+import { DeviceInfo } from "./deviceinfo";
+import { DeviceTrustLevel } from "./CrossSigning";
+import { keyFromPassphrase } from "./key_passphrase";
+import { safeSet, sleep } from "../utils";
+import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
+import { encodeRecoveryKey } from "./recoverykey";
+import { calculateKeyCheck, decryptAES, encryptAES, IEncryptedPayload } from "./aes";
+import {
+ Curve25519SessionData,
+ IAes256AuthData,
+ ICurve25519AuthData,
+ IKeyBackupInfo,
+ IKeyBackupSession,
+} from "./keybackup";
+import { UnstableValue } from "../NamespacedValue";
+import { CryptoEvent } from "./index";
+import { crypto } from "./crypto";
+import { HTTPError, MatrixError } from "../http-api";
+
+const KEY_BACKUP_KEYS_PER_REQUEST = 200;
+const KEY_BACKUP_CHECK_RATE_LIMIT = 5000; // ms
+
+type AuthData = IKeyBackupInfo["auth_data"];
+
+type SigInfo = {
+ deviceId: string;
+ valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation
+ device?: DeviceInfo | null;
+ crossSigningId?: boolean;
+ deviceTrust?: DeviceTrustLevel;
+};
+
+export type TrustInfo = {
+ usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device
+ sigs: SigInfo[];
+ // eslint-disable-next-line camelcase
+ trusted_locally?: boolean;
+};
+
+export interface IKeyBackupCheck {
+ backupInfo?: IKeyBackupInfo;
+ trustInfo: TrustInfo;
+}
+
+/* eslint-disable camelcase */
+export interface IPreparedKeyBackupVersion {
+ algorithm: string;
+ auth_data: AuthData;
+ recovery_key: string;
+ privateKey: Uint8Array;
+}
+/* eslint-enable camelcase */
+
+/** A function used to get the secret key for a backup.
+ */
+type GetKey = () => Promise<ArrayLike<number>>;
+
+interface BackupAlgorithmClass {
+ algorithmName: string;
+ // initialize from an existing backup
+ init(authData: AuthData, getKey: GetKey): Promise<BackupAlgorithm>;
+
+ // prepare a brand new backup
+ prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]>;
+
+ checkBackupVersion(info: IKeyBackupInfo): void;
+}
+
+interface BackupAlgorithm {
+ untrusted: boolean;
+ encryptSession(data: Record<string, any>): Promise<Curve25519SessionData | IEncryptedPayload>;
+ decryptSessions(ciphertexts: Record<string, IKeyBackupSession>): Promise<IMegolmSessionData[]>;
+ authData: AuthData;
+ keyMatches(key: ArrayLike<number>): Promise<boolean>;
+ free(): void;
+}
+
+export interface IKeyBackup {
+ rooms: {
+ [roomId: string]: {
+ sessions: {
+ [sessionId: string]: IKeyBackupSession;
+ };
+ };
+ };
+}
+
+/**
+ * Manages the key backup.
+ */
+export class BackupManager {
+ private algorithm: BackupAlgorithm | undefined;
+ public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version
+ public checkedForBackup: boolean; // Have we checked the server for a backup we can use?
+ private sendingBackups: boolean; // Are we currently sending backups?
+ private sessionLastCheckAttemptedTime: Record<string, number> = {}; // When did we last try to check the server for a given session id?
+
+ public constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) {
+ this.checkedForBackup = false;
+ this.sendingBackups = false;
+ }
+
+ public get version(): string | undefined {
+ return this.backupInfo && this.backupInfo.version;
+ }
+
+ /**
+ * Performs a quick check to ensure that the backup info looks sane.
+ *
+ * Throws an error if a problem is detected.
+ *
+ * @param info - the key backup info
+ */
+ public static checkBackupVersion(info: IKeyBackupInfo): void {
+ const Algorithm = algorithmsByName[info.algorithm];
+ if (!Algorithm) {
+ throw new Error("Unknown backup algorithm: " + info.algorithm);
+ }
+ if (typeof info.auth_data !== "object") {
+ throw new Error("Invalid backup data returned");
+ }
+ return Algorithm.checkBackupVersion(info);
+ }
+
+ public static makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
+ const Algorithm = algorithmsByName[info.algorithm];
+ if (!Algorithm) {
+ throw new Error("Unknown backup algorithm");
+ }
+ return Algorithm.init(info.auth_data, getKey);
+ }
+
+ public async enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
+ this.backupInfo = info;
+ if (this.algorithm) {
+ this.algorithm.free();
+ }
+
+ this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
+
+ this.baseApis.emit(CryptoEvent.KeyBackupStatus, true);
+
+ // There may be keys left over from a partially completed backup, so
+ // schedule a send to check.
+ this.scheduleKeyBackupSend();
+ }
+
+ /**
+ * Disable backing up of keys.
+ */
+ public disableKeyBackup(): void {
+ if (this.algorithm) {
+ this.algorithm.free();
+ }
+ this.algorithm = undefined;
+
+ this.backupInfo = undefined;
+
+ this.baseApis.emit(CryptoEvent.KeyBackupStatus, false);
+ }
+
+ public getKeyBackupEnabled(): boolean | null {
+ if (!this.checkedForBackup) {
+ return null;
+ }
+ return Boolean(this.algorithm);
+ }
+
+ public async prepareKeyBackupVersion(
+ key?: string | Uint8Array | null,
+ algorithm?: string | undefined,
+ ): Promise<IPreparedKeyBackupVersion> {
+ const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm;
+ if (!Algorithm) {
+ throw new Error("Unknown backup algorithm");
+ }
+
+ const [privateKey, authData] = await Algorithm.prepare(key);
+ const recoveryKey = encodeRecoveryKey(privateKey)!;
+ return {
+ algorithm: Algorithm.algorithmName,
+ auth_data: authData,
+ recovery_key: recoveryKey,
+ privateKey,
+ };
+ }
+
+ public async createKeyBackupVersion(info: IKeyBackupInfo): Promise<void> {
+ this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
+ }
+
+ /**
+ * Check the server for an active key backup and
+ * if one is present and has a valid signature from
+ * one of the user's verified devices, start backing up
+ * to it.
+ */
+ public async checkAndStart(): Promise<IKeyBackupCheck | null> {
+ logger.log("Checking key backup status...");
+ if (this.baseApis.isGuest()) {
+ logger.log("Skipping key backup check since user is guest");
+ this.checkedForBackup = true;
+ return null;
+ }
+ let backupInfo: IKeyBackupInfo | undefined;
+ try {
+ backupInfo = (await this.baseApis.getKeyBackupVersion()) ?? undefined;
+ } catch (e) {
+ logger.log("Error checking for active key backup", e);
+ if ((<HTTPError>e).httpStatus === 404) {
+ // 404 is returned when the key backup does not exist, so that
+ // counts as successfully checking.
+ this.checkedForBackup = true;
+ }
+ return null;
+ }
+ this.checkedForBackup = true;
+
+ const trustInfo = await this.isKeyBackupTrusted(backupInfo);
+
+ if (trustInfo.usable && !this.backupInfo) {
+ logger.log(`Found usable key backup v${backupInfo!.version}: enabling key backups`);
+ await this.enableKeyBackup(backupInfo!);
+ } else if (!trustInfo.usable && this.backupInfo) {
+ logger.log("No usable key backup: disabling key backup");
+ this.disableKeyBackup();
+ } else if (!trustInfo.usable && !this.backupInfo) {
+ logger.log("No usable key backup: not enabling key backup");
+ } else if (trustInfo.usable && this.backupInfo) {
+ // may not be the same version: if not, we should switch
+ if (backupInfo!.version !== this.backupInfo.version) {
+ logger.log(
+ `On backup version ${this.backupInfo.version} but ` +
+ `found version ${backupInfo!.version}: switching.`,
+ );
+ this.disableKeyBackup();
+ await this.enableKeyBackup(backupInfo!);
+ // We're now using a new backup, so schedule all the keys we have to be
+ // uploaded to the new backup. This is a bit of a workaround to upload
+ // keys to a new backup in *most* cases, but it won't cover all cases
+ // because we don't remember what backup version we uploaded keys to:
+ // see https://github.com/vector-im/element-web/issues/14833
+ await this.scheduleAllGroupSessionsForBackup();
+ } else {
+ logger.log(`Backup version ${backupInfo!.version} still current`);
+ }
+ }
+
+ return { backupInfo, trustInfo };
+ }
+
+ /**
+ * Forces a re-check of the key backup and enables/disables it
+ * as appropriate.
+ *
+ * @returns Object with backup info (as returned by
+ * getKeyBackupVersion) in backupInfo and
+ * trust information (as returned by isKeyBackupTrusted)
+ * in trustInfo.
+ */
+ public async checkKeyBackup(): Promise<IKeyBackupCheck | null> {
+ this.checkedForBackup = false;
+ return this.checkAndStart();
+ }
+
+ /**
+ * Attempts to retrieve a session from a key backup, if enough time
+ * has elapsed since the last check for this session id.
+ */
+ public async queryKeyBackupRateLimited(
+ targetRoomId: string | undefined,
+ targetSessionId: string | undefined,
+ ): Promise<void> {
+ if (!this.backupInfo) {
+ return;
+ }
+
+ const now = new Date().getTime();
+ if (
+ !this.sessionLastCheckAttemptedTime[targetSessionId!] ||
+ now - this.sessionLastCheckAttemptedTime[targetSessionId!] > KEY_BACKUP_CHECK_RATE_LIMIT
+ ) {
+ this.sessionLastCheckAttemptedTime[targetSessionId!] = now;
+ await this.baseApis.restoreKeyBackupWithCache(targetRoomId!, targetSessionId!, this.backupInfo, {});
+ }
+ }
+
+ /**
+ * Check if the given backup info is trusted.
+ *
+ * @param backupInfo - key backup info dict from /room_keys/version
+ */
+ public async isKeyBackupTrusted(backupInfo?: IKeyBackupInfo): Promise<TrustInfo> {
+ const ret = {
+ usable: false,
+ trusted_locally: false,
+ sigs: [] as SigInfo[],
+ };
+
+ if (!backupInfo || !backupInfo.algorithm || !backupInfo.auth_data || !backupInfo.auth_data.signatures) {
+ logger.info("Key backup is absent or missing required data");
+ return ret;
+ }
+
+ const userId = this.baseApis.getUserId()!;
+ const privKey = await this.baseApis.crypto!.getSessionBackupPrivateKey();
+ if (privKey) {
+ let algorithm: BackupAlgorithm | null = null;
+ try {
+ algorithm = await BackupManager.makeAlgorithm(backupInfo, async () => privKey);
+
+ if (await algorithm.keyMatches(privKey)) {
+ logger.info("Backup is trusted locally");
+ ret.trusted_locally = true;
+ }
+ } catch {
+ // do nothing -- if we have an error, then we don't mark it as
+ // locally trusted
+ } finally {
+ algorithm?.free();
+ }
+ }
+
+ const mySigs = backupInfo.auth_data.signatures[userId] || {};
+
+ for (const keyId of Object.keys(mySigs)) {
+ const keyIdParts = keyId.split(":");
+ if (keyIdParts[0] !== "ed25519") {
+ logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
+ continue;
+ }
+ // Could be a cross-signing master key, but just say this is the device
+ // ID for backwards compat
+ const sigInfo: SigInfo = { deviceId: keyIdParts[1] };
+
+ // first check to see if it's from our cross-signing key
+ const crossSigningId = this.baseApis.crypto!.crossSigningInfo.getId();
+ if (crossSigningId === sigInfo.deviceId) {
+ sigInfo.crossSigningId = true;
+ try {
+ await verifySignature(
+ this.baseApis.crypto!.olmDevice,
+ backupInfo.auth_data,
+ userId,
+ sigInfo.deviceId,
+ crossSigningId,
+ );
+ sigInfo.valid = true;
+ } catch (e) {
+ logger.warn("Bad signature from cross signing key " + crossSigningId, e);
+ sigInfo.valid = false;
+ }
+ ret.sigs.push(sigInfo);
+ continue;
+ }
+
+ // Now look for a sig from a device
+ // At some point this can probably go away and we'll just support
+ // it being signed by the cross-signing master key
+ const device = this.baseApis.crypto!.deviceList.getStoredDevice(userId, sigInfo.deviceId);
+ if (device) {
+ sigInfo.device = device;
+ sigInfo.deviceTrust = this.baseApis.checkDeviceTrust(userId, sigInfo.deviceId);
+ try {
+ await verifySignature(
+ this.baseApis.crypto!.olmDevice,
+ backupInfo.auth_data,
+ userId,
+ device.deviceId,
+ device.getFingerprint(),
+ );
+ sigInfo.valid = true;
+ } catch (e) {
+ logger.info(
+ "Bad signature from key ID " +
+ keyId +
+ " userID " +
+ this.baseApis.getUserId() +
+ " device ID " +
+ device.deviceId +
+ " fingerprint: " +
+ device.getFingerprint(),
+ backupInfo.auth_data,
+ e,
+ );
+ sigInfo.valid = false;
+ }
+ } else {
+ sigInfo.valid = null; // Can't determine validity because we don't have the signing device
+ logger.info("Ignoring signature from unknown key " + keyId);
+ }
+ ret.sigs.push(sigInfo);
+ }
+
+ ret.usable = ret.sigs.some((s) => {
+ return s.valid && ((s.device && s.deviceTrust?.isVerified()) || s.crossSigningId);
+ });
+ return ret;
+ }
+
+ /**
+ * Schedules sending all keys waiting to be sent to the backup, if not already
+ * scheduled. Retries if necessary.
+ *
+ * @param maxDelay - Maximum delay to wait in ms. 0 means no delay.
+ */
+ public async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> {
+ if (this.sendingBackups) return;
+
+ this.sendingBackups = true;
+
+ try {
+ // wait between 0 and `maxDelay` seconds, to avoid backup
+ // requests from different clients hitting the server all at
+ // the same time when a new key is sent
+ const delay = Math.random() * maxDelay;
+ await sleep(delay);
+ let numFailures = 0; // number of consecutive failures
+ for (;;) {
+ if (!this.algorithm) {
+ return;
+ }
+ try {
+ const numBackedUp = await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
+ if (numBackedUp === 0) {
+ // no sessions left needing backup: we're done
+ return;
+ }
+ numFailures = 0;
+ } catch (err) {
+ numFailures++;
+ logger.log("Key backup request failed", err);
+ if ((<MatrixError>err).data) {
+ if (
+ (<MatrixError>err).data.errcode == "M_NOT_FOUND" ||
+ (<MatrixError>err).data.errcode == "M_WRONG_ROOM_KEYS_VERSION"
+ ) {
+ // Re-check key backup status on error, so we can be
+ // sure to present the current situation when asked.
+ await this.checkKeyBackup();
+ // Backup version has changed or this backup version
+ // has been deleted
+ this.baseApis.crypto!.emit(CryptoEvent.KeyBackupFailed, (<MatrixError>err).data.errcode!);
+ throw err;
+ }
+ }
+ }
+ if (numFailures) {
+ // exponential backoff if we have failures
+ await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
+ }
+ }
+ } finally {
+ this.sendingBackups = false;
+ }
+ }
+
+ /**
+ * Take some e2e keys waiting to be backed up and send them
+ * to the backup.
+ *
+ * @param limit - Maximum number of keys to back up
+ * @returns Number of sessions backed up
+ */
+ public async backupPendingKeys(limit: number): Promise<number> {
+ const sessions = await this.baseApis.crypto!.cryptoStore.getSessionsNeedingBackup(limit);
+ if (!sessions.length) {
+ return 0;
+ }
+
+ let remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup();
+ this.baseApis.crypto!.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
+
+ const rooms: IKeyBackup["rooms"] = {};
+ for (const session of sessions) {
+ const roomId = session.sessionData!.room_id;
+ safeSet(rooms, roomId, rooms[roomId] || { sessions: {} });
+
+ const sessionData = this.baseApis.crypto!.olmDevice.exportInboundGroupSession(
+ session.senderKey,
+ session.sessionId,
+ session.sessionData!,
+ );
+ sessionData.algorithm = MEGOLM_ALGORITHM;
+
+ const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length;
+
+ const userId = this.baseApis.crypto!.deviceList.getUserByIdentityKey(MEGOLM_ALGORITHM, session.senderKey);
+ const device =
+ this.baseApis.crypto!.deviceList.getDeviceByIdentityKey(MEGOLM_ALGORITHM, session.senderKey) ??
+ undefined;
+ const verified = this.baseApis.crypto!.checkDeviceInfoTrust(userId!, device).isVerified();
+
+ safeSet(rooms[roomId]["sessions"], session.sessionId, {
+ first_message_index: sessionData.first_known_index,
+ forwarded_count: forwardedCount,
+ is_verified: verified,
+ session_data: await this.algorithm!.encryptSession(sessionData),
+ });
+ }
+
+ await this.baseApis.sendKeyBackup(undefined, undefined, this.backupInfo!.version, { rooms });
+
+ await this.baseApis.crypto!.cryptoStore.unmarkSessionsNeedingBackup(sessions);
+ remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup();
+ this.baseApis.crypto!.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
+
+ return sessions.length;
+ }
+
+ public async backupGroupSession(senderKey: string, sessionId: string): Promise<void> {
+ await this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([
+ {
+ senderKey: senderKey,
+ sessionId: sessionId,
+ },
+ ]);
+
+ if (this.backupInfo) {
+ // don't wait for this to complete: it will delay so
+ // happens in the background
+ this.scheduleKeyBackupSend();
+ }
+ // if this.backupInfo is not set, then the keys will be backed up when
+ // this.enableKeyBackup is called
+ }
+
+ /**
+ * Marks all group sessions as needing to be backed up and schedules them to
+ * upload in the background as soon as possible.
+ */
+ public async scheduleAllGroupSessionsForBackup(): Promise<void> {
+ await this.flagAllGroupSessionsForBackup();
+
+ // Schedule keys to upload in the background as soon as possible.
+ this.scheduleKeyBackupSend(0 /* maxDelay */);
+ }
+
+ /**
+ * Marks all group sessions as needing to be backed up without scheduling
+ * them to upload in the background.
+ * @returns Promise which resolves to the number of sessions now requiring a backup
+ * (which will be equal to the number of sessions in the store).
+ */
+ public async flagAllGroupSessionsForBackup(): Promise<number> {
+ await this.baseApis.crypto!.cryptoStore.doTxn(
+ "readwrite",
+ [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP],
+ (txn) => {
+ this.baseApis.crypto!.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
+ if (session !== null) {
+ this.baseApis.crypto!.cryptoStore.markSessionsNeedingBackup([session], txn);
+ }
+ });
+ },
+ );
+
+ const remaining = await this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup();
+ this.baseApis.emit(CryptoEvent.KeyBackupSessionsRemaining, remaining);
+ return remaining;
+ }
+
+ /**
+ * Counts the number of end to end session keys that are waiting to be backed up
+ * @returns Promise which resolves to the number of sessions requiring backup
+ */
+ public countSessionsNeedingBackup(): Promise<number> {
+ return this.baseApis.crypto!.cryptoStore.countSessionsNeedingBackup();
+ }
+}
+
+export class Curve25519 implements BackupAlgorithm {
+ public static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2";
+
+ public constructor(
+ public authData: ICurve25519AuthData,
+ private publicKey: any, // FIXME: PkEncryption
+ private getKey: () => Promise<Uint8Array>,
+ ) {}
+
+ public static async init(authData: AuthData, getKey: () => Promise<Uint8Array>): Promise<Curve25519> {
+ if (!authData || !("public_key" in authData)) {
+ throw new Error("auth_data missing required information");
+ }
+ const publicKey = new global.Olm.PkEncryption();
+ publicKey.set_recipient_key(authData.public_key);
+ return new Curve25519(authData as ICurve25519AuthData, publicKey, getKey);
+ }
+
+ public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> {
+ const decryption = new global.Olm.PkDecryption();
+ try {
+ const authData: Partial<ICurve25519AuthData> = {};
+ if (!key) {
+ authData.public_key = decryption.generate_key();
+ } else if (key instanceof Uint8Array) {
+ authData.public_key = decryption.init_with_private_key(key);
+ } else {
+ const derivation = await keyFromPassphrase(key);
+ authData.private_key_salt = derivation.salt;
+ authData.private_key_iterations = derivation.iterations;
+ authData.public_key = decryption.init_with_private_key(derivation.key);
+ }
+ const publicKey = new global.Olm.PkEncryption();
+ publicKey.set_recipient_key(authData.public_key);
+
+ return [decryption.get_private_key(), authData as AuthData];
+ } finally {
+ decryption.free();
+ }
+ }
+
+ public static checkBackupVersion(info: IKeyBackupInfo): void {
+ if (!("public_key" in info.auth_data)) {
+ throw new Error("Invalid backup data returned");
+ }
+ }
+
+ public get untrusted(): boolean {
+ return true;
+ }
+
+ public async encryptSession(data: Record<string, any>): Promise<Curve25519SessionData> {
+ const plainText: Record<string, any> = Object.assign({}, data);
+ delete plainText.session_id;
+ delete plainText.room_id;
+ delete plainText.first_known_index;
+ return this.publicKey.encrypt(JSON.stringify(plainText));
+ }
+
+ public async decryptSessions(
+ sessions: Record<string, IKeyBackupSession<Curve25519SessionData>>,
+ ): Promise<IMegolmSessionData[]> {
+ const privKey = await this.getKey();
+ const decryption = new global.Olm.PkDecryption();
+ try {
+ const backupPubKey = decryption.init_with_private_key(privKey);
+
+ if (backupPubKey !== this.authData.public_key) {
+ throw new MatrixError({ errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY });
+ }
+
+ const keys: IMegolmSessionData[] = [];
+
+ for (const [sessionId, sessionData] of Object.entries(sessions)) {
+ try {
+ const decrypted = JSON.parse(
+ decryption.decrypt(
+ sessionData.session_data.ephemeral,
+ sessionData.session_data.mac,
+ sessionData.session_data.ciphertext,
+ ),
+ );
+ decrypted.session_id = sessionId;
+ keys.push(decrypted);
+ } catch (e) {
+ logger.log("Failed to decrypt megolm session from backup", e, sessionData);
+ }
+ }
+ return keys;
+ } finally {
+ decryption.free();
+ }
+ }
+
+ public async keyMatches(key: Uint8Array): Promise<boolean> {
+ const decryption = new global.Olm.PkDecryption();
+ let pubKey: string;
+ try {
+ pubKey = decryption.init_with_private_key(key);
+ } finally {
+ decryption.free();
+ }
+
+ return pubKey === this.authData.public_key;
+ }
+
+ public free(): void {
+ this.publicKey.free();
+ }
+}
+
+function randomBytes(size: number): Uint8Array {
+ const buf = new Uint8Array(size);
+ crypto.getRandomValues(buf);
+ return buf;
+}
+
+const UNSTABLE_MSC3270_NAME = new UnstableValue(
+ "m.megolm_backup.v1.aes-hmac-sha2",
+ "org.matrix.msc3270.v1.aes-hmac-sha2",
+);
+
+export class Aes256 implements BackupAlgorithm {
+ public static algorithmName = UNSTABLE_MSC3270_NAME.name;
+
+ public constructor(public readonly authData: IAes256AuthData, private readonly key: Uint8Array) {}
+
+ public static async init(authData: IAes256AuthData, getKey: () => Promise<Uint8Array>): Promise<Aes256> {
+ if (!authData) {
+ throw new Error("auth_data missing");
+ }
+ const key = await getKey();
+ if (authData.mac) {
+ const { mac } = await calculateKeyCheck(key, authData.iv);
+ if (authData.mac.replace(/=+$/g, "") !== mac.replace(/=+/g, "")) {
+ throw new Error("Key does not match");
+ }
+ }
+ return new Aes256(authData, key);
+ }
+
+ public static async prepare(key?: string | Uint8Array | null): Promise<[Uint8Array, AuthData]> {
+ let outKey: Uint8Array;
+ const authData: Partial<IAes256AuthData> = {};
+ if (!key) {
+ outKey = randomBytes(32);
+ } else if (key instanceof Uint8Array) {
+ outKey = new Uint8Array(key);
+ } else {
+ const derivation = await keyFromPassphrase(key);
+ authData.private_key_salt = derivation.salt;
+ authData.private_key_iterations = derivation.iterations;
+ outKey = derivation.key;
+ }
+
+ const { iv, mac } = await calculateKeyCheck(outKey);
+ authData.iv = iv;
+ authData.mac = mac;
+
+ return [outKey, authData as AuthData];
+ }
+
+ public static checkBackupVersion(info: IKeyBackupInfo): void {
+ if (!("iv" in info.auth_data && "mac" in info.auth_data)) {
+ throw new Error("Invalid backup data returned");
+ }
+ }
+
+ public get untrusted(): boolean {
+ return false;
+ }
+
+ public encryptSession(data: Record<string, any>): Promise<IEncryptedPayload> {
+ const plainText: Record<string, any> = Object.assign({}, data);
+ delete plainText.session_id;
+ delete plainText.room_id;
+ delete plainText.first_known_index;
+ return encryptAES(JSON.stringify(plainText), this.key, data.session_id);
+ }
+
+ public async decryptSessions(
+ sessions: Record<string, IKeyBackupSession<IEncryptedPayload>>,
+ ): Promise<IMegolmSessionData[]> {
+ const keys: IMegolmSessionData[] = [];
+
+ for (const [sessionId, sessionData] of Object.entries(sessions)) {
+ try {
+ const decrypted = JSON.parse(await decryptAES(sessionData.session_data, this.key, sessionId));
+ decrypted.session_id = sessionId;
+ keys.push(decrypted);
+ } catch (e) {
+ logger.log("Failed to decrypt megolm session from backup", e, sessionData);
+ }
+ }
+ return keys;
+ }
+
+ public async keyMatches(key: Uint8Array): Promise<boolean> {
+ if (this.authData.mac) {
+ const { mac } = await calculateKeyCheck(key, this.authData.iv);
+ return this.authData.mac.replace(/=+$/g, "") === mac.replace(/=+/g, "");
+ } else {
+ // if we have no information, we have to assume the key is right
+ return true;
+ }
+ }
+
+ public free(): void {
+ this.key.fill(0);
+ }
+}
+
+export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
+ [Curve25519.algorithmName]: Curve25519,
+ [Aes256.algorithmName]: Aes256,
+};
+
+export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts
new file mode 100644
index 0000000..704754f
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/crypto.ts
@@ -0,0 +1,50 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "../logger";
+
+export let crypto = global.window?.crypto;
+export let subtleCrypto = global.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle;
+export let TextEncoder = global.window?.TextEncoder;
+
+/* eslint-disable @typescript-eslint/no-var-requires */
+if (!crypto) {
+ try {
+ crypto = require("crypto").webcrypto;
+ } catch (e) {
+ logger.error("Failed to load webcrypto", e);
+ }
+}
+if (!subtleCrypto) {
+ subtleCrypto = crypto?.subtle;
+}
+if (!TextEncoder) {
+ try {
+ TextEncoder = require("util").TextEncoder;
+ } catch (e) {
+ logger.error("Failed to load TextEncoder util", e);
+ }
+}
+/* eslint-enable @typescript-eslint/no-var-requires */
+
+export function setCrypto(_crypto: Crypto): void {
+ crypto = _crypto;
+ subtleCrypto = _crypto.subtle ?? _crypto.webkitSubtle;
+}
+
+export function setTextEncoder(_TextEncoder: typeof TextEncoder): void {
+ TextEncoder = _TextEncoder;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts
new file mode 100644
index 0000000..373b236
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/dehydration.ts
@@ -0,0 +1,271 @@
+/*
+Copyright 2020-2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import anotherjson from "another-json";
+
+import type { IDeviceKeys, IOneTimeKey } from "../@types/crypto";
+import { decodeBase64, encodeBase64 } from "./olmlib";
+import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
+import { decryptAES, encryptAES } from "./aes";
+import { logger } from "../logger";
+import { Crypto } from "./index";
+import { Method } from "../http-api";
+import { SecretStorageKeyDescription } from "../secret-storage";
+
+export interface IDehydratedDevice {
+ device_id: string; // eslint-disable-line camelcase
+ device_data: SecretStorageKeyDescription & {
+ // eslint-disable-line camelcase
+ algorithm: string;
+ account: string; // pickle
+ };
+}
+
+export interface IDehydratedDeviceKeyInfo {
+ passphrase?: string;
+}
+
+export const DEHYDRATION_ALGORITHM = "org.matrix.msc2697.v1.olm.libolm_pickle";
+
+const oneweek = 7 * 24 * 60 * 60 * 1000;
+
+export class DehydrationManager {
+ private inProgress = false;
+ private timeoutId: any;
+ private key?: Uint8Array;
+ private keyInfo?: { [props: string]: any };
+ private deviceDisplayName?: string;
+
+ public constructor(private readonly crypto: Crypto) {
+ this.getDehydrationKeyFromCache();
+ }
+
+ public getDehydrationKeyFromCache(): Promise<void> {
+ return this.crypto.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.crypto.cryptoStore.getSecretStorePrivateKey(
+ txn,
+ async (result) => {
+ if (result) {
+ const { key, keyInfo, deviceDisplayName, time } = result;
+ const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey);
+ const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM);
+ this.key = decodeBase64(decrypted);
+ this.keyInfo = keyInfo;
+ this.deviceDisplayName = deviceDisplayName;
+ const now = Date.now();
+ const delay = Math.max(1, time + oneweek - now);
+ this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), delay);
+ }
+ },
+ "dehydration",
+ );
+ });
+ }
+
+ /** set the key, and queue periodic dehydration to the server in the background */
+ public async setKeyAndQueueDehydration(
+ key: Uint8Array,
+ keyInfo: { [props: string]: any } = {},
+ deviceDisplayName?: string,
+ ): Promise<void> {
+ const matches = await this.setKey(key, keyInfo, deviceDisplayName);
+ if (!matches) {
+ // start dehydration in the background
+ this.dehydrateDevice();
+ }
+ }
+
+ public async setKey(
+ key: Uint8Array,
+ keyInfo: { [props: string]: any } = {},
+ deviceDisplayName?: string,
+ ): Promise<boolean | undefined> {
+ if (!key) {
+ // unsetting the key -- cancel any pending dehydration task
+ if (this.timeoutId) {
+ global.clearTimeout(this.timeoutId);
+ this.timeoutId = undefined;
+ }
+ // clear storage
+ await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", null);
+ });
+ this.key = undefined;
+ this.keyInfo = undefined;
+ return;
+ }
+
+ // Check to see if it's the same key as before. If it's different,
+ // dehydrate a new device. If it's the same, we can keep the same
+ // device. (Assume that keyInfo and deviceDisplayName will be the
+ // same if the key is the same.)
+ let matches: boolean = !!this.key && key.length == this.key.length;
+ for (let i = 0; matches && i < key.length; i++) {
+ if (key[i] != this.key![i]) {
+ matches = false;
+ }
+ }
+ if (!matches) {
+ this.key = key;
+ this.keyInfo = keyInfo;
+ this.deviceDisplayName = deviceDisplayName;
+ }
+ return matches;
+ }
+
+ /** returns the device id of the newly created dehydrated device */
+ public async dehydrateDevice(): Promise<string | undefined> {
+ if (this.inProgress) {
+ logger.log("Dehydration already in progress -- not starting new dehydration");
+ return;
+ }
+ this.inProgress = true;
+ if (this.timeoutId) {
+ global.clearTimeout(this.timeoutId);
+ this.timeoutId = undefined;
+ }
+ try {
+ const pickleKey = Buffer.from(this.crypto.olmDevice.pickleKey);
+
+ // update the crypto store with the timestamp
+ const key = await encryptAES(encodeBase64(this.key!), pickleKey, DEHYDRATION_ALGORITHM);
+ await this.crypto.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.crypto.cryptoStore.storeSecretStorePrivateKey(txn, "dehydration", {
+ keyInfo: this.keyInfo,
+ key,
+ deviceDisplayName: this.deviceDisplayName!,
+ time: Date.now(),
+ });
+ });
+ logger.log("Attempting to dehydrate device");
+
+ logger.log("Creating account");
+ // create the account and all the necessary keys
+ const account = new global.Olm.Account();
+ account.create();
+ const e2eKeys = JSON.parse(account.identity_keys());
+
+ const maxKeys = account.max_number_of_one_time_keys();
+ // FIXME: generate in small batches?
+ account.generate_one_time_keys(maxKeys / 2);
+ account.generate_fallback_key();
+ const otks: Record<string, string> = JSON.parse(account.one_time_keys());
+ const fallbacks: Record<string, string> = JSON.parse(account.fallback_key());
+ account.mark_keys_as_published();
+
+ // dehydrate the account and store it on the server
+ const pickledAccount = account.pickle(new Uint8Array(this.key!));
+
+ const deviceData: { [props: string]: any } = {
+ algorithm: DEHYDRATION_ALGORITHM,
+ account: pickledAccount,
+ };
+ if (this.keyInfo!.passphrase) {
+ deviceData.passphrase = this.keyInfo!.passphrase;
+ }
+
+ logger.log("Uploading account to server");
+ // eslint-disable-next-line camelcase
+ const dehydrateResult = await this.crypto.baseApis.http.authedRequest<{ device_id: string }>(
+ Method.Put,
+ "/dehydrated_device",
+ undefined,
+ {
+ device_data: deviceData,
+ initial_device_display_name: this.deviceDisplayName,
+ },
+ {
+ prefix: "/_matrix/client/unstable/org.matrix.msc2697.v2",
+ },
+ );
+
+ // send the keys to the server
+ const deviceId = dehydrateResult.device_id;
+ logger.log("Preparing device keys", deviceId);
+ const deviceKeys: IDeviceKeys = {
+ algorithms: this.crypto.supportedAlgorithms,
+ device_id: deviceId,
+ user_id: this.crypto.userId,
+ keys: {
+ [`ed25519:${deviceId}`]: e2eKeys.ed25519,
+ [`curve25519:${deviceId}`]: e2eKeys.curve25519,
+ },
+ };
+ const deviceSignature = account.sign(anotherjson.stringify(deviceKeys));
+ deviceKeys.signatures = {
+ [this.crypto.userId]: {
+ [`ed25519:${deviceId}`]: deviceSignature,
+ },
+ };
+ if (this.crypto.crossSigningInfo.getId("self_signing")) {
+ await this.crypto.crossSigningInfo.signObject(deviceKeys, "self_signing");
+ }
+
+ logger.log("Preparing one-time keys");
+ const oneTimeKeys: Record<string, IOneTimeKey> = {};
+ for (const [keyId, key] of Object.entries(otks.curve25519)) {
+ const k: IOneTimeKey = { key };
+ const signature = account.sign(anotherjson.stringify(k));
+ k.signatures = {
+ [this.crypto.userId]: {
+ [`ed25519:${deviceId}`]: signature,
+ },
+ };
+ oneTimeKeys[`signed_curve25519:${keyId}`] = k;
+ }
+
+ logger.log("Preparing fallback keys");
+ const fallbackKeys: Record<string, IOneTimeKey> = {};
+ for (const [keyId, key] of Object.entries(fallbacks.curve25519)) {
+ const k: IOneTimeKey = { key, fallback: true };
+ const signature = account.sign(anotherjson.stringify(k));
+ k.signatures = {
+ [this.crypto.userId]: {
+ [`ed25519:${deviceId}`]: signature,
+ },
+ };
+ fallbackKeys[`signed_curve25519:${keyId}`] = k;
+ }
+
+ logger.log("Uploading keys to server");
+ await this.crypto.baseApis.http.authedRequest(
+ Method.Post,
+ "/keys/upload/" + encodeURI(deviceId),
+ undefined,
+ {
+ "device_keys": deviceKeys,
+ "one_time_keys": oneTimeKeys,
+ "org.matrix.msc2732.fallback_keys": fallbackKeys,
+ },
+ );
+ logger.log("Done dehydrating");
+
+ // dehydrate again in a week
+ this.timeoutId = global.setTimeout(this.dehydrateDevice.bind(this), oneweek);
+
+ return deviceId;
+ } finally {
+ this.inProgress = false;
+ }
+ }
+
+ public stop(): void {
+ if (this.timeoutId) {
+ global.clearTimeout(this.timeoutId);
+ this.timeoutId = undefined;
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts
new file mode 100644
index 0000000..b4bb4fd
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/deviceinfo.ts
@@ -0,0 +1,161 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ISignatures } from "../@types/signed";
+
+export interface IDevice {
+ keys: Record<string, string>;
+ algorithms: string[];
+ verified: DeviceVerification;
+ known: boolean;
+ unsigned?: Record<string, any>;
+ signatures?: ISignatures;
+}
+
+enum DeviceVerification {
+ Blocked = -1,
+ Unverified = 0,
+ Verified = 1,
+}
+
+/**
+ * Information about a user's device
+ */
+export class DeviceInfo {
+ /**
+ * rehydrate a DeviceInfo from the session store
+ *
+ * @param obj - raw object from session store
+ * @param deviceId - id of the device
+ *
+ * @returns new DeviceInfo
+ */
+ public static fromStorage(obj: Partial<IDevice>, deviceId: string): DeviceInfo {
+ const res = new DeviceInfo(deviceId);
+ for (const prop in obj) {
+ if (obj.hasOwnProperty(prop)) {
+ // @ts-ignore - this is messy and typescript doesn't like it
+ res[prop as keyof IDevice] = obj[prop as keyof IDevice];
+ }
+ }
+ return res;
+ }
+
+ public static DeviceVerification = {
+ VERIFIED: DeviceVerification.Verified,
+ UNVERIFIED: DeviceVerification.Unverified,
+ BLOCKED: DeviceVerification.Blocked,
+ };
+
+ /** list of algorithms supported by this device */
+ public algorithms: string[] = [];
+ /** a map from `<key type>:<id> -> <base64-encoded key>` */
+ public keys: Record<string, string> = {};
+ /** whether the device has been verified/blocked by the user */
+ public verified = DeviceVerification.Unverified;
+ /**
+ * whether the user knows of this device's existence
+ * (useful when warning the user that a user has added new devices)
+ */
+ public known = false;
+ /** additional data from the homeserver */
+ public unsigned: Record<string, any> = {};
+ public signatures: ISignatures = {};
+
+ /**
+ * @param deviceId - id of the device
+ */
+ public constructor(public readonly deviceId: string) {}
+
+ /**
+ * Prepare a DeviceInfo for JSON serialisation in the session store
+ *
+ * @returns deviceinfo with non-serialised members removed
+ */
+ public toStorage(): IDevice {
+ return {
+ algorithms: this.algorithms,
+ keys: this.keys,
+ verified: this.verified,
+ known: this.known,
+ unsigned: this.unsigned,
+ signatures: this.signatures,
+ };
+ }
+
+ /**
+ * Get the fingerprint for this device (ie, the Ed25519 key)
+ *
+ * @returns base64-encoded fingerprint of this device
+ */
+ public getFingerprint(): string {
+ return this.keys["ed25519:" + this.deviceId];
+ }
+
+ /**
+ * Get the identity key for this device (ie, the Curve25519 key)
+ *
+ * @returns base64-encoded identity key of this device
+ */
+ public getIdentityKey(): string {
+ return this.keys["curve25519:" + this.deviceId];
+ }
+
+ /**
+ * Get the configured display name for this device, if any
+ *
+ * @returns displayname
+ */
+ public getDisplayName(): string | null {
+ return this.unsigned.device_display_name || null;
+ }
+
+ /**
+ * Returns true if this device is blocked
+ *
+ * @returns true if blocked
+ */
+ public isBlocked(): boolean {
+ return this.verified == DeviceVerification.Blocked;
+ }
+
+ /**
+ * Returns true if this device is verified
+ *
+ * @returns true if verified
+ */
+ public isVerified(): boolean {
+ return this.verified == DeviceVerification.Verified;
+ }
+
+ /**
+ * Returns true if this device is unverified
+ *
+ * @returns true if unverified
+ */
+ public isUnverified(): boolean {
+ return this.verified == DeviceVerification.Unverified;
+ }
+
+ /**
+ * Returns true if the user knows about this device's existence
+ *
+ * @returns true if known
+ */
+ public isKnown(): boolean {
+ return this.known === true;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts
new file mode 100644
index 0000000..68df6ca
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/index.ts
@@ -0,0 +1,3936 @@
+/*
+Copyright 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2018-2019 New Vector Ltd
+Copyright 2019-2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import anotherjson from "another-json";
+import { v4 as uuidv4 } from "uuid";
+
+import type { IDeviceKeys, IEventDecryptionResult, IMegolmSessionData, IOneTimeKey } from "../@types/crypto";
+import type { PkDecryption, PkSigning } from "@matrix-org/olm";
+import { EventType, ToDeviceMessageId } from "../@types/event";
+import { TypedReEmitter } from "../ReEmitter";
+import { logger } from "../logger";
+import { IExportedDevice, OlmDevice } from "./OlmDevice";
+import { IOlmDevice } from "./algorithms/megolm";
+import * as olmlib from "./olmlib";
+import { DeviceInfoMap, DeviceList } from "./DeviceList";
+import { DeviceInfo, IDevice } from "./deviceinfo";
+import type { DecryptionAlgorithm, EncryptionAlgorithm } from "./algorithms";
+import * as algorithms from "./algorithms";
+import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./CrossSigning";
+import { EncryptionSetupBuilder } from "./EncryptionSetup";
+import {
+ IAccountDataClient,
+ ISecretRequest,
+ SECRET_STORAGE_ALGORITHM_V1_AES,
+ SecretStorage,
+ SecretStorageKeyObject,
+ SecretStorageKeyTuple,
+} from "./SecretStorage";
+import {
+ IAddSecretStorageKeyOpts,
+ ICreateSecretStorageOpts,
+ IEncryptedEventInfo,
+ IImportRoomKeysOpts,
+ IRecoveryKey,
+} from "./api";
+import { OutgoingRoomKeyRequestManager } from "./OutgoingRoomKeyRequestManager";
+import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
+import { VerificationBase } from "./verification/Base";
+import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD } from "./verification/QRCode";
+import { SAS as SASVerification } from "./verification/SAS";
+import { keyFromPassphrase } from "./key_passphrase";
+import { decodeRecoveryKey, encodeRecoveryKey } from "./recoverykey";
+import { VerificationRequest } from "./verification/request/VerificationRequest";
+import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel";
+import { ToDeviceChannel, ToDeviceRequests, Request } from "./verification/request/ToDeviceChannel";
+import { IllegalMethod } from "./verification/IllegalMethod";
+import { KeySignatureUploadError } from "../errors";
+import { calculateKeyCheck, decryptAES, encryptAES } from "./aes";
+import { DehydrationManager } from "./dehydration";
+import { BackupManager } from "./backup";
+import { IStore } from "../store";
+import { Room, RoomEvent } from "../models/room";
+import { RoomMember, RoomMemberEvent } from "../models/room-member";
+import { EventStatus, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
+import { ToDeviceBatch } from "../models/ToDeviceMessage";
+import {
+ ClientEvent,
+ ICrossSigningKey,
+ IKeysUploadResponse,
+ ISignedKey,
+ IUploadKeySignaturesResponse,
+ MatrixClient,
+} from "../client";
+import type { IRoomEncryption, RoomList } from "./RoomList";
+import { IKeyBackupInfo } from "./keybackup";
+import { ISyncStateData } from "../sync";
+import { CryptoStore } from "./store/base";
+import { IVerificationChannel } from "./verification/request/Channel";
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+import { IContent } from "../models/event";
+import { ISyncResponse, IToDeviceEvent } from "../sync-accumulator";
+import { ISignatures } from "../@types/signed";
+import { IMessage } from "./algorithms/olm";
+import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
+import { RoomState, RoomStateEvent } from "../models/room-state";
+import { MapWithDefault, recursiveMapToObject } from "../utils";
+import { SecretStorageKeyDescription } from "../secret-storage";
+
+const DeviceVerification = DeviceInfo.DeviceVerification;
+
+const defaultVerificationMethods = {
+ [ReciprocateQRCode.NAME]: ReciprocateQRCode,
+ [SASVerification.NAME]: SASVerification,
+
+ // These two can't be used for actual verification, but we do
+ // need to be able to define them here for the verification flows
+ // to start.
+ [SHOW_QR_CODE_METHOD]: IllegalMethod,
+ [SCAN_QR_CODE_METHOD]: IllegalMethod,
+} as const;
+
+/**
+ * verification method names
+ */
+// legacy export identifier
+export const verificationMethods = {
+ RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME,
+ SAS: SASVerification.NAME,
+} as const;
+
+export type VerificationMethod = keyof typeof verificationMethods | string;
+
+export function isCryptoAvailable(): boolean {
+ return Boolean(global.Olm);
+}
+
+const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000;
+
+interface IInitOpts {
+ exportedOlmDevice?: IExportedDevice;
+ pickleKey?: string;
+}
+
+export interface IBootstrapCrossSigningOpts {
+ /** Optional. Reset even if keys already exist. */
+ setupNewCrossSigning?: boolean;
+ /**
+ * A function that makes the request requiring auth. Receives the auth data as an object.
+ * Can be called multiple times, first with an empty authDict, to obtain the flows.
+ */
+ authUploadDeviceSigningKeys?(makeRequest: (authData: any) => Promise<{}>): Promise<void>;
+}
+
+export interface ICryptoCallbacks {
+ getCrossSigningKey?: (keyType: string, pubKey: string) => Promise<Uint8Array | null>;
+ saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
+ shouldUpgradeDeviceVerifications?: (users: Record<string, any>) => Promise<string[]>;
+ getSecretStorageKey?: (
+ keys: { keys: Record<string, SecretStorageKeyDescription> },
+ name: string,
+ ) => Promise<[string, Uint8Array] | null>;
+ cacheSecretStorageKey?: (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => void;
+ onSecretRequested?: (
+ userId: string,
+ deviceId: string,
+ requestId: string,
+ secretName: string,
+ deviceTrust: DeviceTrustLevel,
+ ) => Promise<string | undefined>;
+ getDehydrationKey?: (
+ keyInfo: SecretStorageKeyDescription,
+ checkFunc: (key: Uint8Array) => void,
+ ) => Promise<Uint8Array>;
+ getBackupKey?: () => Promise<Uint8Array>;
+}
+
+/* eslint-disable camelcase */
+interface IRoomKey {
+ room_id: string;
+ algorithm: string;
+}
+
+/**
+ * The parameters of a room key request. The details of the request may
+ * vary with the crypto algorithm, but the management and storage layers for
+ * outgoing requests expect it to have 'room_id' and 'session_id' properties.
+ */
+export interface IRoomKeyRequestBody extends IRoomKey {
+ session_id: string;
+ sender_key: string;
+}
+
+/* eslint-enable camelcase */
+
+interface IDeviceVerificationUpgrade {
+ devices: DeviceInfo[];
+ crossSigningInfo: CrossSigningInfo;
+}
+
+export interface ICheckOwnCrossSigningTrustOpts {
+ allowPrivateKeyRequests?: boolean;
+}
+
+interface IUserOlmSession {
+ deviceIdKey: string;
+ sessions: {
+ sessionId: string;
+ hasReceivedMessage: boolean;
+ }[];
+}
+
+export interface IRoomKeyRequestRecipient {
+ userId: string;
+ deviceId: string;
+}
+
+interface ISignableObject {
+ signatures?: ISignatures;
+ unsigned?: object;
+}
+
+export interface IRequestsMap {
+ getRequest(event: MatrixEvent): VerificationRequest | undefined;
+ getRequestByChannel(channel: IVerificationChannel): VerificationRequest | undefined;
+ setRequest(event: MatrixEvent, request: VerificationRequest): void;
+ setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void;
+}
+
+/* eslint-disable camelcase */
+export interface IOlmEncryptedContent {
+ algorithm: typeof olmlib.OLM_ALGORITHM;
+ sender_key: string;
+ ciphertext: Record<string, IMessage>;
+ [ToDeviceMessageId]?: string;
+}
+
+export interface IMegolmEncryptedContent {
+ algorithm: typeof olmlib.MEGOLM_ALGORITHM;
+ sender_key: string;
+ session_id: string;
+ device_id: string;
+ ciphertext: string;
+ [ToDeviceMessageId]?: string;
+}
+/* eslint-enable camelcase */
+
+export type IEncryptedContent = IOlmEncryptedContent | IMegolmEncryptedContent;
+
+export enum CryptoEvent {
+ DeviceVerificationChanged = "deviceVerificationChanged",
+ UserTrustStatusChanged = "userTrustStatusChanged",
+ UserCrossSigningUpdated = "userCrossSigningUpdated",
+ RoomKeyRequest = "crypto.roomKeyRequest",
+ RoomKeyRequestCancellation = "crypto.roomKeyRequestCancellation",
+ KeyBackupStatus = "crypto.keyBackupStatus",
+ KeyBackupFailed = "crypto.keyBackupFailed",
+ KeyBackupSessionsRemaining = "crypto.keyBackupSessionsRemaining",
+ KeySignatureUploadFailure = "crypto.keySignatureUploadFailure",
+ VerificationRequest = "crypto.verification.request",
+ Warning = "crypto.warning",
+ WillUpdateDevices = "crypto.willUpdateDevices",
+ DevicesUpdated = "crypto.devicesUpdated",
+ KeysChanged = "crossSigning.keysChanged",
+}
+
+export type CryptoEventHandlerMap = {
+ /**
+ * Fires when a device is marked as verified/unverified/blocked/unblocked by
+ * {@link MatrixClient#setDeviceVerified|MatrixClient.setDeviceVerified} or
+ * {@link MatrixClient#setDeviceBlocked|MatrixClient.setDeviceBlocked}.
+ *
+ * @param userId - the owner of the verified device
+ * @param deviceId - the id of the verified device
+ * @param deviceInfo - updated device information
+ */
+ [CryptoEvent.DeviceVerificationChanged]: (userId: string, deviceId: string, device: DeviceInfo) => void;
+ /**
+ * Fires when the trust status of a user changes
+ * If userId is the userId of the logged-in user, this indicated a change
+ * in the trust status of the cross-signing data on the account.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ * @experimental
+ *
+ * @param userId - the userId of the user in question
+ * @param trustLevel - The new trust level of the user
+ */
+ [CryptoEvent.UserTrustStatusChanged]: (userId: string, trustLevel: UserTrustLevel) => void;
+ /**
+ * Fires when we receive a room key request
+ *
+ * @param req - request details
+ */
+ [CryptoEvent.RoomKeyRequest]: (request: IncomingRoomKeyRequest) => void;
+ /**
+ * Fires when we receive a room key request cancellation
+ */
+ [CryptoEvent.RoomKeyRequestCancellation]: (request: IncomingRoomKeyRequestCancellation) => void;
+ /**
+ * Fires whenever the status of e2e key backup changes, as returned by getKeyBackupEnabled()
+ * @param enabled - true if key backup has been enabled, otherwise false
+ * @example
+ * ```
+ * matrixClient.on("crypto.keyBackupStatus", function(enabled){
+ * if (enabled) {
+ * [...]
+ * }
+ * });
+ * ```
+ */
+ [CryptoEvent.KeyBackupStatus]: (enabled: boolean) => void;
+ [CryptoEvent.KeyBackupFailed]: (errcode: string) => void;
+ [CryptoEvent.KeyBackupSessionsRemaining]: (remaining: number) => void;
+ [CryptoEvent.KeySignatureUploadFailure]: (
+ failures: IUploadKeySignaturesResponse["failures"],
+ source: "checkOwnCrossSigningTrust" | "afterCrossSigningLocalKeyChange" | "setDeviceVerification",
+ upload: (opts: { shouldEmit: boolean }) => Promise<void>,
+ ) => void;
+ /**
+ * Fires when a key verification is requested.
+ */
+ [CryptoEvent.VerificationRequest]: (request: VerificationRequest<any>) => void;
+ /**
+ * Fires when the app may wish to warn the user about something related
+ * the end-to-end crypto.
+ *
+ * @param type - One of the strings listed above
+ */
+ [CryptoEvent.Warning]: (type: string) => void;
+ /**
+ * Fires when the user's cross-signing keys have changed or cross-signing
+ * has been enabled/disabled. The client can use getStoredCrossSigningForUser
+ * with the user ID of the logged in user to check if cross-signing is
+ * enabled on the account. If enabled, it can test whether the current key
+ * is trusted using with checkUserTrust with the user ID of the logged
+ * in user. The checkOwnCrossSigningTrust function may be used to reconcile
+ * the trust in the account key.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ * @experimental
+ */
+ [CryptoEvent.KeysChanged]: (data: {}) => void;
+ /**
+ * Fires whenever the stored devices for a user will be updated
+ * @param users - A list of user IDs that will be updated
+ * @param initialFetch - If true, the store is empty (apart
+ * from our own device) and is being seeded.
+ */
+ [CryptoEvent.WillUpdateDevices]: (users: string[], initialFetch: boolean) => void;
+ /**
+ * Fires whenever the stored devices for a user have changed
+ * @param users - A list of user IDs that were updated
+ * @param initialFetch - If true, the store was empty (apart
+ * from our own device) and has been seeded.
+ */
+ [CryptoEvent.DevicesUpdated]: (users: string[], initialFetch: boolean) => void;
+ [CryptoEvent.UserCrossSigningUpdated]: (userId: string) => void;
+};
+
+export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap> implements CryptoBackend {
+ /**
+ * @returns The version of Olm.
+ */
+ public static getOlmVersion(): [number, number, number] {
+ return OlmDevice.getOlmVersion();
+ }
+
+ public readonly backupManager: BackupManager;
+ public readonly crossSigningInfo: CrossSigningInfo;
+ public readonly olmDevice: OlmDevice;
+ public readonly deviceList: DeviceList;
+ public readonly dehydrationManager: DehydrationManager;
+ public readonly secretStorage: SecretStorage;
+
+ private readonly reEmitter: TypedReEmitter<CryptoEvent, CryptoEventHandlerMap>;
+ private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>;
+ public readonly supportedAlgorithms: string[];
+ private readonly outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager;
+ private readonly toDeviceVerificationRequests: ToDeviceRequests;
+ public readonly inRoomVerificationRequests: InRoomRequests;
+
+ private trustCrossSignedDevices = true;
+ // the last time we did a check for the number of one-time-keys on the server.
+ private lastOneTimeKeyCheck: number | null = null;
+ private oneTimeKeyCheckInProgress = false;
+
+ // EncryptionAlgorithm instance for each room
+ private roomEncryptors = new Map<string, EncryptionAlgorithm>();
+ // map from algorithm to DecryptionAlgorithm instance, for each room
+ private roomDecryptors = new Map<string, Map<string, DecryptionAlgorithm>>();
+
+ private deviceKeys: Record<string, string> = {}; // type: key
+
+ public globalBlacklistUnverifiedDevices = false;
+ public globalErrorOnUnknownDevices = true;
+
+ // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations
+ // we received in the current sync.
+ private receivedRoomKeyRequests: IncomingRoomKeyRequest[] = [];
+ private receivedRoomKeyRequestCancellations: IncomingRoomKeyRequestCancellation[] = [];
+ // true if we are currently processing received room key requests
+ private processingRoomKeyRequests = false;
+ // controls whether device tracking is delayed
+ // until calling encryptEvent or trackRoomDevices,
+ // or done immediately upon enabling room encryption.
+ private lazyLoadMembers = false;
+ // in case lazyLoadMembers is true,
+ // track if an initial tracking of all the room members
+ // has happened for a given room. This is delayed
+ // to avoid loading room members as long as possible.
+ private roomDeviceTrackingState: { [roomId: string]: Promise<void> } = {};
+
+ // The timestamp of the last time we forced establishment
+ // of a new session for each device, in milliseconds.
+ // {
+ // userId: {
+ // deviceId: 1234567890000,
+ // },
+ // }
+ // Map: user Id → device Id → timestamp
+ private lastNewSessionForced: MapWithDefault<string, MapWithDefault<string, number>> = new MapWithDefault(
+ () => new MapWithDefault(() => 0),
+ );
+
+ // This flag will be unset whilst the client processes a sync response
+ // so that we don't start requesting keys until we've actually finished
+ // processing the response.
+ private sendKeyRequestsImmediately = false;
+
+ private oneTimeKeyCount?: number;
+ private needsNewFallback?: boolean;
+ private fallbackCleanup?: ReturnType<typeof setTimeout>;
+
+ /**
+ * Cryptography bits
+ *
+ * This module is internal to the js-sdk; the public API is via MatrixClient.
+ *
+ * @internal
+ *
+ * @param baseApis - base matrix api interface
+ *
+ * @param userId - The user ID for the local user
+ *
+ * @param deviceId - The identifier for this device.
+ *
+ * @param clientStore - the MatrixClient data store.
+ *
+ * @param cryptoStore - storage for the crypto layer.
+ *
+ * @param roomList - An initialised RoomList object
+ *
+ * @param verificationMethods - Array of verification methods to use.
+ * Each element can either be a string from MatrixClient.verificationMethods
+ * or a class that implements a verification method.
+ */
+ public constructor(
+ public readonly baseApis: MatrixClient,
+ public readonly userId: string,
+ private readonly deviceId: string,
+ private readonly clientStore: IStore,
+ public readonly cryptoStore: CryptoStore,
+ private readonly roomList: RoomList,
+ verificationMethods: Array<VerificationMethod | (typeof VerificationBase & { NAME: string })>,
+ ) {
+ super();
+ this.reEmitter = new TypedReEmitter(this);
+
+ if (verificationMethods) {
+ this.verificationMethods = new Map();
+ for (const method of verificationMethods) {
+ if (typeof method === "string") {
+ if (defaultVerificationMethods[method]) {
+ this.verificationMethods.set(
+ method,
+ <typeof VerificationBase>defaultVerificationMethods[method],
+ );
+ }
+ } else if (method["NAME"]) {
+ this.verificationMethods.set(method["NAME"], method as typeof VerificationBase);
+ } else {
+ logger.warn(`Excluding unknown verification method ${method}`);
+ }
+ }
+ } else {
+ this.verificationMethods = new Map(Object.entries(defaultVerificationMethods)) as Map<
+ VerificationMethod,
+ typeof VerificationBase
+ >;
+ }
+
+ this.backupManager = new BackupManager(baseApis, async () => {
+ // try to get key from cache
+ const cachedKey = await this.getSessionBackupPrivateKey();
+ if (cachedKey) {
+ return cachedKey;
+ }
+
+ // try to get key from secret storage
+ const storedKey = await this.getSecret("m.megolm_backup.v1");
+
+ if (storedKey) {
+ // ensure that the key is in the right format. If not, fix the key and
+ // store the fixed version
+ const fixedKey = fixBackupKey(storedKey);
+ if (fixedKey) {
+ const keys = await this.getSecretStorageKey();
+ await this.storeSecret("m.megolm_backup.v1", fixedKey, [keys![0]]);
+ }
+
+ return olmlib.decodeBase64(fixedKey || storedKey);
+ }
+
+ // try to get key from app
+ if (this.baseApis.cryptoCallbacks && this.baseApis.cryptoCallbacks.getBackupKey) {
+ return this.baseApis.cryptoCallbacks.getBackupKey();
+ }
+
+ throw new Error("Unable to get private key");
+ });
+
+ this.olmDevice = new OlmDevice(cryptoStore);
+ this.deviceList = new DeviceList(baseApis, cryptoStore, this.olmDevice);
+
+ // XXX: This isn't removed at any point, but then none of the event listeners
+ // this class sets seem to be removed at any point... :/
+ this.deviceList.on(CryptoEvent.UserCrossSigningUpdated, this.onDeviceListUserCrossSigningUpdated);
+ this.reEmitter.reEmit(this.deviceList, [CryptoEvent.DevicesUpdated, CryptoEvent.WillUpdateDevices]);
+
+ this.supportedAlgorithms = Array.from(algorithms.DECRYPTION_CLASSES.keys());
+
+ this.outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager(
+ baseApis,
+ this.deviceId,
+ this.cryptoStore,
+ );
+
+ this.toDeviceVerificationRequests = new ToDeviceRequests();
+ this.inRoomVerificationRequests = new InRoomRequests();
+
+ const cryptoCallbacks = this.baseApis.cryptoCallbacks || {};
+ const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this.olmDevice);
+
+ this.crossSigningInfo = new CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks);
+ // Yes, we pass the client twice here: see SecretStorage
+ this.secretStorage = new SecretStorage(baseApis as IAccountDataClient, cryptoCallbacks, baseApis);
+ this.dehydrationManager = new DehydrationManager(this);
+
+ // Assuming no app-supplied callback, default to getting from SSSS.
+ if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) {
+ cryptoCallbacks.getCrossSigningKey = async (type): Promise<Uint8Array | null> => {
+ return CrossSigningInfo.getFromSecretStorage(type, this.secretStorage);
+ };
+ }
+ }
+
+ /**
+ * Initialise the crypto module so that it is ready for use
+ *
+ * Returns a promise which resolves once the crypto module is ready for use.
+ *
+ * @param exportedOlmDevice - (Optional) data from exported device
+ * that must be re-created.
+ */
+ public async init({ exportedOlmDevice, pickleKey }: IInitOpts = {}): Promise<void> {
+ logger.log("Crypto: initialising Olm...");
+ await global.Olm.init();
+ logger.log(
+ exportedOlmDevice
+ ? "Crypto: initialising Olm device from exported device..."
+ : "Crypto: initialising Olm device...",
+ );
+ await this.olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey });
+ logger.log("Crypto: loading device list...");
+ await this.deviceList.load();
+
+ // build our device keys: these will later be uploaded
+ this.deviceKeys["ed25519:" + this.deviceId] = this.olmDevice.deviceEd25519Key!;
+ this.deviceKeys["curve25519:" + this.deviceId] = this.olmDevice.deviceCurve25519Key!;
+
+ logger.log("Crypto: fetching own devices...");
+ let myDevices = this.deviceList.getRawStoredDevicesForUser(this.userId);
+
+ if (!myDevices) {
+ myDevices = {};
+ }
+
+ if (!myDevices[this.deviceId]) {
+ // add our own deviceinfo to the cryptoStore
+ logger.log("Crypto: adding this device to the store...");
+ const deviceInfo = {
+ keys: this.deviceKeys,
+ algorithms: this.supportedAlgorithms,
+ verified: DeviceVerification.VERIFIED,
+ known: true,
+ };
+
+ myDevices[this.deviceId] = deviceInfo;
+ this.deviceList.storeDevicesForUser(this.userId, myDevices);
+ this.deviceList.saveIfDirty();
+ }
+
+ await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.cryptoStore.getCrossSigningKeys(txn, (keys) => {
+ // can be an empty object after resetting cross-signing keys, see storeTrustedSelfKeys
+ if (keys && Object.keys(keys).length !== 0) {
+ logger.log("Loaded cross-signing public keys from crypto store");
+ this.crossSigningInfo.setKeys(keys);
+ }
+ });
+ });
+ // make sure we are keeping track of our own devices
+ // (this is important for key backups & things)
+ this.deviceList.startTrackingDeviceList(this.userId);
+
+ logger.log("Crypto: checking for key backup...");
+ this.backupManager.checkAndStart();
+ }
+
+ /**
+ * Whether to trust a others users signatures of their devices.
+ * If false, devices will only be considered 'verified' if we have
+ * verified that device individually (effectively disabling cross-signing).
+ *
+ * Default: true
+ *
+ * @returns True if trusting cross-signed devices
+ */
+ public getCryptoTrustCrossSignedDevices(): boolean {
+ return this.trustCrossSignedDevices;
+ }
+
+ /**
+ * See getCryptoTrustCrossSignedDevices
+
+ * This may be set before initCrypto() is called to ensure no races occur.
+ *
+ * @param val - True to trust cross-signed devices
+ */
+ public setCryptoTrustCrossSignedDevices(val: boolean): void {
+ this.trustCrossSignedDevices = val;
+
+ for (const userId of this.deviceList.getKnownUserIds()) {
+ const devices = this.deviceList.getRawStoredDevicesForUser(userId);
+ for (const deviceId of Object.keys(devices)) {
+ const deviceTrust = this.checkDeviceTrust(userId, deviceId);
+ // If the device is locally verified then isVerified() is always true,
+ // so this will only have caused the value to change if the device is
+ // cross-signing verified but not locally verified
+ if (!deviceTrust.isLocallyVerified() && deviceTrust.isCrossSigningVerified()) {
+ const deviceObj = this.deviceList.getStoredDevice(userId, deviceId)!;
+ this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj);
+ }
+ }
+ }
+ }
+
+ /**
+ * Create a recovery key from a user-supplied passphrase.
+ *
+ * @param password - Passphrase string that can be entered by the user
+ * when restoring the backup as an alternative to entering the recovery key.
+ * Optional.
+ * @returns Object with public key metadata, encoded private
+ * recovery key which should be disposed of after displaying to the user,
+ * and raw private key to avoid round tripping if needed.
+ */
+ public async createRecoveryKeyFromPassphrase(password?: string): Promise<IRecoveryKey> {
+ const decryption = new global.Olm.PkDecryption();
+ try {
+ const keyInfo: Partial<IRecoveryKey["keyInfo"]> = {};
+ if (password) {
+ const derivation = await keyFromPassphrase(password);
+ keyInfo.passphrase = {
+ algorithm: "m.pbkdf2",
+ iterations: derivation.iterations,
+ salt: derivation.salt,
+ };
+ keyInfo.pubkey = decryption.init_with_private_key(derivation.key);
+ } else {
+ keyInfo.pubkey = decryption.generate_key();
+ }
+ const privateKey = decryption.get_private_key();
+ const encodedPrivateKey = encodeRecoveryKey(privateKey);
+ return {
+ keyInfo: keyInfo as IRecoveryKey["keyInfo"],
+ encodedPrivateKey,
+ privateKey,
+ };
+ } finally {
+ decryption?.free();
+ }
+ }
+
+ /**
+ * Checks if the user has previously published cross-signing keys
+ *
+ * This means downloading the devicelist for the user and checking if the list includes
+ * the cross-signing pseudo-device.
+ *
+ * @internal
+ */
+ public async userHasCrossSigningKeys(): Promise<boolean> {
+ await this.downloadKeys([this.userId]);
+ return this.deviceList.getStoredCrossSigningForUser(this.userId) !== null;
+ }
+
+ /**
+ * Checks whether cross signing:
+ * - is enabled on this account and trusted by this device
+ * - has private keys either cached locally or stored in secret storage
+ *
+ * If this function returns false, bootstrapCrossSigning() can be used
+ * to fix things such that it returns true. That is to say, after
+ * bootstrapCrossSigning() completes successfully, this function should
+ * return true.
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @returns True if cross-signing is ready to be used on this device
+ */
+ public async isCrossSigningReady(): Promise<boolean> {
+ const publicKeysOnDevice = this.crossSigningInfo.getId();
+ const privateKeysExistSomewhere =
+ (await this.crossSigningInfo.isStoredInKeyCache()) ||
+ (await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage));
+
+ return !!(publicKeysOnDevice && privateKeysExistSomewhere);
+ }
+
+ /**
+ * Checks whether secret storage:
+ * - is enabled on this account
+ * - is storing cross-signing private keys
+ * - is storing session backup key (if enabled)
+ *
+ * If this function returns false, bootstrapSecretStorage() can be used
+ * to fix things such that it returns true. That is to say, after
+ * bootstrapSecretStorage() completes successfully, this function should
+ * return true.
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @returns True if secret storage is ready to be used on this device
+ */
+ public async isSecretStorageReady(): Promise<boolean> {
+ const secretStorageKeyInAccount = await this.secretStorage.hasKey();
+ const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage);
+ const sessionBackupInStorage =
+ !this.backupManager.getKeyBackupEnabled() || (await this.baseApis.isKeyBackupKeyStored());
+
+ return !!(secretStorageKeyInAccount && privateKeysInStorage && sessionBackupInStorage);
+ }
+
+ /**
+ * Bootstrap cross-signing by creating keys if needed. If everything is already
+ * set up, then no changes are made, so this is safe to run to ensure
+ * cross-signing is ready for use.
+ *
+ * This function:
+ * - creates new cross-signing keys if they are not found locally cached nor in
+ * secret storage (if it has been setup)
+ *
+ * The cross-signing API is currently UNSTABLE and may change without notice.
+ *
+ * @param authUploadDeviceSigningKeys - Function
+ * called to await an interactive auth flow when uploading device signing keys.
+ * @param setupNewCrossSigning - Optional. Reset even if keys
+ * already exist.
+ * Args:
+ * A function that makes the request requiring auth. Receives the
+ * auth data as an object. Can be called multiple times, first with an empty
+ * authDict, to obtain the flows.
+ */
+ public async bootstrapCrossSigning({
+ authUploadDeviceSigningKeys,
+ setupNewCrossSigning,
+ }: IBootstrapCrossSigningOpts = {}): Promise<void> {
+ logger.log("Bootstrapping cross-signing");
+
+ const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
+ const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks);
+ const crossSigningInfo = new CrossSigningInfo(
+ this.userId,
+ builder.crossSigningCallbacks,
+ builder.crossSigningCallbacks,
+ );
+
+ // Reset the cross-signing keys
+ const resetCrossSigning = async (): Promise<void> => {
+ crossSigningInfo.resetKeys();
+ // Sign master key with device key
+ await this.signObject(crossSigningInfo.keys.master);
+
+ // Store auth flow helper function, as we need to call it when uploading
+ // to ensure we handle auth errors properly.
+ builder.addCrossSigningKeys(authUploadDeviceSigningKeys, crossSigningInfo.keys);
+
+ // Cross-sign own device
+ const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!;
+ const deviceSignature = await crossSigningInfo.signDevice(this.userId, device);
+ builder.addKeySignature(this.userId, this.deviceId, deviceSignature!);
+
+ // Sign message key backup with cross-signing master key
+ if (this.backupManager.backupInfo) {
+ await crossSigningInfo.signObject(this.backupManager.backupInfo.auth_data, "master");
+ builder.addSessionBackup(this.backupManager.backupInfo);
+ }
+ };
+
+ const publicKeysOnDevice = this.crossSigningInfo.getId();
+ const privateKeysInCache = await this.crossSigningInfo.isStoredInKeyCache();
+ const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage);
+ const privateKeysExistSomewhere = privateKeysInCache || privateKeysInStorage;
+
+ // Log all relevant state for easier parsing of debug logs.
+ logger.log({
+ setupNewCrossSigning,
+ publicKeysOnDevice,
+ privateKeysInCache,
+ privateKeysInStorage,
+ privateKeysExistSomewhere,
+ });
+
+ if (!privateKeysExistSomewhere || setupNewCrossSigning) {
+ logger.log("Cross-signing private keys not found locally or in secret storage, " + "creating new keys");
+ // If a user has multiple devices, it important to only call bootstrap
+ // as part of some UI flow (and not silently during startup), as they
+ // may have setup cross-signing on a platform which has not saved keys
+ // to secret storage, and this would reset them. In such a case, you
+ // should prompt the user to verify any existing devices first (and
+ // request private keys from those devices) before calling bootstrap.
+ await resetCrossSigning();
+ } else if (publicKeysOnDevice && privateKeysInCache) {
+ logger.log("Cross-signing public keys trusted and private keys found locally");
+ } else if (privateKeysInStorage) {
+ logger.log(
+ "Cross-signing private keys not found locally, but they are available " +
+ "in secret storage, reading storage and caching locally",
+ );
+ await this.checkOwnCrossSigningTrust({
+ allowPrivateKeyRequests: true,
+ });
+ }
+
+ // Assuming no app-supplied callback, default to storing new private keys in
+ // secret storage if it exists. If it does not, it is assumed this will be
+ // done as part of setting up secret storage later.
+ const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys;
+ if (crossSigningPrivateKeys.size && !this.baseApis.cryptoCallbacks.saveCrossSigningKeys) {
+ const secretStorage = new SecretStorage(
+ builder.accountDataClientAdapter,
+ builder.ssssCryptoCallbacks,
+ undefined,
+ );
+ if (await secretStorage.hasKey()) {
+ logger.log("Storing new cross-signing private keys in secret storage");
+ // This is writing to in-memory account data in
+ // builder.accountDataClientAdapter so won't fail
+ await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage);
+ }
+ }
+
+ const operation = builder.buildOperation();
+ await operation.apply(this);
+ // This persists private keys and public keys as trusted,
+ // only do this if apply succeeded for now as retry isn't in place yet
+ await builder.persist(this);
+
+ logger.log("Cross-signing ready");
+ }
+
+ /**
+ * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is
+ * already set up, then no changes are made, so this is safe to run to ensure secret
+ * storage is ready for use.
+ *
+ * This function
+ * - creates a new Secure Secret Storage key if no default key exists
+ * - if a key backup exists, it is migrated to store the key in the Secret
+ * Storage
+ * - creates a backup if none exists, and one is requested
+ * - migrates Secure Secret Storage to use the latest algorithm, if an outdated
+ * algorithm is found
+ *
+ * The Secure Secret Storage API is currently UNSTABLE and may change without notice.
+ *
+ * @param createSecretStorageKey - Optional. Function
+ * called to await a secret storage key creation flow.
+ * Returns a Promise which resolves to an object with public key metadata, encoded private
+ * recovery key which should be disposed of after displaying to the user,
+ * and raw private key to avoid round tripping if needed.
+ * @param keyBackupInfo - The current key backup object. If passed,
+ * the passphrase and recovery key from this backup will be used.
+ * @param setupNewKeyBackup - If true, a new key backup version will be
+ * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo
+ * is supplied.
+ * @param setupNewSecretStorage - Optional. Reset even if keys already exist.
+ * @param getKeyBackupPassphrase - Optional. Function called to get the user's
+ * current key backup passphrase. Should return a promise that resolves with a Buffer
+ * containing the key, or rejects if the key cannot be obtained.
+ * Returns:
+ * A promise which resolves to key creation data for
+ * SecretStorage#addKey: an object with `passphrase` etc fields.
+ */
+ // TODO this does not resolve with what it says it does
+ public async bootstrapSecretStorage({
+ createSecretStorageKey = async (): Promise<IRecoveryKey> => ({} as IRecoveryKey),
+ keyBackupInfo,
+ setupNewKeyBackup,
+ setupNewSecretStorage,
+ getKeyBackupPassphrase,
+ }: ICreateSecretStorageOpts = {}): Promise<void> {
+ logger.log("Bootstrapping Secure Secret Storage");
+ const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks;
+ const builder = new EncryptionSetupBuilder(this.baseApis.store.accountData, delegateCryptoCallbacks);
+ const secretStorage = new SecretStorage(
+ builder.accountDataClientAdapter,
+ builder.ssssCryptoCallbacks,
+ undefined,
+ );
+
+ // the ID of the new SSSS key, if we create one
+ let newKeyId: string | null = null;
+
+ // create a new SSSS key and set it as default
+ const createSSSS = async (opts: IAddSecretStorageKeyOpts, privateKey?: Uint8Array): Promise<string> => {
+ if (privateKey) {
+ opts.key = privateKey;
+ }
+
+ const { keyId, keyInfo } = await secretStorage.addKey(SECRET_STORAGE_ALGORITHM_V1_AES, opts);
+
+ if (privateKey) {
+ // make the private key available to encrypt 4S secrets
+ builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey);
+ }
+
+ await secretStorage.setDefaultKeyId(keyId);
+ return keyId;
+ };
+
+ const ensureCanCheckPassphrase = async (keyId: string, keyInfo: SecretStorageKeyDescription): Promise<void> => {
+ if (!keyInfo.mac) {
+ const key = await this.baseApis.cryptoCallbacks.getSecretStorageKey?.(
+ { keys: { [keyId]: keyInfo } },
+ "",
+ );
+ if (key) {
+ const privateKey = key[1];
+ builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey);
+ const { iv, mac } = await calculateKeyCheck(privateKey);
+ keyInfo.iv = iv;
+ keyInfo.mac = mac;
+
+ await builder.setAccountData(`m.secret_storage.key.${keyId}`, keyInfo);
+ }
+ }
+ };
+
+ const signKeyBackupWithCrossSigning = async (keyBackupAuthData: IKeyBackupInfo["auth_data"]): Promise<void> => {
+ if (this.crossSigningInfo.getId() && (await this.crossSigningInfo.isStoredInKeyCache("master"))) {
+ try {
+ logger.log("Adding cross-signing signature to key backup");
+ await this.crossSigningInfo.signObject(keyBackupAuthData, "master");
+ } catch (e) {
+ // This step is not critical (just helpful), so we catch here
+ // and continue if it fails.
+ logger.error("Signing key backup with cross-signing keys failed", e);
+ }
+ } else {
+ logger.warn("Cross-signing keys not available, skipping signature on key backup");
+ }
+ };
+
+ const oldSSSSKey = await this.getSecretStorageKey();
+ const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null];
+ const storageExists =
+ !setupNewSecretStorage && oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES;
+
+ // Log all relevant state for easier parsing of debug logs.
+ logger.log({
+ keyBackupInfo,
+ setupNewKeyBackup,
+ setupNewSecretStorage,
+ storageExists,
+ oldKeyInfo,
+ });
+
+ if (!storageExists && !keyBackupInfo) {
+ // either we don't have anything, or we've been asked to restart
+ // from scratch
+ logger.log("Secret storage does not exist, creating new storage key");
+
+ // if we already have a usable default SSSS key and aren't resetting
+ // SSSS just use it. otherwise, create a new one
+ // Note: we leave the old SSSS key in place: there could be other
+ // secrets using it, in theory. We could move them to the new key but a)
+ // that would mean we'd need to prompt for the old passphrase, and b)
+ // it's not clear that would be the right thing to do anyway.
+ const { keyInfo = {} as IAddSecretStorageKeyOpts, privateKey } = await createSecretStorageKey();
+ newKeyId = await createSSSS(keyInfo, privateKey);
+ } else if (!storageExists && keyBackupInfo) {
+ // we have an existing backup, but no SSSS
+ logger.log("Secret storage does not exist, using key backup key");
+
+ // if we have the backup key already cached, use it; otherwise use the
+ // callback to prompt for the key
+ const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.());
+
+ // create a new SSSS key and use the backup key as the new SSSS key
+ const opts = {} as IAddSecretStorageKeyOpts;
+
+ if (keyBackupInfo.auth_data.private_key_salt && keyBackupInfo.auth_data.private_key_iterations) {
+ // FIXME: ???
+ opts.passphrase = {
+ algorithm: "m.pbkdf2",
+ iterations: keyBackupInfo.auth_data.private_key_iterations,
+ salt: keyBackupInfo.auth_data.private_key_salt,
+ bits: 256,
+ };
+ }
+
+ newKeyId = await createSSSS(opts, backupKey);
+
+ // store the backup key in secret storage
+ await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey!), [newKeyId]);
+
+ // The backup is trusted because the user provided the private key.
+ // Sign the backup with the cross-signing key so the key backup can
+ // be trusted via cross-signing.
+ await signKeyBackupWithCrossSigning(keyBackupInfo.auth_data);
+
+ builder.addSessionBackup(keyBackupInfo);
+ } else {
+ // 4S is already set up
+ logger.log("Secret storage exists");
+
+ if (oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) {
+ // make sure that the default key has the information needed to
+ // check the passphrase
+ await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo);
+ }
+ }
+
+ // If we have cross-signing private keys cached, store them in secret
+ // storage if they are not there already.
+ if (
+ !this.baseApis.cryptoCallbacks.saveCrossSigningKeys &&
+ (await this.isCrossSigningReady()) &&
+ (newKeyId || !(await this.crossSigningInfo.isStoredInSecretStorage(secretStorage)))
+ ) {
+ logger.log("Copying cross-signing private keys from cache to secret storage");
+ const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache();
+ // This is writing to in-memory account data in
+ // builder.accountDataClientAdapter so won't fail
+ await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage);
+ }
+
+ if (setupNewKeyBackup && !keyBackupInfo) {
+ logger.log("Creating new message key backup version");
+ const info = await this.baseApis.prepareKeyBackupVersion(
+ null /* random key */,
+ // don't write to secret storage, as it will write to this.secretStorage.
+ // Here, we want to capture all the side-effects of bootstrapping,
+ // and want to write to the local secretStorage object
+ { secureSecretStorage: false },
+ );
+ // write the key ourselves to 4S
+ const privateKey = decodeRecoveryKey(info.recovery_key);
+ await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey));
+
+ // create keyBackupInfo object to add to builder
+ const data: IKeyBackupInfo = {
+ algorithm: info.algorithm,
+ auth_data: info.auth_data,
+ };
+
+ // Sign with cross-signing master key
+ await signKeyBackupWithCrossSigning(data.auth_data);
+
+ // sign with the device fingerprint
+ await this.signObject(data.auth_data);
+
+ builder.addSessionBackup(data);
+ }
+
+ // Cache the session backup key
+ const sessionBackupKey = await secretStorage.get("m.megolm_backup.v1");
+ if (sessionBackupKey) {
+ logger.info("Got session backup key from secret storage: caching");
+ // fix up the backup key if it's in the wrong format, and replace
+ // in secret storage
+ const fixedBackupKey = fixBackupKey(sessionBackupKey);
+ if (fixedBackupKey) {
+ const keyId = newKeyId || oldKeyId;
+ await secretStorage.store("m.megolm_backup.v1", fixedBackupKey, keyId ? [keyId] : null);
+ }
+ const decodedBackupKey = new Uint8Array(olmlib.decodeBase64(fixedBackupKey || sessionBackupKey));
+ builder.addSessionBackupPrivateKeyToCache(decodedBackupKey);
+ } else if (this.backupManager.getKeyBackupEnabled()) {
+ // key backup is enabled but we don't have a session backup key in SSSS: see if we have one in
+ // the cache or the user can provide one, and if so, write it to SSSS
+ const backupKey = (await this.getSessionBackupPrivateKey()) || (await getKeyBackupPassphrase?.());
+ if (!backupKey) {
+ // This will require user intervention to recover from since we don't have the key
+ // backup key anywhere. The user should probably just set up a new key backup and
+ // the key for the new backup will be stored. If we hit this scenario in the wild
+ // with any frequency, we should do more than just log an error.
+ logger.error("Key backup is enabled but couldn't get key backup key!");
+ return;
+ }
+ logger.info("Got session backup key from cache/user that wasn't in SSSS: saving to SSSS");
+ await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(backupKey));
+ }
+
+ const operation = builder.buildOperation();
+ await operation.apply(this);
+ // this persists private keys and public keys as trusted,
+ // only do this if apply succeeded for now as retry isn't in place yet
+ await builder.persist(this);
+
+ logger.log("Secure Secret Storage ready");
+ }
+
+ public addSecretStorageKey(
+ algorithm: string,
+ opts: IAddSecretStorageKeyOpts,
+ keyID?: string,
+ ): Promise<SecretStorageKeyObject> {
+ return this.secretStorage.addKey(algorithm, opts, keyID);
+ }
+
+ public hasSecretStorageKey(keyID?: string): Promise<boolean> {
+ return this.secretStorage.hasKey(keyID);
+ }
+
+ public getSecretStorageKey(keyID?: string): Promise<SecretStorageKeyTuple | null> {
+ return this.secretStorage.getKey(keyID);
+ }
+
+ public storeSecret(name: string, secret: string, keys?: string[]): Promise<void> {
+ return this.secretStorage.store(name, secret, keys);
+ }
+
+ public getSecret(name: string): Promise<string | undefined> {
+ return this.secretStorage.get(name);
+ }
+
+ public isSecretStored(name: string): Promise<Record<string, SecretStorageKeyDescription> | null> {
+ return this.secretStorage.isStored(name);
+ }
+
+ public requestSecret(name: string, devices: string[]): ISecretRequest {
+ if (!devices) {
+ devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(this.userId));
+ }
+ return this.secretStorage.request(name, devices);
+ }
+
+ public getDefaultSecretStorageKeyId(): Promise<string | null> {
+ return this.secretStorage.getDefaultKeyId();
+ }
+
+ public setDefaultSecretStorageKeyId(k: string): Promise<void> {
+ return this.secretStorage.setDefaultKeyId(k);
+ }
+
+ public checkSecretStorageKey(key: Uint8Array, info: SecretStorageKeyDescription): Promise<boolean> {
+ return this.secretStorage.checkKey(key, info);
+ }
+
+ /**
+ * Checks that a given secret storage private key matches a given public key.
+ * This can be used by the getSecretStorageKey callback to verify that the
+ * private key it is about to supply is the one that was requested.
+ *
+ * @param privateKey - The private key
+ * @param expectedPublicKey - The public key
+ * @returns true if the key matches, otherwise false
+ */
+ public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean {
+ let decryption: PkDecryption | null = null;
+ try {
+ decryption = new global.Olm.PkDecryption();
+ const gotPubkey = decryption.init_with_private_key(privateKey);
+ // make sure it agrees with the given pubkey
+ return gotPubkey === expectedPublicKey;
+ } finally {
+ decryption?.free();
+ }
+ }
+
+ /**
+ * Fetches the backup private key, if cached
+ * @returns the key, if any, or null
+ */
+ public async getSessionBackupPrivateKey(): Promise<Uint8Array | null> {
+ let key = await new Promise<any>((resolve) => {
+ // TODO types
+ this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.cryptoStore.getSecretStorePrivateKey(txn, resolve, "m.megolm_backup.v1");
+ });
+ });
+
+ // make sure we have a Uint8Array, rather than a string
+ if (key && typeof key === "string") {
+ key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key));
+ await this.storeSessionBackupPrivateKey(key);
+ }
+ if (key && key.ciphertext) {
+ const pickleKey = Buffer.from(this.olmDevice.pickleKey);
+ const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1");
+ key = olmlib.decodeBase64(decrypted);
+ }
+ return key;
+ }
+
+ /**
+ * Stores the session backup key to the cache
+ * @param key - the private key
+ * @returns a promise so you can catch failures
+ */
+ public async storeSessionBackupPrivateKey(key: ArrayLike<number>): Promise<void> {
+ if (!(key instanceof Uint8Array)) {
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
+ throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`);
+ }
+ const pickleKey = Buffer.from(this.olmDevice.pickleKey);
+ const encryptedKey = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1");
+ return this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey);
+ });
+ }
+
+ /**
+ * Checks that a given cross-signing private key matches a given public key.
+ * This can be used by the getCrossSigningKey callback to verify that the
+ * private key it is about to supply is the one that was requested.
+ *
+ * @param privateKey - The private key
+ * @param expectedPublicKey - The public key
+ * @returns true if the key matches, otherwise false
+ */
+ public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean {
+ let signing: PkSigning | null = null;
+ try {
+ signing = new global.Olm.PkSigning();
+ const gotPubkey = signing.init_with_seed(privateKey);
+ // make sure it agrees with the given pubkey
+ return gotPubkey === expectedPublicKey;
+ } finally {
+ signing?.free();
+ }
+ }
+
+ /**
+ * Run various follow-up actions after cross-signing keys have changed locally
+ * (either by resetting the keys for the account or by getting them from secret
+ * storage), such as signing the current device, upgrading device
+ * verifications, etc.
+ */
+ private async afterCrossSigningLocalKeyChange(): Promise<void> {
+ logger.info("Starting cross-signing key change post-processing");
+
+ // sign the current device with the new key, and upload to the server
+ const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!;
+ const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device);
+ logger.info(`Starting background key sig upload for ${this.deviceId}`);
+
+ const upload = ({ shouldEmit = false }): Promise<void> => {
+ return this.baseApis
+ .uploadKeySignatures({
+ [this.userId]: {
+ [this.deviceId]: signedDevice!,
+ },
+ })
+ .then((response) => {
+ const { failures } = response || {};
+ if (Object.keys(failures || []).length > 0) {
+ if (shouldEmit) {
+ this.baseApis.emit(
+ CryptoEvent.KeySignatureUploadFailure,
+ failures,
+ "afterCrossSigningLocalKeyChange",
+ upload, // continuation
+ );
+ }
+ throw new KeySignatureUploadError("Key upload failed", { failures });
+ }
+ logger.info(`Finished background key sig upload for ${this.deviceId}`);
+ })
+ .catch((e) => {
+ logger.error(`Error during background key sig upload for ${this.deviceId}`, e);
+ });
+ };
+ upload({ shouldEmit: true });
+
+ const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications;
+ if (shouldUpgradeCb) {
+ logger.info("Starting device verification upgrade");
+
+ // Check all users for signatures if upgrade callback present
+ // FIXME: do this in batches
+ const users: Record<string, IDeviceVerificationUpgrade> = {};
+ for (const [userId, crossSigningInfo] of Object.entries(this.deviceList.crossSigningInfo)) {
+ const upgradeInfo = await this.checkForDeviceVerificationUpgrade(
+ userId,
+ CrossSigningInfo.fromStorage(crossSigningInfo, userId),
+ );
+ if (upgradeInfo) {
+ users[userId] = upgradeInfo;
+ }
+ }
+
+ if (Object.keys(users).length > 0) {
+ logger.info(`Found ${Object.keys(users).length} verif users to upgrade`);
+ try {
+ const usersToUpgrade = await shouldUpgradeCb({ users: users });
+ if (usersToUpgrade) {
+ for (const userId of usersToUpgrade) {
+ if (userId in users) {
+ await this.baseApis.setDeviceVerified(userId, users[userId].crossSigningInfo.getId()!);
+ }
+ }
+ }
+ } catch (e) {
+ logger.log("shouldUpgradeDeviceVerifications threw an error: not upgrading", e);
+ }
+ }
+
+ logger.info("Finished device verification upgrade");
+ }
+
+ logger.info("Finished cross-signing key change post-processing");
+ }
+
+ /**
+ * Check if a user's cross-signing key is a candidate for upgrading from device
+ * verification.
+ *
+ * @param userId - the user whose cross-signing information is to be checked
+ * @param crossSigningInfo - the cross-signing information to check
+ */
+ private async checkForDeviceVerificationUpgrade(
+ userId: string,
+ crossSigningInfo: CrossSigningInfo,
+ ): Promise<IDeviceVerificationUpgrade | undefined> {
+ // only upgrade if this is the first cross-signing key that we've seen for
+ // them, and if their cross-signing key isn't already verified
+ const trustLevel = this.crossSigningInfo.checkUserTrust(crossSigningInfo);
+ if (crossSigningInfo.firstUse && !trustLevel.isVerified()) {
+ const devices = this.deviceList.getRawStoredDevicesForUser(userId);
+ const deviceIds = await this.checkForValidDeviceSignature(userId, crossSigningInfo.keys.master, devices);
+ if (deviceIds.length) {
+ return {
+ devices: deviceIds.map((deviceId) => DeviceInfo.fromStorage(devices[deviceId], deviceId)),
+ crossSigningInfo,
+ };
+ }
+ }
+ }
+
+ /**
+ * Check if the cross-signing key is signed by a verified device.
+ *
+ * @param userId - the user ID whose key is being checked
+ * @param key - the key that is being checked
+ * @param devices - the user's devices. Should be a map from device ID
+ * to device info
+ */
+ private async checkForValidDeviceSignature(
+ userId: string,
+ key: ICrossSigningKey,
+ devices: Record<string, IDevice>,
+ ): Promise<string[]> {
+ const deviceIds: string[] = [];
+ if (devices && key.signatures && key.signatures[userId]) {
+ for (const signame of Object.keys(key.signatures[userId])) {
+ const [, deviceId] = signame.split(":", 2);
+ if (deviceId in devices && devices[deviceId].verified === DeviceVerification.VERIFIED) {
+ try {
+ await olmlib.verifySignature(
+ this.olmDevice,
+ key,
+ userId,
+ deviceId,
+ devices[deviceId].keys[signame],
+ );
+ deviceIds.push(deviceId);
+ } catch (e) {}
+ }
+ }
+ }
+ return deviceIds;
+ }
+
+ /**
+ * Get the user's cross-signing key ID.
+ *
+ * @param type - The type of key to get the ID of. One of
+ * "master", "self_signing", or "user_signing". Defaults to "master".
+ *
+ * @returns the key ID
+ */
+ public getCrossSigningId(type: string): string | null {
+ return this.crossSigningInfo.getId(type);
+ }
+
+ /**
+ * Get the cross signing information for a given user.
+ *
+ * @param userId - the user ID to get the cross-signing info for.
+ *
+ * @returns the cross signing information for the user.
+ */
+ public getStoredCrossSigningForUser(userId: string): CrossSigningInfo | null {
+ return this.deviceList.getStoredCrossSigningForUser(userId);
+ }
+
+ /**
+ * Check whether a given user is trusted.
+ *
+ * @param userId - The ID of the user to check.
+ *
+ * @returns
+ */
+ public checkUserTrust(userId: string): UserTrustLevel {
+ const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId);
+ if (!userCrossSigning) {
+ return new UserTrustLevel(false, false, false);
+ }
+ return this.crossSigningInfo.checkUserTrust(userCrossSigning);
+ }
+
+ /**
+ * Check whether a given device is trusted.
+ *
+ * @param userId - The ID of the user whose devices is to be checked.
+ * @param deviceId - The ID of the device to check
+ *
+ * @returns
+ */
+ public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
+ const device = this.deviceList.getStoredDevice(userId, deviceId);
+ return this.checkDeviceInfoTrust(userId, device);
+ }
+
+ /**
+ * Check whether a given deviceinfo is trusted.
+ *
+ * @param userId - The ID of the user whose devices is to be checked.
+ * @param device - The device info object to check
+ *
+ * @returns
+ */
+ public checkDeviceInfoTrust(userId: string, device?: DeviceInfo): DeviceTrustLevel {
+ const trustedLocally = !!device?.isVerified();
+
+ const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId);
+ if (device && userCrossSigning) {
+ // The trustCrossSignedDevices only affects trust of other people's cross-signing
+ // signatures
+ const trustCrossSig = this.trustCrossSignedDevices || userId === this.userId;
+ return this.crossSigningInfo.checkDeviceTrust(userCrossSigning, device, trustedLocally, trustCrossSig);
+ } else {
+ return new DeviceTrustLevel(false, false, trustedLocally, false);
+ }
+ }
+
+ /**
+ * Check whether one of our own devices is cross-signed by our
+ * user's stored keys, regardless of whether we trust those keys yet.
+ *
+ * @param deviceId - The ID of the device to check
+ *
+ * @returns true if the device is cross-signed
+ */
+ public checkIfOwnDeviceCrossSigned(deviceId: string): boolean {
+ const device = this.deviceList.getStoredDevice(this.userId, deviceId);
+ if (!device) return false;
+ const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(this.userId);
+ return (
+ userCrossSigning?.checkDeviceTrust(userCrossSigning, device, false, true).isCrossSigningVerified() ?? false
+ );
+ }
+
+ /*
+ * Event handler for DeviceList's userNewDevices event
+ */
+ private onDeviceListUserCrossSigningUpdated = async (userId: string): Promise<void> => {
+ if (userId === this.userId) {
+ // An update to our own cross-signing key.
+ // Get the new key first:
+ const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId);
+ const seenPubkey = newCrossSigning ? newCrossSigning.getId() : null;
+ const currentPubkey = this.crossSigningInfo.getId();
+ const changed = currentPubkey !== seenPubkey;
+
+ if (currentPubkey && seenPubkey && !changed) {
+ // If it's not changed, just make sure everything is up to date
+ await this.checkOwnCrossSigningTrust();
+ } else {
+ // We'll now be in a state where cross-signing on the account is not trusted
+ // because our locally stored cross-signing keys will not match the ones
+ // on the server for our account. So we clear our own stored cross-signing keys,
+ // effectively disabling cross-signing until the user gets verified by the device
+ // that reset the keys
+ this.storeTrustedSelfKeys(null);
+ // emit cross-signing has been disabled
+ this.emit(CryptoEvent.KeysChanged, {});
+ // as the trust for our own user has changed,
+ // also emit an event for this
+ this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId));
+ }
+ } else {
+ await this.checkDeviceVerifications(userId);
+
+ // Update verified before latch using the current state and save the new
+ // latch value in the device list store.
+ const crossSigning = this.deviceList.getStoredCrossSigningForUser(userId);
+ if (crossSigning) {
+ crossSigning.updateCrossSigningVerifiedBefore(this.checkUserTrust(userId).isCrossSigningVerified());
+ this.deviceList.setRawStoredCrossSigningForUser(userId, crossSigning.toStorage());
+ }
+
+ this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId));
+ }
+ };
+
+ /**
+ * Check the copy of our cross-signing key that we have in the device list and
+ * see if we can get the private key. If so, mark it as trusted.
+ */
+ public async checkOwnCrossSigningTrust({
+ allowPrivateKeyRequests = false,
+ }: ICheckOwnCrossSigningTrustOpts = {}): Promise<void> {
+ const userId = this.userId;
+
+ // Before proceeding, ensure our cross-signing public keys have been
+ // downloaded via the device list.
+ await this.downloadKeys([this.userId]);
+
+ // Also check which private keys are locally cached.
+ const crossSigningPrivateKeys = await this.crossSigningInfo.getCrossSigningKeysFromCache();
+
+ // If we see an update to our own master key, check it against the master
+ // key we have and, if it matches, mark it as verified
+
+ // First, get the new cross-signing info
+ const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId);
+ if (!newCrossSigning) {
+ logger.error(
+ "Got cross-signing update event for user " + userId + " but no new cross-signing information found!",
+ );
+ return;
+ }
+
+ const seenPubkey = newCrossSigning.getId()!;
+ const masterChanged = this.crossSigningInfo.getId() !== seenPubkey;
+ const masterExistsNotLocallyCached = newCrossSigning.getId() && !crossSigningPrivateKeys.has("master");
+ if (masterChanged) {
+ logger.info("Got new master public key", seenPubkey);
+ }
+ if (allowPrivateKeyRequests && (masterChanged || masterExistsNotLocallyCached)) {
+ logger.info("Attempting to retrieve cross-signing master private key");
+ let signing: PkSigning | null = null;
+ // It's important for control flow that we leave any errors alone for
+ // higher levels to handle so that e.g. cancelling access properly
+ // aborts any larger operation as well.
+ try {
+ const ret = await this.crossSigningInfo.getCrossSigningKey("master", seenPubkey);
+ signing = ret[1];
+ logger.info("Got cross-signing master private key");
+ } finally {
+ signing?.free();
+ }
+ }
+
+ const oldSelfSigningId = this.crossSigningInfo.getId("self_signing");
+ const oldUserSigningId = this.crossSigningInfo.getId("user_signing");
+
+ // Update the version of our keys in our cross-signing object and the local store
+ this.storeTrustedSelfKeys(newCrossSigning.keys);
+
+ const selfSigningChanged = oldSelfSigningId !== newCrossSigning.getId("self_signing");
+ const userSigningChanged = oldUserSigningId !== newCrossSigning.getId("user_signing");
+
+ const selfSigningExistsNotLocallyCached =
+ newCrossSigning.getId("self_signing") && !crossSigningPrivateKeys.has("self_signing");
+ const userSigningExistsNotLocallyCached =
+ newCrossSigning.getId("user_signing") && !crossSigningPrivateKeys.has("user_signing");
+
+ const keySignatures: Record<string, ISignedKey> = {};
+
+ if (selfSigningChanged) {
+ logger.info("Got new self-signing key", newCrossSigning.getId("self_signing"));
+ }
+ if (allowPrivateKeyRequests && (selfSigningChanged || selfSigningExistsNotLocallyCached)) {
+ logger.info("Attempting to retrieve cross-signing self-signing private key");
+ let signing: PkSigning | null = null;
+ try {
+ const ret = await this.crossSigningInfo.getCrossSigningKey(
+ "self_signing",
+ newCrossSigning.getId("self_signing")!,
+ );
+ signing = ret[1];
+ logger.info("Got cross-signing self-signing private key");
+ } finally {
+ signing?.free();
+ }
+
+ const device = this.deviceList.getStoredDevice(this.userId, this.deviceId)!;
+ const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device);
+ keySignatures[this.deviceId] = signedDevice!;
+ }
+ if (userSigningChanged) {
+ logger.info("Got new user-signing key", newCrossSigning.getId("user_signing"));
+ }
+ if (allowPrivateKeyRequests && (userSigningChanged || userSigningExistsNotLocallyCached)) {
+ logger.info("Attempting to retrieve cross-signing user-signing private key");
+ let signing: PkSigning | null = null;
+ try {
+ const ret = await this.crossSigningInfo.getCrossSigningKey(
+ "user_signing",
+ newCrossSigning.getId("user_signing")!,
+ );
+ signing = ret[1];
+ logger.info("Got cross-signing user-signing private key");
+ } finally {
+ signing?.free();
+ }
+ }
+
+ if (masterChanged) {
+ const masterKey = this.crossSigningInfo.keys.master;
+ await this.signObject(masterKey);
+ const deviceSig = masterKey.signatures![this.userId]["ed25519:" + this.deviceId];
+ // Include only the _new_ device signature in the upload.
+ // We may have existing signatures from deleted devices, which will cause
+ // the entire upload to fail.
+ keySignatures[this.crossSigningInfo.getId()!] = Object.assign({} as ISignedKey, masterKey, {
+ signatures: {
+ [this.userId]: {
+ ["ed25519:" + this.deviceId]: deviceSig,
+ },
+ },
+ });
+ }
+
+ const keysToUpload = Object.keys(keySignatures);
+ if (keysToUpload.length) {
+ const upload = ({ shouldEmit = false }): Promise<void> => {
+ logger.info(`Starting background key sig upload for ${keysToUpload}`);
+ return this.baseApis
+ .uploadKeySignatures({ [this.userId]: keySignatures })
+ .then((response) => {
+ const { failures } = response || {};
+ logger.info(`Finished background key sig upload for ${keysToUpload}`);
+ if (Object.keys(failures || []).length > 0) {
+ if (shouldEmit) {
+ this.baseApis.emit(
+ CryptoEvent.KeySignatureUploadFailure,
+ failures,
+ "checkOwnCrossSigningTrust",
+ upload,
+ );
+ }
+ throw new KeySignatureUploadError("Key upload failed", { failures });
+ }
+ })
+ .catch((e) => {
+ logger.error(`Error during background key sig upload for ${keysToUpload}`, e);
+ });
+ };
+ upload({ shouldEmit: true });
+ }
+
+ this.emit(CryptoEvent.UserTrustStatusChanged, userId, this.checkUserTrust(userId));
+
+ if (masterChanged) {
+ this.emit(CryptoEvent.KeysChanged, {});
+ await this.afterCrossSigningLocalKeyChange();
+ }
+
+ // Now we may be able to trust our key backup
+ await this.backupManager.checkKeyBackup();
+ // FIXME: if we previously trusted the backup, should we automatically sign
+ // the backup with the new key (if not already signed)?
+ }
+
+ /**
+ * Store a set of keys as our own, trusted, cross-signing keys.
+ *
+ * @param keys - The new trusted set of keys
+ */
+ private async storeTrustedSelfKeys(keys: Record<string, ICrossSigningKey> | null): Promise<void> {
+ if (keys) {
+ this.crossSigningInfo.setKeys(keys);
+ } else {
+ this.crossSigningInfo.clearKeys();
+ }
+ await this.cryptoStore.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
+ this.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningInfo.keys);
+ });
+ }
+
+ /**
+ * Check if the master key is signed by a verified device, and if so, prompt
+ * the application to mark it as verified.
+ *
+ * @param userId - the user ID whose key should be checked
+ */
+ private async checkDeviceVerifications(userId: string): Promise<void> {
+ const shouldUpgradeCb = this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications;
+ if (!shouldUpgradeCb) {
+ // Upgrading skipped when callback is not present.
+ return;
+ }
+ logger.info(`Starting device verification upgrade for ${userId}`);
+ if (this.crossSigningInfo.keys.user_signing) {
+ const crossSigningInfo = this.deviceList.getStoredCrossSigningForUser(userId);
+ if (crossSigningInfo) {
+ const upgradeInfo = await this.checkForDeviceVerificationUpgrade(userId, crossSigningInfo);
+ if (upgradeInfo) {
+ const usersToUpgrade = await shouldUpgradeCb({
+ users: {
+ [userId]: upgradeInfo,
+ },
+ });
+ if (usersToUpgrade.includes(userId)) {
+ await this.baseApis.setDeviceVerified(userId, crossSigningInfo.getId()!);
+ }
+ }
+ }
+ }
+ logger.info(`Finished device verification upgrade for ${userId}`);
+ }
+
+ /**
+ */
+ public enableLazyLoading(): void {
+ this.lazyLoadMembers = true;
+ }
+
+ /**
+ * Tell the crypto module to register for MatrixClient events which it needs to
+ * listen for
+ *
+ * @param eventEmitter - event source where we can register
+ * for event notifications
+ */
+ public registerEventHandlers(
+ eventEmitter: TypedEventEmitter<
+ RoomMemberEvent.Membership | ClientEvent.ToDeviceEvent | RoomEvent.Timeline | MatrixEventEvent.Decrypted,
+ any
+ >,
+ ): void {
+ eventEmitter.on(RoomMemberEvent.Membership, this.onMembership);
+ eventEmitter.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
+ eventEmitter.on(RoomEvent.Timeline, this.onTimelineEvent);
+ eventEmitter.on(MatrixEventEvent.Decrypted, this.onTimelineEvent);
+ }
+
+ /**
+ * @deprecated this does nothing and will be removed in a future version
+ */
+ public start(): void {
+ logger.warn("MatrixClient.crypto.start() is deprecated");
+ }
+
+ /** Stop background processes related to crypto */
+ public stop(): void {
+ this.outgoingRoomKeyRequestManager.stop();
+ this.deviceList.stop();
+ this.dehydrationManager.stop();
+ }
+
+ /**
+ * Get the Ed25519 key for this device
+ *
+ * @returns base64-encoded ed25519 key.
+ */
+ public getDeviceEd25519Key(): string | null {
+ return this.olmDevice.deviceEd25519Key;
+ }
+
+ /**
+ * Get the Curve25519 key for this device
+ *
+ * @returns base64-encoded curve25519 key.
+ */
+ public getDeviceCurve25519Key(): string | null {
+ return this.olmDevice.deviceCurve25519Key;
+ }
+
+ /**
+ * Set the global override for whether the client should ever send encrypted
+ * messages to unverified devices. This provides the default for rooms which
+ * do not specify a value.
+ *
+ * @param value - whether to blacklist all unverified devices by default
+ *
+ * @deprecated For external code, use {@link MatrixClient#setGlobalBlacklistUnverifiedDevices}. For
+ * internal code, set {@link MatrixClient#globalBlacklistUnverifiedDevices} directly.
+ */
+ public setGlobalBlacklistUnverifiedDevices(value: boolean): void {
+ this.globalBlacklistUnverifiedDevices = value;
+ }
+
+ /**
+ * @returns whether to blacklist all unverified devices by default
+ *
+ * @deprecated For external code, use {@link MatrixClient#getGlobalBlacklistUnverifiedDevices}. For
+ * internal code, reference {@link MatrixClient#globalBlacklistUnverifiedDevices} directly.
+ */
+ public getGlobalBlacklistUnverifiedDevices(): boolean {
+ return this.globalBlacklistUnverifiedDevices;
+ }
+
+ /**
+ * Upload the device keys to the homeserver.
+ * @returns A promise that will resolve when the keys are uploaded.
+ */
+ public uploadDeviceKeys(): Promise<IKeysUploadResponse> {
+ const deviceKeys = {
+ algorithms: this.supportedAlgorithms,
+ device_id: this.deviceId,
+ keys: this.deviceKeys,
+ user_id: this.userId,
+ };
+
+ return this.signObject(deviceKeys).then(() => {
+ return this.baseApis.uploadKeysRequest({
+ device_keys: deviceKeys as Required<IDeviceKeys>,
+ });
+ });
+ }
+
+ /**
+ * Stores the current one_time_key count which will be handled later (in a call of
+ * onSyncCompleted). The count is e.g. coming from a /sync response.
+ *
+ * @param currentCount - The current count of one_time_keys to be stored
+ */
+ public updateOneTimeKeyCount(currentCount: number): void {
+ if (isFinite(currentCount)) {
+ this.oneTimeKeyCount = currentCount;
+ } else {
+ throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number");
+ }
+ }
+
+ public setNeedsNewFallback(needsNewFallback: boolean): void {
+ this.needsNewFallback = needsNewFallback;
+ }
+
+ public getNeedsNewFallback(): boolean {
+ return !!this.needsNewFallback;
+ }
+
+ // check if it's time to upload one-time keys, and do so if so.
+ private maybeUploadOneTimeKeys(): void {
+ // frequency with which to check & upload one-time keys
+ const uploadPeriod = 1000 * 60; // one minute
+
+ // max number of keys to upload at once
+ // Creating keys can be an expensive operation so we limit the
+ // number we generate in one go to avoid blocking the application
+ // for too long.
+ const maxKeysPerCycle = 5;
+
+ if (this.oneTimeKeyCheckInProgress) {
+ return;
+ }
+
+ const now = Date.now();
+ if (this.lastOneTimeKeyCheck !== null && now - this.lastOneTimeKeyCheck < uploadPeriod) {
+ // we've done a key upload recently.
+ return;
+ }
+
+ this.lastOneTimeKeyCheck = now;
+
+ // We need to keep a pool of one time public keys on the server so that
+ // other devices can start conversations with us. But we can only store
+ // a finite number of private keys in the olm Account object.
+ // To complicate things further then can be a delay between a device
+ // claiming a public one time key from the server and it sending us a
+ // message. We need to keep the corresponding private key locally until
+ // we receive the message.
+ // But that message might never arrive leaving us stuck with duff
+ // private keys clogging up our local storage.
+ // So we need some kind of engineering compromise to balance all of
+ // these factors.
+
+ // Check how many keys we can store in the Account object.
+ const maxOneTimeKeys = this.olmDevice.maxNumberOfOneTimeKeys();
+ // Try to keep at most half that number on the server. This leaves the
+ // rest of the slots free to hold keys that have been claimed from the
+ // server but we haven't received a message for.
+ // If we run out of slots when generating new keys then olm will
+ // discard the oldest private keys first. This will eventually clean
+ // out stale private keys that won't receive a message.
+ const keyLimit = Math.floor(maxOneTimeKeys / 2);
+
+ const uploadLoop = async (keyCount: number): Promise<void> => {
+ while (keyLimit > keyCount || this.getNeedsNewFallback()) {
+ // Ask olm to generate new one time keys, then upload them to synapse.
+ if (keyLimit > keyCount) {
+ logger.info("generating oneTimeKeys");
+ const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle);
+ await this.olmDevice.generateOneTimeKeys(keysThisLoop);
+ }
+
+ if (this.getNeedsNewFallback()) {
+ const fallbackKeys = await this.olmDevice.getFallbackKey();
+ // if fallbackKeys is non-empty, we've already generated a
+ // fallback key, but it hasn't been published yet, so we
+ // can use that instead of generating a new one
+ if (!fallbackKeys.curve25519 || Object.keys(fallbackKeys.curve25519).length == 0) {
+ logger.info("generating fallback key");
+ if (this.fallbackCleanup) {
+ // cancel any pending fallback cleanup because generating
+ // a new fallback key will already drop the old fallback
+ // that would have been dropped, and we don't want to kill
+ // the current key
+ clearTimeout(this.fallbackCleanup);
+ delete this.fallbackCleanup;
+ }
+ await this.olmDevice.generateFallbackKey();
+ }
+ }
+
+ logger.info("calling uploadOneTimeKeys");
+ const res = await this.uploadOneTimeKeys();
+ if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) {
+ // if the response contains a more up to date value use this
+ // for the next loop
+ keyCount = res.one_time_key_counts.signed_curve25519;
+ } else {
+ throw new Error(
+ "response for uploading keys does not contain " + "one_time_key_counts.signed_curve25519",
+ );
+ }
+ }
+ };
+
+ this.oneTimeKeyCheckInProgress = true;
+ Promise.resolve()
+ .then(() => {
+ if (this.oneTimeKeyCount !== undefined) {
+ // We already have the current one_time_key count from a /sync response.
+ // Use this value instead of asking the server for the current key count.
+ return Promise.resolve(this.oneTimeKeyCount);
+ }
+ // ask the server how many keys we have
+ return this.baseApis.uploadKeysRequest({}).then((res) => {
+ return res.one_time_key_counts.signed_curve25519 || 0;
+ });
+ })
+ .then((keyCount) => {
+ // Start the uploadLoop with the current keyCount. The function checks if
+ // we need to upload new keys or not.
+ // If there are too many keys on the server then we don't need to
+ // create any more keys.
+ return uploadLoop(keyCount);
+ })
+ .catch((e) => {
+ logger.error("Error uploading one-time keys", e.stack || e);
+ })
+ .finally(() => {
+ // reset oneTimeKeyCount to prevent start uploading based on old data.
+ // it will be set again on the next /sync-response
+ this.oneTimeKeyCount = undefined;
+ this.oneTimeKeyCheckInProgress = false;
+ });
+ }
+
+ // returns a promise which resolves to the response
+ private async uploadOneTimeKeys(): Promise<IKeysUploadResponse> {
+ const promises: Promise<unknown>[] = [];
+
+ let fallbackJson: Record<string, IOneTimeKey> | undefined;
+ if (this.getNeedsNewFallback()) {
+ fallbackJson = {};
+ const fallbackKeys = await this.olmDevice.getFallbackKey();
+ for (const [keyId, key] of Object.entries(fallbackKeys.curve25519)) {
+ const k = { key, fallback: true };
+ fallbackJson["signed_curve25519:" + keyId] = k;
+ promises.push(this.signObject(k));
+ }
+ this.setNeedsNewFallback(false);
+ }
+
+ const oneTimeKeys = await this.olmDevice.getOneTimeKeys();
+ const oneTimeJson: Record<string, { key: string }> = {};
+
+ for (const keyId in oneTimeKeys.curve25519) {
+ if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) {
+ const k = {
+ key: oneTimeKeys.curve25519[keyId],
+ };
+ oneTimeJson["signed_curve25519:" + keyId] = k;
+ promises.push(this.signObject(k));
+ }
+ }
+
+ await Promise.all(promises);
+
+ const requestBody: Record<string, any> = {
+ one_time_keys: oneTimeJson,
+ };
+
+ if (fallbackJson) {
+ requestBody["org.matrix.msc2732.fallback_keys"] = fallbackJson;
+ requestBody["fallback_keys"] = fallbackJson;
+ }
+
+ const res = await this.baseApis.uploadKeysRequest(requestBody);
+
+ if (fallbackJson) {
+ this.fallbackCleanup = setTimeout(() => {
+ delete this.fallbackCleanup;
+ this.olmDevice.forgetOldFallbackKey();
+ }, 60 * 60 * 1000);
+ }
+
+ await this.olmDevice.markKeysAsPublished();
+ return res;
+ }
+
+ /**
+ * Download the keys for a list of users and stores the keys in the session
+ * store.
+ * @param userIds - The users to fetch.
+ * @param forceDownload - Always download the keys even if cached.
+ *
+ * @returns A promise which resolves to a map `userId->deviceId->{@link DeviceInfo}`.
+ */
+ public downloadKeys(userIds: string[], forceDownload?: boolean): Promise<DeviceInfoMap> {
+ return this.deviceList.downloadKeys(userIds, !!forceDownload);
+ }
+
+ /**
+ * Get the stored device keys for a user id
+ *
+ * @param userId - the user to list keys for.
+ *
+ * @returns list of devices, or null if we haven't
+ * managed to get a list of devices for this user yet.
+ */
+ public getStoredDevicesForUser(userId: string): Array<DeviceInfo> | null {
+ return this.deviceList.getStoredDevicesForUser(userId);
+ }
+
+ /**
+ * Get the stored keys for a single device
+ *
+ *
+ * @returns device, or undefined
+ * if we don't know about this device
+ */
+ public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined {
+ return this.deviceList.getStoredDevice(userId, deviceId);
+ }
+
+ /**
+ * Save the device list, if necessary
+ *
+ * @param delay - Time in ms before which the save actually happens.
+ * By default, the save is delayed for a short period in order to batch
+ * multiple writes, but this behaviour can be disabled by passing 0.
+ *
+ * @returns true if the data was saved, false if
+ * it was not (eg. because no changes were pending). The promise
+ * will only resolve once the data is saved, so may take some time
+ * to resolve.
+ */
+ public saveDeviceList(delay: number): Promise<boolean> {
+ return this.deviceList.saveIfDirty(delay);
+ }
+
+ /**
+ * Update the blocked/verified state of the given device
+ *
+ * @param userId - owner of the device
+ * @param deviceId - unique identifier for the device or user's
+ * cross-signing public key ID.
+ *
+ * @param verified - whether to mark the device as verified. Null to
+ * leave unchanged.
+ *
+ * @param blocked - whether to mark the device as blocked. Null to
+ * leave unchanged.
+ *
+ * @param known - whether to mark that the user has been made aware of
+ * the existence of this device. Null to leave unchanged
+ *
+ * @param keys - The list of keys that was present
+ * during the device verification. This will be double checked with the list
+ * of keys the given device has currently.
+ *
+ * @returns updated DeviceInfo
+ */
+ public async setDeviceVerification(
+ userId: string,
+ deviceId: string,
+ verified: boolean | null = null,
+ blocked: boolean | null = null,
+ known: boolean | null = null,
+ keys?: Record<string, string>,
+ ): Promise<DeviceInfo | CrossSigningInfo> {
+ // Check if the 'device' is actually a cross signing key
+ // The js-sdk's verification treats cross-signing keys as devices
+ // and so uses this method to mark them verified.
+ const xsk = this.deviceList.getStoredCrossSigningForUser(userId);
+ if (xsk && xsk.getId() === deviceId) {
+ if (blocked !== null || known !== null) {
+ throw new Error("Cannot set blocked or known for a cross-signing key");
+ }
+ if (!verified) {
+ throw new Error("Cannot set a cross-signing key as unverified");
+ }
+ const gotKeyId = keys ? Object.values(keys)[0] : null;
+ if (keys && (Object.values(keys).length !== 1 || gotKeyId !== xsk.getId())) {
+ throw new Error(`Key did not match expected value: expected ${xsk.getId()}, got ${gotKeyId}`);
+ }
+
+ if (!this.crossSigningInfo.getId() && userId === this.crossSigningInfo.userId) {
+ this.storeTrustedSelfKeys(xsk.keys);
+ // This will cause our own user trust to change, so emit the event
+ this.emit(CryptoEvent.UserTrustStatusChanged, this.userId, this.checkUserTrust(userId));
+ }
+
+ // Now sign the master key with our user signing key (unless it's ourself)
+ if (userId !== this.userId) {
+ logger.info("Master key " + xsk.getId() + " for " + userId + " marked verified. Signing...");
+ const device = await this.crossSigningInfo.signUser(xsk);
+ if (device) {
+ const upload = async ({ shouldEmit = false }): Promise<void> => {
+ logger.info("Uploading signature for " + userId + "...");
+ const response = await this.baseApis.uploadKeySignatures({
+ [userId]: {
+ [deviceId]: device,
+ },
+ });
+ const { failures } = response || {};
+ if (Object.keys(failures || []).length > 0) {
+ if (shouldEmit) {
+ this.baseApis.emit(
+ CryptoEvent.KeySignatureUploadFailure,
+ failures,
+ "setDeviceVerification",
+ upload,
+ );
+ }
+ /* Throwing here causes the process to be cancelled and the other
+ * user to be notified */
+ throw new KeySignatureUploadError("Key upload failed", { failures });
+ }
+ };
+ await upload({ shouldEmit: true });
+
+ // This will emit events when it comes back down the sync
+ // (we could do local echo to speed things up)
+ }
+ return device as any; // TODO types
+ } else {
+ return xsk;
+ }
+ }
+
+ const devices = this.deviceList.getRawStoredDevicesForUser(userId);
+ if (!devices || !devices[deviceId]) {
+ throw new Error("Unknown device " + userId + ":" + deviceId);
+ }
+
+ const dev = devices[deviceId];
+ let verificationStatus = dev.verified;
+
+ if (verified) {
+ if (keys) {
+ for (const [keyId, key] of Object.entries(keys)) {
+ if (dev.keys[keyId] !== key) {
+ throw new Error(`Key did not match expected value: expected ${key}, got ${dev.keys[keyId]}`);
+ }
+ }
+ }
+ verificationStatus = DeviceVerification.VERIFIED;
+ } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) {
+ verificationStatus = DeviceVerification.UNVERIFIED;
+ }
+
+ if (blocked) {
+ verificationStatus = DeviceVerification.BLOCKED;
+ } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) {
+ verificationStatus = DeviceVerification.UNVERIFIED;
+ }
+
+ let knownStatus = dev.known;
+ if (known !== null) {
+ knownStatus = known;
+ }
+
+ if (dev.verified !== verificationStatus || dev.known !== knownStatus) {
+ dev.verified = verificationStatus;
+ dev.known = knownStatus;
+ this.deviceList.storeDevicesForUser(userId, devices);
+ this.deviceList.saveIfDirty();
+ }
+
+ // do cross-signing
+ if (verified && userId === this.userId) {
+ logger.info("Own device " + deviceId + " marked verified: signing");
+
+ // Signing only needed if other device not already signed
+ let device: ISignedKey | undefined;
+ const deviceTrust = this.checkDeviceTrust(userId, deviceId);
+ if (deviceTrust.isCrossSigningVerified()) {
+ logger.log(`Own device ${deviceId} already cross-signing verified`);
+ } else {
+ device = (await this.crossSigningInfo.signDevice(userId, DeviceInfo.fromStorage(dev, deviceId)))!;
+ }
+
+ if (device) {
+ const upload = async ({ shouldEmit = false }): Promise<void> => {
+ logger.info("Uploading signature for " + deviceId);
+ const response = await this.baseApis.uploadKeySignatures({
+ [userId]: {
+ [deviceId]: device!,
+ },
+ });
+ const { failures } = response || {};
+ if (Object.keys(failures || []).length > 0) {
+ if (shouldEmit) {
+ this.baseApis.emit(
+ CryptoEvent.KeySignatureUploadFailure,
+ failures,
+ "setDeviceVerification",
+ upload, // continuation
+ );
+ }
+ throw new KeySignatureUploadError("Key upload failed", { failures });
+ }
+ };
+ await upload({ shouldEmit: true });
+ // XXX: we'll need to wait for the device list to be updated
+ }
+ }
+
+ const deviceObj = DeviceInfo.fromStorage(dev, deviceId);
+ this.emit(CryptoEvent.DeviceVerificationChanged, userId, deviceId, deviceObj);
+ return deviceObj;
+ }
+
+ public findVerificationRequestDMInProgress(roomId: string): VerificationRequest | undefined {
+ return this.inRoomVerificationRequests.findRequestInProgress(roomId);
+ }
+
+ public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest[] {
+ return this.toDeviceVerificationRequests.getRequestsInProgress(userId);
+ }
+
+ public requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> {
+ const existingRequest = this.inRoomVerificationRequests.findRequestInProgress(roomId);
+ if (existingRequest) {
+ return Promise.resolve(existingRequest);
+ }
+ const channel = new InRoomChannel(this.baseApis, roomId, userId);
+ return this.requestVerificationWithChannel(userId, channel, this.inRoomVerificationRequests);
+ }
+
+ public requestVerification(userId: string, devices?: string[]): Promise<VerificationRequest> {
+ if (!devices) {
+ devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId));
+ }
+ const existingRequest = this.toDeviceVerificationRequests.findRequestInProgress(userId, devices);
+ if (existingRequest) {
+ return Promise.resolve(existingRequest);
+ }
+ const channel = new ToDeviceChannel(this.baseApis, userId, devices, ToDeviceChannel.makeTransactionId());
+ return this.requestVerificationWithChannel(userId, channel, this.toDeviceVerificationRequests);
+ }
+
+ private async requestVerificationWithChannel(
+ userId: string,
+ channel: IVerificationChannel,
+ requestsMap: IRequestsMap,
+ ): Promise<VerificationRequest> {
+ let request = new VerificationRequest(channel, this.verificationMethods, this.baseApis);
+ // if transaction id is already known, add request
+ if (channel.transactionId) {
+ requestsMap.setRequestByChannel(channel, request);
+ }
+ await request.sendRequest();
+ // don't replace the request created by a racing remote echo
+ const racingRequest = requestsMap.getRequestByChannel(channel);
+ if (racingRequest) {
+ request = racingRequest;
+ } else {
+ logger.log(
+ `Crypto: adding new request to ` + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`,
+ );
+ requestsMap.setRequestByChannel(channel, request);
+ }
+ return request;
+ }
+
+ public beginKeyVerification(
+ method: string,
+ userId: string,
+ deviceId: string,
+ transactionId: string | null = null,
+ ): VerificationBase<any, any> {
+ let request: Request | undefined;
+ if (transactionId) {
+ request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId);
+ if (!request) {
+ throw new Error(`No request found for user ${userId} with ` + `transactionId ${transactionId}`);
+ }
+ } else {
+ transactionId = ToDeviceChannel.makeTransactionId();
+ const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId);
+ request = new VerificationRequest(channel, this.verificationMethods, this.baseApis);
+ this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request);
+ }
+ return request.beginKeyVerification(method, { userId, deviceId });
+ }
+
+ public async legacyDeviceVerification(
+ userId: string,
+ deviceId: string,
+ method: VerificationMethod,
+ ): Promise<VerificationRequest> {
+ const transactionId = ToDeviceChannel.makeTransactionId();
+ const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId);
+ const request = new VerificationRequest(channel, this.verificationMethods, this.baseApis);
+ this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request);
+ const verifier = request.beginKeyVerification(method, { userId, deviceId });
+ // either reject by an error from verify() while sending .start
+ // or resolve when the request receives the
+ // local (fake remote) echo for sending the .start event
+ await Promise.race([verifier.verify(), request.waitFor((r) => r.started)]);
+ return request;
+ }
+
+ /**
+ * Get information on the active olm sessions with a user
+ * <p>
+ * Returns a map from device id to an object with keys 'deviceIdKey' (the
+ * device's curve25519 identity key) and 'sessions' (an array of objects in the
+ * same format as that returned by
+ * {@link OlmDevice#getSessionInfoForDevice}).
+ * <p>
+ * This method is provided for debugging purposes.
+ *
+ * @param userId - id of user to inspect
+ */
+ public async getOlmSessionsForUser(userId: string): Promise<Record<string, IUserOlmSession>> {
+ const devices = this.getStoredDevicesForUser(userId) || [];
+ const result: { [deviceId: string]: IUserOlmSession } = {};
+ for (const device of devices) {
+ const deviceKey = device.getIdentityKey();
+ const sessions = await this.olmDevice.getSessionInfoForDevice(deviceKey);
+
+ result[device.deviceId] = {
+ deviceIdKey: deviceKey,
+ sessions: sessions,
+ };
+ }
+ return result;
+ }
+
+ /**
+ * Get the device which sent an event
+ *
+ * @param event - event to be checked
+ */
+ public getEventSenderDeviceInfo(event: MatrixEvent): DeviceInfo | null {
+ const senderKey = event.getSenderKey();
+ const algorithm = event.getWireContent().algorithm;
+
+ if (!senderKey || !algorithm) {
+ return null;
+ }
+
+ if (event.isKeySourceUntrusted()) {
+ // we got the key for this event from a source that we consider untrusted
+ return null;
+ }
+
+ // senderKey is the Curve25519 identity key of the device which the event
+ // was sent from. In the case of Megolm, it's actually the Curve25519
+ // identity key of the device which set up the Megolm session.
+
+ const device = this.deviceList.getDeviceByIdentityKey(algorithm, senderKey);
+
+ if (device === null) {
+ // we haven't downloaded the details of this device yet.
+ return null;
+ }
+
+ // so far so good, but now we need to check that the sender of this event
+ // hadn't advertised someone else's Curve25519 key as their own. We do that
+ // by checking the Ed25519 claimed by the event (or, in the case of megolm,
+ // the event which set up the megolm session), to check that it matches the
+ // fingerprint of the purported sending device.
+ //
+ // (see https://github.com/vector-im/vector-web/issues/2215)
+
+ const claimedKey = event.getClaimedEd25519Key();
+ if (!claimedKey) {
+ logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device");
+ return null;
+ }
+
+ if (claimedKey !== device.getFingerprint()) {
+ logger.warn(
+ "Event " +
+ event.getId() +
+ " claims ed25519 key " +
+ claimedKey +
+ " but sender device has key " +
+ device.getFingerprint(),
+ );
+ return null;
+ }
+
+ return device;
+ }
+
+ /**
+ * Get information about the encryption of an event
+ *
+ * @param event - event to be checked
+ *
+ * @returns An object with the fields:
+ * - encrypted: whether the event is encrypted (if not encrypted, some of the
+ * other properties may not be set)
+ * - senderKey: the sender's key
+ * - algorithm: the algorithm used to encrypt the event
+ * - authenticated: whether we can be sure that the owner of the senderKey
+ * sent the event
+ * - sender: the sender's device information, if available
+ * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match
+ * (only meaningful if `sender` is set)
+ */
+ public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
+ const ret: Partial<IEncryptedEventInfo> = {};
+
+ ret.senderKey = event.getSenderKey() ?? undefined;
+ ret.algorithm = event.getWireContent().algorithm;
+
+ if (!ret.senderKey || !ret.algorithm) {
+ ret.encrypted = false;
+ return ret as IEncryptedEventInfo;
+ }
+ ret.encrypted = true;
+
+ if (event.isKeySourceUntrusted()) {
+ // we got the key this event from somewhere else
+ // TODO: check if we can trust the forwarders.
+ ret.authenticated = false;
+ } else {
+ ret.authenticated = true;
+ }
+
+ // senderKey is the Curve25519 identity key of the device which the event
+ // was sent from. In the case of Megolm, it's actually the Curve25519
+ // identity key of the device which set up the Megolm session.
+
+ ret.sender = this.deviceList.getDeviceByIdentityKey(ret.algorithm, ret.senderKey) ?? undefined;
+
+ // so far so good, but now we need to check that the sender of this event
+ // hadn't advertised someone else's Curve25519 key as their own. We do that
+ // by checking the Ed25519 claimed by the event (or, in the case of megolm,
+ // the event which set up the megolm session), to check that it matches the
+ // fingerprint of the purported sending device.
+ //
+ // (see https://github.com/vector-im/vector-web/issues/2215)
+
+ const claimedKey = event.getClaimedEd25519Key();
+ if (!claimedKey) {
+ logger.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device");
+ ret.mismatchedSender = true;
+ }
+
+ if (ret.sender && claimedKey !== ret.sender.getFingerprint()) {
+ logger.warn(
+ "Event " +
+ event.getId() +
+ " claims ed25519 key " +
+ claimedKey +
+ "but sender device has key " +
+ ret.sender.getFingerprint(),
+ );
+ ret.mismatchedSender = true;
+ }
+
+ return ret as IEncryptedEventInfo;
+ }
+
+ /**
+ * Forces the current outbound group session to be discarded such
+ * that another one will be created next time an event is sent.
+ *
+ * @param roomId - The ID of the room to discard the session for
+ *
+ * This should not normally be necessary.
+ */
+ public forceDiscardSession(roomId: string): Promise<void> {
+ const alg = this.roomEncryptors.get(roomId);
+ if (alg === undefined) throw new Error("Room not encrypted");
+ if (alg.forceDiscardSession === undefined) {
+ throw new Error("Room encryption algorithm doesn't support session discarding");
+ }
+ alg.forceDiscardSession();
+ return Promise.resolve();
+ }
+
+ /**
+ * Configure a room to use encryption (ie, save a flag in the cryptoStore).
+ *
+ * @param roomId - The room ID to enable encryption in.
+ *
+ * @param config - The encryption config for the room.
+ *
+ * @param inhibitDeviceQuery - true to suppress device list query for
+ * users in the room (for now). In case lazy loading is enabled,
+ * the device query is always inhibited as the members are not tracked.
+ *
+ * @deprecated It is normally incorrect to call this method directly. Encryption
+ * is enabled by receiving an `m.room.encryption` event (which we may have sent
+ * previously).
+ */
+ public async setRoomEncryption(
+ roomId: string,
+ config: IRoomEncryption,
+ inhibitDeviceQuery?: boolean,
+ ): Promise<void> {
+ const room = this.clientStore.getRoom(roomId);
+ if (!room) {
+ throw new Error(`Unable to enable encryption tracking devices in unknown room ${roomId}`);
+ }
+ await this.setRoomEncryptionImpl(room, config);
+ if (!this.lazyLoadMembers && !inhibitDeviceQuery) {
+ this.deviceList.refreshOutdatedDeviceLists();
+ }
+ }
+
+ /**
+ * Set up encryption for a room.
+ *
+ * This is called when an <tt>m.room.encryption</tt> event is received. It saves a flag
+ * for the room in the cryptoStore (if it wasn't already set), sets up an "encryptor" for
+ * the room, and enables device-list tracking for the room.
+ *
+ * It does <em>not</em> initiate a device list query for the room. That is normally
+ * done once we finish processing the sync, in onSyncCompleted.
+ *
+ * @param room - The room to enable encryption in.
+ * @param config - The encryption config for the room.
+ */
+ private async setRoomEncryptionImpl(room: Room, config: IRoomEncryption): Promise<void> {
+ const roomId = room.roomId;
+
+ // ignore crypto events with no algorithm defined
+ // This will happen if a crypto event is redacted before we fetch the room state
+ // It would otherwise just throw later as an unknown algorithm would, but we may
+ // as well catch this here
+ if (!config.algorithm) {
+ logger.log("Ignoring setRoomEncryption with no algorithm");
+ return;
+ }
+
+ // if state is being replayed from storage, we might already have a configuration
+ // for this room as they are persisted as well.
+ // We just need to make sure the algorithm is initialized in this case.
+ // However, if the new config is different,
+ // we should bail out as room encryption can't be changed once set.
+ const existingConfig = this.roomList.getRoomEncryption(roomId);
+ if (existingConfig) {
+ if (JSON.stringify(existingConfig) != JSON.stringify(config)) {
+ logger.error("Ignoring m.room.encryption event which requests " + "a change of config in " + roomId);
+ return;
+ }
+ }
+ // if we already have encryption in this room, we should ignore this event,
+ // as it would reset the encryption algorithm.
+ // This is at least expected to be called twice, as sync calls onCryptoEvent
+ // for both the timeline and state sections in the /sync response,
+ // the encryption event would appear in both.
+ // If it's called more than twice though,
+ // it signals a bug on client or server.
+ const existingAlg = this.roomEncryptors.get(roomId);
+ if (existingAlg) {
+ return;
+ }
+
+ // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption
+ // because it first stores in memory. We should await the promise only
+ // after all the in-memory state (roomEncryptors and _roomList) has been updated
+ // to avoid races when calling this method multiple times. Hence keep a hold of the promise.
+ let storeConfigPromise: Promise<void> | null = null;
+ if (!existingConfig) {
+ storeConfigPromise = this.roomList.setRoomEncryption(roomId, config);
+ }
+
+ const AlgClass = algorithms.ENCRYPTION_CLASSES.get(config.algorithm);
+ if (!AlgClass) {
+ throw new Error("Unable to encrypt with " + config.algorithm);
+ }
+
+ const alg = new AlgClass({
+ userId: this.userId,
+ deviceId: this.deviceId,
+ crypto: this,
+ olmDevice: this.olmDevice,
+ baseApis: this.baseApis,
+ roomId,
+ config,
+ });
+ this.roomEncryptors.set(roomId, alg);
+
+ if (storeConfigPromise) {
+ await storeConfigPromise;
+ }
+
+ logger.log(`Enabling encryption in ${roomId}`);
+
+ // we don't want to force a download of the full membership list of this room, but as soon as we have that
+ // list we can start tracking the device list.
+ if (room.membersLoaded()) {
+ await this.trackRoomDevicesImpl(room);
+ } else {
+ // wait for the membership list to be loaded
+ const onState = (_state: RoomState): void => {
+ room.off(RoomStateEvent.Update, onState);
+ if (room.membersLoaded()) {
+ this.trackRoomDevicesImpl(room).catch((e) => {
+ logger.error(`Error enabling device tracking in ${roomId}`, e);
+ });
+ }
+ };
+ room.on(RoomStateEvent.Update, onState);
+ }
+ }
+
+ /**
+ * Make sure we are tracking the device lists for all users in this room.
+ *
+ * @param roomId - The room ID to start tracking devices in.
+ * @returns when all devices for the room have been fetched and marked to track
+ * @deprecated there's normally no need to call this function: device list tracking
+ * will be enabled as soon as we have the full membership list.
+ */
+ public trackRoomDevices(roomId: string): Promise<void> {
+ const room = this.clientStore.getRoom(roomId);
+ if (!room) {
+ throw new Error(`Unable to start tracking devices in unknown room ${roomId}`);
+ }
+ return this.trackRoomDevicesImpl(room);
+ }
+
+ /**
+ * Make sure we are tracking the device lists for all users in this room.
+ *
+ * This is normally called when we are about to send an encrypted event, to make sure
+ * we have all the devices in the room; but it is also called when processing an
+ * m.room.encryption state event (if lazy-loading is disabled), or when members are
+ * loaded (if lazy-loading is enabled), to prepare the device list.
+ *
+ * @param room - Room to enable device-list tracking in
+ */
+ private trackRoomDevicesImpl(room: Room): Promise<void> {
+ const roomId = room.roomId;
+ const trackMembers = async (): Promise<void> => {
+ // not an encrypted room
+ if (!this.roomEncryptors.has(roomId)) {
+ return;
+ }
+ logger.log(`Starting to track devices for room ${roomId} ...`);
+ const members = await room.getEncryptionTargetMembers();
+ members.forEach((m) => {
+ this.deviceList.startTrackingDeviceList(m.userId);
+ });
+ };
+
+ let promise = this.roomDeviceTrackingState[roomId];
+ if (!promise) {
+ promise = trackMembers();
+ this.roomDeviceTrackingState[roomId] = promise.catch((err) => {
+ delete this.roomDeviceTrackingState[roomId];
+ throw err;
+ });
+ }
+ return promise;
+ }
+
+ /**
+ * Try to make sure we have established olm sessions for all known devices for
+ * the given users.
+ *
+ * @param users - list of user ids
+ * @param force - If true, force a new Olm session to be created. Default false.
+ *
+ * @returns resolves once the sessions are complete, to
+ * an Object mapping from userId to deviceId to
+ * {@link OlmSessionResult}
+ */
+ public ensureOlmSessionsForUsers(
+ users: string[],
+ force?: boolean,
+ ): Promise<Map<string, Map<string, olmlib.IOlmSessionResult>>> {
+ // map user Id → DeviceInfo[]
+ const devicesByUser: Map<string, DeviceInfo[]> = new Map();
+
+ for (const userId of users) {
+ const userDevices: DeviceInfo[] = [];
+ devicesByUser.set(userId, userDevices);
+
+ const devices = this.getStoredDevicesForUser(userId) || [];
+ for (const deviceInfo of devices) {
+ const key = deviceInfo.getIdentityKey();
+ if (key == this.olmDevice.deviceCurve25519Key) {
+ // don't bother setting up session to ourself
+ continue;
+ }
+ if (deviceInfo.verified == DeviceVerification.BLOCKED) {
+ // don't bother setting up sessions with blocked users
+ continue;
+ }
+
+ userDevices.push(deviceInfo);
+ }
+ }
+
+ return olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, force);
+ }
+
+ /**
+ * Get a list containing all of the room keys
+ *
+ * @returns a list of session export objects
+ */
+ public async exportRoomKeys(): Promise<IMegolmSessionData[]> {
+ const exportedSessions: IMegolmSessionData[] = [];
+ await this.cryptoStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => {
+ this.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => {
+ if (s === null) return;
+
+ const sess = this.olmDevice.exportInboundGroupSession(s.senderKey, s.sessionId, s.sessionData!);
+ delete sess.first_known_index;
+ sess.algorithm = olmlib.MEGOLM_ALGORITHM;
+ exportedSessions.push(sess);
+ });
+ });
+
+ return exportedSessions;
+ }
+
+ /**
+ * Import a list of room keys previously exported by exportRoomKeys
+ *
+ * @param keys - a list of session export objects
+ * @returns a promise which resolves once the keys have been imported
+ */
+ public importRoomKeys(keys: IMegolmSessionData[], opts: IImportRoomKeysOpts = {}): Promise<void> {
+ let successes = 0;
+ let failures = 0;
+ const total = keys.length;
+
+ function updateProgress(): void {
+ opts.progressCallback?.({
+ stage: "load_keys",
+ successes,
+ failures,
+ total,
+ });
+ }
+
+ return Promise.all(
+ keys.map((key) => {
+ if (!key.room_id || !key.algorithm) {
+ logger.warn("ignoring room key entry with missing fields", key);
+ failures++;
+ if (opts.progressCallback) {
+ updateProgress();
+ }
+ return null;
+ }
+
+ const alg = this.getRoomDecryptor(key.room_id, key.algorithm);
+ return alg.importRoomKey(key, opts).finally(() => {
+ successes++;
+ if (opts.progressCallback) {
+ updateProgress();
+ }
+ });
+ }),
+ ).then();
+ }
+
+ /**
+ * Counts the number of end to end session keys that are waiting to be backed up
+ * @returns Promise which resolves to the number of sessions requiring backup
+ */
+ public countSessionsNeedingBackup(): Promise<number> {
+ return this.backupManager.countSessionsNeedingBackup();
+ }
+
+ /**
+ * Perform any background tasks that can be done before a message is ready to
+ * send, in order to speed up sending of the message.
+ *
+ * @param room - the room the event is in
+ */
+ public prepareToEncrypt(room: Room): void {
+ const alg = this.roomEncryptors.get(room.roomId);
+ if (alg) {
+ alg.prepareToEncrypt(room);
+ }
+ }
+
+ /**
+ * Encrypt an event according to the configuration of the room.
+ *
+ * @param event - event to be sent
+ *
+ * @param room - destination room.
+ *
+ * @returns Promise which resolves when the event has been
+ * encrypted, or null if nothing was needed
+ */
+ public async encryptEvent(event: MatrixEvent, room: Room): Promise<void> {
+ const roomId = event.getRoomId()!;
+
+ const alg = this.roomEncryptors.get(roomId);
+ if (!alg) {
+ // MatrixClient has already checked that this room should be encrypted,
+ // so this is an unexpected situation.
+ throw new Error(
+ "Room " +
+ roomId +
+ " was previously configured to use encryption, but is " +
+ "no longer. Perhaps the homeserver is hiding the " +
+ "configuration event.",
+ );
+ }
+
+ // wait for all the room devices to be loaded
+ await this.trackRoomDevicesImpl(room);
+
+ let content = event.getContent();
+ // If event has an m.relates_to then we need
+ // to put this on the wrapping event instead
+ const mRelatesTo = content["m.relates_to"];
+ if (mRelatesTo) {
+ // Clone content here so we don't remove `m.relates_to` from the local-echo
+ content = Object.assign({}, content);
+ delete content["m.relates_to"];
+ }
+
+ // Treat element's performance metrics the same as `m.relates_to` (when present)
+ const elementPerfMetrics = content["io.element.performance_metrics"];
+ if (elementPerfMetrics) {
+ content = Object.assign({}, content);
+ delete content["io.element.performance_metrics"];
+ }
+
+ const encryptedContent = (await alg.encryptMessage(room, event.getType(), content)) as IContent;
+
+ if (mRelatesTo) {
+ encryptedContent["m.relates_to"] = mRelatesTo;
+ }
+ if (elementPerfMetrics) {
+ encryptedContent["io.element.performance_metrics"] = elementPerfMetrics;
+ }
+
+ event.makeEncrypted(
+ "m.room.encrypted",
+ encryptedContent,
+ this.olmDevice.deviceCurve25519Key!,
+ this.olmDevice.deviceEd25519Key!,
+ );
+ }
+
+ /**
+ * Decrypt a received event
+ *
+ *
+ * @returns resolves once we have
+ * finished decrypting. Rejects with an `algorithms.DecryptionError` if there
+ * is a problem decrypting the event.
+ */
+ public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
+ if (event.isRedacted()) {
+ // Try to decrypt the redaction event, to support encrypted
+ // redaction reasons. If we can't decrypt, just fall back to using
+ // the original redacted_because.
+ const redactionEvent = new MatrixEvent({
+ room_id: event.getRoomId(),
+ ...event.getUnsigned().redacted_because,
+ });
+ let redactedBecause: IEvent = event.getUnsigned().redacted_because!;
+ if (redactionEvent.isEncrypted()) {
+ try {
+ const decryptedEvent = await this.decryptEvent(redactionEvent);
+ redactedBecause = decryptedEvent.clearEvent as IEvent;
+ } catch (e) {
+ logger.warn("Decryption of redaction failed. Falling back to unencrypted event.", e);
+ }
+ }
+
+ return {
+ clearEvent: {
+ room_id: event.getRoomId(),
+ type: "m.room.message",
+ content: {},
+ unsigned: {
+ redacted_because: redactedBecause,
+ },
+ },
+ };
+ } else {
+ const content = event.getWireContent();
+ const alg = this.getRoomDecryptor(event.getRoomId()!, content.algorithm);
+ return alg.decryptEvent(event);
+ }
+ }
+
+ /**
+ * Handle the notification from /sync or /keys/changes that device lists have
+ * been changed.
+ *
+ * @param syncData - Object containing sync tokens associated with this sync
+ * @param syncDeviceLists - device_lists field from /sync, or response from
+ * /keys/changes
+ */
+ public async handleDeviceListChanges(
+ syncData: ISyncStateData,
+ syncDeviceLists: Required<ISyncResponse>["device_lists"],
+ ): Promise<void> {
+ // Initial syncs don't have device change lists. We'll either get the complete list
+ // of changes for the interval or will have invalidated everything in willProcessSync
+ if (!syncData.oldSyncToken) return;
+
+ // Here, we're relying on the fact that we only ever save the sync data after
+ // sucessfully saving the device list data, so we're guaranteed that the device
+ // list store is at least as fresh as the sync token from the sync store, ie.
+ // any device changes received in sync tokens prior to the 'next' token here
+ // have been processed and are reflected in the current device list.
+ // If we didn't make this assumption, we'd have to use the /keys/changes API
+ // to get key changes between the sync token in the device list and the 'old'
+ // sync token used here to make sure we didn't miss any.
+ await this.evalDeviceListChanges(syncDeviceLists);
+ }
+
+ /**
+ * Send a request for some room keys, if we have not already done so
+ *
+ * @param resend - whether to resend the key request if there is
+ * already one
+ *
+ * @returns a promise that resolves when the key request is queued
+ */
+ public requestRoomKey(
+ requestBody: IRoomKeyRequestBody,
+ recipients: IRoomKeyRequestRecipient[],
+ resend = false,
+ ): Promise<void> {
+ return this.outgoingRoomKeyRequestManager
+ .queueRoomKeyRequest(requestBody, recipients, resend)
+ .then(() => {
+ if (this.sendKeyRequestsImmediately) {
+ this.outgoingRoomKeyRequestManager.sendQueuedRequests();
+ }
+ })
+ .catch((e) => {
+ // this normally means we couldn't talk to the store
+ logger.error("Error requesting key for event", e);
+ });
+ }
+
+ /**
+ * Cancel any earlier room key request
+ *
+ * @param requestBody - parameters to match for cancellation
+ */
+ public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): void {
+ this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody).catch((e) => {
+ logger.warn("Error clearing pending room key requests", e);
+ });
+ }
+
+ /**
+ * Re-send any outgoing key requests, eg after verification
+ * @returns
+ */
+ public async cancelAndResendAllOutgoingKeyRequests(): Promise<void> {
+ await this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests();
+ }
+
+ /**
+ * handle an m.room.encryption event
+ *
+ * @param room - in which the event was received
+ * @param event - encryption event to be processed
+ */
+ public async onCryptoEvent(room: Room, event: MatrixEvent): Promise<void> {
+ const content = event.getContent<IRoomEncryption>();
+ await this.setRoomEncryptionImpl(room, content);
+ }
+
+ /**
+ * Called before the result of a sync is processed
+ *
+ * @param syncData - the data from the 'MatrixClient.sync' event
+ */
+ public async onSyncWillProcess(syncData: ISyncStateData): Promise<void> {
+ if (!syncData.oldSyncToken) {
+ // If there is no old sync token, we start all our tracking from
+ // scratch, so mark everything as untracked. onCryptoEvent will
+ // be called for all e2e rooms during the processing of the sync,
+ // at which point we'll start tracking all the users of that room.
+ logger.log("Initial sync performed - resetting device tracking state");
+ this.deviceList.stopTrackingAllDeviceLists();
+ // we always track our own device list (for key backups etc)
+ this.deviceList.startTrackingDeviceList(this.userId);
+ this.roomDeviceTrackingState = {};
+ }
+
+ this.sendKeyRequestsImmediately = false;
+ }
+
+ /**
+ * handle the completion of a /sync
+ *
+ * This is called after the processing of each successful /sync response.
+ * It is an opportunity to do a batch process on the information received.
+ *
+ * @param syncData - the data from the 'MatrixClient.sync' event
+ */
+ public async onSyncCompleted(syncData: OnSyncCompletedData): Promise<void> {
+ this.deviceList.setSyncToken(syncData.nextSyncToken ?? null);
+ this.deviceList.saveIfDirty();
+
+ // we always track our own device list (for key backups etc)
+ this.deviceList.startTrackingDeviceList(this.userId);
+
+ this.deviceList.refreshOutdatedDeviceLists();
+
+ // we don't start uploading one-time keys until we've caught up with
+ // to-device messages, to help us avoid throwing away one-time-keys that we
+ // are about to receive messages for
+ // (https://github.com/vector-im/element-web/issues/2782).
+ if (!syncData.catchingUp) {
+ this.maybeUploadOneTimeKeys();
+ this.processReceivedRoomKeyRequests();
+
+ // likewise don't start requesting keys until we've caught up
+ // on to_device messages, otherwise we'll request keys that we're
+ // just about to get.
+ this.outgoingRoomKeyRequestManager.sendQueuedRequests();
+
+ // Sync has finished so send key requests straight away.
+ this.sendKeyRequestsImmediately = true;
+ }
+ }
+
+ /**
+ * Trigger the appropriate invalidations and removes for a given
+ * device list
+ *
+ * @param deviceLists - device_lists field from /sync, or response from
+ * /keys/changes
+ */
+ private async evalDeviceListChanges(deviceLists: Required<ISyncResponse>["device_lists"]): Promise<void> {
+ if (Array.isArray(deviceLists?.changed)) {
+ deviceLists.changed.forEach((u) => {
+ this.deviceList.invalidateUserDeviceList(u);
+ });
+ }
+
+ if (Array.isArray(deviceLists?.left) && deviceLists.left.length) {
+ // Check we really don't share any rooms with these users
+ // any more: the server isn't required to give us the
+ // exact correct set.
+ const e2eUserIds = new Set(await this.getTrackedE2eUsers());
+
+ deviceLists.left.forEach((u) => {
+ if (!e2eUserIds.has(u)) {
+ this.deviceList.stopTrackingDeviceList(u);
+ }
+ });
+ }
+ }
+
+ /**
+ * Get a list of all the IDs of users we share an e2e room with
+ * for which we are tracking devices already
+ *
+ * @returns List of user IDs
+ */
+ private async getTrackedE2eUsers(): Promise<string[]> {
+ const e2eUserIds: string[] = [];
+ for (const room of this.getTrackedE2eRooms()) {
+ const members = await room.getEncryptionTargetMembers();
+ for (const member of members) {
+ e2eUserIds.push(member.userId);
+ }
+ }
+ return e2eUserIds;
+ }
+
+ /**
+ * Get a list of the e2e-enabled rooms we are members of,
+ * and for which we are already tracking the devices
+ *
+ * @returns
+ */
+ private getTrackedE2eRooms(): Room[] {
+ return this.clientStore.getRooms().filter((room) => {
+ // check for rooms with encryption enabled
+ const alg = this.roomEncryptors.get(room.roomId);
+ if (!alg) {
+ return false;
+ }
+ if (!this.roomDeviceTrackingState[room.roomId]) {
+ return false;
+ }
+
+ // ignore any rooms which we have left
+ const myMembership = room.getMyMembership();
+ return myMembership === "join" || myMembership === "invite";
+ });
+ }
+
+ /**
+ * Encrypts and sends a given object via Olm to-device messages to a given
+ * set of devices.
+ * @param userDeviceInfoArr - the devices to send to
+ * @param payload - fields to include in the encrypted payload
+ * @returns Promise which
+ * resolves once the message has been encrypted and sent to the given
+ * userDeviceMap, and returns the `{ contentMap, deviceInfoByDeviceId }`
+ * of the successfully sent messages.
+ */
+ public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> {
+ const toDeviceBatch: ToDeviceBatch = {
+ eventType: EventType.RoomMessageEncrypted,
+ batch: [],
+ };
+
+ try {
+ await Promise.all(
+ userDeviceInfoArr.map(async ({ userId, deviceInfo }) => {
+ const deviceId = deviceInfo.deviceId;
+ const encryptedContent: IEncryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key!,
+ ciphertext: {},
+ [ToDeviceMessageId]: uuidv4(),
+ };
+
+ toDeviceBatch.batch.push({
+ userId,
+ deviceId,
+ payload: encryptedContent,
+ });
+
+ await olmlib.ensureOlmSessionsForDevices(
+ this.olmDevice,
+ this.baseApis,
+ new Map([[userId, [deviceInfo]]]),
+ );
+ await olmlib.encryptMessageForDevice(
+ encryptedContent.ciphertext,
+ this.userId,
+ this.deviceId,
+ this.olmDevice,
+ userId,
+ deviceInfo,
+ payload,
+ );
+ }),
+ );
+
+ // prune out any devices that encryptMessageForDevice could not encrypt for,
+ // in which case it will have just not added anything to the ciphertext object.
+ // There's no point sending messages to devices if we couldn't encrypt to them,
+ // since that's effectively a blank message.
+ toDeviceBatch.batch = toDeviceBatch.batch.filter((msg) => {
+ if (Object.keys(msg.payload.ciphertext).length > 0) {
+ return true;
+ } else {
+ logger.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`);
+ return false;
+ }
+ });
+
+ try {
+ await this.baseApis.queueToDevice(toDeviceBatch);
+ } catch (e) {
+ logger.error("sendToDevice failed", e);
+ throw e;
+ }
+ } catch (e) {
+ logger.error("encryptAndSendToDevices promises failed", e);
+ throw e;
+ }
+ }
+
+ private onMembership = (event: MatrixEvent, member: RoomMember, oldMembership?: string): void => {
+ try {
+ this.onRoomMembership(event, member, oldMembership);
+ } catch (e) {
+ logger.error("Error handling membership change:", e);
+ }
+ };
+
+ public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
+ // all we do here is filter out encrypted to-device messages with the wrong algorithm. Decryption
+ // happens later in decryptEvent, via the EventMapper
+ return events.filter((toDevice) => {
+ if (
+ toDevice.type === EventType.RoomMessageEncrypted &&
+ !["m.olm.v1.curve25519-aes-sha2"].includes(toDevice.content?.algorithm)
+ ) {
+ logger.log("Ignoring invalid encrypted to-device event from " + toDevice.sender);
+ return false;
+ }
+ return true;
+ });
+ }
+
+ public preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void> {
+ const currentCount = oneTimeKeysCounts.get("signed_curve25519") || 0;
+ this.updateOneTimeKeyCount(currentCount);
+ return Promise.resolve();
+ }
+
+ public preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void> {
+ this.setNeedsNewFallback(!unusedFallbackKeys.has("signed_curve25519"));
+ return Promise.resolve();
+ }
+
+ private onToDeviceEvent = (event: MatrixEvent): void => {
+ try {
+ logger.log(
+ `received to-device ${event.getType()} from: ` +
+ `${event.getSender()} id: ${event.getContent()[ToDeviceMessageId]}`,
+ );
+
+ if (event.getType() == "m.room_key" || event.getType() == "m.forwarded_room_key") {
+ this.onRoomKeyEvent(event);
+ } else if (event.getType() == "m.room_key_request") {
+ this.onRoomKeyRequestEvent(event);
+ } else if (event.getType() === "m.secret.request") {
+ this.secretStorage.onRequestReceived(event);
+ } else if (event.getType() === "m.secret.send") {
+ this.secretStorage.onSecretReceived(event);
+ } else if (event.getType() === "m.room_key.withheld") {
+ this.onRoomKeyWithheldEvent(event);
+ } else if (event.getContent().transaction_id) {
+ this.onKeyVerificationMessage(event);
+ } else if (event.getContent().msgtype === "m.bad.encrypted") {
+ this.onToDeviceBadEncrypted(event);
+ } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
+ if (!event.isBeingDecrypted()) {
+ event.attemptDecryption(this);
+ }
+ // once the event has been decrypted, try again
+ event.once(MatrixEventEvent.Decrypted, (ev) => {
+ this.onToDeviceEvent(ev);
+ });
+ }
+ } catch (e) {
+ logger.error("Error handling toDeviceEvent:", e);
+ }
+ };
+
+ /**
+ * Handle a key event
+ *
+ * @internal
+ * @param event - key event
+ */
+ private onRoomKeyEvent(event: MatrixEvent): void {
+ const content = event.getContent();
+
+ if (!content.room_id || !content.algorithm) {
+ logger.error("key event is missing fields");
+ return;
+ }
+
+ if (!this.backupManager.checkedForBackup) {
+ // don't bother awaiting on this - the important thing is that we retry if we
+ // haven't managed to check before
+ this.backupManager.checkAndStart();
+ }
+
+ const alg = this.getRoomDecryptor(content.room_id, content.algorithm);
+ alg.onRoomKeyEvent(event);
+ }
+
+ /**
+ * Handle a key withheld event
+ *
+ * @internal
+ * @param event - key withheld event
+ */
+ private onRoomKeyWithheldEvent(event: MatrixEvent): void {
+ const content = event.getContent();
+
+ if (
+ (content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) ||
+ !content.algorithm ||
+ !content.sender_key
+ ) {
+ logger.error("key withheld event is missing fields");
+ return;
+ }
+
+ logger.info(
+ `Got room key withheld event from ${event.getSender()} ` +
+ `for ${content.algorithm} session ${content.sender_key}|${content.session_id} ` +
+ `in room ${content.room_id} with code ${content.code} (${content.reason})`,
+ );
+
+ const alg = this.getRoomDecryptor(content.room_id, content.algorithm);
+ if (alg.onRoomKeyWithheldEvent) {
+ alg.onRoomKeyWithheldEvent(event);
+ }
+ if (!content.room_id) {
+ // retry decryption for all events sent by the sender_key. This will
+ // update the events to show a message indicating that the olm session was
+ // wedged.
+ const roomDecryptors = this.getRoomDecryptors(content.algorithm);
+ for (const decryptor of roomDecryptors) {
+ decryptor.retryDecryptionFromSender(content.sender_key);
+ }
+ }
+ }
+
+ /**
+ * Handle a general key verification event.
+ *
+ * @internal
+ * @param event - verification start event
+ */
+ private onKeyVerificationMessage(event: MatrixEvent): void {
+ if (!ToDeviceChannel.validateEvent(event, this.baseApis)) {
+ return;
+ }
+ const createRequest = (event: MatrixEvent): VerificationRequest | undefined => {
+ if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) {
+ return;
+ }
+ const content = event.getContent();
+ const deviceId = content && content.from_device;
+ if (!deviceId) {
+ return;
+ }
+ const userId = event.getSender()!;
+ const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId]);
+ return new VerificationRequest(channel, this.verificationMethods, this.baseApis);
+ };
+ this.handleVerificationEvent(event, this.toDeviceVerificationRequests, createRequest);
+ }
+
+ /**
+ * Handle key verification requests sent as timeline events
+ *
+ * @internal
+ * @param event - the timeline event
+ * @param room - not used
+ * @param atStart - not used
+ * @param removed - not used
+ * @param whether - this is a live event
+ */
+ private onTimelineEvent = (
+ event: MatrixEvent,
+ room: Room,
+ atStart: boolean,
+ removed: boolean,
+ { liveEvent = true } = {},
+ ): void => {
+ if (!InRoomChannel.validateEvent(event, this.baseApis)) {
+ return;
+ }
+ const createRequest = (event: MatrixEvent): VerificationRequest => {
+ const channel = new InRoomChannel(this.baseApis, event.getRoomId()!);
+ return new VerificationRequest(channel, this.verificationMethods, this.baseApis);
+ };
+ this.handleVerificationEvent(event, this.inRoomVerificationRequests, createRequest, liveEvent);
+ };
+
+ private async handleVerificationEvent(
+ event: MatrixEvent,
+ requestsMap: IRequestsMap,
+ createRequest: (event: MatrixEvent) => VerificationRequest | undefined,
+ isLiveEvent = true,
+ ): Promise<void> {
+ // Wait for event to get its final ID with pendingEventOrdering: "chronological", since DM channels depend on it.
+ if (event.isSending() && event.status != EventStatus.SENT) {
+ let eventIdListener: () => void;
+ let statusListener: () => void;
+ try {
+ await new Promise<void>((resolve, reject) => {
+ eventIdListener = resolve;
+ statusListener = (): void => {
+ if (event.status == EventStatus.CANCELLED) {
+ reject(new Error("Event status set to CANCELLED."));
+ }
+ };
+ event.once(MatrixEventEvent.LocalEventIdReplaced, eventIdListener);
+ event.on(MatrixEventEvent.Status, statusListener);
+ });
+ } catch (err) {
+ logger.error("error while waiting for the verification event to be sent: ", err);
+ return;
+ } finally {
+ event.removeListener(MatrixEventEvent.LocalEventIdReplaced, eventIdListener!);
+ event.removeListener(MatrixEventEvent.Status, statusListener!);
+ }
+ }
+ let request: VerificationRequest | undefined = requestsMap.getRequest(event);
+ let isNewRequest = false;
+ if (!request) {
+ request = createRequest(event);
+ // a request could not be made from this event, so ignore event
+ if (!request) {
+ logger.log(
+ `Crypto: could not find VerificationRequest for ` +
+ `${event.getType()}, and could not create one, so ignoring.`,
+ );
+ return;
+ }
+ isNewRequest = true;
+ requestsMap.setRequest(event, request);
+ }
+ event.setVerificationRequest(request);
+ try {
+ await request.channel.handleEvent(event, request, isLiveEvent);
+ } catch (err) {
+ logger.error("error while handling verification event", err);
+ }
+ const shouldEmit =
+ isNewRequest &&
+ !request.initiatedByMe &&
+ !request.invalid && // check it has enough events to pass the UNSENT stage
+ !request.observeOnly;
+ if (shouldEmit) {
+ this.baseApis.emit(CryptoEvent.VerificationRequest, request);
+ }
+ }
+
+ /**
+ * Handle a toDevice event that couldn't be decrypted
+ *
+ * @internal
+ * @param event - undecryptable event
+ */
+ private async onToDeviceBadEncrypted(event: MatrixEvent): Promise<void> {
+ const content = event.getWireContent();
+ const sender = event.getSender();
+ const algorithm = content.algorithm;
+ const deviceKey = content.sender_key;
+
+ this.baseApis.emit(ClientEvent.UndecryptableToDeviceEvent, event);
+
+ // retry decryption for all events sent by the sender_key. This will
+ // update the events to show a message indicating that the olm session was
+ // wedged.
+ const retryDecryption = (): void => {
+ const roomDecryptors = this.getRoomDecryptors(olmlib.MEGOLM_ALGORITHM);
+ for (const decryptor of roomDecryptors) {
+ decryptor.retryDecryptionFromSender(deviceKey);
+ }
+ };
+
+ if (sender === undefined || deviceKey === undefined || deviceKey === undefined) {
+ return;
+ }
+
+ // check when we last forced a new session with this device: if we've already done so
+ // recently, don't do it again.
+ const lastNewSessionDevices = this.lastNewSessionForced.getOrCreate(sender);
+ const lastNewSessionForced = lastNewSessionDevices.getOrCreate(deviceKey);
+ if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) {
+ logger.debug(
+ "New session already forced with device " +
+ sender +
+ ":" +
+ deviceKey +
+ " at " +
+ lastNewSessionForced +
+ ": not forcing another",
+ );
+ await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true);
+ retryDecryption();
+ return;
+ }
+
+ // establish a new olm session with this device since we're failing to decrypt messages
+ // on a current session.
+ // Note that an undecryptable message from another device could easily be spoofed -
+ // is there anything we can do to mitigate this?
+ let device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey);
+ if (!device) {
+ // if we don't know about the device, fetch the user's devices again
+ // and retry before giving up
+ await this.downloadKeys([sender], false);
+ device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey);
+ if (!device) {
+ logger.info("Couldn't find device for identity key " + deviceKey + ": not re-establishing session");
+ await this.olmDevice.recordSessionProblem(deviceKey, "wedged", false);
+ retryDecryption();
+ return;
+ }
+ }
+ const devicesByUser = new Map([[sender, [device]]]);
+ await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, true);
+
+ lastNewSessionDevices.set(deviceKey, Date.now());
+
+ // Now send a blank message on that session so the other side knows about it.
+ // (The keyshare request is sent in the clear so that won't do)
+ // We send this first such that, as long as the toDevice messages arrive in the
+ // same order we sent them, the other end will get this first, set up the new session,
+ // then get the keyshare request and send the key over this new session (because it
+ // is the session it has most recently received a message on).
+ const encryptedContent: IEncryptedContent = {
+ algorithm: olmlib.OLM_ALGORITHM,
+ sender_key: this.olmDevice.deviceCurve25519Key!,
+ ciphertext: {},
+ [ToDeviceMessageId]: uuidv4(),
+ };
+ await olmlib.encryptMessageForDevice(
+ encryptedContent.ciphertext,
+ this.userId,
+ this.deviceId,
+ this.olmDevice,
+ sender,
+ device,
+ { type: "m.dummy" },
+ );
+
+ await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true);
+ retryDecryption();
+
+ await this.baseApis.sendToDevice(
+ "m.room.encrypted",
+ new Map([[sender, new Map([[device.deviceId, encryptedContent]])]]),
+ );
+
+ // Most of the time this probably won't be necessary since we'll have queued up a key request when
+ // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending
+ // it. This won't always be the case though so we need to re-send any that have already been sent
+ // to avoid races.
+ const requestsToResend = await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest(
+ sender,
+ device.deviceId,
+ );
+ for (const keyReq of requestsToResend) {
+ this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true);
+ }
+ }
+
+ /**
+ * Handle a change in the membership state of a member of a room
+ *
+ * @internal
+ * @param event - event causing the change
+ * @param member - user whose membership changed
+ * @param oldMembership - previous membership
+ */
+ private onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {
+ // this event handler is registered on the *client* (as opposed to the room
+ // member itself), which means it is only called on changes to the *live*
+ // membership state (ie, it is not called when we back-paginate, nor when
+ // we load the state in the initialsync).
+ //
+ // Further, it is automatically registered and called when new members
+ // arrive in the room.
+
+ const roomId = member.roomId;
+
+ const alg = this.roomEncryptors.get(roomId);
+ if (!alg) {
+ // not encrypting in this room
+ return;
+ }
+ // only mark users in this room as tracked if we already started tracking in this room
+ // this way we don't start device queries after sync on behalf of this room which we won't use
+ // the result of anyway, as we'll need to do a query again once all the members are fetched
+ // by calling _trackRoomDevices
+ if (roomId in this.roomDeviceTrackingState) {
+ if (member.membership == "join") {
+ logger.log("Join event for " + member.userId + " in " + roomId);
+ // make sure we are tracking the deviceList for this user
+ this.deviceList.startTrackingDeviceList(member.userId);
+ } else if (
+ member.membership == "invite" &&
+ this.clientStore.getRoom(roomId)?.shouldEncryptForInvitedMembers()
+ ) {
+ logger.log("Invite event for " + member.userId + " in " + roomId);
+ this.deviceList.startTrackingDeviceList(member.userId);
+ }
+ }
+
+ alg.onRoomMembership(event, member, oldMembership);
+ }
+
+ /**
+ * Called when we get an m.room_key_request event.
+ *
+ * @internal
+ * @param event - key request event
+ */
+ private onRoomKeyRequestEvent(event: MatrixEvent): void {
+ const content = event.getContent();
+ if (content.action === "request") {
+ // Queue it up for now, because they tend to arrive before the room state
+ // events at initial sync, and we want to see if we know anything about the
+ // room before passing them on to the app.
+ const req = new IncomingRoomKeyRequest(event);
+ this.receivedRoomKeyRequests.push(req);
+ } else if (content.action === "request_cancellation") {
+ const req = new IncomingRoomKeyRequestCancellation(event);
+ this.receivedRoomKeyRequestCancellations.push(req);
+ }
+ }
+
+ /**
+ * Process any m.room_key_request events which were queued up during the
+ * current sync.
+ *
+ * @internal
+ */
+ private async processReceivedRoomKeyRequests(): Promise<void> {
+ if (this.processingRoomKeyRequests) {
+ // we're still processing last time's requests; keep queuing new ones
+ // up for now.
+ return;
+ }
+ this.processingRoomKeyRequests = true;
+
+ try {
+ // we need to grab and clear the queues in the synchronous bit of this method,
+ // so that we don't end up racing with the next /sync.
+ const requests = this.receivedRoomKeyRequests;
+ this.receivedRoomKeyRequests = [];
+ const cancellations = this.receivedRoomKeyRequestCancellations;
+ this.receivedRoomKeyRequestCancellations = [];
+
+ // Process all of the requests, *then* all of the cancellations.
+ //
+ // This makes sure that if we get a request and its cancellation in the
+ // same /sync result, then we process the request before the
+ // cancellation (and end up with a cancelled request), rather than the
+ // cancellation before the request (and end up with an outstanding
+ // request which should have been cancelled.)
+ await Promise.all(requests.map((req) => this.processReceivedRoomKeyRequest(req)));
+ await Promise.all(
+ cancellations.map((cancellation) => this.processReceivedRoomKeyRequestCancellation(cancellation)),
+ );
+ } catch (e) {
+ logger.error(`Error processing room key requsts: ${e}`);
+ } finally {
+ this.processingRoomKeyRequests = false;
+ }
+ }
+
+ /**
+ * Helper for processReceivedRoomKeyRequests
+ *
+ */
+ private async processReceivedRoomKeyRequest(req: IncomingRoomKeyRequest): Promise<void> {
+ const userId = req.userId;
+ const deviceId = req.deviceId;
+
+ const body = req.requestBody;
+ const roomId = body.room_id;
+ const alg = body.algorithm;
+
+ logger.log(
+ `m.room_key_request from ${userId}:${deviceId}` +
+ ` for ${roomId} / ${body.session_id} (id ${req.requestId})`,
+ );
+
+ if (userId !== this.userId) {
+ if (!this.roomEncryptors.get(roomId)) {
+ logger.debug(`room key request for unencrypted room ${roomId}`);
+ return;
+ }
+ const encryptor = this.roomEncryptors.get(roomId)!;
+ const device = this.deviceList.getStoredDevice(userId, deviceId);
+ if (!device) {
+ logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`);
+ return;
+ }
+
+ try {
+ await encryptor.reshareKeyWithDevice!(body.sender_key, body.session_id, userId, device);
+ } catch (e) {
+ logger.warn(
+ "Failed to re-share keys for session " +
+ body.session_id +
+ " with device " +
+ userId +
+ ":" +
+ device.deviceId,
+ e,
+ );
+ }
+ return;
+ }
+
+ if (deviceId === this.deviceId) {
+ // We'll always get these because we send room key requests to
+ // '*' (ie. 'all devices') which includes the sending device,
+ // so ignore requests from ourself because apart from it being
+ // very silly, it won't work because an Olm session cannot send
+ // messages to itself.
+ // The log here is probably superfluous since we know this will
+ // always happen, but let's log anyway for now just in case it
+ // causes issues.
+ logger.log("Ignoring room key request from ourselves");
+ return;
+ }
+
+ // todo: should we queue up requests we don't yet have keys for,
+ // in case they turn up later?
+
+ // if we don't have a decryptor for this room/alg, we don't have
+ // the keys for the requested events, and can drop the requests.
+ if (!this.roomDecryptors.has(roomId)) {
+ logger.log(`room key request for unencrypted room ${roomId}`);
+ return;
+ }
+
+ const decryptor = this.roomDecryptors.get(roomId)!.get(alg);
+ if (!decryptor) {
+ logger.log(`room key request for unknown alg ${alg} in room ${roomId}`);
+ return;
+ }
+
+ if (!(await decryptor.hasKeysForKeyRequest(req))) {
+ logger.log(`room key request for unknown session ${roomId} / ` + body.session_id);
+ return;
+ }
+
+ req.share = (): void => {
+ decryptor.shareKeysWithDevice(req);
+ };
+
+ // if the device is verified already, share the keys
+ if (this.checkDeviceTrust(userId, deviceId).isVerified()) {
+ logger.log("device is already verified: sharing keys");
+ req.share();
+ return;
+ }
+
+ this.emit(CryptoEvent.RoomKeyRequest, req);
+ }
+
+ /**
+ * Helper for processReceivedRoomKeyRequests
+ *
+ */
+ private async processReceivedRoomKeyRequestCancellation(
+ cancellation: IncomingRoomKeyRequestCancellation,
+ ): Promise<void> {
+ logger.log(
+ `m.room_key_request cancellation for ${cancellation.userId}:` +
+ `${cancellation.deviceId} (id ${cancellation.requestId})`,
+ );
+
+ // we should probably only notify the app of cancellations we told it
+ // about, but we don't currently have a record of that, so we just pass
+ // everything through.
+ this.emit(CryptoEvent.RoomKeyRequestCancellation, cancellation);
+ }
+
+ /**
+ * Get a decryptor for a given room and algorithm.
+ *
+ * If we already have a decryptor for the given room and algorithm, return
+ * it. Otherwise try to instantiate it.
+ *
+ * @internal
+ *
+ * @param roomId - room id for decryptor. If undefined, a temporary
+ * decryptor is instantiated.
+ *
+ * @param algorithm - crypto algorithm
+ *
+ * @throws {@link DecryptionError} if the algorithm is unknown
+ */
+ public getRoomDecryptor(roomId: string | null, algorithm: string): DecryptionAlgorithm {
+ let decryptors: Map<string, DecryptionAlgorithm> | undefined;
+ let alg: DecryptionAlgorithm | undefined;
+
+ if (roomId) {
+ decryptors = this.roomDecryptors.get(roomId);
+ if (!decryptors) {
+ decryptors = new Map<string, DecryptionAlgorithm>();
+ this.roomDecryptors.set(roomId, decryptors);
+ }
+
+ alg = decryptors.get(algorithm);
+ if (alg) {
+ return alg;
+ }
+ }
+
+ const AlgClass = algorithms.DECRYPTION_CLASSES.get(algorithm);
+ if (!AlgClass) {
+ throw new algorithms.DecryptionError(
+ "UNKNOWN_ENCRYPTION_ALGORITHM",
+ 'Unknown encryption algorithm "' + algorithm + '".',
+ );
+ }
+ alg = new AlgClass({
+ userId: this.userId,
+ crypto: this,
+ olmDevice: this.olmDevice,
+ baseApis: this.baseApis,
+ roomId: roomId ?? undefined,
+ });
+
+ if (decryptors) {
+ decryptors.set(algorithm, alg);
+ }
+ return alg;
+ }
+
+ /**
+ * Get all the room decryptors for a given encryption algorithm.
+ *
+ * @param algorithm - The encryption algorithm
+ *
+ * @returns An array of room decryptors
+ */
+ private getRoomDecryptors(algorithm: string): DecryptionAlgorithm[] {
+ const decryptors: DecryptionAlgorithm[] = [];
+ for (const d of this.roomDecryptors.values()) {
+ if (d.has(algorithm)) {
+ decryptors.push(d.get(algorithm)!);
+ }
+ }
+ return decryptors;
+ }
+
+ /**
+ * sign the given object with our ed25519 key
+ *
+ * @param obj - Object to which we will add a 'signatures' property
+ */
+ public async signObject<T extends ISignableObject & object>(obj: T): Promise<void> {
+ const sigs = new Map(Object.entries(obj.signatures || {}));
+ const unsigned = obj.unsigned;
+
+ delete obj.signatures;
+ delete obj.unsigned;
+
+ const userSignatures = sigs.get(this.userId) || {};
+ sigs.set(this.userId, userSignatures);
+ userSignatures["ed25519:" + this.deviceId] = await this.olmDevice.sign(anotherjson.stringify(obj));
+ obj.signatures = recursiveMapToObject(sigs);
+ if (unsigned !== undefined) obj.unsigned = unsigned;
+ }
+}
+
+/**
+ * Fix up the backup key, that may be in the wrong format due to a bug in a
+ * migration step. Some backup keys were stored as a comma-separated list of
+ * integers, rather than a base64-encoded byte array. If this function is
+ * passed a string that looks like a list of integers rather than a base64
+ * string, it will attempt to convert it to the right format.
+ *
+ * @param key - the key to check
+ * @returns If the key is in the wrong format, then the fixed
+ * key will be returned. Otherwise null will be returned.
+ *
+ */
+export function fixBackupKey(key?: string): string | null {
+ if (typeof key !== "string" || key.indexOf(",") < 0) {
+ return null;
+ }
+ const fixedKey = Uint8Array.from(key.split(","), (x) => parseInt(x));
+ return olmlib.encodeBase64(fixedKey);
+}
+
+/**
+ * Represents a received m.room_key_request event
+ */
+export class IncomingRoomKeyRequest {
+ /** user requesting the key */
+ public readonly userId: string;
+ /** device requesting the key */
+ public readonly deviceId: string;
+ /** unique id for the request */
+ public readonly requestId: string;
+ public readonly requestBody: IRoomKeyRequestBody;
+ /**
+ * callback which, when called, will ask
+ * the relevant crypto algorithm implementation to share the keys for
+ * this request.
+ */
+ public share: () => void;
+
+ public constructor(event: MatrixEvent) {
+ const content = event.getContent();
+
+ this.userId = event.getSender()!;
+ this.deviceId = content.requesting_device_id;
+ this.requestId = content.request_id;
+ this.requestBody = content.body || {};
+ this.share = (): void => {
+ throw new Error("don't know how to share keys for this request yet");
+ };
+ }
+}
+
+/**
+ * Represents a received m.room_key_request cancellation
+ */
+class IncomingRoomKeyRequestCancellation {
+ /** user requesting the cancellation */
+ public readonly userId: string;
+ /** device requesting the cancellation */
+ public readonly deviceId: string;
+ /** unique id for the request to be cancelled */
+ public readonly requestId: string;
+
+ public constructor(event: MatrixEvent) {
+ const content = event.getContent();
+
+ this.userId = event.getSender()!;
+ this.deviceId = content.requesting_device_id;
+ this.requestId = content.request_id;
+ }
+}
+
+// a number of types are re-exported for backwards compatibility, in case any applications are referencing it.
+export type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts
new file mode 100644
index 0000000..f6fe7b6
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/key_passphrase.ts
@@ -0,0 +1,93 @@
+/*
+Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { randomString } from "../randomstring";
+import { subtleCrypto, TextEncoder } from "./crypto";
+
+const DEFAULT_ITERATIONS = 500000;
+
+const DEFAULT_BITSIZE = 256;
+
+/* eslint-disable camelcase */
+interface IAuthData {
+ private_key_salt?: string;
+ private_key_iterations?: number;
+ private_key_bits?: number;
+}
+/* eslint-enable camelcase */
+
+interface IKey {
+ key: Uint8Array;
+ salt: string;
+ iterations: number;
+}
+
+export function keyFromAuthData(authData: IAuthData, password: string): Promise<Uint8Array> {
+ if (!global.Olm) {
+ throw new Error("Olm is not available");
+ }
+
+ if (!authData.private_key_salt || !authData.private_key_iterations) {
+ throw new Error("Salt and/or iterations not found: " + "this backup cannot be restored with a passphrase");
+ }
+
+ return deriveKey(
+ password,
+ authData.private_key_salt,
+ authData.private_key_iterations,
+ authData.private_key_bits || DEFAULT_BITSIZE,
+ );
+}
+
+export async function keyFromPassphrase(password: string): Promise<IKey> {
+ if (!global.Olm) {
+ throw new Error("Olm is not available");
+ }
+
+ const salt = randomString(32);
+
+ const key = await deriveKey(password, salt, DEFAULT_ITERATIONS, DEFAULT_BITSIZE);
+
+ return { key, salt, iterations: DEFAULT_ITERATIONS };
+}
+
+export async function deriveKey(
+ password: string,
+ salt: string,
+ iterations: number,
+ numBits = DEFAULT_BITSIZE,
+): Promise<Uint8Array> {
+ if (!subtleCrypto || !TextEncoder) {
+ throw new Error("Password-based backup is not available on this platform");
+ }
+
+ const key = await subtleCrypto.importKey("raw", new TextEncoder().encode(password), { name: "PBKDF2" }, false, [
+ "deriveBits",
+ ]);
+
+ const keybits = await subtleCrypto.deriveBits(
+ {
+ name: "PBKDF2",
+ salt: new TextEncoder().encode(salt),
+ iterations: iterations,
+ hash: "SHA-512",
+ },
+ key,
+ numBits,
+ );
+
+ return new Uint8Array(keybits);
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts
new file mode 100644
index 0000000..67e213c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/keybackup.ts
@@ -0,0 +1,77 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ISigned } from "../@types/signed";
+import { IEncryptedPayload } from "./aes";
+
+export interface Curve25519SessionData {
+ ciphertext: string;
+ ephemeral: string;
+ mac: string;
+}
+
+/* eslint-disable camelcase */
+export interface IKeyBackupSession<T = Curve25519SessionData | IEncryptedPayload> {
+ first_message_index: number;
+ forwarded_count: number;
+ is_verified: boolean;
+ session_data: T;
+}
+
+export interface IKeyBackupRoomSessions {
+ [sessionId: string]: IKeyBackupSession;
+}
+
+export interface ICurve25519AuthData {
+ public_key: string;
+ private_key_salt?: string;
+ private_key_iterations?: number;
+ private_key_bits?: number;
+}
+
+export interface IAes256AuthData {
+ iv: string;
+ mac: string;
+ private_key_salt?: string;
+ private_key_iterations?: number;
+}
+
+export interface IKeyBackupInfo {
+ algorithm: string;
+ auth_data: ISigned & (ICurve25519AuthData | IAes256AuthData);
+ count?: number;
+ etag?: string;
+ version?: string; // number contained within
+}
+/* eslint-enable camelcase */
+
+export interface IKeyBackupPrepareOpts {
+ /**
+ * Whether to use Secure Secret Storage to store the key encrypting key backups.
+ * Optional, defaults to false.
+ */
+ secureSecretStorage: boolean;
+}
+
+export interface IKeyBackupRestoreResult {
+ total: number;
+ imported: number;
+}
+
+export interface IKeyBackupRestoreOpts {
+ cacheCompleteCallback?: () => void;
+ progressCallback?: (progress: { stage: string }) => void;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts
new file mode 100644
index 0000000..c37b7f0
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/olmlib.ts
@@ -0,0 +1,566 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Utilities common to olm encryption algorithms
+ */
+
+import anotherjson from "another-json";
+
+import type { PkSigning } from "@matrix-org/olm";
+import type { IOneTimeKey } from "../@types/crypto";
+import { OlmDevice } from "./OlmDevice";
+import { DeviceInfo } from "./deviceinfo";
+import { logger } from "../logger";
+import { IClaimOTKsResult, MatrixClient } from "../client";
+import { ISignatures } from "../@types/signed";
+import { MatrixEvent } from "../models/event";
+import { EventType } from "../@types/event";
+import { IMessage } from "./algorithms/olm";
+import { MapWithDefault } from "../utils";
+
+enum Algorithm {
+ Olm = "m.olm.v1.curve25519-aes-sha2",
+ Megolm = "m.megolm.v1.aes-sha2",
+ MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2",
+}
+
+/**
+ * matrix algorithm tag for olm
+ */
+export const OLM_ALGORITHM = Algorithm.Olm;
+
+/**
+ * matrix algorithm tag for megolm
+ */
+export const MEGOLM_ALGORITHM = Algorithm.Megolm;
+
+/**
+ * matrix algorithm tag for megolm backups
+ */
+export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup;
+
+export interface IOlmSessionResult {
+ /** device info */
+ device: DeviceInfo;
+ /** base64 olm session id; null if no session could be established */
+ sessionId: string | null;
+}
+
+/**
+ * Encrypt an event payload for an Olm device
+ *
+ * @param resultsObject - The `ciphertext` property
+ * of the m.room.encrypted event to which to add our result
+ *
+ * @param olmDevice - olm.js wrapper
+ * @param payloadFields - fields to include in the encrypted payload
+ *
+ * Returns a promise which resolves (to undefined) when the payload
+ * has been encrypted into `resultsObject`
+ */
+export async function encryptMessageForDevice(
+ resultsObject: Record<string, IMessage>,
+ ourUserId: string,
+ ourDeviceId: string | undefined,
+ olmDevice: OlmDevice,
+ recipientUserId: string,
+ recipientDevice: DeviceInfo,
+ payloadFields: Record<string, any>,
+): Promise<void> {
+ const deviceKey = recipientDevice.getIdentityKey();
+ const sessionId = await olmDevice.getSessionIdForDevice(deviceKey);
+ if (sessionId === null) {
+ // If we don't have a session for a device then
+ // we can't encrypt a message for it.
+ logger.log(
+ `[olmlib.encryptMessageForDevice] Unable to find Olm session for device ` +
+ `${recipientUserId}:${recipientDevice.deviceId}`,
+ );
+ return;
+ }
+
+ logger.log(
+ `[olmlib.encryptMessageForDevice] Using Olm session ${sessionId} for device ` +
+ `${recipientUserId}:${recipientDevice.deviceId}`,
+ );
+
+ const payload = {
+ sender: ourUserId,
+ // TODO this appears to no longer be used whatsoever
+ sender_device: ourDeviceId,
+
+ // Include the Ed25519 key so that the recipient knows what
+ // device this message came from.
+ // We don't need to include the curve25519 key since the
+ // recipient will already know this from the olm headers.
+ // When combined with the device keys retrieved from the
+ // homeserver signed by the ed25519 key this proves that
+ // the curve25519 key and the ed25519 key are owned by
+ // the same device.
+ keys: {
+ ed25519: olmDevice.deviceEd25519Key,
+ },
+
+ // include the recipient device details in the payload,
+ // to avoid unknown key attacks, per
+ // https://github.com/vector-im/vector-web/issues/2483
+ recipient: recipientUserId,
+ recipient_keys: {
+ ed25519: recipientDevice.getFingerprint(),
+ },
+ ...payloadFields,
+ };
+
+ // TODO: technically, a bunch of that stuff only needs to be included for
+ // pre-key messages: after that, both sides know exactly which devices are
+ // involved in the session. If we're looking to reduce data transfer in the
+ // future, we could elide them for subsequent messages.
+
+ resultsObject[deviceKey] = await olmDevice.encryptMessage(deviceKey, sessionId, JSON.stringify(payload));
+}
+
+interface IExistingOlmSession {
+ device: DeviceInfo;
+ sessionId: string | null;
+}
+
+/**
+ * Get the existing olm sessions for the given devices, and the devices that
+ * don't have olm sessions.
+ *
+ *
+ *
+ * @param devicesByUser - map from userid to list of devices to ensure sessions for
+ *
+ * @returns resolves to an array. The first element of the array is a
+ * a map of user IDs to arrays of deviceInfo, representing the devices that
+ * don't have established olm sessions. The second element of the array is
+ * a map from userId to deviceId to {@link OlmSessionResult}
+ */
+export async function getExistingOlmSessions(
+ olmDevice: OlmDevice,
+ baseApis: MatrixClient,
+ devicesByUser: Record<string, DeviceInfo[]>,
+): Promise<[Map<string, DeviceInfo[]>, Map<string, Map<string, IExistingOlmSession>>]> {
+ // map user Id → DeviceInfo[]
+ const devicesWithoutSession: MapWithDefault<string, DeviceInfo[]> = new MapWithDefault(() => []);
+ // map user Id → device Id → IExistingOlmSession
+ const sessions: MapWithDefault<string, Map<string, IExistingOlmSession>> = new MapWithDefault(() => new Map());
+
+ const promises: Promise<void>[] = [];
+
+ for (const [userId, devices] of Object.entries(devicesByUser)) {
+ for (const deviceInfo of devices) {
+ const deviceId = deviceInfo.deviceId;
+ const key = deviceInfo.getIdentityKey();
+ promises.push(
+ (async (): Promise<void> => {
+ const sessionId = await olmDevice.getSessionIdForDevice(key, true);
+ if (sessionId === null) {
+ devicesWithoutSession.getOrCreate(userId).push(deviceInfo);
+ } else {
+ sessions.getOrCreate(userId).set(deviceId, {
+ device: deviceInfo,
+ sessionId: sessionId,
+ });
+ }
+ })(),
+ );
+ }
+ }
+
+ await Promise.all(promises);
+
+ return [devicesWithoutSession, sessions];
+}
+
+/**
+ * Try to make sure we have established olm sessions for the given devices.
+ *
+ * @param devicesByUser - map from userid to list of devices to ensure sessions for
+ *
+ * @param force - If true, establish a new session even if one
+ * already exists.
+ *
+ * @param otkTimeout - The timeout in milliseconds when requesting
+ * one-time keys for establishing new olm sessions.
+ *
+ * @param failedServers - An array to fill with remote servers that
+ * failed to respond to one-time-key requests.
+ *
+ * @param log - A possibly customised log
+ *
+ * @returns resolves once the sessions are complete, to
+ * an Object mapping from userId to deviceId to
+ * {@link OlmSessionResult}
+ */
+export async function ensureOlmSessionsForDevices(
+ olmDevice: OlmDevice,
+ baseApis: MatrixClient,
+ devicesByUser: Map<string, DeviceInfo[]>,
+ force = false,
+ otkTimeout?: number,
+ failedServers?: string[],
+ log = logger,
+): Promise<Map<string, Map<string, IOlmSessionResult>>> {
+ const devicesWithoutSession: [string, string][] = [
+ // [userId, deviceId], ...
+ ];
+ // map user Id → device Id → IExistingOlmSession
+ const result: Map<string, Map<string, IExistingOlmSession>> = new Map();
+ // map device key → resolve session fn
+ const resolveSession: Map<string, (sessionId?: string) => void> = new Map();
+
+ // Mark all sessions this task intends to update as in progress. It is
+ // important to do this for all devices this task cares about in a single
+ // synchronous operation, as otherwise it is possible to have deadlocks
+ // where multiple tasks wait indefinitely on another task to update some set
+ // of common devices.
+ for (const devices of devicesByUser.values()) {
+ for (const deviceInfo of devices) {
+ const key = deviceInfo.getIdentityKey();
+
+ if (key === olmDevice.deviceCurve25519Key) {
+ // We don't start sessions with ourself, so there's no need to
+ // mark it in progress.
+ continue;
+ }
+
+ if (!olmDevice.sessionsInProgress[key]) {
+ // pre-emptively mark the session as in-progress to avoid race
+ // conditions. If we find that we already have a session, then
+ // we'll resolve
+ olmDevice.sessionsInProgress[key] = new Promise((resolve) => {
+ resolveSession.set(key, (v: any): void => {
+ delete olmDevice.sessionsInProgress[key];
+ resolve(v);
+ });
+ });
+ }
+ }
+ }
+
+ for (const [userId, devices] of devicesByUser) {
+ const resultDevices = new Map();
+ result.set(userId, resultDevices);
+
+ for (const deviceInfo of devices) {
+ const deviceId = deviceInfo.deviceId;
+ const key = deviceInfo.getIdentityKey();
+
+ if (key === olmDevice.deviceCurve25519Key) {
+ // We should never be trying to start a session with ourself.
+ // Apart from talking to yourself being the first sign of madness,
+ // olm sessions can't do this because they get confused when
+ // they get a message and see that the 'other side' has started a
+ // new chain when this side has an active sender chain.
+ // If you see this message being logged in the wild, we should find
+ // the thing that is trying to send Olm messages to itself and fix it.
+ log.info("Attempted to start session with ourself! Ignoring");
+ // We must fill in the section in the return value though, as callers
+ // expect it to be there.
+ resultDevices.set(deviceId, {
+ device: deviceInfo,
+ sessionId: null,
+ });
+ continue;
+ }
+
+ const forWhom = `for ${key} (${userId}:${deviceId})`;
+ const sessionId = await olmDevice.getSessionIdForDevice(key, !!resolveSession.get(key), log);
+ const resolveSessionFn = resolveSession.get(key);
+ if (sessionId !== null && resolveSessionFn) {
+ // we found a session, but we had marked the session as
+ // in-progress, so resolve it now, which will unmark it and
+ // unblock anything that was waiting
+ resolveSessionFn();
+ }
+ if (sessionId === null || force) {
+ if (force) {
+ log.info(`Forcing new Olm session ${forWhom}`);
+ } else {
+ log.info(`Making new Olm session ${forWhom}`);
+ }
+ devicesWithoutSession.push([userId, deviceId]);
+ }
+ resultDevices.set(deviceId, {
+ device: deviceInfo,
+ sessionId: sessionId,
+ });
+ }
+ }
+
+ if (devicesWithoutSession.length === 0) {
+ return result;
+ }
+
+ const oneTimeKeyAlgorithm = "signed_curve25519";
+ let res: IClaimOTKsResult;
+ let taskDetail = `one-time keys for ${devicesWithoutSession.length} devices`;
+ try {
+ log.debug(`Claiming ${taskDetail}`);
+ res = await baseApis.claimOneTimeKeys(devicesWithoutSession, oneTimeKeyAlgorithm, otkTimeout);
+ log.debug(`Claimed ${taskDetail}`);
+ } catch (e) {
+ for (const resolver of resolveSession.values()) {
+ resolver();
+ }
+ log.log(`Failed to claim ${taskDetail}`, e, devicesWithoutSession);
+ throw e;
+ }
+
+ if (failedServers && "failures" in res) {
+ failedServers.push(...Object.keys(res.failures));
+ }
+
+ const otkResult = res.one_time_keys || ({} as IClaimOTKsResult["one_time_keys"]);
+ const promises: Promise<void>[] = [];
+ for (const [userId, devices] of devicesByUser) {
+ const userRes = otkResult[userId] || {};
+ for (const deviceInfo of devices) {
+ const deviceId = deviceInfo.deviceId;
+ const key = deviceInfo.getIdentityKey();
+
+ if (key === olmDevice.deviceCurve25519Key) {
+ // We've already logged about this above. Skip here too
+ // otherwise we'll log saying there are no one-time keys
+ // which will be confusing.
+ continue;
+ }
+
+ if (result.get(userId)?.get(deviceId)?.sessionId && !force) {
+ // we already have a result for this device
+ continue;
+ }
+
+ const deviceRes = userRes[deviceId] || {};
+ let oneTimeKey: IOneTimeKey | null = null;
+ for (const keyId in deviceRes) {
+ if (keyId.indexOf(oneTimeKeyAlgorithm + ":") === 0) {
+ oneTimeKey = deviceRes[keyId];
+ }
+ }
+
+ if (!oneTimeKey) {
+ log.warn(`No one-time keys (alg=${oneTimeKeyAlgorithm}) ` + `for device ${userId}:${deviceId}`);
+ resolveSession.get(key)?.();
+ continue;
+ }
+
+ promises.push(
+ _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo).then(
+ (sid) => {
+ resolveSession.get(key)?.(sid ?? undefined);
+ const deviceInfo = result.get(userId)?.get(deviceId);
+ if (deviceInfo) deviceInfo.sessionId = sid;
+ },
+ (e) => {
+ resolveSession.get(key)?.();
+ throw e;
+ },
+ ),
+ );
+ }
+ }
+
+ taskDetail = `Olm sessions for ${promises.length} devices`;
+ log.debug(`Starting ${taskDetail}`);
+ await Promise.all(promises);
+ log.debug(`Started ${taskDetail}`);
+ return result;
+}
+
+async function _verifyKeyAndStartSession(
+ olmDevice: OlmDevice,
+ oneTimeKey: IOneTimeKey,
+ userId: string,
+ deviceInfo: DeviceInfo,
+): Promise<string | null> {
+ const deviceId = deviceInfo.deviceId;
+ try {
+ await verifySignature(olmDevice, oneTimeKey, userId, deviceId, deviceInfo.getFingerprint());
+ } catch (e) {
+ logger.error("Unable to verify signature on one-time key for device " + userId + ":" + deviceId + ":", e);
+ return null;
+ }
+
+ let sid;
+ try {
+ sid = await olmDevice.createOutboundSession(deviceInfo.getIdentityKey(), oneTimeKey.key);
+ } catch (e) {
+ // possibly a bad key
+ logger.error("Error starting olm session with device " + userId + ":" + deviceId + ": " + e);
+ return null;
+ }
+
+ logger.log("Started new olm sessionid " + sid + " for device " + userId + ":" + deviceId);
+ return sid;
+}
+
+export interface IObject {
+ unsigned?: object;
+ signatures?: ISignatures;
+}
+
+/**
+ * Verify the signature on an object
+ *
+ * @param olmDevice - olm wrapper to use for verify op
+ *
+ * @param obj - object to check signature on.
+ *
+ * @param signingUserId - ID of the user whose signature should be checked
+ *
+ * @param signingDeviceId - ID of the device whose signature should be checked
+ *
+ * @param signingKey - base64-ed ed25519 public key
+ *
+ * Returns a promise which resolves (to undefined) if the the signature is good,
+ * or rejects with an Error if it is bad.
+ */
+export async function verifySignature(
+ olmDevice: OlmDevice,
+ obj: IOneTimeKey | IObject,
+ signingUserId: string,
+ signingDeviceId: string,
+ signingKey: string,
+): Promise<void> {
+ const signKeyId = "ed25519:" + signingDeviceId;
+ const signatures = obj.signatures || {};
+ const userSigs = signatures[signingUserId] || {};
+ const signature = userSigs[signKeyId];
+ if (!signature) {
+ throw Error("No signature");
+ }
+
+ // prepare the canonical json: remove unsigned and signatures, and stringify with anotherjson
+ const mangledObj = Object.assign({}, obj);
+ if ("unsigned" in mangledObj) {
+ delete mangledObj.unsigned;
+ }
+ delete mangledObj.signatures;
+ const json = anotherjson.stringify(mangledObj);
+
+ olmDevice.verifySignature(signingKey, json, signature);
+}
+
+/**
+ * Sign a JSON object using public key cryptography
+ * @param obj - Object to sign. The object will be modified to include
+ * the new signature
+ * @param key - the signing object or the private key
+ * seed
+ * @param userId - The user ID who owns the signing key
+ * @param pubKey - The public key (ignored if key is a seed)
+ * @returns the signature for the object
+ */
+export function pkSign(obj: object & IObject, key: Uint8Array | PkSigning, userId: string, pubKey: string): string {
+ let createdKey = false;
+ if (key instanceof Uint8Array) {
+ const keyObj = new global.Olm.PkSigning();
+ pubKey = keyObj.init_with_seed(key);
+ key = keyObj;
+ createdKey = true;
+ }
+ const sigs = obj.signatures || {};
+ delete obj.signatures;
+ const unsigned = obj.unsigned;
+ if (obj.unsigned) delete obj.unsigned;
+ try {
+ const mysigs = sigs[userId] || {};
+ sigs[userId] = mysigs;
+
+ return (mysigs["ed25519:" + pubKey] = key.sign(anotherjson.stringify(obj)));
+ } finally {
+ obj.signatures = sigs;
+ if (unsigned) obj.unsigned = unsigned;
+ if (createdKey) {
+ key.free();
+ }
+ }
+}
+
+/**
+ * Verify a signed JSON object
+ * @param obj - Object to verify
+ * @param pubKey - The public key to use to verify
+ * @param userId - The user ID who signed the object
+ */
+export function pkVerify(obj: IObject, pubKey: string, userId: string): void {
+ const keyId = "ed25519:" + pubKey;
+ if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) {
+ throw new Error("No signature");
+ }
+ const signature = obj.signatures[userId][keyId];
+ const util = new global.Olm.Utility();
+ const sigs = obj.signatures;
+ delete obj.signatures;
+ const unsigned = obj.unsigned;
+ if (obj.unsigned) delete obj.unsigned;
+ try {
+ util.ed25519_verify(pubKey, anotherjson.stringify(obj), signature);
+ } finally {
+ obj.signatures = sigs;
+ if (unsigned) obj.unsigned = unsigned;
+ util.free();
+ }
+}
+
+/**
+ * Check that an event was encrypted using olm.
+ */
+export function isOlmEncrypted(event: MatrixEvent): boolean {
+ if (!event.getSenderKey()) {
+ logger.error("Event has no sender key (not encrypted?)");
+ return false;
+ }
+ if (
+ event.getWireType() !== EventType.RoomMessageEncrypted ||
+ !["m.olm.v1.curve25519-aes-sha2"].includes(event.getWireContent().algorithm)
+ ) {
+ logger.error("Event was not encrypted using an appropriate algorithm");
+ return false;
+ }
+ return true;
+}
+
+/**
+ * Encode a typed array of uint8 as base64.
+ * @param uint8Array - The data to encode.
+ * @returns The base64.
+ */
+export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string {
+ return Buffer.from(uint8Array).toString("base64");
+}
+
+/**
+ * Encode a typed array of uint8 as unpadded base64.
+ * @param uint8Array - The data to encode.
+ * @returns The unpadded base64.
+ */
+export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string {
+ return encodeBase64(uint8Array).replace(/=+$/g, "");
+}
+
+/**
+ * Decode a base64 string to a typed array of uint8.
+ * @param base64 - The base64 to decode.
+ * @returns The decoded data.
+ */
+export function decodeBase64(base64: string): Uint8Array {
+ return Buffer.from(base64, "base64");
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts
new file mode 100644
index 0000000..4107b76
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/recoverykey.ts
@@ -0,0 +1,62 @@
+/*
+Copyright 2018 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import * as bs58 from "bs58";
+
+// picked arbitrarily but to try & avoid clashing with any bitcoin ones
+// (which are also base58 encoded, but bitcoin's involve a lot more hashing)
+const OLM_RECOVERY_KEY_PREFIX = [0x8b, 0x01];
+
+export function encodeRecoveryKey(key: ArrayLike<number>): string | undefined {
+ const buf = Buffer.alloc(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1);
+ buf.set(OLM_RECOVERY_KEY_PREFIX, 0);
+ buf.set(key, OLM_RECOVERY_KEY_PREFIX.length);
+
+ let parity = 0;
+ for (let i = 0; i < buf.length - 1; ++i) {
+ parity ^= buf[i];
+ }
+ buf[buf.length - 1] = parity;
+ const base58key = bs58.encode(buf);
+
+ return base58key.match(/.{1,4}/g)?.join(" ");
+}
+
+export function decodeRecoveryKey(recoveryKey: string): Uint8Array {
+ const result = bs58.decode(recoveryKey.replace(/ /g, ""));
+
+ let parity = 0;
+ for (const b of result) {
+ parity ^= b;
+ }
+ if (parity !== 0) {
+ throw new Error("Incorrect parity");
+ }
+
+ for (let i = 0; i < OLM_RECOVERY_KEY_PREFIX.length; ++i) {
+ if (result[i] !== OLM_RECOVERY_KEY_PREFIX[i]) {
+ throw new Error("Incorrect prefix");
+ }
+ }
+
+ if (result.length !== OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH + 1) {
+ throw new Error("Incorrect length");
+ }
+
+ return Uint8Array.from(
+ result.slice(OLM_RECOVERY_KEY_PREFIX.length, OLM_RECOVERY_KEY_PREFIX.length + global.Olm.PRIVATE_KEY_LENGTH),
+ );
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts
new file mode 100644
index 0000000..4c88ec2
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/base.ts
@@ -0,0 +1,226 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
+import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
+import { ICrossSigningKey } from "../../client";
+import { IOlmDevice } from "../algorithms/megolm";
+import { TrackingStatus } from "../DeviceList";
+import { IRoomEncryption } from "../RoomList";
+import { IDevice } from "../deviceinfo";
+import { ICrossSigningInfo } from "../CrossSigning";
+import { PrefixedLogger } from "../../logger";
+import { InboundGroupSessionData } from "../OlmDevice";
+import { MatrixEvent } from "../../models/event";
+import { DehydrationManager } from "../dehydration";
+import { IEncryptedPayload } from "../aes";
+
+/**
+ * Internal module. Definitions for storage for the crypto module
+ */
+
+export interface SecretStorePrivateKeys {
+ "dehydration": {
+ keyInfo: DehydrationManager["keyInfo"];
+ key: IEncryptedPayload;
+ deviceDisplayName: string;
+ time: number;
+ } | null;
+ "m.megolm_backup.v1": IEncryptedPayload;
+}
+
+/**
+ * Abstraction of things that can store data required for end-to-end encryption
+ */
+export interface CryptoStore {
+ startup(): Promise<CryptoStore>;
+ deleteAllData(): Promise<void>;
+ getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest>;
+ getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null>;
+ getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null>;
+ getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]>;
+ getOutgoingRoomKeyRequestsByTarget(
+ userId: string,
+ deviceId: string,
+ wantedStates: number[],
+ ): Promise<OutgoingRoomKeyRequest[]>;
+ updateOutgoingRoomKeyRequest(
+ requestId: string,
+ expectedState: number,
+ updates: Partial<OutgoingRoomKeyRequest>,
+ ): Promise<OutgoingRoomKeyRequest | null>;
+ deleteOutgoingRoomKeyRequest(requestId: string, expectedState: number): Promise<OutgoingRoomKeyRequest | null>;
+
+ // Olm Account
+ getAccount(txn: unknown, func: (accountPickle: string | null) => void): void;
+ storeAccount(txn: unknown, accountPickle: string): void;
+ getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void;
+ getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: unknown,
+ func: (key: SecretStorePrivateKeys[K] | null) => void,
+ type: K,
+ ): void;
+ storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void;
+ storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: unknown,
+ type: K,
+ key: SecretStorePrivateKeys[K],
+ ): void;
+
+ // Olm Sessions
+ countEndToEndSessions(txn: unknown, func: (count: number) => void): void;
+ getEndToEndSession(
+ deviceKey: string,
+ sessionId: string,
+ txn: unknown,
+ func: (session: ISessionInfo | null) => void,
+ ): void;
+ getEndToEndSessions(
+ deviceKey: string,
+ txn: unknown,
+ func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
+ ): void;
+ getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo | null) => void): void;
+ storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void;
+ storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void>;
+ getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null>;
+ filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]>;
+
+ // Inbound Group Sessions
+ getEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ txn: unknown,
+ func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
+ ): void;
+ getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void;
+ addEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: unknown,
+ ): void;
+ storeEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: unknown,
+ ): void;
+ storeEndToEndInboundGroupSessionWithheld(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: IWithheld,
+ txn: unknown,
+ ): void;
+
+ // Device Data
+ getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void;
+ storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void;
+ storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void;
+ getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void;
+ getSessionsNeedingBackup(limit: number): Promise<ISession[]>;
+ countSessionsNeedingBackup(txn?: unknown): Promise<number>;
+ unmarkSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>;
+ markSessionsNeedingBackup(sessions: ISession[], txn?: unknown): Promise<void>;
+ addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string, txn?: unknown): void;
+ getSharedHistoryInboundGroupSessions(
+ roomId: string,
+ txn?: unknown,
+ ): Promise<[senderKey: string, sessionId: string][]>;
+ addParkedSharedHistory(roomId: string, data: ParkedSharedHistory, txn?: unknown): void;
+ takeParkedSharedHistory(roomId: string, txn?: unknown): Promise<ParkedSharedHistory[]>;
+
+ // Session key backups
+ doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T, log?: PrefixedLogger): Promise<T>;
+}
+
+export type Mode = "readonly" | "readwrite";
+
+export interface ISession {
+ senderKey: string;
+ sessionId: string;
+ sessionData?: InboundGroupSessionData;
+}
+
+export interface ISessionInfo {
+ deviceKey?: string;
+ sessionId?: string;
+ session?: string;
+ lastReceivedMessageTs?: number;
+}
+
+export interface IDeviceData {
+ devices: {
+ [userId: string]: {
+ [deviceId: string]: IDevice;
+ };
+ };
+ trackingStatus: {
+ [userId: string]: TrackingStatus;
+ };
+ crossSigningInfo?: Record<string, ICrossSigningInfo>;
+ syncToken?: string;
+}
+
+export interface IProblem {
+ type: string;
+ fixed: boolean;
+ time: number;
+}
+
+export interface IWithheld {
+ // eslint-disable-next-line camelcase
+ room_id: string;
+ code: string;
+ reason: string;
+}
+
+/**
+ * Represents an outgoing room key request
+ */
+export interface OutgoingRoomKeyRequest {
+ /**
+ * Unique id for this request. Used for both an id within the request for later pairing with a cancellation,
+ * and for the transaction id when sending the to_device messages to our local server.
+ */
+ requestId: string;
+ requestTxnId?: string;
+ /**
+ * Transaction id for the cancellation, if any
+ */
+ cancellationTxnId?: string;
+ /**
+ * List of recipients for the request
+ */
+ recipients: IRoomKeyRequestRecipient[];
+ /**
+ * Parameters for the request
+ */
+ requestBody: IRoomKeyRequestBody;
+ /**
+ * current state of this request (states are defined in {@link OutgoingRoomKeyRequestManager})
+ */
+ state: RoomKeyRequestState;
+}
+
+export interface ParkedSharedHistory {
+ senderId: string;
+ senderKey: string;
+ sessionId: string;
+ sessionKey: string;
+ keysClaimed: ReturnType<MatrixEvent["getKeysClaimed"]>; // XXX: Less type dependence on MatrixEvent
+ forwardingCurve25519KeyChain: string[];
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts
new file mode 100644
index 0000000..7827697
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store-backend.ts
@@ -0,0 +1,1062 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger, PrefixedLogger } from "../../logger";
+import * as utils from "../../utils";
+import {
+ CryptoStore,
+ IDeviceData,
+ IProblem,
+ ISession,
+ ISessionInfo,
+ IWithheld,
+ Mode,
+ OutgoingRoomKeyRequest,
+ ParkedSharedHistory,
+ SecretStorePrivateKeys,
+} from "./base";
+import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
+import { ICrossSigningKey } from "../../client";
+import { IOlmDevice } from "../algorithms/megolm";
+import { IRoomEncryption } from "../RoomList";
+import { InboundGroupSessionData } from "../OlmDevice";
+
+const PROFILE_TRANSACTIONS = false;
+
+/**
+ * Implementation of a CryptoStore which is backed by an existing
+ * IndexedDB connection. Generally you want IndexedDBCryptoStore
+ * which connects to the database and defers to one of these.
+ */
+export class Backend implements CryptoStore {
+ private nextTxnId = 0;
+
+ /**
+ */
+ public constructor(private db: IDBDatabase) {
+ // make sure we close the db on `onversionchange` - otherwise
+ // attempts to delete the database will block (and subsequent
+ // attempts to re-create it will also block).
+ db.onversionchange = (): void => {
+ logger.log(`versionchange for indexeddb ${this.db.name}: closing`);
+ db.close();
+ };
+ }
+
+ public async startup(): Promise<CryptoStore> {
+ // No work to do, as the startup is done by the caller (e.g IndexedDBCryptoStore)
+ // by passing us a ready IDBDatabase instance
+ return this;
+ }
+ public async deleteAllData(): Promise<void> {
+ throw Error("This is not implemented, call IDBFactory::deleteDatabase(dbName) instead.");
+ }
+
+ /**
+ * Look for an existing outgoing room key request, and if none is found,
+ * add a new one
+ *
+ *
+ * @returns resolves to
+ * {@link OutgoingRoomKeyRequest}: either the
+ * same instance as passed in, or the existing one.
+ */
+ public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
+ const requestBody = request.requestBody;
+
+ return new Promise((resolve, reject) => {
+ const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
+ txn.onerror = reject;
+
+ // first see if we already have an entry for this request.
+ this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
+ if (existing) {
+ // this entry matches the request - return it.
+ logger.log(
+ `already have key request outstanding for ` +
+ `${requestBody.room_id} / ${requestBody.session_id}: ` +
+ `not sending another`,
+ );
+ resolve(existing);
+ return;
+ }
+
+ // we got to the end of the list without finding a match
+ // - add the new request.
+ logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
+ txn.oncomplete = (): void => {
+ resolve(request);
+ };
+ const store = txn.objectStore("outgoingRoomKeyRequests");
+ store.add(request);
+ });
+ });
+ }
+
+ /**
+ * Look for an existing room key request
+ *
+ * @param requestBody - existing request to look for
+ *
+ * @returns resolves to the matching
+ * {@link OutgoingRoomKeyRequest}, or null if
+ * not found
+ */
+ public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
+ return new Promise((resolve, reject) => {
+ const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
+ txn.onerror = reject;
+
+ this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => {
+ resolve(existing);
+ });
+ });
+ }
+
+ /**
+ * look for an existing room key request in the db
+ *
+ * @internal
+ * @param txn - database transaction
+ * @param requestBody - existing request to look for
+ * @param callback - function to call with the results of the
+ * search. Either passed a matching
+ * {@link OutgoingRoomKeyRequest}, or null if
+ * not found.
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ private _getOutgoingRoomKeyRequest(
+ txn: IDBTransaction,
+ requestBody: IRoomKeyRequestBody,
+ callback: (req: OutgoingRoomKeyRequest | null) => void,
+ ): void {
+ const store = txn.objectStore("outgoingRoomKeyRequests");
+
+ const idx = store.index("session");
+ const cursorReq = idx.openCursor([requestBody.room_id, requestBody.session_id]);
+
+ cursorReq.onsuccess = (): void => {
+ const cursor = cursorReq.result;
+ if (!cursor) {
+ // no match found
+ callback(null);
+ return;
+ }
+
+ const existing = cursor.value;
+
+ if (utils.deepCompare(existing.requestBody, requestBody)) {
+ // got a match
+ callback(existing);
+ return;
+ }
+
+ // look at the next entry in the index
+ cursor.continue();
+ };
+ }
+
+ /**
+ * Look for room key requests by state
+ *
+ * @param wantedStates - list of acceptable states
+ *
+ * @returns resolves to the a
+ * {@link OutgoingRoomKeyRequest}, or null if
+ * there are no pending requests in those states. If there are multiple
+ * requests in those states, an arbitrary one is chosen.
+ */
+ public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
+ if (wantedStates.length === 0) {
+ return Promise.resolve(null);
+ }
+
+ // this is a bit tortuous because we need to make sure we do the lookup
+ // in a single transaction, to avoid having a race with the insertion
+ // code.
+
+ // index into the wantedStates array
+ let stateIndex = 0;
+ let result: OutgoingRoomKeyRequest;
+
+ function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
+ const cursor = this.result;
+ if (cursor) {
+ // got a match
+ result = cursor.value;
+ return;
+ }
+
+ // try the next state in the list
+ stateIndex++;
+ if (stateIndex >= wantedStates.length) {
+ // no matches
+ return;
+ }
+
+ const wantedState = wantedStates[stateIndex];
+ const cursorReq = (this.source as IDBIndex).openCursor(wantedState);
+ cursorReq.onsuccess = onsuccess;
+ }
+
+ const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
+ const store = txn.objectStore("outgoingRoomKeyRequests");
+
+ const wantedState = wantedStates[stateIndex];
+ const cursorReq = store.index("state").openCursor(wantedState);
+ cursorReq.onsuccess = onsuccess;
+
+ return promiseifyTxn(txn).then(() => result);
+ }
+
+ /**
+ *
+ * @returns All elements in a given state
+ */
+ public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
+ return new Promise((resolve, reject) => {
+ const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
+ const store = txn.objectStore("outgoingRoomKeyRequests");
+ const index = store.index("state");
+ const request = index.getAll(wantedState);
+
+ request.onsuccess = (): void => resolve(request.result);
+ request.onerror = (): void => reject(request.error);
+ });
+ }
+
+ public getOutgoingRoomKeyRequestsByTarget(
+ userId: string,
+ deviceId: string,
+ wantedStates: number[],
+ ): Promise<OutgoingRoomKeyRequest[]> {
+ let stateIndex = 0;
+ const results: OutgoingRoomKeyRequest[] = [];
+
+ function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
+ const cursor = this.result;
+ if (cursor) {
+ const keyReq = cursor.value;
+ if (
+ keyReq.recipients.some(
+ (recipient: IRoomKeyRequestRecipient) =>
+ recipient.userId === userId && recipient.deviceId === deviceId,
+ )
+ ) {
+ results.push(keyReq);
+ }
+ cursor.continue();
+ } else {
+ // try the next state in the list
+ stateIndex++;
+ if (stateIndex >= wantedStates.length) {
+ // no matches
+ return;
+ }
+
+ const wantedState = wantedStates[stateIndex];
+ const cursorReq = (this.source as IDBIndex).openCursor(wantedState);
+ cursorReq.onsuccess = onsuccess;
+ }
+ }
+
+ const txn = this.db.transaction("outgoingRoomKeyRequests", "readonly");
+ const store = txn.objectStore("outgoingRoomKeyRequests");
+
+ const wantedState = wantedStates[stateIndex];
+ const cursorReq = store.index("state").openCursor(wantedState);
+ cursorReq.onsuccess = onsuccess;
+
+ return promiseifyTxn(txn).then(() => results);
+ }
+
+ /**
+ * Look for an existing room key request by id and state, and update it if
+ * found
+ *
+ * @param requestId - ID of request to update
+ * @param expectedState - state we expect to find the request in
+ * @param updates - name/value map of updates to apply
+ *
+ * @returns resolves to
+ * {@link OutgoingRoomKeyRequest}
+ * updated request, or null if no matching row was found
+ */
+ public updateOutgoingRoomKeyRequest(
+ requestId: string,
+ expectedState: number,
+ updates: Partial<OutgoingRoomKeyRequest>,
+ ): Promise<OutgoingRoomKeyRequest | null> {
+ let result: OutgoingRoomKeyRequest | null = null;
+
+ function onsuccess(this: IDBRequest<IDBCursorWithValue | null>): void {
+ const cursor = this.result;
+ if (!cursor) {
+ return;
+ }
+ const data = cursor.value;
+ if (data.state != expectedState) {
+ logger.warn(
+ `Cannot update room key request from ${expectedState} ` +
+ `as it was already updated to ${data.state}`,
+ );
+ return;
+ }
+ Object.assign(data, updates);
+ cursor.update(data);
+ result = data;
+ }
+
+ const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
+ const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
+ cursorReq.onsuccess = onsuccess;
+ return promiseifyTxn(txn).then(() => result);
+ }
+
+ /**
+ * Look for an existing room key request by id and state, and delete it if
+ * found
+ *
+ * @param requestId - ID of request to update
+ * @param expectedState - state we expect to find the request in
+ *
+ * @returns resolves once the operation is completed
+ */
+ public deleteOutgoingRoomKeyRequest(
+ requestId: string,
+ expectedState: number,
+ ): Promise<OutgoingRoomKeyRequest | null> {
+ const txn = this.db.transaction("outgoingRoomKeyRequests", "readwrite");
+ const cursorReq = txn.objectStore("outgoingRoomKeyRequests").openCursor(requestId);
+ cursorReq.onsuccess = (): void => {
+ const cursor = cursorReq.result;
+ if (!cursor) {
+ return;
+ }
+ const data = cursor.value;
+ if (data.state != expectedState) {
+ logger.warn(`Cannot delete room key request in state ${data.state} ` + `(expected ${expectedState})`);
+ return;
+ }
+ cursor.delete();
+ };
+ return promiseifyTxn<OutgoingRoomKeyRequest | null>(txn);
+ }
+
+ // Olm Account
+
+ public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void {
+ const objectStore = txn.objectStore("account");
+ const getReq = objectStore.get("-");
+ getReq.onsuccess = function (): void {
+ try {
+ func(getReq.result || null);
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ };
+ }
+
+ public storeAccount(txn: IDBTransaction, accountPickle: string): void {
+ const objectStore = txn.objectStore("account");
+ objectStore.put(accountPickle, "-");
+ }
+
+ public getCrossSigningKeys(
+ txn: IDBTransaction,
+ func: (keys: Record<string, ICrossSigningKey> | null) => void,
+ ): void {
+ const objectStore = txn.objectStore("account");
+ const getReq = objectStore.get("crossSigningKeys");
+ getReq.onsuccess = function (): void {
+ try {
+ func(getReq.result || null);
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ };
+ }
+
+ public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: IDBTransaction,
+ func: (key: SecretStorePrivateKeys[K] | null) => void,
+ type: K,
+ ): void {
+ const objectStore = txn.objectStore("account");
+ const getReq = objectStore.get(`ssss_cache:${type}`);
+ getReq.onsuccess = function (): void {
+ try {
+ func(getReq.result || null);
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ };
+ }
+
+ public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
+ const objectStore = txn.objectStore("account");
+ objectStore.put(keys, "crossSigningKeys");
+ }
+
+ public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: IDBTransaction,
+ type: K,
+ key: SecretStorePrivateKeys[K],
+ ): void {
+ const objectStore = txn.objectStore("account");
+ objectStore.put(key, `ssss_cache:${type}`);
+ }
+
+ // Olm Sessions
+
+ public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void {
+ const objectStore = txn.objectStore("sessions");
+ const countReq = objectStore.count();
+ countReq.onsuccess = function (): void {
+ try {
+ func(countReq.result);
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ };
+ }
+
+ public getEndToEndSessions(
+ deviceKey: string,
+ txn: IDBTransaction,
+ func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
+ ): void {
+ const objectStore = txn.objectStore("sessions");
+ const idx = objectStore.index("deviceKey");
+ const getReq = idx.openCursor(deviceKey);
+ const results: Parameters<Parameters<Backend["getEndToEndSessions"]>[2]>[0] = {};
+ getReq.onsuccess = function (): void {
+ const cursor = getReq.result;
+ if (cursor) {
+ results[cursor.value.sessionId] = {
+ session: cursor.value.session,
+ lastReceivedMessageTs: cursor.value.lastReceivedMessageTs,
+ };
+ cursor.continue();
+ } else {
+ try {
+ func(results);
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ }
+ };
+ }
+
+ public getEndToEndSession(
+ deviceKey: string,
+ sessionId: string,
+ txn: IDBTransaction,
+ func: (session: ISessionInfo | null) => void,
+ ): void {
+ const objectStore = txn.objectStore("sessions");
+ const getReq = objectStore.get([deviceKey, sessionId]);
+ getReq.onsuccess = function (): void {
+ try {
+ if (getReq.result) {
+ func({
+ session: getReq.result.session,
+ lastReceivedMessageTs: getReq.result.lastReceivedMessageTs,
+ });
+ } else {
+ func(null);
+ }
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ };
+ }
+
+ public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void {
+ const objectStore = txn.objectStore("sessions");
+ const getReq = objectStore.openCursor();
+ getReq.onsuccess = function (): void {
+ try {
+ const cursor = getReq.result;
+ if (cursor) {
+ func(cursor.value);
+ cursor.continue();
+ } else {
+ func(null);
+ }
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ };
+ }
+
+ public storeEndToEndSession(
+ deviceKey: string,
+ sessionId: string,
+ sessionInfo: ISessionInfo,
+ txn: IDBTransaction,
+ ): void {
+ const objectStore = txn.objectStore("sessions");
+ objectStore.put({
+ deviceKey,
+ sessionId,
+ session: sessionInfo.session,
+ lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs,
+ });
+ }
+
+ public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
+ const txn = this.db.transaction("session_problems", "readwrite");
+ const objectStore = txn.objectStore("session_problems");
+ objectStore.put({
+ deviceKey,
+ type,
+ fixed,
+ time: Date.now(),
+ });
+ await promiseifyTxn(txn);
+ }
+
+ public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
+ let result: IProblem | null = null;
+ const txn = this.db.transaction("session_problems", "readwrite");
+ const objectStore = txn.objectStore("session_problems");
+ const index = objectStore.index("deviceKey");
+ const req = index.getAll(deviceKey);
+ req.onsuccess = (): void => {
+ const problems = req.result;
+ if (!problems.length) {
+ result = null;
+ return;
+ }
+ problems.sort((a, b) => {
+ return a.time - b.time;
+ });
+ const lastProblem = problems[problems.length - 1];
+ for (const problem of problems) {
+ if (problem.time > timestamp) {
+ result = Object.assign({}, problem, { fixed: lastProblem.fixed });
+ return;
+ }
+ }
+ if (lastProblem.fixed) {
+ result = null;
+ } else {
+ result = lastProblem;
+ }
+ };
+ await promiseifyTxn(txn);
+ return result;
+ }
+
+ // FIXME: we should probably prune this when devices get deleted
+ public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
+ const txn = this.db.transaction("notified_error_devices", "readwrite");
+ const objectStore = txn.objectStore("notified_error_devices");
+
+ const ret: IOlmDevice[] = [];
+
+ await Promise.all(
+ devices.map((device) => {
+ return new Promise<void>((resolve) => {
+ const { userId, deviceInfo } = device;
+ const getReq = objectStore.get([userId, deviceInfo.deviceId]);
+ getReq.onsuccess = function (): void {
+ if (!getReq.result) {
+ objectStore.put({ userId, deviceId: deviceInfo.deviceId });
+ ret.push(device);
+ }
+ resolve();
+ };
+ });
+ }),
+ );
+
+ return ret;
+ }
+
+ // Inbound group sessions
+
+ public getEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ txn: IDBTransaction,
+ func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
+ ): void {
+ let session: InboundGroupSessionData | null | boolean = false;
+ let withheld: IWithheld | null | boolean = false;
+ const objectStore = txn.objectStore("inbound_group_sessions");
+ const getReq = objectStore.get([senderCurve25519Key, sessionId]);
+ getReq.onsuccess = function (): void {
+ try {
+ if (getReq.result) {
+ session = getReq.result.session;
+ } else {
+ session = null;
+ }
+ if (withheld !== false) {
+ func(session as InboundGroupSessionData, withheld as IWithheld);
+ }
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ };
+
+ const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld");
+ const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]);
+ withheldGetReq.onsuccess = function (): void {
+ try {
+ if (withheldGetReq.result) {
+ withheld = withheldGetReq.result.session;
+ } else {
+ withheld = null;
+ }
+ if (session !== false) {
+ func(session as InboundGroupSessionData, withheld as IWithheld);
+ }
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ };
+ }
+
+ public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void {
+ const objectStore = txn.objectStore("inbound_group_sessions");
+ const getReq = objectStore.openCursor();
+ getReq.onsuccess = function (): void {
+ const cursor = getReq.result;
+ if (cursor) {
+ try {
+ func({
+ senderKey: cursor.value.senderCurve25519Key,
+ sessionId: cursor.value.sessionId,
+ sessionData: cursor.value.session,
+ });
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ cursor.continue();
+ } else {
+ try {
+ func(null);
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ }
+ };
+ }
+
+ public addEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: IDBTransaction,
+ ): void {
+ const objectStore = txn.objectStore("inbound_group_sessions");
+ const addReq = objectStore.add({
+ senderCurve25519Key,
+ sessionId,
+ session: sessionData,
+ });
+ addReq.onerror = (ev): void => {
+ if (addReq.error?.name === "ConstraintError") {
+ // This stops the error from triggering the txn's onerror
+ ev.stopPropagation();
+ // ...and this stops it from aborting the transaction
+ ev.preventDefault();
+ logger.log("Ignoring duplicate inbound group session: " + senderCurve25519Key + " / " + sessionId);
+ } else {
+ abortWithException(txn, new Error("Failed to add inbound group session: " + addReq.error));
+ }
+ };
+ }
+
+ public storeEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: IDBTransaction,
+ ): void {
+ const objectStore = txn.objectStore("inbound_group_sessions");
+ objectStore.put({
+ senderCurve25519Key,
+ sessionId,
+ session: sessionData,
+ });
+ }
+
+ public storeEndToEndInboundGroupSessionWithheld(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: IWithheld,
+ txn: IDBTransaction,
+ ): void {
+ const objectStore = txn.objectStore("inbound_group_sessions_withheld");
+ objectStore.put({
+ senderCurve25519Key,
+ sessionId,
+ session: sessionData,
+ });
+ }
+
+ public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void {
+ const objectStore = txn.objectStore("device_data");
+ const getReq = objectStore.get("-");
+ getReq.onsuccess = function (): void {
+ try {
+ func(getReq.result || null);
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ };
+ }
+
+ public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void {
+ const objectStore = txn.objectStore("device_data");
+ objectStore.put(deviceData, "-");
+ }
+
+ public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void {
+ const objectStore = txn.objectStore("rooms");
+ objectStore.put(roomInfo, roomId);
+ }
+
+ public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void {
+ const rooms: Parameters<Parameters<Backend["getEndToEndRooms"]>[1]>[0] = {};
+ const objectStore = txn.objectStore("rooms");
+ const getReq = objectStore.openCursor();
+ getReq.onsuccess = function (): void {
+ const cursor = getReq.result;
+ if (cursor) {
+ rooms[cursor.key as string] = cursor.value;
+ cursor.continue();
+ } else {
+ try {
+ func(rooms);
+ } catch (e) {
+ abortWithException(txn, <Error>e);
+ }
+ }
+ };
+ }
+
+ // session backups
+
+ public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
+ return new Promise((resolve, reject) => {
+ const sessions: ISession[] = [];
+
+ const txn = this.db.transaction(["sessions_needing_backup", "inbound_group_sessions"], "readonly");
+ txn.onerror = reject;
+ txn.oncomplete = function (): void {
+ resolve(sessions);
+ };
+ const objectStore = txn.objectStore("sessions_needing_backup");
+ const sessionStore = txn.objectStore("inbound_group_sessions");
+ const getReq = objectStore.openCursor();
+ getReq.onsuccess = function (): void {
+ const cursor = getReq.result;
+ if (cursor) {
+ const sessionGetReq = sessionStore.get(cursor.key);
+ sessionGetReq.onsuccess = function (): void {
+ sessions.push({
+ senderKey: sessionGetReq.result.senderCurve25519Key,
+ sessionId: sessionGetReq.result.sessionId,
+ sessionData: sessionGetReq.result.session,
+ });
+ };
+ if (!limit || sessions.length < limit) {
+ cursor.continue();
+ }
+ }
+ };
+ });
+ }
+
+ public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> {
+ if (!txn) {
+ txn = this.db.transaction("sessions_needing_backup", "readonly");
+ }
+ const objectStore = txn.objectStore("sessions_needing_backup");
+ return new Promise((resolve, reject) => {
+ const req = objectStore.count();
+ req.onerror = reject;
+ req.onsuccess = (): void => resolve(req.result);
+ });
+ }
+
+ public async unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
+ if (!txn) {
+ txn = this.db.transaction("sessions_needing_backup", "readwrite");
+ }
+ const objectStore = txn.objectStore("sessions_needing_backup");
+ await Promise.all(
+ sessions.map((session) => {
+ return new Promise((resolve, reject) => {
+ const req = objectStore.delete([session.senderKey, session.sessionId]);
+ req.onsuccess = resolve;
+ req.onerror = reject;
+ });
+ }),
+ );
+ }
+
+ public async markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
+ if (!txn) {
+ txn = this.db.transaction("sessions_needing_backup", "readwrite");
+ }
+ const objectStore = txn.objectStore("sessions_needing_backup");
+ await Promise.all(
+ sessions.map((session) => {
+ return new Promise((resolve, reject) => {
+ const req = objectStore.put({
+ senderCurve25519Key: session.senderKey,
+ sessionId: session.sessionId,
+ });
+ req.onsuccess = resolve;
+ req.onerror = reject;
+ });
+ }),
+ );
+ }
+
+ public addSharedHistoryInboundGroupSession(
+ roomId: string,
+ senderKey: string,
+ sessionId: string,
+ txn?: IDBTransaction,
+ ): void {
+ if (!txn) {
+ txn = this.db.transaction("shared_history_inbound_group_sessions", "readwrite");
+ }
+ const objectStore = txn.objectStore("shared_history_inbound_group_sessions");
+ const req = objectStore.get([roomId]);
+ req.onsuccess = (): void => {
+ const { sessions } = req.result || { sessions: [] };
+ sessions.push([senderKey, sessionId]);
+ objectStore.put({ roomId, sessions });
+ };
+ }
+
+ public getSharedHistoryInboundGroupSessions(
+ roomId: string,
+ txn?: IDBTransaction,
+ ): Promise<[senderKey: string, sessionId: string][]> {
+ if (!txn) {
+ txn = this.db.transaction("shared_history_inbound_group_sessions", "readonly");
+ }
+ const objectStore = txn.objectStore("shared_history_inbound_group_sessions");
+ const req = objectStore.get([roomId]);
+ return new Promise((resolve, reject) => {
+ req.onsuccess = (): void => {
+ const { sessions } = req.result || { sessions: [] };
+ resolve(sessions);
+ };
+ req.onerror = reject;
+ });
+ }
+
+ public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void {
+ if (!txn) {
+ txn = this.db.transaction("parked_shared_history", "readwrite");
+ }
+ const objectStore = txn.objectStore("parked_shared_history");
+ const req = objectStore.get([roomId]);
+ req.onsuccess = (): void => {
+ const { parked } = req.result || { parked: [] };
+ parked.push(parkedData);
+ objectStore.put({ roomId, parked });
+ };
+ }
+
+ public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> {
+ if (!txn) {
+ txn = this.db.transaction("parked_shared_history", "readwrite");
+ }
+ const cursorReq = txn.objectStore("parked_shared_history").openCursor(roomId);
+ return new Promise((resolve, reject) => {
+ cursorReq.onsuccess = (): void => {
+ const cursor = cursorReq.result;
+ if (!cursor) {
+ resolve([]);
+ return;
+ }
+ const data = cursor.value;
+ cursor.delete();
+ resolve(data);
+ };
+ cursorReq.onerror = reject;
+ });
+ }
+
+ public doTxn<T>(
+ mode: Mode,
+ stores: string | string[],
+ func: (txn: IDBTransaction) => T,
+ log: PrefixedLogger = logger,
+ ): Promise<T> {
+ let startTime: number;
+ let description: string;
+ if (PROFILE_TRANSACTIONS) {
+ const txnId = this.nextTxnId++;
+ startTime = Date.now();
+ description = `${mode} crypto store transaction ${txnId} in ${stores}`;
+ log.debug(`Starting ${description}`);
+ }
+ const txn = this.db.transaction(stores, mode);
+ const promise = promiseifyTxn(txn);
+ const result = func(txn);
+ if (PROFILE_TRANSACTIONS) {
+ promise.then(
+ () => {
+ const elapsedTime = Date.now() - startTime;
+ log.debug(`Finished ${description}, took ${elapsedTime} ms`);
+ },
+ () => {
+ const elapsedTime = Date.now() - startTime;
+ log.error(`Failed ${description}, took ${elapsedTime} ms`);
+ },
+ );
+ }
+ return promise.then(() => {
+ return result;
+ });
+ }
+}
+
+type DbMigration = (db: IDBDatabase) => void;
+const DB_MIGRATIONS: DbMigration[] = [
+ (db): void => {
+ createDatabase(db);
+ },
+ (db): void => {
+ db.createObjectStore("account");
+ },
+ (db): void => {
+ const sessionsStore = db.createObjectStore("sessions", {
+ keyPath: ["deviceKey", "sessionId"],
+ });
+ sessionsStore.createIndex("deviceKey", "deviceKey");
+ },
+ (db): void => {
+ db.createObjectStore("inbound_group_sessions", {
+ keyPath: ["senderCurve25519Key", "sessionId"],
+ });
+ },
+ (db): void => {
+ db.createObjectStore("device_data");
+ },
+ (db): void => {
+ db.createObjectStore("rooms");
+ },
+ (db): void => {
+ db.createObjectStore("sessions_needing_backup", {
+ keyPath: ["senderCurve25519Key", "sessionId"],
+ });
+ },
+ (db): void => {
+ db.createObjectStore("inbound_group_sessions_withheld", {
+ keyPath: ["senderCurve25519Key", "sessionId"],
+ });
+ },
+ (db): void => {
+ const problemsStore = db.createObjectStore("session_problems", {
+ keyPath: ["deviceKey", "time"],
+ });
+ problemsStore.createIndex("deviceKey", "deviceKey");
+
+ db.createObjectStore("notified_error_devices", {
+ keyPath: ["userId", "deviceId"],
+ });
+ },
+ (db): void => {
+ db.createObjectStore("shared_history_inbound_group_sessions", {
+ keyPath: ["roomId"],
+ });
+ },
+ (db): void => {
+ db.createObjectStore("parked_shared_history", {
+ keyPath: ["roomId"],
+ });
+ },
+ // Expand as needed.
+];
+export const VERSION = DB_MIGRATIONS.length;
+
+export function upgradeDatabase(db: IDBDatabase, oldVersion: number): void {
+ logger.log(`Upgrading IndexedDBCryptoStore from version ${oldVersion}` + ` to ${VERSION}`);
+ DB_MIGRATIONS.forEach((migration, index) => {
+ if (oldVersion <= index) migration(db);
+ });
+}
+
+function createDatabase(db: IDBDatabase): void {
+ const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" });
+
+ // we assume that the RoomKeyRequestBody will have room_id and session_id
+ // properties, to make the index efficient.
+ outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]);
+
+ outgoingRoomKeyRequestsStore.createIndex("state", "state");
+}
+
+interface IWrappedIDBTransaction extends IDBTransaction {
+ _mx_abortexception: Error; // eslint-disable-line camelcase
+}
+
+/*
+ * Aborts a transaction with a given exception
+ * The transaction promise will be rejected with this exception.
+ */
+function abortWithException(txn: IDBTransaction, e: Error): void {
+ // We cheekily stick our exception onto the transaction object here
+ // We could alternatively make the thing we pass back to the app
+ // an object containing the transaction and exception.
+ (txn as IWrappedIDBTransaction)._mx_abortexception = e;
+ try {
+ txn.abort();
+ } catch (e) {
+ // sometimes we won't be able to abort the transaction
+ // (ie. if it's aborted or completed)
+ }
+}
+
+function promiseifyTxn<T>(txn: IDBTransaction): Promise<T | null> {
+ return new Promise((resolve, reject) => {
+ txn.oncomplete = (): void => {
+ if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) {
+ reject((txn as IWrappedIDBTransaction)._mx_abortexception);
+ }
+ resolve(null);
+ };
+ txn.onerror = (event): void => {
+ if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) {
+ reject((txn as IWrappedIDBTransaction)._mx_abortexception);
+ } else {
+ logger.log("Error performing indexeddb txn", event);
+ reject(txn.error);
+ }
+ };
+ txn.onabort = (event): void => {
+ if ((txn as IWrappedIDBTransaction)._mx_abortexception !== undefined) {
+ reject((txn as IWrappedIDBTransaction)._mx_abortexception);
+ } else {
+ logger.log("Error performing indexeddb txn", event);
+ reject(txn.error);
+ }
+ };
+ });
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts
new file mode 100644
index 0000000..320235f
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/indexeddb-crypto-store.ts
@@ -0,0 +1,708 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger, PrefixedLogger } from "../../logger";
+import { LocalStorageCryptoStore } from "./localStorage-crypto-store";
+import { MemoryCryptoStore } from "./memory-crypto-store";
+import * as IndexedDBCryptoStoreBackend from "./indexeddb-crypto-store-backend";
+import { InvalidCryptoStoreError, InvalidCryptoStoreState } from "../../errors";
+import * as IndexedDBHelpers from "../../indexeddb-helpers";
+import {
+ CryptoStore,
+ IDeviceData,
+ IProblem,
+ ISession,
+ ISessionInfo,
+ IWithheld,
+ Mode,
+ OutgoingRoomKeyRequest,
+ ParkedSharedHistory,
+ SecretStorePrivateKeys,
+} from "./base";
+import { IRoomKeyRequestBody } from "../index";
+import { ICrossSigningKey } from "../../client";
+import { IOlmDevice } from "../algorithms/megolm";
+import { IRoomEncryption } from "../RoomList";
+import { InboundGroupSessionData } from "../OlmDevice";
+
+/**
+ * Internal module. indexeddb storage for e2e.
+ */
+
+/**
+ * An implementation of CryptoStore, which is normally backed by an indexeddb,
+ * but with fallback to MemoryCryptoStore.
+ */
+export class IndexedDBCryptoStore implements CryptoStore {
+ public static STORE_ACCOUNT = "account";
+ public static STORE_SESSIONS = "sessions";
+ public static STORE_INBOUND_GROUP_SESSIONS = "inbound_group_sessions";
+ public static STORE_INBOUND_GROUP_SESSIONS_WITHHELD = "inbound_group_sessions_withheld";
+ public static STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS = "shared_history_inbound_group_sessions";
+ public static STORE_PARKED_SHARED_HISTORY = "parked_shared_history";
+ public static STORE_DEVICE_DATA = "device_data";
+ public static STORE_ROOMS = "rooms";
+ public static STORE_BACKUP = "sessions_needing_backup";
+
+ public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> {
+ return IndexedDBHelpers.exists(indexedDB, dbName);
+ }
+
+ private backendPromise?: Promise<CryptoStore>;
+ private backend?: CryptoStore;
+
+ /**
+ * Create a new IndexedDBCryptoStore
+ *
+ * @param indexedDB - global indexedDB instance
+ * @param dbName - name of db to connect to
+ */
+ public constructor(private readonly indexedDB: IDBFactory, private readonly dbName: string) {}
+
+ /**
+ * Ensure the database exists and is up-to-date, or fall back to
+ * a local storage or in-memory store.
+ *
+ * This must be called before the store can be used.
+ *
+ * @returns resolves to either an IndexedDBCryptoStoreBackend.Backend,
+ * or a MemoryCryptoStore
+ */
+ public startup(): Promise<CryptoStore> {
+ if (this.backendPromise) {
+ return this.backendPromise;
+ }
+
+ this.backendPromise = new Promise<CryptoStore>((resolve, reject) => {
+ if (!this.indexedDB) {
+ reject(new Error("no indexeddb support available"));
+ return;
+ }
+
+ logger.log(`connecting to indexeddb ${this.dbName}`);
+
+ const req = this.indexedDB.open(this.dbName, IndexedDBCryptoStoreBackend.VERSION);
+
+ req.onupgradeneeded = (ev): void => {
+ const db = req.result;
+ const oldVersion = ev.oldVersion;
+ IndexedDBCryptoStoreBackend.upgradeDatabase(db, oldVersion);
+ };
+
+ req.onblocked = (): void => {
+ logger.log(`can't yet open IndexedDBCryptoStore because it is open elsewhere`);
+ };
+
+ req.onerror = (ev): void => {
+ logger.log("Error connecting to indexeddb", ev);
+ reject(req.error);
+ };
+
+ req.onsuccess = (): void => {
+ const db = req.result;
+
+ logger.log(`connected to indexeddb ${this.dbName}`);
+ resolve(new IndexedDBCryptoStoreBackend.Backend(db));
+ };
+ })
+ .then((backend) => {
+ // Edge has IndexedDB but doesn't support compund keys which we use fairly extensively.
+ // Try a dummy query which will fail if the browser doesn't support compund keys, so
+ // we can fall back to a different backend.
+ return backend
+ .doTxn(
+ "readonly",
+ [
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
+ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD,
+ ],
+ (txn) => {
+ backend.getEndToEndInboundGroupSession("", "", txn, () => {});
+ },
+ )
+ .then(() => backend);
+ })
+ .catch((e) => {
+ if (e.name === "VersionError") {
+ logger.warn("Crypto DB is too new for us to use!", e);
+ // don't fall back to a different store: the user has crypto data
+ // in this db so we should use it or nothing at all.
+ throw new InvalidCryptoStoreError(InvalidCryptoStoreState.TooNew);
+ }
+ logger.warn(
+ `unable to connect to indexeddb ${this.dbName}` + `: falling back to localStorage store: ${e}`,
+ );
+
+ try {
+ return new LocalStorageCryptoStore(global.localStorage);
+ } catch (e) {
+ logger.warn(`unable to open localStorage: falling back to in-memory store: ${e}`);
+ return new MemoryCryptoStore();
+ }
+ })
+ .then((backend) => {
+ this.backend = backend;
+ return backend;
+ });
+
+ return this.backendPromise;
+ }
+
+ /**
+ * Delete all data from this store.
+ *
+ * @returns resolves when the store has been cleared.
+ */
+ public deleteAllData(): Promise<void> {
+ return new Promise<void>((resolve, reject) => {
+ if (!this.indexedDB) {
+ reject(new Error("no indexeddb support available"));
+ return;
+ }
+
+ logger.log(`Removing indexeddb instance: ${this.dbName}`);
+ const req = this.indexedDB.deleteDatabase(this.dbName);
+
+ req.onblocked = (): void => {
+ logger.log(`can't yet delete IndexedDBCryptoStore because it is open elsewhere`);
+ };
+
+ req.onerror = (ev): void => {
+ logger.log("Error deleting data from indexeddb", ev);
+ reject(req.error);
+ };
+
+ req.onsuccess = (): void => {
+ logger.log(`Removed indexeddb instance: ${this.dbName}`);
+ resolve();
+ };
+ }).catch((e) => {
+ // in firefox, with indexedDB disabled, this fails with a
+ // DOMError. We treat this as non-fatal, so that people can
+ // still use the app.
+ logger.warn(`unable to delete IndexedDBCryptoStore: ${e}`);
+ });
+ }
+
+ /**
+ * Look for an existing outgoing room key request, and if none is found,
+ * add a new one
+ *
+ *
+ * @returns resolves to
+ * {@link OutgoingRoomKeyRequest}: either the
+ * same instance as passed in, or the existing one.
+ */
+ public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
+ return this.backend!.getOrAddOutgoingRoomKeyRequest(request);
+ }
+
+ /**
+ * Look for an existing room key request
+ *
+ * @param requestBody - existing request to look for
+ *
+ * @returns resolves to the matching
+ * {@link OutgoingRoomKeyRequest}, or null if
+ * not found
+ */
+ public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
+ return this.backend!.getOutgoingRoomKeyRequest(requestBody);
+ }
+
+ /**
+ * Look for room key requests by state
+ *
+ * @param wantedStates - list of acceptable states
+ *
+ * @returns resolves to the a
+ * {@link OutgoingRoomKeyRequest}, or null if
+ * there are no pending requests in those states. If there are multiple
+ * requests in those states, an arbitrary one is chosen.
+ */
+ public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
+ return this.backend!.getOutgoingRoomKeyRequestByState(wantedStates);
+ }
+
+ /**
+ * Look for room key requests by state –
+ * unlike above, return a list of all entries in one state.
+ *
+ * @returns Returns an array of requests in the given state
+ */
+ public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
+ return this.backend!.getAllOutgoingRoomKeyRequestsByState(wantedState);
+ }
+
+ /**
+ * Look for room key requests by target device and state
+ *
+ * @param userId - Target user ID
+ * @param deviceId - Target device ID
+ * @param wantedStates - list of acceptable states
+ *
+ * @returns resolves to a list of all the
+ * {@link OutgoingRoomKeyRequest}
+ */
+ public getOutgoingRoomKeyRequestsByTarget(
+ userId: string,
+ deviceId: string,
+ wantedStates: number[],
+ ): Promise<OutgoingRoomKeyRequest[]> {
+ return this.backend!.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates);
+ }
+
+ /**
+ * Look for an existing room key request by id and state, and update it if
+ * found
+ *
+ * @param requestId - ID of request to update
+ * @param expectedState - state we expect to find the request in
+ * @param updates - name/value map of updates to apply
+ *
+ * @returns resolves to
+ * {@link OutgoingRoomKeyRequest}
+ * updated request, or null if no matching row was found
+ */
+ public updateOutgoingRoomKeyRequest(
+ requestId: string,
+ expectedState: number,
+ updates: Partial<OutgoingRoomKeyRequest>,
+ ): Promise<OutgoingRoomKeyRequest | null> {
+ return this.backend!.updateOutgoingRoomKeyRequest(requestId, expectedState, updates);
+ }
+
+ /**
+ * Look for an existing room key request by id and state, and delete it if
+ * found
+ *
+ * @param requestId - ID of request to update
+ * @param expectedState - state we expect to find the request in
+ *
+ * @returns resolves once the operation is completed
+ */
+ public deleteOutgoingRoomKeyRequest(
+ requestId: string,
+ expectedState: number,
+ ): Promise<OutgoingRoomKeyRequest | null> {
+ return this.backend!.deleteOutgoingRoomKeyRequest(requestId, expectedState);
+ }
+
+ // Olm Account
+
+ /*
+ * Get the account pickle from the store.
+ * This requires an active transaction. See doTxn().
+ *
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called with the account pickle
+ */
+ public getAccount(txn: IDBTransaction, func: (accountPickle: string | null) => void): void {
+ this.backend!.getAccount(txn, func);
+ }
+
+ /**
+ * Write the account pickle to the store.
+ * This requires an active transaction. See doTxn().
+ *
+ * @param txn - An active transaction. See doTxn().
+ * @param accountPickle - The new account pickle to store.
+ */
+ public storeAccount(txn: IDBTransaction, accountPickle: string): void {
+ this.backend!.storeAccount(txn, accountPickle);
+ }
+
+ /**
+ * Get the public part of the cross-signing keys (eg. self-signing key,
+ * user signing key).
+ *
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called with the account keys object:
+ * `{ key_type: base64 encoded seed }` where key type = user_signing_key_seed or self_signing_key_seed
+ */
+ public getCrossSigningKeys(
+ txn: IDBTransaction,
+ func: (keys: Record<string, ICrossSigningKey> | null) => void,
+ ): void {
+ this.backend!.getCrossSigningKeys(txn, func);
+ }
+
+ /**
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called with the private key
+ * @param type - A key type
+ */
+ public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: IDBTransaction,
+ func: (key: SecretStorePrivateKeys[K] | null) => void,
+ type: K,
+ ): void {
+ this.backend!.getSecretStorePrivateKey(txn, func, type);
+ }
+
+ /**
+ * Write the cross-signing keys back to the store
+ *
+ * @param txn - An active transaction. See doTxn().
+ * @param keys - keys object as getCrossSigningKeys()
+ */
+ public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
+ this.backend!.storeCrossSigningKeys(txn, keys);
+ }
+
+ /**
+ * Write the cross-signing private keys back to the store
+ *
+ * @param txn - An active transaction. See doTxn().
+ * @param type - The type of cross-signing private key to store
+ * @param key - keys object as getCrossSigningKeys()
+ */
+ public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: IDBTransaction,
+ type: K,
+ key: SecretStorePrivateKeys[K],
+ ): void {
+ this.backend!.storeSecretStorePrivateKey(txn, type, key);
+ }
+
+ // Olm sessions
+
+ /**
+ * Returns the number of end-to-end sessions in the store
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called with the count of sessions
+ */
+ public countEndToEndSessions(txn: IDBTransaction, func: (count: number) => void): void {
+ this.backend!.countEndToEndSessions(txn, func);
+ }
+
+ /**
+ * Retrieve a specific end-to-end session between the logged-in user
+ * and another device.
+ * @param deviceKey - The public key of the other device.
+ * @param sessionId - The ID of the session to retrieve
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called with A map from sessionId
+ * to session information object with 'session' key being the
+ * Base64 end-to-end session and lastReceivedMessageTs being the
+ * timestamp in milliseconds at which the session last received
+ * a message.
+ */
+ public getEndToEndSession(
+ deviceKey: string,
+ sessionId: string,
+ txn: IDBTransaction,
+ func: (session: ISessionInfo | null) => void,
+ ): void {
+ this.backend!.getEndToEndSession(deviceKey, sessionId, txn, func);
+ }
+
+ /**
+ * Retrieve the end-to-end sessions between the logged-in user and another
+ * device.
+ * @param deviceKey - The public key of the other device.
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called with A map from sessionId
+ * to session information object with 'session' key being the
+ * Base64 end-to-end session and lastReceivedMessageTs being the
+ * timestamp in milliseconds at which the session last received
+ * a message.
+ */
+ public getEndToEndSessions(
+ deviceKey: string,
+ txn: IDBTransaction,
+ func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
+ ): void {
+ this.backend!.getEndToEndSessions(deviceKey, txn, func);
+ }
+
+ /**
+ * Retrieve all end-to-end sessions
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called one for each session with
+ * an object with, deviceKey, lastReceivedMessageTs, sessionId
+ * and session keys.
+ */
+ public getAllEndToEndSessions(txn: IDBTransaction, func: (session: ISessionInfo | null) => void): void {
+ this.backend!.getAllEndToEndSessions(txn, func);
+ }
+
+ /**
+ * Store a session between the logged-in user and another device
+ * @param deviceKey - The public key of the other device.
+ * @param sessionId - The ID for this end-to-end session.
+ * @param sessionInfo - Session information object
+ * @param txn - An active transaction. See doTxn().
+ */
+ public storeEndToEndSession(
+ deviceKey: string,
+ sessionId: string,
+ sessionInfo: ISessionInfo,
+ txn: IDBTransaction,
+ ): void {
+ this.backend!.storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn);
+ }
+
+ public storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
+ return this.backend!.storeEndToEndSessionProblem(deviceKey, type, fixed);
+ }
+
+ public getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
+ return this.backend!.getEndToEndSessionProblem(deviceKey, timestamp);
+ }
+
+ public filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
+ return this.backend!.filterOutNotifiedErrorDevices(devices);
+ }
+
+ // Inbound group sessions
+
+ /**
+ * Retrieve the end-to-end inbound group session for a given
+ * server key and session ID
+ * @param senderCurve25519Key - The sender's curve 25519 key
+ * @param sessionId - The ID of the session
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called with A map from sessionId
+ * to Base64 end-to-end session.
+ */
+ public getEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ txn: IDBTransaction,
+ func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
+ ): void {
+ this.backend!.getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func);
+ }
+
+ /**
+ * Fetches all inbound group sessions in the store
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Called once for each group session
+ * in the store with an object having keys `{senderKey, sessionId, sessionData}`,
+ * then once with null to indicate the end of the list.
+ */
+ public getAllEndToEndInboundGroupSessions(txn: IDBTransaction, func: (session: ISession | null) => void): void {
+ this.backend!.getAllEndToEndInboundGroupSessions(txn, func);
+ }
+
+ /**
+ * Adds an end-to-end inbound group session to the store.
+ * If there already exists an inbound group session with the same
+ * senderCurve25519Key and sessionID, the session will not be added.
+ * @param senderCurve25519Key - The sender's curve 25519 key
+ * @param sessionId - The ID of the session
+ * @param sessionData - The session data structure
+ * @param txn - An active transaction. See doTxn().
+ */
+ public addEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: IDBTransaction,
+ ): void {
+ this.backend!.addEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
+ }
+
+ /**
+ * Writes an end-to-end inbound group session to the store.
+ * If there already exists an inbound group session with the same
+ * senderCurve25519Key and sessionID, it will be overwritten.
+ * @param senderCurve25519Key - The sender's curve 25519 key
+ * @param sessionId - The ID of the session
+ * @param sessionData - The session data structure
+ * @param txn - An active transaction. See doTxn().
+ */
+ public storeEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: IDBTransaction,
+ ): void {
+ this.backend!.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
+ }
+
+ public storeEndToEndInboundGroupSessionWithheld(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: IWithheld,
+ txn: IDBTransaction,
+ ): void {
+ this.backend!.storeEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId, sessionData, txn);
+ }
+
+ // End-to-end device tracking
+
+ /**
+ * Store the state of all tracked devices
+ * This contains devices for each user, a tracking state for each user
+ * and a sync token matching the point in time the snapshot represents.
+ * These all need to be written out in full each time such that the snapshot
+ * is always consistent, so they are stored in one object.
+ *
+ * @param txn - An active transaction. See doTxn().
+ */
+ public storeEndToEndDeviceData(deviceData: IDeviceData, txn: IDBTransaction): void {
+ this.backend!.storeEndToEndDeviceData(deviceData, txn);
+ }
+
+ /**
+ * Get the state of all tracked devices
+ *
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Function called with the
+ * device data
+ */
+ public getEndToEndDeviceData(txn: IDBTransaction, func: (deviceData: IDeviceData | null) => void): void {
+ this.backend!.getEndToEndDeviceData(txn, func);
+ }
+
+ // End to End Rooms
+
+ /**
+ * Store the end-to-end state for a room.
+ * @param roomId - The room's ID.
+ * @param roomInfo - The end-to-end info for the room.
+ * @param txn - An active transaction. See doTxn().
+ */
+ public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: IDBTransaction): void {
+ this.backend!.storeEndToEndRoom(roomId, roomInfo, txn);
+ }
+
+ /**
+ * Get an object of `roomId->roomInfo` for all e2e rooms in the store
+ * @param txn - An active transaction. See doTxn().
+ * @param func - Function called with the end-to-end encrypted rooms
+ */
+ public getEndToEndRooms(txn: IDBTransaction, func: (rooms: Record<string, IRoomEncryption>) => void): void {
+ this.backend!.getEndToEndRooms(txn, func);
+ }
+
+ // session backups
+
+ /**
+ * Get the inbound group sessions that need to be backed up.
+ * @param limit - The maximum number of sessions to retrieve. 0
+ * for no limit.
+ * @returns resolves to an array of inbound group sessions
+ */
+ public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
+ return this.backend!.getSessionsNeedingBackup(limit);
+ }
+
+ /**
+ * Count the inbound group sessions that need to be backed up.
+ * @param txn - An active transaction. See doTxn(). (optional)
+ * @returns resolves to the number of sessions
+ */
+ public countSessionsNeedingBackup(txn?: IDBTransaction): Promise<number> {
+ return this.backend!.countSessionsNeedingBackup(txn);
+ }
+
+ /**
+ * Unmark sessions as needing to be backed up.
+ * @param sessions - The sessions that need to be backed up.
+ * @param txn - An active transaction. See doTxn(). (optional)
+ * @returns resolves when the sessions are unmarked
+ */
+ public unmarkSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
+ return this.backend!.unmarkSessionsNeedingBackup(sessions, txn);
+ }
+
+ /**
+ * Mark sessions as needing to be backed up.
+ * @param sessions - The sessions that need to be backed up.
+ * @param txn - An active transaction. See doTxn(). (optional)
+ * @returns resolves when the sessions are marked
+ */
+ public markSessionsNeedingBackup(sessions: ISession[], txn?: IDBTransaction): Promise<void> {
+ return this.backend!.markSessionsNeedingBackup(sessions, txn);
+ }
+
+ /**
+ * Add a shared-history group session for a room.
+ * @param roomId - The room that the key belongs to
+ * @param senderKey - The sender's curve 25519 key
+ * @param sessionId - The ID of the session
+ * @param txn - An active transaction. See doTxn(). (optional)
+ */
+ public addSharedHistoryInboundGroupSession(
+ roomId: string,
+ senderKey: string,
+ sessionId: string,
+ txn?: IDBTransaction,
+ ): void {
+ this.backend!.addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn);
+ }
+
+ /**
+ * Get the shared-history group session for a room.
+ * @param roomId - The room that the key belongs to
+ * @param txn - An active transaction. See doTxn(). (optional)
+ * @returns Promise which resolves to an array of [senderKey, sessionId]
+ */
+ public getSharedHistoryInboundGroupSessions(
+ roomId: string,
+ txn?: IDBTransaction,
+ ): Promise<[senderKey: string, sessionId: string][]> {
+ return this.backend!.getSharedHistoryInboundGroupSessions(roomId, txn);
+ }
+
+ /**
+ * Park a shared-history group session for a room we may be invited to later.
+ */
+ public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory, txn?: IDBTransaction): void {
+ this.backend!.addParkedSharedHistory(roomId, parkedData, txn);
+ }
+
+ /**
+ * Pop out all shared-history group sessions for a room.
+ */
+ public takeParkedSharedHistory(roomId: string, txn?: IDBTransaction): Promise<ParkedSharedHistory[]> {
+ return this.backend!.takeParkedSharedHistory(roomId, txn);
+ }
+
+ /**
+ * Perform a transaction on the crypto store. Any store methods
+ * that require a transaction (txn) object to be passed in may
+ * only be called within a callback of either this function or
+ * one of the store functions operating on the same transaction.
+ *
+ * @param mode - 'readwrite' if you need to call setter
+ * functions with this transaction. Otherwise, 'readonly'.
+ * @param stores - List IndexedDBCryptoStore.STORE_*
+ * options representing all types of object that will be
+ * accessed or written to with this transaction.
+ * @param func - Function called with the
+ * transaction object: an opaque object that should be passed
+ * to store functions.
+ * @param log - A possibly customised log
+ * @returns Promise that resolves with the result of the `func`
+ * when the transaction is complete. If the backend is
+ * async (ie. the indexeddb backend) any of the callback
+ * functions throwing an exception will cause this promise to
+ * reject with that exception. On synchronous backends, the
+ * exception will propagate to the caller of the getFoo method.
+ */
+ public doTxn<T>(
+ mode: Mode,
+ stores: Iterable<string>,
+ func: (txn: IDBTransaction) => T,
+ log?: PrefixedLogger,
+ ): Promise<T> {
+ return this.backend!.doTxn<T>(mode, stores, func as (txn: unknown) => T, log);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts
new file mode 100644
index 0000000..5552540
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/localStorage-crypto-store.ts
@@ -0,0 +1,403 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "../../logger";
+import { MemoryCryptoStore } from "./memory-crypto-store";
+import { IDeviceData, IProblem, ISession, ISessionInfo, IWithheld, Mode, SecretStorePrivateKeys } from "./base";
+import { IOlmDevice } from "../algorithms/megolm";
+import { IRoomEncryption } from "../RoomList";
+import { ICrossSigningKey } from "../../client";
+import { InboundGroupSessionData } from "../OlmDevice";
+import { safeSet } from "../../utils";
+
+/**
+ * Internal module. Partial localStorage backed storage for e2e.
+ * This is not a full crypto store, just the in-memory store with
+ * some things backed by localStorage. It exists because indexedDB
+ * is broken in Firefox private mode or set to, "will not remember
+ * history".
+ */
+
+const E2E_PREFIX = "crypto.";
+const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account";
+const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys";
+const KEY_NOTIFIED_ERROR_DEVICES = E2E_PREFIX + "notified_error_devices";
+const KEY_DEVICE_DATA = E2E_PREFIX + "device_data";
+const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/";
+const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/";
+const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/";
+const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup";
+
+function keyEndToEndSessions(deviceKey: string): string {
+ return E2E_PREFIX + "sessions/" + deviceKey;
+}
+
+function keyEndToEndSessionProblems(deviceKey: string): string {
+ return E2E_PREFIX + "session.problems/" + deviceKey;
+}
+
+function keyEndToEndInboundGroupSession(senderKey: string, sessionId: string): string {
+ return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId;
+}
+
+function keyEndToEndInboundGroupSessionWithheld(senderKey: string, sessionId: string): string {
+ return KEY_INBOUND_SESSION_WITHHELD_PREFIX + senderKey + "/" + sessionId;
+}
+
+function keyEndToEndRoomsPrefix(roomId: string): string {
+ return KEY_ROOMS_PREFIX + roomId;
+}
+
+export class LocalStorageCryptoStore extends MemoryCryptoStore {
+ public static exists(store: Storage): boolean {
+ const length = store.length;
+ for (let i = 0; i < length; i++) {
+ if (store.key(i)?.startsWith(E2E_PREFIX)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public constructor(private readonly store: Storage) {
+ super();
+ }
+
+ // Olm Sessions
+
+ public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
+ let count = 0;
+ for (let i = 0; i < this.store.length; ++i) {
+ if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) ++count;
+ }
+ func(count);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ private _getEndToEndSessions(deviceKey: string): Record<string, ISessionInfo> {
+ const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey));
+ const fixedSessions: Record<string, ISessionInfo> = {};
+
+ // fix up any old sessions to be objects rather than just the base64 pickle
+ for (const [sid, val] of Object.entries(sessions || {})) {
+ if (typeof val === "string") {
+ fixedSessions[sid] = {
+ session: val,
+ };
+ } else {
+ fixedSessions[sid] = val;
+ }
+ }
+
+ return fixedSessions;
+ }
+
+ public getEndToEndSession(
+ deviceKey: string,
+ sessionId: string,
+ txn: unknown,
+ func: (session: ISessionInfo) => void,
+ ): void {
+ const sessions = this._getEndToEndSessions(deviceKey);
+ func(sessions[sessionId] || {});
+ }
+
+ public getEndToEndSessions(
+ deviceKey: string,
+ txn: unknown,
+ func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
+ ): void {
+ func(this._getEndToEndSessions(deviceKey) || {});
+ }
+
+ public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void {
+ for (let i = 0; i < this.store.length; ++i) {
+ if (this.store.key(i)?.startsWith(keyEndToEndSessions(""))) {
+ const deviceKey = this.store.key(i)!.split("/")[1];
+ for (const sess of Object.values(this._getEndToEndSessions(deviceKey))) {
+ func(sess);
+ }
+ }
+ }
+ }
+
+ public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
+ const sessions = this._getEndToEndSessions(deviceKey) || {};
+ sessions[sessionId] = sessionInfo;
+ setJsonItem(this.store, keyEndToEndSessions(deviceKey), sessions);
+ }
+
+ public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
+ const key = keyEndToEndSessionProblems(deviceKey);
+ const problems = getJsonItem<IProblem[]>(this.store, key) || [];
+ problems.push({ type, fixed, time: Date.now() });
+ problems.sort((a, b) => {
+ return a.time - b.time;
+ });
+ setJsonItem(this.store, key, problems);
+ }
+
+ public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
+ const key = keyEndToEndSessionProblems(deviceKey);
+ const problems = getJsonItem<IProblem[]>(this.store, key) || [];
+ if (!problems.length) {
+ return null;
+ }
+ const lastProblem = problems[problems.length - 1];
+ for (const problem of problems) {
+ if (problem.time > timestamp) {
+ return Object.assign({}, problem, { fixed: lastProblem.fixed });
+ }
+ }
+ if (lastProblem.fixed) {
+ return null;
+ } else {
+ return lastProblem;
+ }
+ }
+
+ public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
+ const notifiedErrorDevices =
+ getJsonItem<MemoryCryptoStore["notifiedErrorDevices"]>(this.store, KEY_NOTIFIED_ERROR_DEVICES) || {};
+ const ret: IOlmDevice[] = [];
+
+ for (const device of devices) {
+ const { userId, deviceInfo } = device;
+ if (userId in notifiedErrorDevices) {
+ if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
+ ret.push(device);
+ safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
+ }
+ } else {
+ ret.push(device);
+ safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
+ }
+ }
+
+ setJsonItem(this.store, KEY_NOTIFIED_ERROR_DEVICES, notifiedErrorDevices);
+
+ return ret;
+ }
+
+ // Inbound Group Sessions
+
+ public getEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ txn: unknown,
+ func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
+ ): void {
+ func(
+ getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId)),
+ getJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId)),
+ );
+ }
+
+ public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
+ for (let i = 0; i < this.store.length; ++i) {
+ const key = this.store.key(i);
+ if (key?.startsWith(KEY_INBOUND_SESSION_PREFIX)) {
+ // we can't use split, as the components we are trying to split out
+ // might themselves contain '/' characters. We rely on the
+ // senderKey being a (32-byte) curve25519 key, base64-encoded
+ // (hence 43 characters long).
+
+ func({
+ senderKey: key.slice(KEY_INBOUND_SESSION_PREFIX.length, KEY_INBOUND_SESSION_PREFIX.length + 43),
+ sessionId: key.slice(KEY_INBOUND_SESSION_PREFIX.length + 44),
+ sessionData: getJsonItem(this.store, key)!,
+ });
+ }
+ }
+ func(null);
+ }
+
+ public addEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: unknown,
+ ): void {
+ const existing = getJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId));
+ if (!existing) {
+ this.storeEndToEndInboundGroupSession(senderCurve25519Key, sessionId, sessionData, txn);
+ }
+ }
+
+ public storeEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: unknown,
+ ): void {
+ setJsonItem(this.store, keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), sessionData);
+ }
+
+ public storeEndToEndInboundGroupSessionWithheld(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: IWithheld,
+ txn: unknown,
+ ): void {
+ setJsonItem(this.store, keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), sessionData);
+ }
+
+ public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
+ func(getJsonItem(this.store, KEY_DEVICE_DATA));
+ }
+
+ public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void {
+ setJsonItem(this.store, KEY_DEVICE_DATA, deviceData);
+ }
+
+ public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void {
+ setJsonItem(this.store, keyEndToEndRoomsPrefix(roomId), roomInfo);
+ }
+
+ public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
+ const result: Record<string, IRoomEncryption> = {};
+ const prefix = keyEndToEndRoomsPrefix("");
+
+ for (let i = 0; i < this.store.length; ++i) {
+ const key = this.store.key(i);
+ if (key?.startsWith(prefix)) {
+ const roomId = key.slice(prefix.length);
+ result[roomId] = getJsonItem(this.store, key)!;
+ }
+ }
+ func(result);
+ }
+
+ public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
+ const sessionsNeedingBackup = getJsonItem<string[]>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+ const sessions: ISession[] = [];
+
+ for (const session in sessionsNeedingBackup) {
+ if (Object.prototype.hasOwnProperty.call(sessionsNeedingBackup, session)) {
+ // see getAllEndToEndInboundGroupSessions for the magic number explanations
+ const senderKey = session.slice(0, 43);
+ const sessionId = session.slice(44);
+ this.getEndToEndInboundGroupSession(senderKey, sessionId, null, (sessionData) => {
+ sessions.push({
+ senderKey: senderKey,
+ sessionId: sessionId,
+ sessionData: sessionData!,
+ });
+ });
+ if (limit && sessions.length >= limit) {
+ break;
+ }
+ }
+ }
+ return Promise.resolve(sessions);
+ }
+
+ public countSessionsNeedingBackup(): Promise<number> {
+ const sessionsNeedingBackup = getJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+ return Promise.resolve(Object.keys(sessionsNeedingBackup).length);
+ }
+
+ public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
+ const sessionsNeedingBackup =
+ getJsonItem<{
+ [senderKeySessionId: string]: string;
+ }>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+ for (const session of sessions) {
+ delete sessionsNeedingBackup[session.senderKey + "/" + session.sessionId];
+ }
+ setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup);
+ return Promise.resolve();
+ }
+
+ public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
+ const sessionsNeedingBackup =
+ getJsonItem<{
+ [senderKeySessionId: string]: boolean;
+ }>(this.store, KEY_SESSIONS_NEEDING_BACKUP) || {};
+ for (const session of sessions) {
+ sessionsNeedingBackup[session.senderKey + "/" + session.sessionId] = true;
+ }
+ setJsonItem(this.store, KEY_SESSIONS_NEEDING_BACKUP, sessionsNeedingBackup);
+ return Promise.resolve();
+ }
+
+ /**
+ * Delete all data from this store.
+ *
+ * @returns Promise which resolves when the store has been cleared.
+ */
+ public deleteAllData(): Promise<void> {
+ this.store.removeItem(KEY_END_TO_END_ACCOUNT);
+ return Promise.resolve();
+ }
+
+ // Olm account
+
+ public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void {
+ const accountPickle = getJsonItem<string>(this.store, KEY_END_TO_END_ACCOUNT);
+ func(accountPickle);
+ }
+
+ public storeAccount(txn: unknown, accountPickle: string): void {
+ setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, accountPickle);
+ }
+
+ public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
+ const keys = getJsonItem<Record<string, ICrossSigningKey>>(this.store, KEY_CROSS_SIGNING_KEYS);
+ func(keys);
+ }
+
+ public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: unknown,
+ func: (key: SecretStorePrivateKeys[K] | null) => void,
+ type: K,
+ ): void {
+ const key = getJsonItem<SecretStorePrivateKeys[K]>(this.store, E2E_PREFIX + `ssss_cache.${type}`);
+ func(key);
+ }
+
+ public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
+ setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys);
+ }
+
+ public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: unknown,
+ type: K,
+ key: SecretStorePrivateKeys[K],
+ ): void {
+ setJsonItem(this.store, E2E_PREFIX + `ssss_cache.${type}`, key);
+ }
+
+ public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn: unknown) => T): Promise<T> {
+ return Promise.resolve(func(null));
+ }
+}
+
+function getJsonItem<T>(store: Storage, key: string): T | null {
+ try {
+ // if the key is absent, store.getItem() returns null, and
+ // JSON.parse(null) === null, so this returns null.
+ return JSON.parse(store.getItem(key)!);
+ } catch (e) {
+ logger.log("Error: Failed to get key %s: %s", key, (<Error>e).message);
+ logger.log((<Error>e).stack);
+ }
+ return null;
+}
+
+function setJsonItem<T>(store: Storage, key: string, val: T): void {
+ store.setItem(key, JSON.stringify(val));
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts
new file mode 100644
index 0000000..29ae81b
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/store/memory-crypto-store.ts
@@ -0,0 +1,533 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "../../logger";
+import * as utils from "../../utils";
+import {
+ CryptoStore,
+ IDeviceData,
+ IProblem,
+ ISession,
+ ISessionInfo,
+ IWithheld,
+ Mode,
+ OutgoingRoomKeyRequest,
+ ParkedSharedHistory,
+ SecretStorePrivateKeys,
+} from "./base";
+import { IRoomKeyRequestBody } from "../index";
+import { ICrossSigningKey } from "../../client";
+import { IOlmDevice } from "../algorithms/megolm";
+import { IRoomEncryption } from "../RoomList";
+import { InboundGroupSessionData } from "../OlmDevice";
+import { safeSet } from "../../utils";
+
+/**
+ * Internal module. in-memory storage for e2e.
+ */
+
+export class MemoryCryptoStore implements CryptoStore {
+ private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = [];
+ private account: string | null = null;
+ private crossSigningKeys: Record<string, ICrossSigningKey> | null = null;
+ private privateKeys: Partial<SecretStorePrivateKeys> = {};
+
+ private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {};
+ private sessionProblems: { [deviceKey: string]: IProblem[] } = {};
+ private notifiedErrorDevices: { [userId: string]: { [deviceId: string]: boolean } } = {};
+ private inboundGroupSessions: { [sessionKey: string]: InboundGroupSessionData } = {};
+ private inboundGroupSessionsWithheld: Record<string, IWithheld> = {};
+ // Opaque device data object
+ private deviceData: IDeviceData | null = null;
+ private rooms: { [roomId: string]: IRoomEncryption } = {};
+ private sessionsNeedingBackup: { [sessionKey: string]: boolean } = {};
+ private sharedHistoryInboundGroupSessions: { [roomId: string]: [senderKey: string, sessionId: string][] } = {};
+ private parkedSharedHistory = new Map<string, ParkedSharedHistory[]>(); // keyed by room ID
+
+ /**
+ * Ensure the database exists and is up-to-date.
+ *
+ * This must be called before the store can be used.
+ *
+ * @returns resolves to the store.
+ */
+ public async startup(): Promise<CryptoStore> {
+ // No startup work to do for the memory store.
+ return this;
+ }
+
+ /**
+ * Delete all data from this store.
+ *
+ * @returns Promise which resolves when the store has been cleared.
+ */
+ public deleteAllData(): Promise<void> {
+ return Promise.resolve();
+ }
+
+ /**
+ * Look for an existing outgoing room key request, and if none is found,
+ * add a new one
+ *
+ *
+ * @returns resolves to
+ * {@link OutgoingRoomKeyRequest}: either the
+ * same instance as passed in, or the existing one.
+ */
+ public getOrAddOutgoingRoomKeyRequest(request: OutgoingRoomKeyRequest): Promise<OutgoingRoomKeyRequest> {
+ const requestBody = request.requestBody;
+
+ return utils.promiseTry(() => {
+ // first see if we already have an entry for this request.
+ const existing = this._getOutgoingRoomKeyRequest(requestBody);
+
+ if (existing) {
+ // this entry matches the request - return it.
+ logger.log(
+ `already have key request outstanding for ` +
+ `${requestBody.room_id} / ${requestBody.session_id}: ` +
+ `not sending another`,
+ );
+ return existing;
+ }
+
+ // we got to the end of the list without finding a match
+ // - add the new request.
+ logger.log(`enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id);
+ this.outgoingRoomKeyRequests.push(request);
+ return request;
+ });
+ }
+
+ /**
+ * Look for an existing room key request
+ *
+ * @param requestBody - existing request to look for
+ *
+ * @returns resolves to the matching
+ * {@link OutgoingRoomKeyRequest}, or null if
+ * not found
+ */
+ public getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise<OutgoingRoomKeyRequest | null> {
+ return Promise.resolve(this._getOutgoingRoomKeyRequest(requestBody));
+ }
+
+ /**
+ * Looks for existing room key request, and returns the result synchronously.
+ *
+ * @internal
+ *
+ * @param requestBody - existing request to look for
+ *
+ * @returns
+ * the matching request, or null if not found
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ private _getOutgoingRoomKeyRequest(requestBody: IRoomKeyRequestBody): OutgoingRoomKeyRequest | null {
+ for (const existing of this.outgoingRoomKeyRequests) {
+ if (utils.deepCompare(existing.requestBody, requestBody)) {
+ return existing;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Look for room key requests by state
+ *
+ * @param wantedStates - list of acceptable states
+ *
+ * @returns resolves to the a
+ * {@link OutgoingRoomKeyRequest}, or null if
+ * there are no pending requests in those states
+ */
+ public getOutgoingRoomKeyRequestByState(wantedStates: number[]): Promise<OutgoingRoomKeyRequest | null> {
+ for (const req of this.outgoingRoomKeyRequests) {
+ for (const state of wantedStates) {
+ if (req.state === state) {
+ return Promise.resolve(req);
+ }
+ }
+ }
+ return Promise.resolve(null);
+ }
+
+ /**
+ *
+ * @returns All OutgoingRoomKeyRequests in state
+ */
+ public getAllOutgoingRoomKeyRequestsByState(wantedState: number): Promise<OutgoingRoomKeyRequest[]> {
+ return Promise.resolve(this.outgoingRoomKeyRequests.filter((r) => r.state == wantedState));
+ }
+
+ public getOutgoingRoomKeyRequestsByTarget(
+ userId: string,
+ deviceId: string,
+ wantedStates: number[],
+ ): Promise<OutgoingRoomKeyRequest[]> {
+ const results: OutgoingRoomKeyRequest[] = [];
+
+ for (const req of this.outgoingRoomKeyRequests) {
+ for (const state of wantedStates) {
+ if (
+ req.state === state &&
+ req.recipients.some((recipient) => recipient.userId === userId && recipient.deviceId === deviceId)
+ ) {
+ results.push(req);
+ }
+ }
+ }
+ return Promise.resolve(results);
+ }
+
+ /**
+ * Look for an existing room key request by id and state, and update it if
+ * found
+ *
+ * @param requestId - ID of request to update
+ * @param expectedState - state we expect to find the request in
+ * @param updates - name/value map of updates to apply
+ *
+ * @returns resolves to
+ * {@link OutgoingRoomKeyRequest}
+ * updated request, or null if no matching row was found
+ */
+ public updateOutgoingRoomKeyRequest(
+ requestId: string,
+ expectedState: number,
+ updates: Partial<OutgoingRoomKeyRequest>,
+ ): Promise<OutgoingRoomKeyRequest | null> {
+ for (const req of this.outgoingRoomKeyRequests) {
+ if (req.requestId !== requestId) {
+ continue;
+ }
+
+ if (req.state !== expectedState) {
+ logger.warn(
+ `Cannot update room key request from ${expectedState} ` +
+ `as it was already updated to ${req.state}`,
+ );
+ return Promise.resolve(null);
+ }
+ Object.assign(req, updates);
+ return Promise.resolve(req);
+ }
+
+ return Promise.resolve(null);
+ }
+
+ /**
+ * Look for an existing room key request by id and state, and delete it if
+ * found
+ *
+ * @param requestId - ID of request to update
+ * @param expectedState - state we expect to find the request in
+ *
+ * @returns resolves once the operation is completed
+ */
+ public deleteOutgoingRoomKeyRequest(
+ requestId: string,
+ expectedState: number,
+ ): Promise<OutgoingRoomKeyRequest | null> {
+ for (let i = 0; i < this.outgoingRoomKeyRequests.length; i++) {
+ const req = this.outgoingRoomKeyRequests[i];
+
+ if (req.requestId !== requestId) {
+ continue;
+ }
+
+ if (req.state != expectedState) {
+ logger.warn(`Cannot delete room key request in state ${req.state} ` + `(expected ${expectedState})`);
+ return Promise.resolve(null);
+ }
+
+ this.outgoingRoomKeyRequests.splice(i, 1);
+ return Promise.resolve(req);
+ }
+
+ return Promise.resolve(null);
+ }
+
+ // Olm Account
+
+ public getAccount(txn: unknown, func: (accountPickle: string | null) => void): void {
+ func(this.account);
+ }
+
+ public storeAccount(txn: unknown, accountPickle: string): void {
+ this.account = accountPickle;
+ }
+
+ public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
+ func(this.crossSigningKeys);
+ }
+
+ public getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: unknown,
+ func: (key: SecretStorePrivateKeys[K] | null) => void,
+ type: K,
+ ): void {
+ const result = this.privateKeys[type] as SecretStorePrivateKeys[K] | undefined;
+ func(result || null);
+ }
+
+ public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
+ this.crossSigningKeys = keys;
+ }
+
+ public storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
+ txn: unknown,
+ type: K,
+ key: SecretStorePrivateKeys[K],
+ ): void {
+ this.privateKeys[type] = key;
+ }
+
+ // Olm Sessions
+
+ public countEndToEndSessions(txn: unknown, func: (count: number) => void): void {
+ func(Object.keys(this.sessions).length);
+ }
+
+ public getEndToEndSession(
+ deviceKey: string,
+ sessionId: string,
+ txn: unknown,
+ func: (session: ISessionInfo) => void,
+ ): void {
+ const deviceSessions = this.sessions[deviceKey] || {};
+ func(deviceSessions[sessionId] || null);
+ }
+
+ public getEndToEndSessions(
+ deviceKey: string,
+ txn: unknown,
+ func: (sessions: { [sessionId: string]: ISessionInfo }) => void,
+ ): void {
+ func(this.sessions[deviceKey] || {});
+ }
+
+ public getAllEndToEndSessions(txn: unknown, func: (session: ISessionInfo) => void): void {
+ Object.entries(this.sessions).forEach(([deviceKey, deviceSessions]) => {
+ Object.entries(deviceSessions).forEach(([sessionId, session]) => {
+ func({
+ ...session,
+ deviceKey,
+ sessionId,
+ });
+ });
+ });
+ }
+
+ public storeEndToEndSession(deviceKey: string, sessionId: string, sessionInfo: ISessionInfo, txn: unknown): void {
+ let deviceSessions = this.sessions[deviceKey];
+ if (deviceSessions === undefined) {
+ deviceSessions = {};
+ this.sessions[deviceKey] = deviceSessions;
+ }
+ deviceSessions[sessionId] = sessionInfo;
+ }
+
+ public async storeEndToEndSessionProblem(deviceKey: string, type: string, fixed: boolean): Promise<void> {
+ const problems = (this.sessionProblems[deviceKey] = this.sessionProblems[deviceKey] || []);
+ problems.push({ type, fixed, time: Date.now() });
+ problems.sort((a, b) => {
+ return a.time - b.time;
+ });
+ }
+
+ public async getEndToEndSessionProblem(deviceKey: string, timestamp: number): Promise<IProblem | null> {
+ const problems = this.sessionProblems[deviceKey] || [];
+ if (!problems.length) {
+ return null;
+ }
+ const lastProblem = problems[problems.length - 1];
+ for (const problem of problems) {
+ if (problem.time > timestamp) {
+ return Object.assign({}, problem, { fixed: lastProblem.fixed });
+ }
+ }
+ if (lastProblem.fixed) {
+ return null;
+ } else {
+ return lastProblem;
+ }
+ }
+
+ public async filterOutNotifiedErrorDevices(devices: IOlmDevice[]): Promise<IOlmDevice[]> {
+ const notifiedErrorDevices = this.notifiedErrorDevices;
+ const ret: IOlmDevice[] = [];
+
+ for (const device of devices) {
+ const { userId, deviceInfo } = device;
+ if (userId in notifiedErrorDevices) {
+ if (!(deviceInfo.deviceId in notifiedErrorDevices[userId])) {
+ ret.push(device);
+ safeSet(notifiedErrorDevices[userId], deviceInfo.deviceId, true);
+ }
+ } else {
+ ret.push(device);
+ safeSet(notifiedErrorDevices, userId, { [deviceInfo.deviceId]: true });
+ }
+ }
+
+ return ret;
+ }
+
+ // Inbound Group Sessions
+
+ public getEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ txn: unknown,
+ func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
+ ): void {
+ const k = senderCurve25519Key + "/" + sessionId;
+ func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null);
+ }
+
+ public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
+ for (const key of Object.keys(this.inboundGroupSessions)) {
+ // we can't use split, as the components we are trying to split out
+ // might themselves contain '/' characters. We rely on the
+ // senderKey being a (32-byte) curve25519 key, base64-encoded
+ // (hence 43 characters long).
+
+ func({
+ senderKey: key.slice(0, 43),
+ sessionId: key.slice(44),
+ sessionData: this.inboundGroupSessions[key],
+ });
+ }
+ func(null);
+ }
+
+ public addEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: unknown,
+ ): void {
+ const k = senderCurve25519Key + "/" + sessionId;
+ if (this.inboundGroupSessions[k] === undefined) {
+ this.inboundGroupSessions[k] = sessionData;
+ }
+ }
+
+ public storeEndToEndInboundGroupSession(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: InboundGroupSessionData,
+ txn: unknown,
+ ): void {
+ this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData;
+ }
+
+ public storeEndToEndInboundGroupSessionWithheld(
+ senderCurve25519Key: string,
+ sessionId: string,
+ sessionData: IWithheld,
+ txn: unknown,
+ ): void {
+ const k = senderCurve25519Key + "/" + sessionId;
+ this.inboundGroupSessionsWithheld[k] = sessionData;
+ }
+
+ // Device Data
+
+ public getEndToEndDeviceData(txn: unknown, func: (deviceData: IDeviceData | null) => void): void {
+ func(this.deviceData);
+ }
+
+ public storeEndToEndDeviceData(deviceData: IDeviceData, txn: unknown): void {
+ this.deviceData = deviceData;
+ }
+
+ // E2E rooms
+
+ public storeEndToEndRoom(roomId: string, roomInfo: IRoomEncryption, txn: unknown): void {
+ this.rooms[roomId] = roomInfo;
+ }
+
+ public getEndToEndRooms(txn: unknown, func: (rooms: Record<string, IRoomEncryption>) => void): void {
+ func(this.rooms);
+ }
+
+ public getSessionsNeedingBackup(limit: number): Promise<ISession[]> {
+ const sessions: ISession[] = [];
+ for (const session in this.sessionsNeedingBackup) {
+ if (this.inboundGroupSessions[session]) {
+ sessions.push({
+ senderKey: session.slice(0, 43),
+ sessionId: session.slice(44),
+ sessionData: this.inboundGroupSessions[session],
+ });
+ if (limit && session.length >= limit) {
+ break;
+ }
+ }
+ }
+ return Promise.resolve(sessions);
+ }
+
+ public countSessionsNeedingBackup(): Promise<number> {
+ return Promise.resolve(Object.keys(this.sessionsNeedingBackup).length);
+ }
+
+ public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
+ for (const session of sessions) {
+ const sessionKey = session.senderKey + "/" + session.sessionId;
+ delete this.sessionsNeedingBackup[sessionKey];
+ }
+ return Promise.resolve();
+ }
+
+ public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
+ for (const session of sessions) {
+ const sessionKey = session.senderKey + "/" + session.sessionId;
+ this.sessionsNeedingBackup[sessionKey] = true;
+ }
+ return Promise.resolve();
+ }
+
+ public addSharedHistoryInboundGroupSession(roomId: string, senderKey: string, sessionId: string): void {
+ const sessions = this.sharedHistoryInboundGroupSessions[roomId] || [];
+ sessions.push([senderKey, sessionId]);
+ this.sharedHistoryInboundGroupSessions[roomId] = sessions;
+ }
+
+ public getSharedHistoryInboundGroupSessions(roomId: string): Promise<[senderKey: string, sessionId: string][]> {
+ return Promise.resolve(this.sharedHistoryInboundGroupSessions[roomId] || []);
+ }
+
+ public addParkedSharedHistory(roomId: string, parkedData: ParkedSharedHistory): void {
+ const parked = this.parkedSharedHistory.get(roomId) ?? [];
+ parked.push(parkedData);
+ this.parkedSharedHistory.set(roomId, parked);
+ }
+
+ public takeParkedSharedHistory(roomId: string): Promise<ParkedSharedHistory[]> {
+ const parked = this.parkedSharedHistory.get(roomId) ?? [];
+ this.parkedSharedHistory.delete(roomId);
+ return Promise.resolve(parked);
+ }
+
+ // Session key backups
+
+ public doTxn<T>(mode: Mode, stores: Iterable<string>, func: (txn?: unknown) => T): Promise<T> {
+ return Promise.resolve(func(null));
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts
new file mode 100644
index 0000000..89c700c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Base.ts
@@ -0,0 +1,369 @@
+/*
+Copyright 2018 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Base class for verification methods.
+ */
+
+import { MatrixEvent } from "../../models/event";
+import { EventType } from "../../@types/event";
+import { logger } from "../../logger";
+import { DeviceInfo } from "../deviceinfo";
+import { newTimeoutError } from "./Error";
+import { KeysDuringVerification, requestKeysDuringVerification } from "../CrossSigning";
+import { IVerificationChannel } from "./request/Channel";
+import { MatrixClient } from "../../client";
+import { VerificationRequest } from "./request/VerificationRequest";
+import { ListenerMap, TypedEventEmitter } from "../../models/typed-event-emitter";
+
+const timeoutException = new Error("Verification timed out");
+
+export class SwitchStartEventError extends Error {
+ public constructor(public readonly startEvent: MatrixEvent | null) {
+ super();
+ }
+}
+
+export type KeyVerifier = (keyId: string, device: DeviceInfo, keyInfo: string) => void;
+
+export enum VerificationEvent {
+ Cancel = "cancel",
+}
+
+export type VerificationEventHandlerMap = {
+ [VerificationEvent.Cancel]: (e: Error | MatrixEvent) => void;
+};
+
+export class VerificationBase<
+ Events extends string,
+ Arguments extends ListenerMap<Events | VerificationEvent>,
+> extends TypedEventEmitter<Events | VerificationEvent, Arguments, VerificationEventHandlerMap> {
+ private cancelled = false;
+ private _done = false;
+ private promise: Promise<void> | null = null;
+ private transactionTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
+ protected expectedEvent?: string;
+ private resolve?: () => void;
+ private reject?: (e: Error | MatrixEvent) => void;
+ private resolveEvent?: (e: MatrixEvent) => void;
+ private rejectEvent?: (e: Error) => void;
+ private started?: boolean;
+
+ /**
+ * Base class for verification methods.
+ *
+ * <p>Once a verifier object is created, the verification can be started by
+ * calling the verify() method, which will return a promise that will
+ * resolve when the verification is completed, or reject if it could not
+ * complete.</p>
+ *
+ * <p>Subclasses must have a NAME class property.</p>
+ *
+ * @param channel - the verification channel to send verification messages over.
+ * TODO: Channel types
+ *
+ * @param baseApis - base matrix api interface
+ *
+ * @param userId - the user ID that is being verified
+ *
+ * @param deviceId - the device ID that is being verified
+ *
+ * @param startEvent - the m.key.verification.start event that
+ * initiated this verification, if any
+ *
+ * @param request - the key verification request object related to
+ * this verification, if any
+ */
+ public constructor(
+ public readonly channel: IVerificationChannel,
+ public readonly baseApis: MatrixClient,
+ public readonly userId: string,
+ public readonly deviceId: string,
+ public startEvent: MatrixEvent | null,
+ public readonly request: VerificationRequest,
+ ) {
+ super();
+ }
+
+ public get initiatedByMe(): boolean {
+ // if there is no start event yet,
+ // we probably want to send it,
+ // which happens if we initiate
+ if (!this.startEvent) {
+ return true;
+ }
+ const sender = this.startEvent.getSender();
+ const content = this.startEvent.getContent();
+ return sender === this.baseApis.getUserId() && content.from_device === this.baseApis.getDeviceId();
+ }
+
+ public get hasBeenCancelled(): boolean {
+ return this.cancelled;
+ }
+
+ private resetTimer(): void {
+ logger.info("Refreshing/starting the verification transaction timeout timer");
+ if (this.transactionTimeoutTimer !== null) {
+ clearTimeout(this.transactionTimeoutTimer);
+ }
+ this.transactionTimeoutTimer = setTimeout(() => {
+ if (!this._done && !this.cancelled) {
+ logger.info("Triggering verification timeout");
+ this.cancel(timeoutException);
+ }
+ }, 10 * 60 * 1000); // 10 minutes
+ }
+
+ private endTimer(): void {
+ if (this.transactionTimeoutTimer !== null) {
+ clearTimeout(this.transactionTimeoutTimer);
+ this.transactionTimeoutTimer = null;
+ }
+ }
+
+ protected send(type: string, uncompletedContent: Record<string, any>): Promise<void> {
+ return this.channel.send(type, uncompletedContent);
+ }
+
+ protected waitForEvent(type: string): Promise<MatrixEvent> {
+ if (this._done) {
+ return Promise.reject(new Error("Verification is already done"));
+ }
+ const existingEvent = this.request.getEventFromOtherParty(type);
+ if (existingEvent) {
+ return Promise.resolve(existingEvent);
+ }
+
+ this.expectedEvent = type;
+ return new Promise((resolve, reject) => {
+ this.resolveEvent = resolve;
+ this.rejectEvent = reject;
+ });
+ }
+
+ public canSwitchStartEvent(event: MatrixEvent): boolean {
+ return false;
+ }
+
+ public switchStartEvent(event: MatrixEvent): void {
+ if (this.canSwitchStartEvent(event)) {
+ logger.log("Verification Base: switching verification start event", { restartingFlow: !!this.rejectEvent });
+ if (this.rejectEvent) {
+ const reject = this.rejectEvent;
+ this.rejectEvent = undefined;
+ reject(new SwitchStartEventError(event));
+ } else {
+ this.startEvent = event;
+ }
+ }
+ }
+
+ public handleEvent(e: MatrixEvent): void {
+ if (this._done) {
+ return;
+ } else if (e.getType() === this.expectedEvent) {
+ // if we receive an expected m.key.verification.done, then just
+ // ignore it, since we don't need to do anything about it
+ if (this.expectedEvent !== EventType.KeyVerificationDone) {
+ this.expectedEvent = undefined;
+ this.rejectEvent = undefined;
+ this.resetTimer();
+ this.resolveEvent?.(e);
+ }
+ } else if (e.getType() === EventType.KeyVerificationCancel) {
+ const reject = this.reject;
+ this.reject = undefined;
+ // there is only promise to reject if verify has been called
+ if (reject) {
+ const content = e.getContent();
+ const { reason, code } = content;
+ reject(new Error(`Other side cancelled verification ` + `because ${reason} (${code})`));
+ }
+ } else if (this.expectedEvent) {
+ // only cancel if there is an event expected.
+ // if there is no event expected, it means verify() wasn't called
+ // and we're just replaying the timeline events when syncing
+ // after a refresh when the events haven't been stored in the cache yet.
+ const exception = new Error(
+ "Unexpected message: expecting " + this.expectedEvent + " but got " + e.getType(),
+ );
+ this.expectedEvent = undefined;
+ if (this.rejectEvent) {
+ const reject = this.rejectEvent;
+ this.rejectEvent = undefined;
+ reject(exception);
+ }
+ this.cancel(exception);
+ }
+ }
+
+ public async done(): Promise<KeysDuringVerification | void> {
+ this.endTimer(); // always kill the activity timer
+ if (!this._done) {
+ this.request.onVerifierFinished();
+ this.resolve?.();
+ return requestKeysDuringVerification(this.baseApis, this.userId, this.deviceId);
+ }
+ }
+
+ public cancel(e: Error | MatrixEvent): void {
+ this.endTimer(); // always kill the activity timer
+ if (!this._done) {
+ this.cancelled = true;
+ this.request.onVerifierCancelled();
+ if (this.userId && this.deviceId) {
+ // send a cancellation to the other user (if it wasn't
+ // cancelled by the other user)
+ if (e === timeoutException) {
+ const timeoutEvent = newTimeoutError();
+ this.send(timeoutEvent.getType(), timeoutEvent.getContent());
+ } else if (e instanceof MatrixEvent) {
+ const sender = e.getSender();
+ if (sender !== this.userId) {
+ const content = e.getContent();
+ if (e.getType() === EventType.KeyVerificationCancel) {
+ content.code = content.code || "m.unknown";
+ content.reason = content.reason || content.body || "Unknown reason";
+ this.send(EventType.KeyVerificationCancel, content);
+ } else {
+ this.send(EventType.KeyVerificationCancel, {
+ code: "m.unknown",
+ reason: content.body || "Unknown reason",
+ });
+ }
+ }
+ } else {
+ this.send(EventType.KeyVerificationCancel, {
+ code: "m.unknown",
+ reason: e.toString(),
+ });
+ }
+ }
+ if (this.promise !== null) {
+ // when we cancel without a promise, we end up with a promise
+ // but no reject function. If cancel is called again, we'd error.
+ if (this.reject) this.reject(e);
+ } else {
+ // FIXME: this causes an "Uncaught promise" console message
+ // if nothing ends up chaining this promise.
+ this.promise = Promise.reject(e);
+ }
+ // Also emit a 'cancel' event that the app can listen for to detect cancellation
+ // before calling verify()
+ this.emit(VerificationEvent.Cancel, e);
+ }
+ }
+
+ /**
+ * Begin the key verification
+ *
+ * @returns Promise which resolves when the verification has
+ * completed.
+ */
+ public verify(): Promise<void> {
+ if (this.promise) return this.promise;
+
+ this.promise = new Promise((resolve, reject) => {
+ this.resolve = (...args): void => {
+ this._done = true;
+ this.endTimer();
+ resolve(...args);
+ };
+ this.reject = (e: Error | MatrixEvent): void => {
+ this._done = true;
+ this.endTimer();
+ reject(e);
+ };
+ });
+ if (this.doVerification && !this.started) {
+ this.started = true;
+ this.resetTimer(); // restart the timeout
+ new Promise<void>((resolve, reject) => {
+ const crossSignId = this.baseApis.crypto!.deviceList.getStoredCrossSigningForUser(this.userId)?.getId();
+ if (crossSignId === this.deviceId) {
+ reject(new Error("Device ID is the same as the cross-signing ID"));
+ }
+ resolve();
+ })
+ .then(() => this.doVerification!())
+ .then(this.done.bind(this), this.cancel.bind(this));
+ }
+ return this.promise;
+ }
+
+ protected doVerification?: () => Promise<void>;
+
+ protected async verifyKeys(userId: string, keys: Record<string, string>, verifier: KeyVerifier): Promise<void> {
+ // we try to verify all the keys that we're told about, but we might
+ // not know about all of them, so keep track of the keys that we know
+ // about, and ignore the rest
+ const verifiedDevices: [string, string, string][] = [];
+
+ for (const [keyId, keyInfo] of Object.entries(keys)) {
+ const deviceId = keyId.split(":", 2)[1];
+ const device = this.baseApis.getStoredDevice(userId, deviceId);
+ if (device) {
+ verifier(keyId, device, keyInfo);
+ verifiedDevices.push([deviceId, keyId, device.keys[keyId]]);
+ } else {
+ const crossSigningInfo = this.baseApis.crypto!.deviceList.getStoredCrossSigningForUser(userId);
+ if (crossSigningInfo && crossSigningInfo.getId() === deviceId) {
+ verifier(
+ keyId,
+ DeviceInfo.fromStorage(
+ {
+ keys: {
+ [keyId]: deviceId,
+ },
+ },
+ deviceId,
+ ),
+ keyInfo,
+ );
+ verifiedDevices.push([deviceId, keyId, deviceId]);
+ } else {
+ logger.warn(`verification: Could not find device ${deviceId} to verify`);
+ }
+ }
+ }
+
+ // if none of the keys could be verified, then error because the app
+ // should be informed about that
+ if (!verifiedDevices.length) {
+ throw new Error("No devices could be verified");
+ }
+
+ logger.info("Verification completed! Marking devices verified: ", verifiedDevices);
+ // TODO: There should probably be a batch version of this, otherwise it's going
+ // to upload each signature in a separate API call which is silly because the
+ // API supports as many signatures as you like.
+ for (const [deviceId, keyId, key] of verifiedDevices) {
+ await this.baseApis.crypto!.setDeviceVerification(userId, deviceId, true, null, null, { [keyId]: key });
+ }
+
+ // if one of the user's own devices is being marked as verified / unverified,
+ // check the key backup status, since whether or not we use this depends on
+ // whether it has a signature from a verified device
+ if (userId == this.baseApis.credentials.userId) {
+ await this.baseApis.checkKeyBackup();
+ }
+ }
+
+ public get events(): string[] | undefined {
+ return undefined;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts
new file mode 100644
index 0000000..da73ebb
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/Error.ts
@@ -0,0 +1,76 @@
+/*
+Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Error messages.
+ */
+
+import { MatrixEvent } from "../../models/event";
+import { EventType } from "../../@types/event";
+
+export function newVerificationError(code: string, reason: string, extraData?: Record<string, any>): MatrixEvent {
+ const content = Object.assign({}, { code, reason }, extraData);
+ return new MatrixEvent({
+ type: EventType.KeyVerificationCancel,
+ content,
+ });
+}
+
+export function errorFactory(code: string, reason: string): (extraData?: Record<string, any>) => MatrixEvent {
+ return function (extraData?: Record<string, any>) {
+ return newVerificationError(code, reason, extraData);
+ };
+}
+
+/**
+ * The verification was cancelled by the user.
+ */
+export const newUserCancelledError = errorFactory("m.user", "Cancelled by user");
+
+/**
+ * The verification timed out.
+ */
+export const newTimeoutError = errorFactory("m.timeout", "Timed out");
+
+/**
+ * An unknown method was selected.
+ */
+export const newUnknownMethodError = errorFactory("m.unknown_method", "Unknown method");
+
+/**
+ * An unexpected message was sent.
+ */
+export const newUnexpectedMessageError = errorFactory("m.unexpected_message", "Unexpected message");
+
+/**
+ * The key does not match.
+ */
+export const newKeyMismatchError = errorFactory("m.key_mismatch", "Key mismatch");
+
+/**
+ * An invalid message was sent.
+ */
+export const newInvalidMessageError = errorFactory("m.invalid_message", "Invalid message");
+
+export function errorFromEvent(event: MatrixEvent): { code: string; reason: string } {
+ const content = event.getContent();
+ if (content) {
+ const { code, reason } = content;
+ return { code, reason };
+ } else {
+ return { code: "Unknown error", reason: "m.unknown" };
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts
new file mode 100644
index 0000000..c437e0c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/IllegalMethod.ts
@@ -0,0 +1,50 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Verification method that is illegal to have (cannot possibly
+ * do verification with this method).
+ */
+
+import { VerificationBase as Base, VerificationEvent, VerificationEventHandlerMap } from "./Base";
+import { IVerificationChannel } from "./request/Channel";
+import { MatrixClient } from "../../client";
+import { MatrixEvent } from "../../models/event";
+import { VerificationRequest } from "./request/VerificationRequest";
+
+export class IllegalMethod extends Base<VerificationEvent, VerificationEventHandlerMap> {
+ public static factory(
+ channel: IVerificationChannel,
+ baseApis: MatrixClient,
+ userId: string,
+ deviceId: string,
+ startEvent: MatrixEvent,
+ request: VerificationRequest,
+ ): IllegalMethod {
+ return new IllegalMethod(channel, baseApis, userId, deviceId, startEvent, request);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ public static get NAME(): string {
+ // Typically the name will be something else, but to complete
+ // the contract we offer a default one here.
+ return "org.matrix.illegal_method";
+ }
+
+ protected doVerification = async (): Promise<void> => {
+ throw new Error("Verification is not possible with this method");
+ };
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts
new file mode 100644
index 0000000..bfb532e
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/QRCode.ts
@@ -0,0 +1,311 @@
+/*
+Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * QR code key verification.
+ */
+
+import { VerificationBase as Base, VerificationEventHandlerMap } from "./Base";
+import { newKeyMismatchError, newUserCancelledError } from "./Error";
+import { decodeBase64, encodeUnpaddedBase64 } from "../olmlib";
+import { logger } from "../../logger";
+import { VerificationRequest } from "./request/VerificationRequest";
+import { MatrixClient } from "../../client";
+import { IVerificationChannel } from "./request/Channel";
+import { MatrixEvent } from "../../models/event";
+
+export const SHOW_QR_CODE_METHOD = "m.qr_code.show.v1";
+export const SCAN_QR_CODE_METHOD = "m.qr_code.scan.v1";
+
+interface IReciprocateQr {
+ confirm(): void;
+ cancel(): void;
+}
+
+export enum QrCodeEvent {
+ ShowReciprocateQr = "show_reciprocate_qr",
+}
+
+type EventHandlerMap = {
+ [QrCodeEvent.ShowReciprocateQr]: (qr: IReciprocateQr) => void;
+} & VerificationEventHandlerMap;
+
+export class ReciprocateQRCode extends Base<QrCodeEvent, EventHandlerMap> {
+ public reciprocateQREvent?: IReciprocateQr;
+
+ public static factory(
+ channel: IVerificationChannel,
+ baseApis: MatrixClient,
+ userId: string,
+ deviceId: string,
+ startEvent: MatrixEvent,
+ request: VerificationRequest,
+ ): ReciprocateQRCode {
+ return new ReciprocateQRCode(channel, baseApis, userId, deviceId, startEvent, request);
+ }
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ public static get NAME(): string {
+ return "m.reciprocate.v1";
+ }
+
+ protected doVerification = async (): Promise<void> => {
+ if (!this.startEvent) {
+ // TODO: Support scanning QR codes
+ throw new Error("It is not currently possible to start verification" + "with this method yet.");
+ }
+
+ const { qrCodeData } = this.request;
+ // 1. check the secret
+ if (this.startEvent.getContent()["secret"] !== qrCodeData?.encodedSharedSecret) {
+ throw newKeyMismatchError();
+ }
+
+ // 2. ask if other user shows shield as well
+ await new Promise<void>((resolve, reject) => {
+ this.reciprocateQREvent = {
+ confirm: resolve,
+ cancel: () => reject(newUserCancelledError()),
+ };
+ this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent);
+ });
+
+ // 3. determine key to sign / mark as trusted
+ const keys: Record<string, string> = {};
+
+ switch (qrCodeData?.mode) {
+ case Mode.VerifyOtherUser: {
+ // add master key to keys to be signed, only if we're not doing self-verification
+ const masterKey = qrCodeData.otherUserMasterKey;
+ keys[`ed25519:${masterKey}`] = masterKey!;
+ break;
+ }
+ case Mode.VerifySelfTrusted: {
+ const deviceId = this.request.targetDevice.deviceId;
+ keys[`ed25519:${deviceId}`] = qrCodeData.otherDeviceKey!;
+ break;
+ }
+ case Mode.VerifySelfUntrusted: {
+ const masterKey = qrCodeData.myMasterKey;
+ keys[`ed25519:${masterKey}`] = masterKey!;
+ break;
+ }
+ }
+
+ // 4. sign the key (or mark own MSK as verified in case of MODE_VERIFY_SELF_TRUSTED)
+ await this.verifyKeys(this.userId, keys, (keyId, device, keyInfo) => {
+ // make sure the device has the expected keys
+ const targetKey = keys[keyId];
+ if (!targetKey) throw newKeyMismatchError();
+
+ if (keyInfo !== targetKey) {
+ logger.error("key ID from key info does not match");
+ throw newKeyMismatchError();
+ }
+ for (const deviceKeyId in device.keys) {
+ if (!deviceKeyId.startsWith("ed25519")) continue;
+ const deviceTargetKey = keys[deviceKeyId];
+ if (!deviceTargetKey) throw newKeyMismatchError();
+ if (device.keys[deviceKeyId] !== deviceTargetKey) {
+ logger.error("master key does not match");
+ throw newKeyMismatchError();
+ }
+ }
+ });
+ };
+}
+
+const CODE_VERSION = 0x02; // the version of binary QR codes we support
+const BINARY_PREFIX = "MATRIX"; // ASCII, used to prefix the binary format
+
+enum Mode {
+ VerifyOtherUser = 0x00, // Verifying someone who isn't us
+ VerifySelfTrusted = 0x01, // We trust the master key
+ VerifySelfUntrusted = 0x02, // We do not trust the master key
+}
+
+interface IQrData {
+ prefix: string;
+ version: number;
+ mode: Mode;
+ transactionId?: string;
+ firstKeyB64: string;
+ secondKeyB64: string;
+ secretB64: string;
+}
+
+export class QRCodeData {
+ public constructor(
+ public readonly mode: Mode,
+ private readonly sharedSecret: string,
+ // only set when mode is MODE_VERIFY_OTHER_USER, master key of other party at time of generating QR code
+ public readonly otherUserMasterKey: string | null,
+ // only set when mode is MODE_VERIFY_SELF_TRUSTED, device key of other party at time of generating QR code
+ public readonly otherDeviceKey: string | null,
+ // only set when mode is MODE_VERIFY_SELF_UNTRUSTED, own master key at time of generating QR code
+ public readonly myMasterKey: string | null,
+ private readonly buffer: Buffer,
+ ) {}
+
+ public static async create(request: VerificationRequest, client: MatrixClient): Promise<QRCodeData> {
+ const sharedSecret = QRCodeData.generateSharedSecret();
+ const mode = QRCodeData.determineMode(request, client);
+ let otherUserMasterKey: string | null = null;
+ let otherDeviceKey: string | null = null;
+ let myMasterKey: string | null = null;
+ if (mode === Mode.VerifyOtherUser) {
+ const otherUserCrossSigningInfo = client.getStoredCrossSigningForUser(request.otherUserId);
+ otherUserMasterKey = otherUserCrossSigningInfo!.getId("master");
+ } else if (mode === Mode.VerifySelfTrusted) {
+ otherDeviceKey = await QRCodeData.getOtherDeviceKey(request, client);
+ } else if (mode === Mode.VerifySelfUntrusted) {
+ const myUserId = client.getUserId()!;
+ const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId);
+ myMasterKey = myCrossSigningInfo!.getId("master");
+ }
+ const qrData = QRCodeData.generateQrData(
+ request,
+ client,
+ mode,
+ sharedSecret,
+ otherUserMasterKey!,
+ otherDeviceKey!,
+ myMasterKey!,
+ );
+ const buffer = QRCodeData.generateBuffer(qrData);
+ return new QRCodeData(mode, sharedSecret, otherUserMasterKey, otherDeviceKey, myMasterKey, buffer);
+ }
+
+ /**
+ * The unpadded base64 encoded shared secret.
+ */
+ public get encodedSharedSecret(): string {
+ return this.sharedSecret;
+ }
+
+ public getBuffer(): Buffer {
+ return this.buffer;
+ }
+
+ private static generateSharedSecret(): string {
+ const secretBytes = new Uint8Array(11);
+ global.crypto.getRandomValues(secretBytes);
+ return encodeUnpaddedBase64(secretBytes);
+ }
+
+ private static async getOtherDeviceKey(request: VerificationRequest, client: MatrixClient): Promise<string> {
+ const myUserId = client.getUserId()!;
+ const otherDevice = request.targetDevice;
+ const device = otherDevice.deviceId ? client.getStoredDevice(myUserId, otherDevice.deviceId) : undefined;
+ if (!device) {
+ throw new Error("could not find device " + otherDevice?.deviceId);
+ }
+ return device.getFingerprint();
+ }
+
+ private static determineMode(request: VerificationRequest, client: MatrixClient): Mode {
+ const myUserId = client.getUserId();
+ const otherUserId = request.otherUserId;
+
+ let mode = Mode.VerifyOtherUser;
+ if (myUserId === otherUserId) {
+ // Mode changes depending on whether or not we trust the master cross signing key
+ const myTrust = client.checkUserTrust(myUserId);
+ if (myTrust.isCrossSigningVerified()) {
+ mode = Mode.VerifySelfTrusted;
+ } else {
+ mode = Mode.VerifySelfUntrusted;
+ }
+ }
+ return mode;
+ }
+
+ private static generateQrData(
+ request: VerificationRequest,
+ client: MatrixClient,
+ mode: Mode,
+ encodedSharedSecret: string,
+ otherUserMasterKey?: string,
+ otherDeviceKey?: string,
+ myMasterKey?: string,
+ ): IQrData {
+ const myUserId = client.getUserId()!;
+ const transactionId = request.channel.transactionId;
+ const qrData: IQrData = {
+ prefix: BINARY_PREFIX,
+ version: CODE_VERSION,
+ mode,
+ transactionId,
+ firstKeyB64: "", // worked out shortly
+ secondKeyB64: "", // worked out shortly
+ secretB64: encodedSharedSecret,
+ };
+
+ const myCrossSigningInfo = client.getStoredCrossSigningForUser(myUserId);
+
+ if (mode === Mode.VerifyOtherUser) {
+ // First key is our master cross signing key
+ qrData.firstKeyB64 = myCrossSigningInfo!.getId("master")!;
+ // Second key is the other user's master cross signing key
+ qrData.secondKeyB64 = otherUserMasterKey!;
+ } else if (mode === Mode.VerifySelfTrusted) {
+ // First key is our master cross signing key
+ qrData.firstKeyB64 = myCrossSigningInfo!.getId("master")!;
+ qrData.secondKeyB64 = otherDeviceKey!;
+ } else if (mode === Mode.VerifySelfUntrusted) {
+ // First key is our device's key
+ qrData.firstKeyB64 = client.getDeviceEd25519Key()!;
+ // Second key is what we think our master cross signing key is
+ qrData.secondKeyB64 = myMasterKey!;
+ }
+ return qrData;
+ }
+
+ private static generateBuffer(qrData: IQrData): Buffer {
+ let buf = Buffer.alloc(0); // we'll concat our way through life
+
+ const appendByte = (b: number): void => {
+ const tmpBuf = Buffer.from([b]);
+ buf = Buffer.concat([buf, tmpBuf]);
+ };
+ const appendInt = (i: number): void => {
+ const tmpBuf = Buffer.alloc(2);
+ tmpBuf.writeInt16BE(i, 0);
+ buf = Buffer.concat([buf, tmpBuf]);
+ };
+ const appendStr = (s: string, enc: BufferEncoding, withLengthPrefix = true): void => {
+ const tmpBuf = Buffer.from(s, enc);
+ if (withLengthPrefix) appendInt(tmpBuf.byteLength);
+ buf = Buffer.concat([buf, tmpBuf]);
+ };
+ const appendEncBase64 = (b64: string): void => {
+ const b = decodeBase64(b64);
+ const tmpBuf = Buffer.from(b);
+ buf = Buffer.concat([buf, tmpBuf]);
+ };
+
+ // Actually build the buffer for the QR code
+ appendStr(qrData.prefix, "ascii", false);
+ appendByte(qrData.version);
+ appendByte(qrData.mode);
+ appendStr(qrData.transactionId!, "utf-8");
+ appendEncBase64(qrData.firstKeyB64);
+ appendEncBase64(qrData.secondKeyB64);
+ appendEncBase64(qrData.secretB64);
+
+ return buf;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts
new file mode 100644
index 0000000..a8d237d
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SAS.ts
@@ -0,0 +1,492 @@
+/*
+Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Short Authentication String (SAS) verification.
+ */
+
+import anotherjson from "another-json";
+import { Utility, SAS as OlmSAS } from "@matrix-org/olm";
+
+import { VerificationBase as Base, SwitchStartEventError, VerificationEventHandlerMap } from "./Base";
+import {
+ errorFactory,
+ newInvalidMessageError,
+ newKeyMismatchError,
+ newUnknownMethodError,
+ newUserCancelledError,
+} from "./Error";
+import { logger } from "../../logger";
+import { IContent, MatrixEvent } from "../../models/event";
+import { generateDecimalSas } from "./SASDecimal";
+import { EventType } from "../../@types/event";
+
+const START_TYPE = EventType.KeyVerificationStart;
+
+const EVENTS = [EventType.KeyVerificationAccept, EventType.KeyVerificationKey, EventType.KeyVerificationMac];
+
+let olmutil: Utility;
+
+const newMismatchedSASError = errorFactory("m.mismatched_sas", "Mismatched short authentication string");
+
+const newMismatchedCommitmentError = errorFactory("m.mismatched_commitment", "Mismatched commitment");
+
+type EmojiMapping = [emoji: string, name: string];
+
+const emojiMapping: EmojiMapping[] = [
+ ["🐶", "dog"], // 0
+ ["🐱", "cat"], // 1
+ ["🦁", "lion"], // 2
+ ["🐎", "horse"], // 3
+ ["🦄", "unicorn"], // 4
+ ["🐷", "pig"], // 5
+ ["🐘", "elephant"], // 6
+ ["🐰", "rabbit"], // 7
+ ["🐼", "panda"], // 8
+ ["🐓", "rooster"], // 9
+ ["🐧", "penguin"], // 10
+ ["🐢", "turtle"], // 11
+ ["🐟", "fish"], // 12
+ ["🐙", "octopus"], // 13
+ ["🦋", "butterfly"], // 14
+ ["🌷", "flower"], // 15
+ ["🌳", "tree"], // 16
+ ["🌵", "cactus"], // 17
+ ["🍄", "mushroom"], // 18
+ ["🌏", "globe"], // 19
+ ["🌙", "moon"], // 20
+ ["☁️", "cloud"], // 21
+ ["🔥", "fire"], // 22
+ ["🍌", "banana"], // 23
+ ["🍎", "apple"], // 24
+ ["🍓", "strawberry"], // 25
+ ["🌽", "corn"], // 26
+ ["🍕", "pizza"], // 27
+ ["🎂", "cake"], // 28
+ ["❤️", "heart"], // 29
+ ["🙂", "smiley"], // 30
+ ["🤖", "robot"], // 31
+ ["🎩", "hat"], // 32
+ ["👓", "glasses"], // 33
+ ["🔧", "spanner"], // 34
+ ["🎅", "santa"], // 35
+ ["👍", "thumbs up"], // 36
+ ["☂️", "umbrella"], // 37
+ ["⌛", "hourglass"], // 38
+ ["⏰", "clock"], // 39
+ ["🎁", "gift"], // 40
+ ["💡", "light bulb"], // 41
+ ["📕", "book"], // 42
+ ["✏️", "pencil"], // 43
+ ["📎", "paperclip"], // 44
+ ["✂️", "scissors"], // 45
+ ["🔒", "lock"], // 46
+ ["🔑", "key"], // 47
+ ["🔨", "hammer"], // 48
+ ["☎️", "telephone"], // 49
+ ["🏁", "flag"], // 50
+ ["🚂", "train"], // 51
+ ["🚲", "bicycle"], // 52
+ ["✈️", "aeroplane"], // 53
+ ["🚀", "rocket"], // 54
+ ["🏆", "trophy"], // 55
+ ["⚽", "ball"], // 56
+ ["🎸", "guitar"], // 57
+ ["🎺", "trumpet"], // 58
+ ["🔔", "bell"], // 59
+ ["⚓️", "anchor"], // 60
+ ["🎧", "headphones"], // 61
+ ["📁", "folder"], // 62
+ ["📌", "pin"], // 63
+];
+
+function generateEmojiSas(sasBytes: number[]): EmojiMapping[] {
+ const emojis = [
+ // just like base64 encoding
+ sasBytes[0] >> 2,
+ ((sasBytes[0] & 0x3) << 4) | (sasBytes[1] >> 4),
+ ((sasBytes[1] & 0xf) << 2) | (sasBytes[2] >> 6),
+ sasBytes[2] & 0x3f,
+ sasBytes[3] >> 2,
+ ((sasBytes[3] & 0x3) << 4) | (sasBytes[4] >> 4),
+ ((sasBytes[4] & 0xf) << 2) | (sasBytes[5] >> 6),
+ ];
+
+ return emojis.map((num) => emojiMapping[num]);
+}
+
+const sasGenerators = {
+ decimal: generateDecimalSas,
+ emoji: generateEmojiSas,
+} as const;
+
+export interface IGeneratedSas {
+ decimal?: [number, number, number];
+ emoji?: EmojiMapping[];
+}
+
+export interface ISasEvent {
+ sas: IGeneratedSas;
+ confirm(): Promise<void>;
+ cancel(): void;
+ mismatch(): void;
+}
+
+function generateSas(sasBytes: Uint8Array, methods: string[]): IGeneratedSas {
+ const sas: IGeneratedSas = {};
+ for (const method of methods) {
+ if (method in sasGenerators) {
+ // @ts-ignore - ts doesn't like us mixing types like this
+ sas[method] = sasGenerators[method](Array.from(sasBytes));
+ }
+ }
+ return sas;
+}
+
+const macMethods = {
+ "hkdf-hmac-sha256": "calculate_mac",
+ "org.matrix.msc3783.hkdf-hmac-sha256": "calculate_mac_fixed_base64",
+ "hkdf-hmac-sha256.v2": "calculate_mac_fixed_base64",
+ "hmac-sha256": "calculate_mac_long_kdf",
+} as const;
+
+type MacMethod = keyof typeof macMethods;
+
+function calculateMAC(olmSAS: OlmSAS, method: MacMethod) {
+ return function (input: string, info: string): string {
+ const mac = olmSAS[macMethods[method]](input, info);
+ logger.log("SAS calculateMAC:", method, [input, info], mac);
+ return mac;
+ };
+}
+
+const calculateKeyAgreement = {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ "curve25519-hkdf-sha256": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array {
+ const ourInfo = `${sas.baseApis.getUserId()}|${sas.baseApis.deviceId}|` + `${sas.ourSASPubKey}|`;
+ const theirInfo = `${sas.userId}|${sas.deviceId}|${sas.theirSASPubKey}|`;
+ const sasInfo =
+ "MATRIX_KEY_VERIFICATION_SAS|" +
+ (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) +
+ sas.channel.transactionId;
+ return olmSAS.generate_bytes(sasInfo, bytes);
+ },
+ "curve25519": function (sas: SAS, olmSAS: OlmSAS, bytes: number): Uint8Array {
+ const ourInfo = `${sas.baseApis.getUserId()}${sas.baseApis.deviceId}`;
+ const theirInfo = `${sas.userId}${sas.deviceId}`;
+ const sasInfo =
+ "MATRIX_KEY_VERIFICATION_SAS" +
+ (sas.initiatedByMe ? ourInfo + theirInfo : theirInfo + ourInfo) +
+ sas.channel.transactionId;
+ return olmSAS.generate_bytes(sasInfo, bytes);
+ },
+} as const;
+
+type KeyAgreement = keyof typeof calculateKeyAgreement;
+
+/* lists of algorithms/methods that are supported. The key agreement, hashes,
+ * and MAC lists should be sorted in order of preference (most preferred
+ * first).
+ */
+const KEY_AGREEMENT_LIST: KeyAgreement[] = ["curve25519-hkdf-sha256", "curve25519"];
+const HASHES_LIST = ["sha256"];
+const MAC_LIST: MacMethod[] = [
+ "hkdf-hmac-sha256.v2",
+ "org.matrix.msc3783.hkdf-hmac-sha256",
+ "hkdf-hmac-sha256",
+ "hmac-sha256",
+];
+const SAS_LIST = Object.keys(sasGenerators);
+
+const KEY_AGREEMENT_SET = new Set(KEY_AGREEMENT_LIST);
+const HASHES_SET = new Set(HASHES_LIST);
+const MAC_SET = new Set(MAC_LIST);
+const SAS_SET = new Set(SAS_LIST);
+
+function intersection<T>(anArray: T[], aSet: Set<T>): T[] {
+ return Array.isArray(anArray) ? anArray.filter((x) => aSet.has(x)) : [];
+}
+
+export enum SasEvent {
+ ShowSas = "show_sas",
+}
+
+type EventHandlerMap = {
+ [SasEvent.ShowSas]: (sas: ISasEvent) => void;
+} & VerificationEventHandlerMap;
+
+export class SAS extends Base<SasEvent, EventHandlerMap> {
+ private waitingForAccept?: boolean;
+ public ourSASPubKey?: string;
+ public theirSASPubKey?: string;
+ public sasEvent?: ISasEvent;
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ public static get NAME(): string {
+ return "m.sas.v1";
+ }
+
+ public get events(): string[] {
+ return EVENTS;
+ }
+
+ protected doVerification = async (): Promise<void> => {
+ await global.Olm.init();
+ olmutil = olmutil || new global.Olm.Utility();
+
+ // make sure user's keys are downloaded
+ await this.baseApis.downloadKeys([this.userId]);
+
+ let retry = false;
+ do {
+ try {
+ if (this.initiatedByMe) {
+ return await this.doSendVerification();
+ } else {
+ return await this.doRespondVerification();
+ }
+ } catch (err) {
+ if (err instanceof SwitchStartEventError) {
+ // this changes what initiatedByMe returns
+ this.startEvent = err.startEvent;
+ retry = true;
+ } else {
+ throw err;
+ }
+ }
+ } while (retry);
+ };
+
+ public canSwitchStartEvent(event: MatrixEvent): boolean {
+ if (event.getType() !== START_TYPE) {
+ return false;
+ }
+ const content = event.getContent();
+ return content?.method === SAS.NAME && !!this.waitingForAccept;
+ }
+
+ private async sendStart(): Promise<Record<string, any>> {
+ const startContent = this.channel.completeContent(START_TYPE, {
+ method: SAS.NAME,
+ from_device: this.baseApis.deviceId,
+ key_agreement_protocols: KEY_AGREEMENT_LIST,
+ hashes: HASHES_LIST,
+ message_authentication_codes: MAC_LIST,
+ // FIXME: allow app to specify what SAS methods can be used
+ short_authentication_string: SAS_LIST,
+ });
+ await this.channel.sendCompleted(START_TYPE, startContent);
+ return startContent;
+ }
+
+ private async verifyAndCheckMAC(
+ keyAgreement: KeyAgreement,
+ sasMethods: string[],
+ olmSAS: OlmSAS,
+ macMethod: MacMethod,
+ ): Promise<void> {
+ const sasBytes = calculateKeyAgreement[keyAgreement](this, olmSAS, 6);
+ const verifySAS = new Promise<void>((resolve, reject) => {
+ this.sasEvent = {
+ sas: generateSas(sasBytes, sasMethods),
+ confirm: async (): Promise<void> => {
+ try {
+ await this.sendMAC(olmSAS, macMethod);
+ resolve();
+ } catch (err) {
+ reject(err);
+ }
+ },
+ cancel: () => reject(newUserCancelledError()),
+ mismatch: () => reject(newMismatchedSASError()),
+ };
+ this.emit(SasEvent.ShowSas, this.sasEvent);
+ });
+
+ const [e] = await Promise.all([
+ this.waitForEvent(EventType.KeyVerificationMac).then((e) => {
+ // we don't expect any more messages from the other
+ // party, and they may send a m.key.verification.done
+ // when they're done on their end
+ this.expectedEvent = EventType.KeyVerificationDone;
+ return e;
+ }),
+ verifySAS,
+ ]);
+ const content = e.getContent();
+ await this.checkMAC(olmSAS, content, macMethod);
+ }
+
+ private async doSendVerification(): Promise<void> {
+ this.waitingForAccept = true;
+ let startContent;
+ if (this.startEvent) {
+ startContent = this.channel.completedContentFromEvent(this.startEvent);
+ } else {
+ startContent = await this.sendStart();
+ }
+
+ // we might have switched to a different start event,
+ // but was we didn't call _waitForEvent there was no
+ // call that could throw yet. So check manually that
+ // we're still on the initiator side
+ if (!this.initiatedByMe) {
+ throw new SwitchStartEventError(this.startEvent);
+ }
+
+ let e: MatrixEvent;
+ try {
+ e = await this.waitForEvent(EventType.KeyVerificationAccept);
+ } finally {
+ this.waitingForAccept = false;
+ }
+ let content = e.getContent();
+ const sasMethods = intersection(content.short_authentication_string, SAS_SET);
+ if (
+ !(
+ KEY_AGREEMENT_SET.has(content.key_agreement_protocol) &&
+ HASHES_SET.has(content.hash) &&
+ MAC_SET.has(content.message_authentication_code) &&
+ sasMethods.length
+ )
+ ) {
+ throw newUnknownMethodError();
+ }
+ if (typeof content.commitment !== "string") {
+ throw newInvalidMessageError();
+ }
+ const keyAgreement = content.key_agreement_protocol;
+ const macMethod = content.message_authentication_code;
+ const hashCommitment = content.commitment;
+ const olmSAS = new global.Olm.SAS();
+ try {
+ this.ourSASPubKey = olmSAS.get_pubkey();
+ await this.send(EventType.KeyVerificationKey, {
+ key: this.ourSASPubKey,
+ });
+
+ e = await this.waitForEvent(EventType.KeyVerificationKey);
+ // FIXME: make sure event is properly formed
+ content = e.getContent();
+ const commitmentStr = content.key + anotherjson.stringify(startContent);
+ // TODO: use selected hash function (when we support multiple)
+ if (olmutil.sha256(commitmentStr) !== hashCommitment) {
+ throw newMismatchedCommitmentError();
+ }
+ this.theirSASPubKey = content.key;
+ olmSAS.set_their_key(content.key);
+
+ await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod);
+ } finally {
+ olmSAS.free();
+ }
+ }
+
+ private async doRespondVerification(): Promise<void> {
+ // as m.related_to is not included in the encrypted content in e2e rooms,
+ // we need to make sure it is added
+ let content = this.channel.completedContentFromEvent(this.startEvent!);
+
+ // Note: we intersect using our pre-made lists, rather than the sets,
+ // so that the result will be in our order of preference. Then
+ // fetching the first element from the array will give our preferred
+ // method out of the ones offered by the other party.
+ const keyAgreement = intersection(KEY_AGREEMENT_LIST, new Set(content.key_agreement_protocols))[0];
+ const hashMethod = intersection(HASHES_LIST, new Set(content.hashes))[0];
+ const macMethod = intersection(MAC_LIST, new Set(content.message_authentication_codes))[0];
+ // FIXME: allow app to specify what SAS methods can be used
+ const sasMethods = intersection(content.short_authentication_string, SAS_SET);
+ if (!(keyAgreement !== undefined && hashMethod !== undefined && macMethod !== undefined && sasMethods.length)) {
+ throw newUnknownMethodError();
+ }
+
+ const olmSAS = new global.Olm.SAS();
+ try {
+ const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(content);
+ await this.send(EventType.KeyVerificationAccept, {
+ key_agreement_protocol: keyAgreement,
+ hash: hashMethod,
+ message_authentication_code: macMethod,
+ short_authentication_string: sasMethods,
+ // TODO: use selected hash function (when we support multiple)
+ commitment: olmutil.sha256(commitmentStr),
+ });
+
+ const e = await this.waitForEvent(EventType.KeyVerificationKey);
+ // FIXME: make sure event is properly formed
+ content = e.getContent();
+ this.theirSASPubKey = content.key;
+ olmSAS.set_their_key(content.key);
+ this.ourSASPubKey = olmSAS.get_pubkey();
+ await this.send(EventType.KeyVerificationKey, {
+ key: this.ourSASPubKey,
+ });
+
+ await this.verifyAndCheckMAC(keyAgreement, sasMethods, olmSAS, macMethod);
+ } finally {
+ olmSAS.free();
+ }
+ }
+
+ private sendMAC(olmSAS: OlmSAS, method: MacMethod): Promise<void> {
+ const mac: Record<string, string> = {};
+ const keyList: string[] = [];
+ const baseInfo =
+ "MATRIX_KEY_VERIFICATION_MAC" +
+ this.baseApis.getUserId() +
+ this.baseApis.deviceId +
+ this.userId +
+ this.deviceId +
+ this.channel.transactionId;
+
+ const deviceKeyId = `ed25519:${this.baseApis.deviceId}`;
+ mac[deviceKeyId] = calculateMAC(olmSAS, method)(this.baseApis.getDeviceEd25519Key()!, baseInfo + deviceKeyId);
+ keyList.push(deviceKeyId);
+
+ const crossSigningId = this.baseApis.getCrossSigningId();
+ if (crossSigningId) {
+ const crossSigningKeyId = `ed25519:${crossSigningId}`;
+ mac[crossSigningKeyId] = calculateMAC(olmSAS, method)(crossSigningId, baseInfo + crossSigningKeyId);
+ keyList.push(crossSigningKeyId);
+ }
+
+ const keys = calculateMAC(olmSAS, method)(keyList.sort().join(","), baseInfo + "KEY_IDS");
+ return this.send(EventType.KeyVerificationMac, { mac, keys });
+ }
+
+ private async checkMAC(olmSAS: OlmSAS, content: IContent, method: MacMethod): Promise<void> {
+ const baseInfo =
+ "MATRIX_KEY_VERIFICATION_MAC" +
+ this.userId +
+ this.deviceId +
+ this.baseApis.getUserId() +
+ this.baseApis.deviceId +
+ this.channel.transactionId;
+
+ if (
+ content.keys !==
+ calculateMAC(olmSAS, method)(Object.keys(content.mac).sort().join(","), baseInfo + "KEY_IDS")
+ ) {
+ throw newKeyMismatchError();
+ }
+
+ await this.verifyKeys(this.userId, content.mac, (keyId, device, keyInfo) => {
+ if (keyInfo !== calculateMAC(olmSAS, method)(device.keys[keyId], baseInfo + keyId)) {
+ throw newKeyMismatchError();
+ }
+ });
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts
new file mode 100644
index 0000000..0cb4630
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/SASDecimal.ts
@@ -0,0 +1,37 @@
+/*
+Copyright 2018 - 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Implementation of decimal encoding of SAS as per:
+ * https://spec.matrix.org/v1.4/client-server-api/#sas-method-decimal
+ * @param sasBytes - the five bytes generated by HKDF
+ * @returns the derived three numbers between 1000 and 9191 inclusive
+ */
+export function generateDecimalSas(sasBytes: number[]): [number, number, number] {
+ /*
+ * +--------+--------+--------+--------+--------+
+ * | Byte 0 | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
+ * +--------+--------+--------+--------+--------+
+ * bits: 87654321 87654321 87654321 87654321 87654321
+ * \____________/\_____________/\____________/
+ * 1st number 2nd number 3rd number
+ */
+ return [
+ ((sasBytes[0] << 5) | (sasBytes[1] >> 3)) + 1000,
+ (((sasBytes[1] & 0x7) << 10) | (sasBytes[2] << 2) | (sasBytes[3] >> 6)) + 1000,
+ (((sasBytes[3] & 0x3f) << 7) | (sasBytes[4] >> 1)) + 1000,
+ ];
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts
new file mode 100644
index 0000000..48415f9
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/Channel.ts
@@ -0,0 +1,34 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixEvent } from "../../../models/event";
+import { VerificationRequest } from "./VerificationRequest";
+
+export interface IVerificationChannel {
+ request?: VerificationRequest;
+ readonly userId?: string;
+ readonly roomId?: string;
+ readonly deviceId?: string;
+ readonly transactionId?: string;
+ readonly receiveStartFromOtherDevices?: boolean;
+ getTimestamp(event: MatrixEvent): number;
+ send(type: string, uncompletedContent: Record<string, any>): Promise<void>;
+ completeContent(type: string, content: Record<string, any>): Record<string, any>;
+ sendCompleted(type: string, content: Record<string, any>): Promise<void>;
+ completedContentFromEvent(event: MatrixEvent): Record<string, any>;
+ canCreateRequest(type: string): boolean;
+ handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent: boolean): Promise<void>;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts
new file mode 100644
index 0000000..ff11bf1
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/InRoomChannel.ts
@@ -0,0 +1,356 @@
+/*
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { VerificationRequest, REQUEST_TYPE, READY_TYPE, START_TYPE } from "./VerificationRequest";
+import { logger } from "../../../logger";
+import { IVerificationChannel } from "./Channel";
+import { EventType } from "../../../@types/event";
+import { MatrixClient } from "../../../client";
+import { MatrixEvent } from "../../../models/event";
+import { IRequestsMap } from "../..";
+
+const MESSAGE_TYPE = EventType.RoomMessage;
+const M_REFERENCE = "m.reference";
+const M_RELATES_TO = "m.relates_to";
+
+/**
+ * A key verification channel that sends verification events in the timeline of a room.
+ * Uses the event id of the initial m.key.verification.request event as a transaction id.
+ */
+export class InRoomChannel implements IVerificationChannel {
+ private requestEventId?: string;
+
+ /**
+ * @param client - the matrix client, to send messages with and get current user & device from.
+ * @param roomId - id of the room where verification events should be posted in, should be a DM with the given user.
+ * @param userId - id of user that the verification request is directed at, should be present in the room.
+ */
+ public constructor(private readonly client: MatrixClient, public readonly roomId: string, public userId?: string) {}
+
+ public get receiveStartFromOtherDevices(): boolean {
+ return true;
+ }
+
+ /** The transaction id generated/used by this verification channel */
+ public get transactionId(): string | undefined {
+ return this.requestEventId;
+ }
+
+ public static getOtherPartyUserId(event: MatrixEvent, client: MatrixClient): string | undefined {
+ const type = InRoomChannel.getEventType(event);
+ if (type !== REQUEST_TYPE) {
+ return;
+ }
+ const ownUserId = client.getUserId();
+ const sender = event.getSender();
+ const content = event.getContent();
+ const receiver = content.to;
+
+ if (sender === ownUserId) {
+ return receiver;
+ } else if (receiver === ownUserId) {
+ return sender;
+ }
+ }
+
+ /**
+ * @param event - the event to get the timestamp of
+ * @returns the timestamp when the event was sent
+ */
+ public getTimestamp(event: MatrixEvent): number {
+ return event.getTs();
+ }
+
+ /**
+ * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel
+ * @param type - the event type to check
+ * @returns boolean flag
+ */
+ public static canCreateRequest(type: string): boolean {
+ return type === REQUEST_TYPE;
+ }
+
+ public canCreateRequest(type: string): boolean {
+ return InRoomChannel.canCreateRequest(type);
+ }
+
+ /**
+ * Extract the transaction id used by a given key verification event, if any
+ * @param event - the event
+ * @returns the transaction id
+ */
+ public static getTransactionId(event: MatrixEvent): string | undefined {
+ if (InRoomChannel.getEventType(event) === REQUEST_TYPE) {
+ return event.getId();
+ } else {
+ const relation = event.getRelation();
+ if (relation?.rel_type === M_REFERENCE) {
+ return relation.event_id;
+ }
+ }
+ }
+
+ /**
+ * Checks whether this event is a well-formed key verification event.
+ * This only does checks that don't rely on the current state of a potentially already channel
+ * so we can prevent channels being created by invalid events.
+ * `handleEvent` can do more checks and choose to ignore invalid events.
+ * @param event - the event to validate
+ * @param client - the client to get the current user and device id from
+ * @returns whether the event is valid and should be passed to handleEvent
+ */
+ public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean {
+ const txnId = InRoomChannel.getTransactionId(event);
+ if (typeof txnId !== "string" || txnId.length === 0) {
+ return false;
+ }
+ const type = InRoomChannel.getEventType(event);
+ const content = event.getContent();
+
+ // from here on we're fairly sure that this is supposed to be
+ // part of a verification request, so be noisy when rejecting something
+ if (type === REQUEST_TYPE) {
+ if (!content || typeof content.to !== "string" || !content.to.length) {
+ logger.log("InRoomChannel: validateEvent: " + "no valid to " + (content && content.to));
+ return false;
+ }
+
+ // ignore requests that are not direct to or sent by the syncing user
+ if (!InRoomChannel.getOtherPartyUserId(event, client)) {
+ logger.log(
+ "InRoomChannel: validateEvent: " +
+ `not directed to or sent by me: ${event.getSender()}` +
+ `, ${content && content.to}`,
+ );
+ return false;
+ }
+ }
+
+ return VerificationRequest.validateEvent(type, event, client);
+ }
+
+ /**
+ * As m.key.verification.request events are as m.room.message events with the InRoomChannel
+ * to have a fallback message in non-supporting clients, we map the real event type
+ * to the symbolic one to keep things in unison with ToDeviceChannel
+ * @param event - the event to get the type of
+ * @returns the "symbolic" event type
+ */
+ public static getEventType(event: MatrixEvent): string {
+ const type = event.getType();
+ if (type === MESSAGE_TYPE) {
+ const content = event.getContent();
+ if (content) {
+ const { msgtype } = content;
+ if (msgtype === REQUEST_TYPE) {
+ return REQUEST_TYPE;
+ }
+ }
+ }
+ if (type && type !== REQUEST_TYPE) {
+ return type;
+ } else {
+ return "";
+ }
+ }
+
+ /**
+ * Changes the state of the channel, request, and verifier in response to a key verification event.
+ * @param event - to handle
+ * @param request - the request to forward handling to
+ * @param isLiveEvent - whether this is an even received through sync or not
+ * @returns a promise that resolves when any requests as an answer to the passed-in event are sent.
+ */
+ public async handleEvent(event: MatrixEvent, request: VerificationRequest, isLiveEvent = false): Promise<void> {
+ // prevent processing the same event multiple times, as under
+ // some circumstances Room.timeline can get emitted twice for the same event
+ if (request.hasEventId(event.getId()!)) {
+ return;
+ }
+ const type = InRoomChannel.getEventType(event);
+ // do validations that need state (roomId, userId),
+ // ignore if invalid
+
+ if (event.getRoomId() !== this.roomId) {
+ return;
+ }
+ // set userId if not set already
+ if (!this.userId) {
+ const userId = InRoomChannel.getOtherPartyUserId(event, this.client);
+ if (userId) {
+ this.userId = userId;
+ }
+ }
+ // ignore events not sent by us or the other party
+ const ownUserId = this.client.getUserId();
+ const sender = event.getSender();
+ if (this.userId) {
+ if (sender !== ownUserId && sender !== this.userId) {
+ logger.log(`InRoomChannel: ignoring verification event from non-participating sender ${sender}`);
+ return;
+ }
+ }
+ if (!this.requestEventId) {
+ this.requestEventId = InRoomChannel.getTransactionId(event);
+ }
+
+ const isRemoteEcho = !!event.getUnsigned().transaction_id;
+ const isSentByUs = event.getSender() === this.client.getUserId();
+
+ return request.handleEvent(type, event, isLiveEvent, isRemoteEcho, isSentByUs);
+ }
+
+ /**
+ * Adds the transaction id (relation) back to a received event
+ * so it has the same format as returned by `completeContent` before sending.
+ * The relation can not appear on the event content because of encryption,
+ * relations are excluded from encryption.
+ * @param event - the received event
+ * @returns the content object with the relation added again
+ */
+ public completedContentFromEvent(event: MatrixEvent): Record<string, any> {
+ // ensure m.related_to is included in e2ee rooms
+ // as the field is excluded from encryption
+ const content = Object.assign({}, event.getContent());
+ content[M_RELATES_TO] = event.getRelation()!;
+ return content;
+ }
+
+ /**
+ * Add all the fields to content needed for sending it over this channel.
+ * This is public so verification methods (SAS uses this) can get the exact
+ * content that will be sent independent of the used channel,
+ * as they need to calculate the hash of it.
+ * @param type - the event type
+ * @param content - the (incomplete) content
+ * @returns the complete content, as it will be sent.
+ */
+ public completeContent(type: string, content: Record<string, any>): Record<string, any> {
+ content = Object.assign({}, content);
+ if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) {
+ content.from_device = this.client.getDeviceId();
+ }
+ if (type === REQUEST_TYPE) {
+ // type is mapped to m.room.message in the send method
+ content = {
+ body:
+ this.client.getUserId() +
+ " is requesting to verify " +
+ "your key, but your client does not support in-chat key " +
+ "verification. You will need to use legacy key " +
+ "verification to verify keys.",
+ msgtype: REQUEST_TYPE,
+ to: this.userId,
+ from_device: content.from_device,
+ methods: content.methods,
+ };
+ } else {
+ content[M_RELATES_TO] = {
+ rel_type: M_REFERENCE,
+ event_id: this.transactionId,
+ };
+ }
+ return content;
+ }
+
+ /**
+ * Send an event over the channel with the content not having gone through `completeContent`.
+ * @param type - the event type
+ * @param uncompletedContent - the (incomplete) content
+ * @returns the promise of the request
+ */
+ public send(type: string, uncompletedContent: Record<string, any>): Promise<void> {
+ const content = this.completeContent(type, uncompletedContent);
+ return this.sendCompleted(type, content);
+ }
+
+ /**
+ * Send an event over the channel with the content having gone through `completeContent` already.
+ * @param type - the event type
+ * @returns the promise of the request
+ */
+ public async sendCompleted(type: string, content: Record<string, any>): Promise<void> {
+ let sendType = type;
+ if (type === REQUEST_TYPE) {
+ sendType = MESSAGE_TYPE;
+ }
+ const response = await this.client.sendEvent(this.roomId, sendType, content);
+ if (type === REQUEST_TYPE) {
+ this.requestEventId = response.event_id;
+ }
+ }
+}
+
+export class InRoomRequests implements IRequestsMap {
+ private requestsByRoomId = new Map<string, Map<string, VerificationRequest>>();
+
+ public getRequest(event: MatrixEvent): VerificationRequest | undefined {
+ const roomId = event.getRoomId()!;
+ const txnId = InRoomChannel.getTransactionId(event)!;
+ return this.getRequestByTxnId(roomId, txnId);
+ }
+
+ public getRequestByChannel(channel: InRoomChannel): VerificationRequest | undefined {
+ return this.getRequestByTxnId(channel.roomId, channel.transactionId!);
+ }
+
+ private getRequestByTxnId(roomId: string, txnId: string): VerificationRequest | undefined {
+ const requestsByTxnId = this.requestsByRoomId.get(roomId);
+ if (requestsByTxnId) {
+ return requestsByTxnId.get(txnId);
+ }
+ }
+
+ public setRequest(event: MatrixEvent, request: VerificationRequest): void {
+ this.doSetRequest(event.getRoomId()!, InRoomChannel.getTransactionId(event)!, request);
+ }
+
+ public setRequestByChannel(channel: IVerificationChannel, request: VerificationRequest): void {
+ this.doSetRequest(channel.roomId!, channel.transactionId!, request);
+ }
+
+ private doSetRequest(roomId: string, txnId: string, request: VerificationRequest): void {
+ let requestsByTxnId = this.requestsByRoomId.get(roomId);
+ if (!requestsByTxnId) {
+ requestsByTxnId = new Map();
+ this.requestsByRoomId.set(roomId, requestsByTxnId);
+ }
+ requestsByTxnId.set(txnId, request);
+ }
+
+ public removeRequest(event: MatrixEvent): void {
+ const roomId = event.getRoomId()!;
+ const requestsByTxnId = this.requestsByRoomId.get(roomId);
+ if (requestsByTxnId) {
+ requestsByTxnId.delete(InRoomChannel.getTransactionId(event)!);
+ if (requestsByTxnId.size === 0) {
+ this.requestsByRoomId.delete(roomId);
+ }
+ }
+ }
+
+ public findRequestInProgress(roomId: string): VerificationRequest | undefined {
+ const requestsByTxnId = this.requestsByRoomId.get(roomId);
+ if (requestsByTxnId) {
+ for (const request of requestsByTxnId.values()) {
+ if (request.pending) {
+ return request;
+ }
+ }
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts
new file mode 100644
index 0000000..d51b85a
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/ToDeviceChannel.ts
@@ -0,0 +1,354 @@
+/*
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { randomString } from "../../../randomstring";
+import { logger } from "../../../logger";
+import {
+ CANCEL_TYPE,
+ PHASE_STARTED,
+ PHASE_READY,
+ REQUEST_TYPE,
+ READY_TYPE,
+ START_TYPE,
+ VerificationRequest,
+} from "./VerificationRequest";
+import { errorFromEvent, newUnexpectedMessageError } from "../Error";
+import { MatrixEvent } from "../../../models/event";
+import { IVerificationChannel } from "./Channel";
+import { MatrixClient } from "../../../client";
+import { IRequestsMap } from "../..";
+
+export type Request = VerificationRequest<ToDeviceChannel>;
+
+/**
+ * A key verification channel that sends verification events over to_device messages.
+ * Generates its own transaction ids.
+ */
+export class ToDeviceChannel implements IVerificationChannel {
+ public request?: VerificationRequest;
+
+ // userId and devices of user we're about to verify
+ public constructor(
+ private readonly client: MatrixClient,
+ public readonly userId: string,
+ private readonly devices: string[],
+ public transactionId?: string,
+ public deviceId?: string,
+ ) {}
+
+ public isToDevices(devices: string[]): boolean {
+ if (devices.length === this.devices.length) {
+ for (const device of devices) {
+ if (!this.devices.includes(device)) {
+ return false;
+ }
+ }
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public static getEventType(event: MatrixEvent): string {
+ return event.getType();
+ }
+
+ /**
+ * Extract the transaction id used by a given key verification event, if any
+ * @param event - the event
+ * @returns the transaction id
+ */
+ public static getTransactionId(event: MatrixEvent): string {
+ const content = event.getContent();
+ return content && content.transaction_id;
+ }
+
+ /**
+ * Checks whether the given event type should be allowed to initiate a new VerificationRequest over this channel
+ * @param type - the event type to check
+ * @returns boolean flag
+ */
+ public static canCreateRequest(type: string): boolean {
+ return type === REQUEST_TYPE || type === START_TYPE;
+ }
+
+ public canCreateRequest(type: string): boolean {
+ return ToDeviceChannel.canCreateRequest(type);
+ }
+
+ /**
+ * Checks whether this event is a well-formed key verification event.
+ * This only does checks that don't rely on the current state of a potentially already channel
+ * so we can prevent channels being created by invalid events.
+ * `handleEvent` can do more checks and choose to ignore invalid events.
+ * @param event - the event to validate
+ * @param client - the client to get the current user and device id from
+ * @returns whether the event is valid and should be passed to handleEvent
+ */
+ public static validateEvent(event: MatrixEvent, client: MatrixClient): boolean {
+ if (event.isCancelled()) {
+ logger.warn("Ignoring flagged verification request from " + event.getSender());
+ return false;
+ }
+ const content = event.getContent();
+ if (!content) {
+ logger.warn("ToDeviceChannel.validateEvent: invalid: no content");
+ return false;
+ }
+
+ if (!content.transaction_id) {
+ logger.warn("ToDeviceChannel.validateEvent: invalid: no transaction_id");
+ return false;
+ }
+
+ const type = event.getType();
+
+ if (type === REQUEST_TYPE) {
+ if (!Number.isFinite(content.timestamp)) {
+ logger.warn("ToDeviceChannel.validateEvent: invalid: no timestamp");
+ return false;
+ }
+ if (event.getSender() === client.getUserId() && content.from_device == client.getDeviceId()) {
+ // ignore requests from ourselves, because it doesn't make sense for a
+ // device to verify itself
+ logger.warn("ToDeviceChannel.validateEvent: invalid: from own device");
+ return false;
+ }
+ }
+
+ return VerificationRequest.validateEvent(type, event, client);
+ }
+
+ /**
+ * @param event - the event to get the timestamp of
+ * @returns the timestamp when the event was sent
+ */
+ public getTimestamp(event: MatrixEvent): number {
+ const content = event.getContent();
+ return content && content.timestamp;
+ }
+
+ /**
+ * Changes the state of the channel, request, and verifier in response to a key verification event.
+ * @param event - to handle
+ * @param request - the request to forward handling to
+ * @param isLiveEvent - whether this is an even received through sync or not
+ * @returns a promise that resolves when any requests as an answer to the passed-in event are sent.
+ */
+ public async handleEvent(event: MatrixEvent, request: Request, isLiveEvent = false): Promise<void> {
+ const type = event.getType();
+ const content = event.getContent();
+ if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) {
+ if (!this.transactionId) {
+ this.transactionId = content.transaction_id;
+ }
+ const deviceId = content.from_device;
+ // adopt deviceId if not set before and valid
+ if (!this.deviceId && this.devices.includes(deviceId)) {
+ this.deviceId = deviceId;
+ }
+ // if no device id or different from adopted one, cancel with sender
+ if (!this.deviceId || this.deviceId !== deviceId) {
+ // also check that message came from the device we sent the request to earlier on
+ // and do send a cancel message to that device
+ // (but don't cancel the request for the device we should be talking to)
+ const cancelContent = this.completeContent(CANCEL_TYPE, errorFromEvent(newUnexpectedMessageError()));
+ return this.sendToDevices(CANCEL_TYPE, cancelContent, [deviceId]);
+ }
+ }
+ const wasStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY;
+
+ await request.handleEvent(event.getType(), event, isLiveEvent, false, false);
+
+ const isStarted = request.phase === PHASE_STARTED || request.phase === PHASE_READY;
+
+ const isAcceptingEvent = type === START_TYPE || type === READY_TYPE;
+ // the request has picked a ready or start event, tell the other devices about it
+ if (isAcceptingEvent && !wasStarted && isStarted && this.deviceId) {
+ const nonChosenDevices = this.devices.filter((d) => d !== this.deviceId && d !== this.client.getDeviceId());
+ if (nonChosenDevices.length) {
+ const message = this.completeContent(CANCEL_TYPE, {
+ code: "m.accepted",
+ reason: "Verification request accepted by another device",
+ });
+ await this.sendToDevices(CANCEL_TYPE, message, nonChosenDevices);
+ }
+ }
+ }
+
+ /**
+ * See {@link InRoomChannel#completedContentFromEvent} for why this is needed.
+ * @param event - the received event
+ * @returns the content object
+ */
+ public completedContentFromEvent(event: MatrixEvent): Record<string, any> {
+ return event.getContent();
+ }
+
+ /**
+ * Add all the fields to content needed for sending it over this channel.
+ * This is public so verification methods (SAS uses this) can get the exact
+ * content that will be sent independent of the used channel,
+ * as they need to calculate the hash of it.
+ * @param type - the event type
+ * @param content - the (incomplete) content
+ * @returns the complete content, as it will be sent.
+ */
+ public completeContent(type: string, content: Record<string, any>): Record<string, any> {
+ // make a copy
+ content = Object.assign({}, content);
+ if (this.transactionId) {
+ content.transaction_id = this.transactionId;
+ }
+ if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) {
+ content.from_device = this.client.getDeviceId();
+ }
+ if (type === REQUEST_TYPE) {
+ content.timestamp = Date.now();
+ }
+ return content;
+ }
+
+ /**
+ * Send an event over the channel with the content not having gone through `completeContent`.
+ * @param type - the event type
+ * @param uncompletedContent - the (incomplete) content
+ * @returns the promise of the request
+ */
+ public send(type: string, uncompletedContent: Record<string, any> = {}): Promise<void> {
+ // create transaction id when sending request
+ if ((type === REQUEST_TYPE || type === START_TYPE) && !this.transactionId) {
+ this.transactionId = ToDeviceChannel.makeTransactionId();
+ }
+ const content = this.completeContent(type, uncompletedContent);
+ return this.sendCompleted(type, content);
+ }
+
+ /**
+ * Send an event over the channel with the content having gone through `completeContent` already.
+ * @param type - the event type
+ * @returns the promise of the request
+ */
+ public async sendCompleted(type: string, content: Record<string, any>): Promise<void> {
+ let result;
+ if (type === REQUEST_TYPE || (type === CANCEL_TYPE && !this.deviceId)) {
+ result = await this.sendToDevices(type, content, this.devices);
+ } else {
+ result = await this.sendToDevices(type, content, [this.deviceId!]);
+ }
+ // the VerificationRequest state machine requires remote echos of the event
+ // the client sends itself, so we fake this for to_device messages
+ const remoteEchoEvent = new MatrixEvent({
+ sender: this.client.getUserId()!,
+ content,
+ type,
+ });
+ await this.request!.handleEvent(
+ type,
+ remoteEchoEvent,
+ /*isLiveEvent=*/ true,
+ /*isRemoteEcho=*/ true,
+ /*isSentByUs=*/ true,
+ );
+ return result;
+ }
+
+ private async sendToDevices(type: string, content: Record<string, any>, devices: string[]): Promise<void> {
+ if (devices.length) {
+ const deviceMessages: Map<string, Record<string, any>> = new Map();
+ for (const deviceId of devices) {
+ deviceMessages.set(deviceId, content);
+ }
+
+ await this.client.sendToDevice(type, new Map([[this.userId, deviceMessages]]));
+ }
+ }
+
+ /**
+ * Allow Crypto module to create and know the transaction id before the .start event gets sent.
+ * @returns the transaction id
+ */
+ public static makeTransactionId(): string {
+ return randomString(32);
+ }
+}
+
+export class ToDeviceRequests implements IRequestsMap {
+ private requestsByUserId = new Map<string, Map<string, Request>>();
+
+ public getRequest(event: MatrixEvent): Request | undefined {
+ return this.getRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event));
+ }
+
+ public getRequestByChannel(channel: ToDeviceChannel): Request | undefined {
+ return this.getRequestBySenderAndTxnId(channel.userId, channel.transactionId!);
+ }
+
+ public getRequestBySenderAndTxnId(sender: string, txnId: string): Request | undefined {
+ const requestsByTxnId = this.requestsByUserId.get(sender);
+ if (requestsByTxnId) {
+ return requestsByTxnId.get(txnId);
+ }
+ }
+
+ public setRequest(event: MatrixEvent, request: Request): void {
+ this.setRequestBySenderAndTxnId(event.getSender()!, ToDeviceChannel.getTransactionId(event), request);
+ }
+
+ public setRequestByChannel(channel: ToDeviceChannel, request: Request): void {
+ this.setRequestBySenderAndTxnId(channel.userId, channel.transactionId!, request);
+ }
+
+ public setRequestBySenderAndTxnId(sender: string, txnId: string, request: Request): void {
+ let requestsByTxnId = this.requestsByUserId.get(sender);
+ if (!requestsByTxnId) {
+ requestsByTxnId = new Map();
+ this.requestsByUserId.set(sender, requestsByTxnId);
+ }
+ requestsByTxnId.set(txnId, request);
+ }
+
+ public removeRequest(event: MatrixEvent): void {
+ const userId = event.getSender()!;
+ const requestsByTxnId = this.requestsByUserId.get(userId);
+ if (requestsByTxnId) {
+ requestsByTxnId.delete(ToDeviceChannel.getTransactionId(event));
+ if (requestsByTxnId.size === 0) {
+ this.requestsByUserId.delete(userId);
+ }
+ }
+ }
+
+ public findRequestInProgress(userId: string, devices: string[]): Request | undefined {
+ const requestsByTxnId = this.requestsByUserId.get(userId);
+ if (requestsByTxnId) {
+ for (const request of requestsByTxnId.values()) {
+ if (request.pending && request.channel.isToDevices(devices)) {
+ return request;
+ }
+ }
+ }
+ }
+
+ public getRequestsInProgress(userId: string): Request[] {
+ const requestsByTxnId = this.requestsByUserId.get(userId);
+ if (requestsByTxnId) {
+ return Array.from(requestsByTxnId.values()).filter((r) => r.pending);
+ }
+ return [];
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts
new file mode 100644
index 0000000..617432e
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/crypto/verification/request/VerificationRequest.ts
@@ -0,0 +1,926 @@
+/*
+Copyright 2018 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "../../../logger";
+import { errorFactory, errorFromEvent, newUnexpectedMessageError, newUnknownMethodError } from "../Error";
+import { QRCodeData, SCAN_QR_CODE_METHOD } from "../QRCode";
+import { IVerificationChannel } from "./Channel";
+import { MatrixClient } from "../../../client";
+import { MatrixEvent } from "../../../models/event";
+import { EventType } from "../../../@types/event";
+import { VerificationBase } from "../Base";
+import { VerificationMethod } from "../../index";
+import { TypedEventEmitter } from "../../../models/typed-event-emitter";
+
+// How long after the event's timestamp that the request times out
+const TIMEOUT_FROM_EVENT_TS = 10 * 60 * 1000; // 10 minutes
+
+// How long after we receive the event that the request times out
+const TIMEOUT_FROM_EVENT_RECEIPT = 2 * 60 * 1000; // 2 minutes
+
+// to avoid almost expired verification notifications
+// from showing a notification and almost immediately
+// disappearing, also ignore verification requests that
+// are this amount of time away from expiring.
+const VERIFICATION_REQUEST_MARGIN = 3 * 1000; // 3 seconds
+
+export const EVENT_PREFIX = "m.key.verification.";
+export const REQUEST_TYPE = EVENT_PREFIX + "request";
+export const START_TYPE = EVENT_PREFIX + "start";
+export const CANCEL_TYPE = EVENT_PREFIX + "cancel";
+export const DONE_TYPE = EVENT_PREFIX + "done";
+export const READY_TYPE = EVENT_PREFIX + "ready";
+
+export enum Phase {
+ Unsent = 1,
+ Requested,
+ Ready,
+ Started,
+ Cancelled,
+ Done,
+}
+
+// Legacy export fields
+export const PHASE_UNSENT = Phase.Unsent;
+export const PHASE_REQUESTED = Phase.Requested;
+export const PHASE_READY = Phase.Ready;
+export const PHASE_STARTED = Phase.Started;
+export const PHASE_CANCELLED = Phase.Cancelled;
+export const PHASE_DONE = Phase.Done;
+
+interface ITargetDevice {
+ userId?: string;
+ deviceId?: string;
+}
+
+interface ITransition {
+ phase: Phase;
+ event?: MatrixEvent;
+}
+
+export enum VerificationRequestEvent {
+ Change = "change",
+}
+
+type EventHandlerMap = {
+ /**
+ * Fires whenever the state of the request object has changed.
+ */
+ [VerificationRequestEvent.Change]: () => void;
+};
+
+/**
+ * State machine for verification requests.
+ * Things that differ based on what channel is used to
+ * send and receive verification events are put in `InRoomChannel` or `ToDeviceChannel`.
+ */
+export class VerificationRequest<C extends IVerificationChannel = IVerificationChannel> extends TypedEventEmitter<
+ VerificationRequestEvent,
+ EventHandlerMap
+> {
+ private eventsByUs = new Map<string, MatrixEvent>();
+ private eventsByThem = new Map<string, MatrixEvent>();
+ private _observeOnly = false;
+ private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
+ private _accepting = false;
+ private _declining = false;
+ private verifierHasFinished = false;
+ private _cancelled = false;
+ private _chosenMethod: VerificationMethod | null = null;
+ // we keep a copy of the QR Code data (including other user master key) around
+ // for QR reciprocate verification, to protect against
+ // cross-signing identity reset between the .ready and .start event
+ // and signing the wrong key after .start
+ private _qrCodeData: QRCodeData | null = null;
+
+ // The timestamp when we received the request event from the other side
+ private requestReceivedAt: number | null = null;
+
+ private commonMethods: VerificationMethod[] = [];
+ private _phase!: Phase;
+ public _cancellingUserId?: string; // Used in tests only
+ private _verifier?: VerificationBase<any, any>;
+
+ public constructor(
+ public readonly channel: C,
+ private readonly verificationMethods: Map<VerificationMethod, typeof VerificationBase>,
+ private readonly client: MatrixClient,
+ ) {
+ super();
+ this.channel.request = this;
+ this.setPhase(PHASE_UNSENT, false);
+ }
+
+ /**
+ * Stateless validation logic not specific to the channel.
+ * Invoked by the same static method in either channel.
+ * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel.
+ * @param event - the event to validate. Don't call getType() on it but use the `type` parameter instead.
+ * @param client - the client to get the current user and device id from
+ * @returns whether the event is valid and should be passed to handleEvent
+ */
+ public static validateEvent(type: string, event: MatrixEvent, client: MatrixClient): boolean {
+ const content = event.getContent();
+
+ if (!type || !type.startsWith(EVENT_PREFIX)) {
+ return false;
+ }
+
+ // from here on we're fairly sure that this is supposed to be
+ // part of a verification request, so be noisy when rejecting something
+ if (!content) {
+ logger.log("VerificationRequest: validateEvent: no content");
+ return false;
+ }
+
+ if (type === REQUEST_TYPE || type === READY_TYPE) {
+ if (!Array.isArray(content.methods)) {
+ logger.log("VerificationRequest: validateEvent: " + "fail because methods");
+ return false;
+ }
+ }
+
+ if (type === REQUEST_TYPE || type === READY_TYPE || type === START_TYPE) {
+ if (typeof content.from_device !== "string" || content.from_device.length === 0) {
+ logger.log("VerificationRequest: validateEvent: " + "fail because from_device");
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public get invalid(): boolean {
+ return this.phase === PHASE_UNSENT;
+ }
+
+ /** returns whether the phase is PHASE_REQUESTED */
+ public get requested(): boolean {
+ return this.phase === PHASE_REQUESTED;
+ }
+
+ /** returns whether the phase is PHASE_CANCELLED */
+ public get cancelled(): boolean {
+ return this.phase === PHASE_CANCELLED;
+ }
+
+ /** returns whether the phase is PHASE_READY */
+ public get ready(): boolean {
+ return this.phase === PHASE_READY;
+ }
+
+ /** returns whether the phase is PHASE_STARTED */
+ public get started(): boolean {
+ return this.phase === PHASE_STARTED;
+ }
+
+ /** returns whether the phase is PHASE_DONE */
+ public get done(): boolean {
+ return this.phase === PHASE_DONE;
+ }
+
+ /** once the phase is PHASE_STARTED (and !initiatedByMe) or PHASE_READY: common methods supported by both sides */
+ public get methods(): VerificationMethod[] {
+ return this.commonMethods;
+ }
+
+ /** the method picked in the .start event */
+ public get chosenMethod(): VerificationMethod | null {
+ return this._chosenMethod;
+ }
+
+ public calculateEventTimeout(event: MatrixEvent): number {
+ let effectiveExpiresAt = this.channel.getTimestamp(event) + TIMEOUT_FROM_EVENT_TS;
+
+ if (this.requestReceivedAt && !this.initiatedByMe && this.phase <= PHASE_REQUESTED) {
+ const expiresAtByReceipt = this.requestReceivedAt + TIMEOUT_FROM_EVENT_RECEIPT;
+ effectiveExpiresAt = Math.min(effectiveExpiresAt, expiresAtByReceipt);
+ }
+
+ return Math.max(0, effectiveExpiresAt - Date.now());
+ }
+
+ /** The current remaining amount of ms before the request should be automatically cancelled */
+ public get timeout(): number {
+ const requestEvent = this.getEventByEither(REQUEST_TYPE);
+ if (requestEvent) {
+ return this.calculateEventTimeout(requestEvent);
+ }
+ return 0;
+ }
+
+ /**
+ * The key verification request event.
+ * @returns The request event, or falsey if not found.
+ */
+ public get requestEvent(): MatrixEvent | undefined {
+ return this.getEventByEither(REQUEST_TYPE);
+ }
+
+ /** current phase of the request. Some properties might only be defined in a current phase. */
+ public get phase(): Phase {
+ return this._phase;
+ }
+
+ /** The verifier to do the actual verification, once the method has been established. Only defined when the `phase` is PHASE_STARTED. */
+ public get verifier(): VerificationBase<any, any> | undefined {
+ return this._verifier;
+ }
+
+ public get canAccept(): boolean {
+ return this.phase < PHASE_READY && !this._accepting && !this._declining;
+ }
+
+ public get accepting(): boolean {
+ return this._accepting;
+ }
+
+ public get declining(): boolean {
+ return this._declining;
+ }
+
+ /** whether this request has sent it's initial event and needs more events to complete */
+ public get pending(): boolean {
+ return !this.observeOnly && this._phase !== PHASE_DONE && this._phase !== PHASE_CANCELLED;
+ }
+
+ /** Only set after a .ready if the other party can scan a QR code */
+ public get qrCodeData(): QRCodeData | null {
+ return this._qrCodeData;
+ }
+
+ /** Checks whether the other party supports a given verification method.
+ * This is useful when setting up the QR code UI, as it is somewhat asymmetrical:
+ * if the other party supports SCAN_QR, we should show a QR code in the UI, and vice versa.
+ * For methods that need to be supported by both ends, use the `methods` property.
+ * @param method - the method to check
+ * @param force - to check even if the phase is not ready or started yet, internal usage
+ * @returns whether or not the other party said the supported the method */
+ public otherPartySupportsMethod(method: string, force = false): boolean {
+ if (!force && !this.ready && !this.started) {
+ return false;
+ }
+ const theirMethodEvent = this.eventsByThem.get(REQUEST_TYPE) || this.eventsByThem.get(READY_TYPE);
+ if (!theirMethodEvent) {
+ // if we started straight away with .start event,
+ // we are assuming that the other side will support the
+ // chosen method, so return true for that.
+ if (this.started && this.initiatedByMe) {
+ const myStartEvent = this.eventsByUs.get(START_TYPE);
+ const content = myStartEvent && myStartEvent.getContent();
+ const myStartMethod = content && content.method;
+ return method == myStartMethod;
+ }
+ return false;
+ }
+ const content = theirMethodEvent.getContent();
+ if (!content) {
+ return false;
+ }
+ const { methods } = content;
+ if (!Array.isArray(methods)) {
+ return false;
+ }
+
+ return methods.includes(method);
+ }
+
+ /** Whether this request was initiated by the syncing user.
+ * For InRoomChannel, this is who sent the .request event.
+ * For ToDeviceChannel, this is who sent the .start event
+ */
+ public get initiatedByMe(): boolean {
+ // event created by us but no remote echo has been received yet
+ const noEventsYet = this.eventsByUs.size + this.eventsByThem.size === 0;
+ if (this._phase === PHASE_UNSENT && noEventsYet) {
+ return true;
+ }
+ const hasMyRequest = this.eventsByUs.has(REQUEST_TYPE);
+ const hasTheirRequest = this.eventsByThem.has(REQUEST_TYPE);
+ if (hasMyRequest && !hasTheirRequest) {
+ return true;
+ }
+ if (!hasMyRequest && hasTheirRequest) {
+ return false;
+ }
+ const hasMyStart = this.eventsByUs.has(START_TYPE);
+ const hasTheirStart = this.eventsByThem.has(START_TYPE);
+ if (hasMyStart && !hasTheirStart) {
+ return true;
+ }
+ return false;
+ }
+
+ /** The id of the user that initiated the request */
+ public get requestingUserId(): string {
+ if (this.initiatedByMe) {
+ return this.client.getUserId()!;
+ } else {
+ return this.otherUserId;
+ }
+ }
+
+ /** The id of the user that (will) receive(d) the request */
+ public get receivingUserId(): string {
+ if (this.initiatedByMe) {
+ return this.otherUserId;
+ } else {
+ return this.client.getUserId()!;
+ }
+ }
+
+ /** The user id of the other party in this request */
+ public get otherUserId(): string {
+ return this.channel.userId!;
+ }
+
+ public get isSelfVerification(): boolean {
+ return this.client.getUserId() === this.otherUserId;
+ }
+
+ /**
+ * The id of the user that cancelled the request,
+ * only defined when phase is PHASE_CANCELLED
+ */
+ public get cancellingUserId(): string | undefined {
+ const myCancel = this.eventsByUs.get(CANCEL_TYPE);
+ const theirCancel = this.eventsByThem.get(CANCEL_TYPE);
+
+ if (myCancel && (!theirCancel || myCancel.getId()! < theirCancel.getId()!)) {
+ return myCancel.getSender();
+ }
+ if (theirCancel) {
+ return theirCancel.getSender();
+ }
+ return undefined;
+ }
+
+ /**
+ * The cancellation code e.g m.user which is responsible for cancelling this verification
+ */
+ public get cancellationCode(): string {
+ const ev = this.getEventByEither(CANCEL_TYPE);
+ return ev ? ev.getContent().code : null;
+ }
+
+ public get observeOnly(): boolean {
+ return this._observeOnly;
+ }
+
+ /**
+ * Gets which device the verification should be started with
+ * given the events sent so far in the verification. This is the
+ * same algorithm used to determine which device to send the
+ * verification to when no specific device is specified.
+ * @returns The device information
+ */
+ public get targetDevice(): ITargetDevice {
+ const theirFirstEvent =
+ this.eventsByThem.get(REQUEST_TYPE) ||
+ this.eventsByThem.get(READY_TYPE) ||
+ this.eventsByThem.get(START_TYPE);
+ const theirFirstContent = theirFirstEvent?.getContent();
+ const fromDevice = theirFirstContent?.from_device;
+ return {
+ userId: this.otherUserId,
+ deviceId: fromDevice,
+ };
+ }
+
+ /* Start the key verification, creating a verifier and sending a .start event.
+ * If no previous events have been sent, pass in `targetDevice` to set who to direct this request to.
+ * @param method - the name of the verification method to use.
+ * @param targetDevice.userId the id of the user to direct this request to
+ * @param targetDevice.deviceId the id of the device to direct this request to
+ * @returns the verifier of the given method
+ */
+ public beginKeyVerification(
+ method: VerificationMethod,
+ targetDevice: ITargetDevice | null = null,
+ ): VerificationBase<any, any> {
+ // need to allow also when unsent in case of to_device
+ if (!this.observeOnly && !this._verifier) {
+ const validStartPhase =
+ this.phase === PHASE_REQUESTED ||
+ this.phase === PHASE_READY ||
+ (this.phase === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE));
+ if (validStartPhase) {
+ // when called on a request that was initiated with .request event
+ // check the method is supported by both sides
+ if (this.commonMethods.length && !this.commonMethods.includes(method)) {
+ throw newUnknownMethodError();
+ }
+ this._verifier = this.createVerifier(method, null, targetDevice);
+ if (!this._verifier) {
+ throw newUnknownMethodError();
+ }
+ this._chosenMethod = method;
+ }
+ }
+ return this._verifier!;
+ }
+
+ /**
+ * sends the initial .request event.
+ * @returns resolves when the event has been sent.
+ */
+ public async sendRequest(): Promise<void> {
+ if (!this.observeOnly && this._phase === PHASE_UNSENT) {
+ const methods = [...this.verificationMethods.keys()];
+ await this.channel.send(REQUEST_TYPE, { methods });
+ }
+ }
+
+ /**
+ * Cancels the request, sending a cancellation to the other party
+ * @param reason - the error reason to send the cancellation with
+ * @param code - the error code to send the cancellation with
+ * @returns resolves when the event has been sent.
+ */
+ public async cancel({ reason = "User declined", code = "m.user" } = {}): Promise<void> {
+ if (!this.observeOnly && this._phase !== PHASE_CANCELLED) {
+ this._declining = true;
+ this.emit(VerificationRequestEvent.Change);
+ if (this._verifier) {
+ return this._verifier.cancel(errorFactory(code, reason)());
+ } else {
+ this._cancellingUserId = this.client.getUserId()!;
+ await this.channel.send(CANCEL_TYPE, { code, reason });
+ }
+ }
+ }
+
+ /**
+ * Accepts the request, sending a .ready event to the other party
+ * @returns resolves when the event has been sent.
+ */
+ public async accept(): Promise<void> {
+ if (!this.observeOnly && this.phase === PHASE_REQUESTED && !this.initiatedByMe) {
+ const methods = [...this.verificationMethods.keys()];
+ this._accepting = true;
+ this.emit(VerificationRequestEvent.Change);
+ await this.channel.send(READY_TYPE, { methods });
+ }
+ }
+
+ /**
+ * Can be used to listen for state changes until the callback returns true.
+ * @param fn - callback to evaluate whether the request is in the desired state.
+ * Takes the request as an argument.
+ * @returns that resolves once the callback returns true
+ * @throws Error when the request is cancelled
+ */
+ public waitFor(fn: (request: VerificationRequest) => boolean): Promise<VerificationRequest> {
+ return new Promise((resolve, reject) => {
+ const check = (): boolean => {
+ let handled = false;
+ if (fn(this)) {
+ resolve(this);
+ handled = true;
+ } else if (this.cancelled) {
+ reject(new Error("cancelled"));
+ handled = true;
+ }
+ if (handled) {
+ this.off(VerificationRequestEvent.Change, check);
+ }
+ return handled;
+ };
+ if (!check()) {
+ this.on(VerificationRequestEvent.Change, check);
+ }
+ });
+ }
+
+ private setPhase(phase: Phase, notify = true): void {
+ this._phase = phase;
+ if (notify) {
+ this.emit(VerificationRequestEvent.Change);
+ }
+ }
+
+ private getEventByEither(type: string): MatrixEvent | undefined {
+ return this.eventsByThem.get(type) || this.eventsByUs.get(type);
+ }
+
+ private getEventBy(type: string, byThem = false): MatrixEvent | undefined {
+ if (byThem) {
+ return this.eventsByThem.get(type);
+ } else {
+ return this.eventsByUs.get(type);
+ }
+ }
+
+ private calculatePhaseTransitions(): ITransition[] {
+ const transitions: ITransition[] = [{ phase: PHASE_UNSENT }];
+ const phase = (): Phase => transitions[transitions.length - 1].phase;
+
+ // always pass by .request first to be sure channel.userId has been set
+ const hasRequestByThem = this.eventsByThem.has(REQUEST_TYPE);
+ const requestEvent = this.getEventBy(REQUEST_TYPE, hasRequestByThem);
+ if (requestEvent) {
+ transitions.push({ phase: PHASE_REQUESTED, event: requestEvent });
+ }
+
+ const readyEvent = requestEvent && this.getEventBy(READY_TYPE, !hasRequestByThem);
+ if (readyEvent && phase() === PHASE_REQUESTED) {
+ transitions.push({ phase: PHASE_READY, event: readyEvent });
+ }
+
+ let startEvent: MatrixEvent | undefined;
+ if (readyEvent || !requestEvent) {
+ const theirStartEvent = this.eventsByThem.get(START_TYPE);
+ const ourStartEvent = this.eventsByUs.get(START_TYPE);
+ // any party can send .start after a .ready or unsent
+ if (theirStartEvent && ourStartEvent) {
+ startEvent =
+ theirStartEvent.getSender()! < ourStartEvent.getSender()! ? theirStartEvent : ourStartEvent;
+ } else {
+ startEvent = theirStartEvent ? theirStartEvent : ourStartEvent;
+ }
+ } else {
+ startEvent = this.getEventBy(START_TYPE, !hasRequestByThem);
+ }
+ if (startEvent) {
+ const fromRequestPhase =
+ phase() === PHASE_REQUESTED && requestEvent?.getSender() !== startEvent.getSender();
+ const fromUnsentPhase = phase() === PHASE_UNSENT && this.channel.canCreateRequest(START_TYPE);
+ if (fromRequestPhase || phase() === PHASE_READY || fromUnsentPhase) {
+ transitions.push({ phase: PHASE_STARTED, event: startEvent });
+ }
+ }
+
+ const ourDoneEvent = this.eventsByUs.get(DONE_TYPE);
+ if (this.verifierHasFinished || (ourDoneEvent && phase() === PHASE_STARTED)) {
+ transitions.push({ phase: PHASE_DONE });
+ }
+
+ const cancelEvent = this.getEventByEither(CANCEL_TYPE);
+ if ((this._cancelled || cancelEvent) && phase() !== PHASE_DONE) {
+ transitions.push({ phase: PHASE_CANCELLED, event: cancelEvent });
+ return transitions;
+ }
+
+ return transitions;
+ }
+
+ private transitionToPhase(transition: ITransition): void {
+ const { phase, event } = transition;
+ // get common methods
+ if (phase === PHASE_REQUESTED || phase === PHASE_READY) {
+ if (!this.wasSentByOwnDevice(event)) {
+ const content = event!.getContent<{
+ methods: string[];
+ }>();
+ this.commonMethods = content.methods.filter((m) => this.verificationMethods.has(m));
+ }
+ }
+ // detect if we're not a party in the request, and we should just observe
+ if (!this.observeOnly) {
+ // if requested or accepted by one of my other devices
+ if (phase === PHASE_REQUESTED || phase === PHASE_STARTED || phase === PHASE_READY) {
+ if (
+ this.channel.receiveStartFromOtherDevices &&
+ this.wasSentByOwnUser(event) &&
+ !this.wasSentByOwnDevice(event)
+ ) {
+ this._observeOnly = true;
+ }
+ }
+ }
+ // create verifier
+ if (phase === PHASE_STARTED) {
+ const { method } = event!.getContent();
+ if (!this._verifier && !this.observeOnly) {
+ this._verifier = this.createVerifier(method, event);
+ if (!this._verifier) {
+ this.cancel({
+ code: "m.unknown_method",
+ reason: `Unknown method: ${method}`,
+ });
+ } else {
+ this._chosenMethod = method;
+ }
+ }
+ }
+ }
+
+ private applyPhaseTransitions(): ITransition[] {
+ const transitions = this.calculatePhaseTransitions();
+ const existingIdx = transitions.findIndex((t) => t.phase === this.phase);
+ // trim off phases we already went through, if any
+ const newTransitions = transitions.slice(existingIdx + 1);
+ // transition to all new phases
+ for (const transition of newTransitions) {
+ this.transitionToPhase(transition);
+ }
+ return newTransitions;
+ }
+
+ private isWinningStartRace(newEvent: MatrixEvent): boolean {
+ if (newEvent.getType() !== START_TYPE) {
+ return false;
+ }
+ const oldEvent = this._verifier!.startEvent;
+
+ let oldRaceIdentifier;
+ if (this.isSelfVerification) {
+ // if the verifier does not have a startEvent,
+ // it is because it's still sending and we are on the initator side
+ // we know we are sending a .start event because we already
+ // have a verifier (checked in calling method)
+ if (oldEvent) {
+ const oldContent = oldEvent.getContent();
+ oldRaceIdentifier = oldContent && oldContent.from_device;
+ } else {
+ oldRaceIdentifier = this.client.getDeviceId();
+ }
+ } else {
+ if (oldEvent) {
+ oldRaceIdentifier = oldEvent.getSender();
+ } else {
+ oldRaceIdentifier = this.client.getUserId();
+ }
+ }
+
+ let newRaceIdentifier;
+ if (this.isSelfVerification) {
+ const newContent = newEvent.getContent();
+ newRaceIdentifier = newContent && newContent.from_device;
+ } else {
+ newRaceIdentifier = newEvent.getSender();
+ }
+ return newRaceIdentifier < oldRaceIdentifier;
+ }
+
+ public hasEventId(eventId: string): boolean {
+ for (const event of this.eventsByUs.values()) {
+ if (event.getId() === eventId) {
+ return true;
+ }
+ }
+ for (const event of this.eventsByThem.values()) {
+ if (event.getId() === eventId) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Changes the state of the request and verifier in response to a key verification event.
+ * @param type - the "symbolic" event type, as returned by the `getEventType` function on the channel.
+ * @param event - the event to handle. Don't call getType() on it but use the `type` parameter instead.
+ * @param isLiveEvent - whether this is an even received through sync or not
+ * @param isRemoteEcho - whether this is the remote echo of an event sent by the same device
+ * @param isSentByUs - whether this event is sent by a party that can accept and/or observe the request like one of our peers.
+ * For InRoomChannel this means any device for the syncing user. For ToDeviceChannel, just the syncing device.
+ * @returns a promise that resolves when any requests as an answer to the passed-in event are sent.
+ */
+ public async handleEvent(
+ type: string,
+ event: MatrixEvent,
+ isLiveEvent: boolean,
+ isRemoteEcho: boolean,
+ isSentByUs: boolean,
+ ): Promise<void> {
+ // if reached phase cancelled or done, ignore anything else that comes
+ if (this.done || this.cancelled) {
+ return;
+ }
+ const wasObserveOnly = this._observeOnly;
+
+ this.adjustObserveOnly(event, isLiveEvent);
+
+ if (!this.observeOnly && !isRemoteEcho) {
+ if (await this.cancelOnError(type, event)) {
+ return;
+ }
+ }
+
+ // This assumes verification won't need to send an event with
+ // the same type for the same party twice.
+ // This is true for QR and SAS verification, and was
+ // added here to prevent verification getting cancelled
+ // when the server duplicates an event (https://github.com/matrix-org/synapse/issues/3365)
+ const isDuplicateEvent = isSentByUs ? this.eventsByUs.has(type) : this.eventsByThem.has(type);
+ if (isDuplicateEvent) {
+ return;
+ }
+
+ const oldPhase = this.phase;
+ this.addEvent(type, event, isSentByUs);
+
+ // this will create if needed the verifier so needs to happen before calling it
+ const newTransitions = this.applyPhaseTransitions();
+ try {
+ // only pass events from the other side to the verifier,
+ // no remote echos of our own events
+ if (this._verifier && !this.observeOnly) {
+ const newEventWinsRace = this.isWinningStartRace(event);
+ if (this._verifier.canSwitchStartEvent(event) && newEventWinsRace) {
+ this._verifier.switchStartEvent(event);
+ } else if (!isRemoteEcho) {
+ if (type === CANCEL_TYPE || this._verifier.events?.includes(type)) {
+ this._verifier.handleEvent(event);
+ }
+ }
+ }
+
+ if (newTransitions.length) {
+ // create QRCodeData if the other side can scan
+ // important this happens before emitting a phase change,
+ // so listeners can rely on it being there already
+ // We only do this for live events because it is important that
+ // we sign the keys that were in the QR code, and not the keys
+ // we happen to have at some later point in time.
+ if (isLiveEvent && newTransitions.some((t) => t.phase === PHASE_READY)) {
+ const shouldGenerateQrCode = this.otherPartySupportsMethod(SCAN_QR_CODE_METHOD, true);
+ if (shouldGenerateQrCode) {
+ this._qrCodeData = await QRCodeData.create(this, this.client);
+ }
+ }
+
+ const lastTransition = newTransitions[newTransitions.length - 1];
+ const { phase } = lastTransition;
+
+ this.setupTimeout(phase);
+ // set phase as last thing as this emits the "change" event
+ this.setPhase(phase);
+ } else if (this._observeOnly !== wasObserveOnly) {
+ this.emit(VerificationRequestEvent.Change);
+ }
+ } finally {
+ // log events we processed so we can see from rageshakes what events were added to a request
+ logger.log(
+ `Verification request ${this.channel.transactionId}: ` +
+ `${type} event with id:${event.getId()}, ` +
+ `content:${JSON.stringify(event.getContent())} ` +
+ `deviceId:${this.channel.deviceId}, ` +
+ `sender:${event.getSender()}, isSentByUs:${isSentByUs}, ` +
+ `isLiveEvent:${isLiveEvent}, isRemoteEcho:${isRemoteEcho}, ` +
+ `phase:${oldPhase}=>${this.phase}, ` +
+ `observeOnly:${wasObserveOnly}=>${this._observeOnly}`,
+ );
+ }
+ }
+
+ private setupTimeout(phase: Phase): void {
+ const shouldTimeout = !this.timeoutTimer && !this.observeOnly && phase === PHASE_REQUESTED;
+
+ if (shouldTimeout) {
+ this.timeoutTimer = setTimeout(this.cancelOnTimeout, this.timeout);
+ }
+ if (this.timeoutTimer) {
+ const shouldClear =
+ phase === PHASE_STARTED || phase === PHASE_READY || phase === PHASE_DONE || phase === PHASE_CANCELLED;
+ if (shouldClear) {
+ clearTimeout(this.timeoutTimer);
+ this.timeoutTimer = null;
+ }
+ }
+ }
+
+ private cancelOnTimeout = async (): Promise<void> => {
+ try {
+ if (this.initiatedByMe) {
+ await this.cancel({
+ reason: "Other party didn't accept in time",
+ code: "m.timeout",
+ });
+ } else {
+ await this.cancel({
+ reason: "User didn't accept in time",
+ code: "m.timeout",
+ });
+ }
+ } catch (err) {
+ logger.error("Error while cancelling verification request", err);
+ }
+ };
+
+ private async cancelOnError(type: string, event: MatrixEvent): Promise<boolean> {
+ if (type === START_TYPE) {
+ const method = event.getContent().method;
+ if (!this.verificationMethods.has(method)) {
+ await this.cancel(errorFromEvent(newUnknownMethodError()));
+ return true;
+ }
+ }
+
+ const isUnexpectedRequest = type === REQUEST_TYPE && this.phase !== PHASE_UNSENT;
+ const isUnexpectedReady = type === READY_TYPE && this.phase !== PHASE_REQUESTED && this.phase !== PHASE_STARTED;
+ // only if phase has passed from PHASE_UNSENT should we cancel, because events
+ // are allowed to come in in any order (at least with InRoomChannel). So we only know
+ // we're dealing with a valid request we should participate in once we've moved to PHASE_REQUESTED.
+ // Before that, we could be looking at somebody else's verification request and we just
+ // happen to be in the room
+ if (this.phase !== PHASE_UNSENT && (isUnexpectedRequest || isUnexpectedReady)) {
+ logger.warn(`Cancelling, unexpected ${type} verification ` + `event from ${event.getSender()}`);
+ const reason = `Unexpected ${type} event in phase ${this.phase}`;
+ await this.cancel(errorFromEvent(newUnexpectedMessageError({ reason })));
+ return true;
+ }
+ return false;
+ }
+
+ private adjustObserveOnly(event: MatrixEvent, isLiveEvent = false): void {
+ // don't send out events for historical requests
+ if (!isLiveEvent) {
+ this._observeOnly = true;
+ }
+ if (this.calculateEventTimeout(event) < VERIFICATION_REQUEST_MARGIN) {
+ this._observeOnly = true;
+ }
+ }
+
+ private addEvent(type: string, event: MatrixEvent, isSentByUs = false): void {
+ if (isSentByUs) {
+ this.eventsByUs.set(type, event);
+ } else {
+ this.eventsByThem.set(type, event);
+ }
+
+ // once we know the userId of the other party (from the .request event)
+ // see if any event by anyone else crept into this.eventsByThem
+ if (type === REQUEST_TYPE) {
+ for (const [type, event] of this.eventsByThem.entries()) {
+ if (event.getSender() !== this.otherUserId) {
+ this.eventsByThem.delete(type);
+ }
+ }
+ // also remember when we received the request event
+ this.requestReceivedAt = Date.now();
+ }
+ }
+
+ private createVerifier(
+ method: VerificationMethod,
+ startEvent: MatrixEvent | null = null,
+ targetDevice: ITargetDevice | null = null,
+ ): VerificationBase<any, any> | undefined {
+ if (!targetDevice) {
+ targetDevice = this.targetDevice;
+ }
+ const { userId, deviceId } = targetDevice;
+
+ const VerifierCtor = this.verificationMethods.get(method);
+ if (!VerifierCtor) {
+ logger.warn("could not find verifier constructor for method", method);
+ return;
+ }
+ return new VerifierCtor(this.channel, this.client, userId!, deviceId!, startEvent, this);
+ }
+
+ private wasSentByOwnUser(event?: MatrixEvent): boolean {
+ return event?.getSender() === this.client.getUserId();
+ }
+
+ // only for .request, .ready or .start
+ private wasSentByOwnDevice(event?: MatrixEvent): boolean {
+ if (!this.wasSentByOwnUser(event)) {
+ return false;
+ }
+ const content = event!.getContent();
+ if (!content || content.from_device !== this.client.getDeviceId()) {
+ return false;
+ }
+ return true;
+ }
+
+ public onVerifierCancelled(): void {
+ this._cancelled = true;
+ // move to cancelled phase
+ const newTransitions = this.applyPhaseTransitions();
+ if (newTransitions.length) {
+ this.setPhase(newTransitions[newTransitions.length - 1].phase);
+ }
+ }
+
+ public onVerifierFinished(): void {
+ this.channel.send(EventType.KeyVerificationDone, {});
+ this.verifierHasFinished = true;
+ // move to .done phase
+ const newTransitions = this.applyPhaseTransitions();
+ if (newTransitions.length) {
+ this.setPhase(newTransitions[newTransitions.length - 1].phase);
+ }
+ }
+
+ public getEventFromOtherParty(type: string): MatrixEvent | undefined {
+ return this.eventsByThem.get(type);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/embedded.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/embedded.ts
new file mode 100644
index 0000000..a08b79a
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/embedded.ts
@@ -0,0 +1,347 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {
+ WidgetApi,
+ WidgetApiToWidgetAction,
+ MatrixCapabilities,
+ IWidgetApiRequest,
+ IWidgetApiAcknowledgeResponseData,
+ ISendEventToWidgetActionRequest,
+ ISendToDeviceToWidgetActionRequest,
+ ISendEventFromWidgetResponseData,
+} from "matrix-widget-api";
+
+import { IEvent, IContent, EventStatus } from "./models/event";
+import { ISendEventResponse } from "./@types/requests";
+import { EventType } from "./@types/event";
+import { logger } from "./logger";
+import { MatrixClient, ClientEvent, IMatrixClientCreateOpts, IStartClientOpts, SendToDeviceContentMap } from "./client";
+import { SyncApi, SyncState } from "./sync";
+import { SlidingSyncSdk } from "./sliding-sync-sdk";
+import { MatrixEvent } from "./models/event";
+import { User } from "./models/user";
+import { Room } from "./models/room";
+import { ToDeviceBatch, ToDevicePayload } from "./models/ToDeviceMessage";
+import { DeviceInfo } from "./crypto/deviceinfo";
+import { IOlmDevice } from "./crypto/algorithms/megolm";
+import { MapWithDefault, recursiveMapToObject } from "./utils";
+
+interface IStateEventRequest {
+ eventType: string;
+ stateKey?: string;
+}
+
+export interface ICapabilities {
+ /**
+ * Event types that this client expects to send.
+ */
+ sendEvent?: string[];
+ /**
+ * Event types that this client expects to receive.
+ */
+ receiveEvent?: string[];
+
+ /**
+ * Message types that this client expects to send, or true for all message
+ * types.
+ */
+ sendMessage?: string[] | true;
+ /**
+ * Message types that this client expects to receive, or true for all
+ * message types.
+ */
+ receiveMessage?: string[] | true;
+
+ /**
+ * Types of state events that this client expects to send.
+ */
+ sendState?: IStateEventRequest[];
+ /**
+ * Types of state events that this client expects to receive.
+ */
+ receiveState?: IStateEventRequest[];
+
+ /**
+ * To-device event types that this client expects to send.
+ */
+ sendToDevice?: string[];
+ /**
+ * To-device event types that this client expects to receive.
+ */
+ receiveToDevice?: string[];
+
+ /**
+ * Whether this client needs access to TURN servers.
+ * @defaultValue false
+ */
+ turnServers?: boolean;
+}
+
+/**
+ * A MatrixClient that routes its requests through the widget API instead of the
+ * real CS API.
+ * @experimental This class is considered unstable!
+ */
+export class RoomWidgetClient extends MatrixClient {
+ private room?: Room;
+ private widgetApiReady = new Promise<void>((resolve) => this.widgetApi.once("ready", resolve));
+ private lifecycle?: AbortController;
+ private syncState: SyncState | null = null;
+
+ public constructor(
+ private readonly widgetApi: WidgetApi,
+ private readonly capabilities: ICapabilities,
+ private readonly roomId: string,
+ opts: IMatrixClientCreateOpts,
+ ) {
+ super(opts);
+
+ // Request capabilities for the functionality this client needs to support
+ if (
+ capabilities.sendEvent?.length ||
+ capabilities.receiveEvent?.length ||
+ capabilities.sendMessage === true ||
+ (Array.isArray(capabilities.sendMessage) && capabilities.sendMessage.length) ||
+ capabilities.receiveMessage === true ||
+ (Array.isArray(capabilities.receiveMessage) && capabilities.receiveMessage.length) ||
+ capabilities.sendState?.length ||
+ capabilities.receiveState?.length
+ ) {
+ widgetApi.requestCapabilityForRoomTimeline(roomId);
+ }
+ capabilities.sendEvent?.forEach((eventType) => widgetApi.requestCapabilityToSendEvent(eventType));
+ capabilities.receiveEvent?.forEach((eventType) => widgetApi.requestCapabilityToReceiveEvent(eventType));
+ if (capabilities.sendMessage === true) {
+ widgetApi.requestCapabilityToSendMessage();
+ } else if (Array.isArray(capabilities.sendMessage)) {
+ capabilities.sendMessage.forEach((msgType) => widgetApi.requestCapabilityToSendMessage(msgType));
+ }
+ if (capabilities.receiveMessage === true) {
+ widgetApi.requestCapabilityToReceiveMessage();
+ } else if (Array.isArray(capabilities.receiveMessage)) {
+ capabilities.receiveMessage.forEach((msgType) => widgetApi.requestCapabilityToReceiveMessage(msgType));
+ }
+ capabilities.sendState?.forEach(({ eventType, stateKey }) =>
+ widgetApi.requestCapabilityToSendState(eventType, stateKey),
+ );
+ capabilities.receiveState?.forEach(({ eventType, stateKey }) =>
+ widgetApi.requestCapabilityToReceiveState(eventType, stateKey),
+ );
+ capabilities.sendToDevice?.forEach((eventType) => widgetApi.requestCapabilityToSendToDevice(eventType));
+ capabilities.receiveToDevice?.forEach((eventType) => widgetApi.requestCapabilityToReceiveToDevice(eventType));
+ if (capabilities.turnServers) {
+ widgetApi.requestCapability(MatrixCapabilities.MSC3846TurnServers);
+ }
+
+ widgetApi.on(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
+ widgetApi.on(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
+
+ // Open communication with the host
+ widgetApi.start();
+ }
+
+ public async startClient(opts: IStartClientOpts = {}): Promise<void> {
+ this.lifecycle = new AbortController();
+
+ // Create our own user object artificially (instead of waiting for sync)
+ // so it's always available, even if the user is not in any rooms etc.
+ const userId = this.getUserId();
+ if (userId) {
+ this.store.storeUser(new User(userId));
+ }
+
+ // Even though we have no access token and cannot sync, the sync class
+ // still has some valuable helper methods that we make use of, so we
+ // instantiate it anyways
+ if (opts.slidingSync) {
+ this.syncApi = new SlidingSyncSdk(opts.slidingSync, this, opts, this.buildSyncApiOptions());
+ } else {
+ this.syncApi = new SyncApi(this, opts, this.buildSyncApiOptions());
+ }
+
+ this.room = this.syncApi.createRoom(this.roomId);
+ this.store.storeRoom(this.room);
+
+ await this.widgetApiReady;
+
+ // Backfill the requested events
+ // We only get the most recent event for every type + state key combo,
+ // so it doesn't really matter what order we inject them in
+ await Promise.all(
+ this.capabilities.receiveState?.map(async ({ eventType, stateKey }) => {
+ const rawEvents = await this.widgetApi.readStateEvents(eventType, undefined, stateKey, [this.roomId]);
+ const events = rawEvents.map((rawEvent) => new MatrixEvent(rawEvent as Partial<IEvent>));
+
+ await this.syncApi!.injectRoomEvents(this.room!, [], events);
+ events.forEach((event) => {
+ this.emit(ClientEvent.Event, event);
+ logger.info(`Backfilled event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
+ });
+ }) ?? [],
+ );
+ this.setSyncState(SyncState.Syncing);
+ logger.info("Finished backfilling events");
+
+ // Watch for TURN servers, if requested
+ if (this.capabilities.turnServers) this.watchTurnServers();
+ }
+
+ public stopClient(): void {
+ this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendEvent}`, this.onEvent);
+ this.widgetApi.off(`action:${WidgetApiToWidgetAction.SendToDevice}`, this.onToDevice);
+
+ super.stopClient();
+ this.lifecycle!.abort(); // Signal to other async tasks that the client has stopped
+ }
+
+ public async joinRoom(roomIdOrAlias: string): Promise<Room> {
+ if (roomIdOrAlias === this.roomId) return this.room!;
+ throw new Error(`Unknown room: ${roomIdOrAlias}`);
+ }
+
+ protected async encryptAndSendEvent(room: Room, event: MatrixEvent): Promise<ISendEventResponse> {
+ let response: ISendEventFromWidgetResponseData;
+ try {
+ response = await this.widgetApi.sendRoomEvent(event.getType(), event.getContent(), room.roomId);
+ } catch (e) {
+ this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
+ throw e;
+ }
+
+ room.updatePendingEvent(event, EventStatus.SENT, response.event_id);
+ return { event_id: response.event_id };
+ }
+
+ public async sendStateEvent(
+ roomId: string,
+ eventType: string,
+ content: any,
+ stateKey = "",
+ ): Promise<ISendEventResponse> {
+ return await this.widgetApi.sendStateEvent(eventType, stateKey, content, roomId);
+ }
+
+ public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<{}> {
+ await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
+ return {};
+ }
+
+ public async queueToDevice({ eventType, batch }: ToDeviceBatch): Promise<void> {
+ // map: user Id → device Id → payload
+ const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
+ for (const { userId, deviceId, payload } of batch) {
+ contentMap.getOrCreate(userId).set(deviceId, payload);
+ }
+
+ await this.widgetApi.sendToDevice(eventType, false, recursiveMapToObject(contentMap));
+ }
+
+ public async encryptAndSendToDevices(userDeviceInfoArr: IOlmDevice<DeviceInfo>[], payload: object): Promise<void> {
+ // map: user Id → device Id → payload
+ const contentMap: MapWithDefault<string, Map<string, object>> = new MapWithDefault(() => new Map());
+ for (const {
+ userId,
+ deviceInfo: { deviceId },
+ } of userDeviceInfoArr) {
+ contentMap.getOrCreate(userId).set(deviceId, payload);
+ }
+
+ await this.widgetApi.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap));
+ }
+
+ // Overridden since we get TURN servers automatically over the widget API,
+ // and this method would otherwise complain about missing an access token
+ public async checkTurnServers(): Promise<boolean> {
+ return this.turnServers.length > 0;
+ }
+
+ // Overridden since we 'sync' manually without the sync API
+ public getSyncState(): SyncState | null {
+ return this.syncState;
+ }
+
+ private setSyncState(state: SyncState): void {
+ const oldState = this.syncState;
+ this.syncState = state;
+ this.emit(ClientEvent.Sync, state, oldState);
+ }
+
+ private async ack(ev: CustomEvent<IWidgetApiRequest>): Promise<void> {
+ await this.widgetApi.transport.reply<IWidgetApiAcknowledgeResponseData>(ev.detail, {});
+ }
+
+ private onEvent = async (ev: CustomEvent<ISendEventToWidgetActionRequest>): Promise<void> => {
+ ev.preventDefault();
+
+ // Verify the room ID matches, since it's possible for the client to
+ // send us events from other rooms if this widget is always on screen
+ if (ev.detail.data.room_id === this.roomId) {
+ const event = new MatrixEvent(ev.detail.data as Partial<IEvent>);
+ await this.syncApi!.injectRoomEvents(this.room!, [], [event]);
+ this.emit(ClientEvent.Event, event);
+ this.setSyncState(SyncState.Syncing);
+ logger.info(`Received event ${event.getId()} ${event.getType()} ${event.getStateKey()}`);
+ } else {
+ const { event_id: eventId, room_id: roomId } = ev.detail.data;
+ logger.info(`Received event ${eventId} for a different room ${roomId}; discarding`);
+ }
+
+ await this.ack(ev);
+ };
+
+ private onToDevice = async (ev: CustomEvent<ISendToDeviceToWidgetActionRequest>): Promise<void> => {
+ ev.preventDefault();
+
+ const event = new MatrixEvent({
+ type: ev.detail.data.type,
+ sender: ev.detail.data.sender,
+ content: ev.detail.data.content as IContent,
+ });
+ // Mark the event as encrypted if it was, using fake contents and keys since those are unknown to us
+ if (ev.detail.data.encrypted) event.makeEncrypted(EventType.RoomMessageEncrypted, {}, "", "");
+
+ this.emit(ClientEvent.ToDeviceEvent, event);
+ this.setSyncState(SyncState.Syncing);
+ await this.ack(ev);
+ };
+
+ private async watchTurnServers(): Promise<void> {
+ const servers = this.widgetApi.getTurnServers();
+ const onClientStopped = (): void => {
+ servers.return(undefined);
+ };
+ this.lifecycle!.signal.addEventListener("abort", onClientStopped);
+
+ try {
+ for await (const server of servers) {
+ this.turnServers = [
+ {
+ urls: server.uris,
+ username: server.username,
+ credential: server.password,
+ },
+ ];
+ this.emit(ClientEvent.TurnServers, this.turnServers);
+ logger.log(`Received TURN server: ${server.uris}`);
+ }
+ } catch (e) {
+ logger.warn("Error watching TURN servers", e);
+ } finally {
+ this.lifecycle!.signal.removeEventListener("abort", onClientStopped);
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/errors.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/errors.ts
new file mode 100644
index 0000000..9d24651
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/errors.ts
@@ -0,0 +1,53 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export enum InvalidStoreState {
+ ToggledLazyLoading,
+}
+
+export class InvalidStoreError extends Error {
+ public static TOGGLED_LAZY_LOADING = InvalidStoreState.ToggledLazyLoading;
+
+ public constructor(public readonly reason: InvalidStoreState, public readonly value: any) {
+ const message =
+ `Store is invalid because ${reason}, ` +
+ `please stop the client, delete all data and start the client again`;
+ super(message);
+ this.name = "InvalidStoreError";
+ }
+}
+
+export enum InvalidCryptoStoreState {
+ TooNew = "TOO_NEW",
+}
+
+export class InvalidCryptoStoreError extends Error {
+ public static TOO_NEW = InvalidCryptoStoreState.TooNew;
+
+ public constructor(public readonly reason: InvalidCryptoStoreState) {
+ const message =
+ `Crypto store is invalid because ${reason}, ` +
+ `please stop the client, delete all data and start the client again`;
+ super(message);
+ this.name = "InvalidCryptoStoreError";
+ }
+}
+
+export class KeySignatureUploadError extends Error {
+ public constructor(message: string, public readonly value: any) {
+ super(message);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/event-mapper.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/event-mapper.ts
new file mode 100644
index 0000000..828d87e
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/event-mapper.ts
@@ -0,0 +1,97 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixClient } from "./client";
+import { IEvent, MatrixEvent, MatrixEventEvent } from "./models/event";
+import { RelationType } from "./@types/event";
+
+export type EventMapper = (obj: Partial<IEvent>) => MatrixEvent;
+
+export interface MapperOpts {
+ // don't re-emit events emitted on an event mapped by this mapper on the client
+ preventReEmit?: boolean;
+ // decrypt event proactively
+ decrypt?: boolean;
+ // the event is a to_device event
+ toDevice?: boolean;
+}
+
+export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
+ let preventReEmit = Boolean(options.preventReEmit);
+ const decrypt = options.decrypt !== false;
+
+ function mapper(plainOldJsObject: Partial<IEvent>): MatrixEvent {
+ if (options.toDevice) {
+ delete plainOldJsObject.room_id;
+ }
+
+ const room = client.getRoom(plainOldJsObject.room_id);
+
+ let event: MatrixEvent | undefined;
+ // If the event is already known to the room, let's re-use the model rather than duplicating.
+ // We avoid doing this to state events as they may be forward or backwards looking which tweaks behaviour.
+ if (room && plainOldJsObject.state_key === undefined) {
+ event = room.findEventById(plainOldJsObject.event_id!);
+ }
+
+ if (!event || event.status) {
+ event = new MatrixEvent(plainOldJsObject);
+ } else {
+ // merge the latest unsigned data from the server
+ event.setUnsigned({ ...event.getUnsigned(), ...plainOldJsObject.unsigned });
+ // prevent doubling up re-emitters
+ preventReEmit = true;
+ }
+
+ // if there is a complete edit bundled alongside the event, perform the replacement.
+ // (prior to MSC3925, events were automatically replaced on the server-side. MSC3925 proposes that that doesn't
+ // happen automatically but the server does provide us with the whole content of the edit event.)
+ const bundledEdit = event.getServerAggregatedRelation<Partial<IEvent>>(RelationType.Replace);
+ if (bundledEdit?.content) {
+ const replacement = mapper(bundledEdit);
+ // XXX: it's worth noting that the spec says we should only respect encrypted edits if, once decrypted, the
+ // replacement has a `m.new_content` property. The problem is that we haven't yet decrypted the replacement
+ // (it should be happening in the background), so we can't enforce this. Possibly we should for decryption
+ // to complete, but that sounds a bit racy. For now, we just assume it's ok.
+ event.makeReplaced(replacement);
+ }
+
+ const thread = room?.findThreadForEvent(event);
+ if (thread) {
+ event.setThread(thread);
+ }
+
+ // TODO: once we get rid of the old libolm-backed crypto, we can restrict this to room events (rather than
+ // to-device events), because the rust implementation decrypts to-device messages at a higher level.
+ // Generally we probably want to use a different eventMapper implementation for to-device events because
+ if (event.isEncrypted()) {
+ if (!preventReEmit) {
+ client.reEmitter.reEmit(event, [MatrixEventEvent.Decrypted]);
+ }
+ if (decrypt) {
+ client.decryptEventIfNeeded(event);
+ }
+ }
+
+ if (!preventReEmit) {
+ client.reEmitter.reEmit(event, [MatrixEventEvent.Replaced, MatrixEventEvent.VisibilityChange]);
+ room?.reEmitter.reEmit(event, [MatrixEventEvent.BeforeRedaction]);
+ }
+ return event;
+ }
+
+ return mapper;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/ExtensibleEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/ExtensibleEvent.ts
new file mode 100644
index 0000000..0496592
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/ExtensibleEvent.ts
@@ -0,0 +1,58 @@
+/*
+Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ExtensibleEventType, IPartialEvent } from "../@types/extensible_events";
+
+/**
+ * Represents an Extensible Event in Matrix.
+ */
+export abstract class ExtensibleEvent<TContent extends object = object> {
+ protected constructor(public readonly wireFormat: IPartialEvent<TContent>) {}
+
+ /**
+ * Shortcut to wireFormat.content
+ */
+ public get wireContent(): TContent {
+ return this.wireFormat.content;
+ }
+
+ /**
+ * Serializes the event into a format which can be used to send the
+ * event to the room.
+ * @returns The serialized event.
+ */
+ public abstract serialize(): IPartialEvent<object>;
+
+ /**
+ * Determines if this event is equivalent to the provided event type.
+ * This is recommended over `instanceof` checks due to issues in the JS
+ * runtime (and layering of dependencies in some projects).
+ *
+ * Implementations should pass this check off to their super classes
+ * if their own checks fail. Some primary implementations do not extend
+ * fallback classes given they support the primary type first. Thus,
+ * those classes may return false if asked about their fallback
+ * representation.
+ *
+ * Note that this only checks primary event types: legacy events, like
+ * m.room.message, should/will fail this check.
+ * @param primaryEventType - The (potentially namespaced) event
+ * type.
+ * @returns True if this event *could* be represented as the
+ * given type.
+ */
+ public abstract isEquivalentTo(primaryEventType: ExtensibleEventType): boolean;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/InvalidEventError.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/InvalidEventError.ts
new file mode 100644
index 0000000..12e59ad
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/InvalidEventError.ts
@@ -0,0 +1,24 @@
+/*
+Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Thrown when an event is unforgivably unparsable.
+ */
+export class InvalidEventError extends Error {
+ public constructor(message: string) {
+ super(message);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/MessageEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/MessageEvent.ts
new file mode 100644
index 0000000..3d049f4
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/MessageEvent.ts
@@ -0,0 +1,145 @@
+/*
+Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Optional } from "matrix-events-sdk";
+
+import { ExtensibleEvent } from "./ExtensibleEvent";
+import {
+ ExtensibleEventType,
+ IMessageRendering,
+ IPartialEvent,
+ isEventTypeSame,
+ M_HTML,
+ M_MESSAGE,
+ ExtensibleAnyMessageEventContent,
+ M_TEXT,
+} from "../@types/extensible_events";
+import { isOptionalAString, isProvided } from "./utilities";
+import { InvalidEventError } from "./InvalidEventError";
+
+/**
+ * Represents a message event. Message events are the simplest form of event with
+ * just text (optionally of different mimetypes, like HTML).
+ *
+ * Message events can additionally be an Emote or Notice, though typically those
+ * are represented as EmoteEvent and NoticeEvent respectively.
+ */
+export class MessageEvent extends ExtensibleEvent<ExtensibleAnyMessageEventContent> {
+ /**
+ * The default text for the event.
+ */
+ public readonly text: string;
+
+ /**
+ * The default HTML for the event, if provided.
+ */
+ public readonly html: Optional<string>;
+
+ /**
+ * All the different renderings of the message. Note that this is the same
+ * format as an m.message body but may contain elements not found directly
+ * in the event content: this is because this is interpreted based off the
+ * other information available in the event.
+ */
+ public readonly renderings: IMessageRendering[];
+
+ /**
+ * Creates a new MessageEvent from a pure format. Note that the event is
+ * *not* parsed here: it will be treated as a literal m.message primary
+ * typed event.
+ * @param wireFormat - The event.
+ */
+ public constructor(wireFormat: IPartialEvent<ExtensibleAnyMessageEventContent>) {
+ super(wireFormat);
+
+ const mmessage = M_MESSAGE.findIn(this.wireContent);
+ const mtext = M_TEXT.findIn<string>(this.wireContent);
+ const mhtml = M_HTML.findIn<string>(this.wireContent);
+ if (isProvided(mmessage)) {
+ if (!Array.isArray(mmessage)) {
+ throw new InvalidEventError("m.message contents must be an array");
+ }
+ const text = mmessage.find((r) => !isProvided(r.mimetype) || r.mimetype === "text/plain");
+ const html = mmessage.find((r) => r.mimetype === "text/html");
+
+ if (!text) throw new InvalidEventError("m.message is missing a plain text representation");
+
+ this.text = text.body;
+ this.html = html?.body;
+ this.renderings = mmessage;
+ } else if (isOptionalAString(mtext)) {
+ this.text = mtext;
+ this.html = mhtml;
+ this.renderings = [{ body: mtext, mimetype: "text/plain" }];
+ if (this.html) {
+ this.renderings.push({ body: this.html, mimetype: "text/html" });
+ }
+ } else {
+ throw new InvalidEventError("Missing textual representation for event");
+ }
+ }
+
+ public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean {
+ return isEventTypeSame(primaryEventType, M_MESSAGE);
+ }
+
+ protected serializeMMessageOnly(): ExtensibleAnyMessageEventContent {
+ let messageRendering: ExtensibleAnyMessageEventContent = {
+ [M_MESSAGE.name]: this.renderings,
+ };
+
+ // Use the shorthand if it's just a simple text event
+ if (this.renderings.length === 1) {
+ const mime = this.renderings[0].mimetype;
+ if (mime === undefined || mime === "text/plain") {
+ messageRendering = {
+ [M_TEXT.name]: this.renderings[0].body,
+ };
+ }
+ }
+
+ return messageRendering;
+ }
+
+ public serialize(): IPartialEvent<object> {
+ return {
+ type: "m.room.message",
+ content: {
+ ...this.serializeMMessageOnly(),
+ body: this.text,
+ msgtype: "m.text",
+ format: this.html ? "org.matrix.custom.html" : undefined,
+ formatted_body: this.html ?? undefined,
+ },
+ };
+ }
+
+ /**
+ * Creates a new MessageEvent from text and HTML.
+ * @param text - The text.
+ * @param html - Optional HTML.
+ * @returns The representative message event.
+ */
+ public static from(text: string, html?: string): MessageEvent {
+ return new MessageEvent({
+ type: M_MESSAGE.name,
+ content: {
+ [M_TEXT.name]: text,
+ [M_HTML.name]: html,
+ },
+ });
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollEndEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollEndEvent.ts
new file mode 100644
index 0000000..243f190
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollEndEvent.ts
@@ -0,0 +1,97 @@
+/*
+Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {
+ ExtensibleEventType,
+ IPartialEvent,
+ isEventTypeSame,
+ M_TEXT,
+ REFERENCE_RELATION,
+} from "../@types/extensible_events";
+import { M_POLL_END, PollEndEventContent } from "../@types/polls";
+import { ExtensibleEvent } from "./ExtensibleEvent";
+import { InvalidEventError } from "./InvalidEventError";
+import { MessageEvent } from "./MessageEvent";
+
+/**
+ * Represents a poll end/closure event.
+ */
+export class PollEndEvent extends ExtensibleEvent<PollEndEventContent> {
+ /**
+ * The poll start event ID referenced by the response.
+ */
+ public readonly pollEventId: string;
+
+ /**
+ * The closing message for the event.
+ */
+ public readonly closingMessage: MessageEvent;
+
+ /**
+ * Creates a new PollEndEvent from a pure format. Note that the event is *not*
+ * parsed here: it will be treated as a literal m.poll.response primary typed event.
+ * @param wireFormat - The event.
+ */
+ public constructor(wireFormat: IPartialEvent<PollEndEventContent>) {
+ super(wireFormat);
+
+ const rel = this.wireContent["m.relates_to"];
+ if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") {
+ throw new InvalidEventError("Relationship must be a reference to an event");
+ }
+
+ this.pollEventId = rel.event_id;
+ this.closingMessage = new MessageEvent(this.wireFormat);
+ }
+
+ public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean {
+ return isEventTypeSame(primaryEventType, M_POLL_END);
+ }
+
+ public serialize(): IPartialEvent<object> {
+ return {
+ type: M_POLL_END.name,
+ content: {
+ "m.relates_to": {
+ rel_type: REFERENCE_RELATION.name,
+ event_id: this.pollEventId,
+ },
+ [M_POLL_END.name]: {},
+ ...this.closingMessage.serialize().content,
+ },
+ };
+ }
+
+ /**
+ * Creates a new PollEndEvent from a poll event ID.
+ * @param pollEventId - The poll start event ID.
+ * @param message - A closing message, typically revealing the top answer.
+ * @returns The representative poll closure event.
+ */
+ public static from(pollEventId: string, message: string): PollEndEvent {
+ return new PollEndEvent({
+ type: M_POLL_END.name,
+ content: {
+ "m.relates_to": {
+ rel_type: REFERENCE_RELATION.name,
+ event_id: pollEventId,
+ },
+ [M_POLL_END.name]: {},
+ [M_TEXT.name]: message,
+ },
+ });
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollResponseEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollResponseEvent.ts
new file mode 100644
index 0000000..a61fc2e
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollResponseEvent.ts
@@ -0,0 +1,143 @@
+/*
+Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ExtensibleEvent } from "./ExtensibleEvent";
+import { M_POLL_RESPONSE, PollResponseEventContent, PollResponseSubtype } from "../@types/polls";
+import { ExtensibleEventType, IPartialEvent, isEventTypeSame, REFERENCE_RELATION } from "../@types/extensible_events";
+import { InvalidEventError } from "./InvalidEventError";
+import { PollStartEvent } from "./PollStartEvent";
+
+/**
+ * Represents a poll response event.
+ */
+export class PollResponseEvent extends ExtensibleEvent<PollResponseEventContent> {
+ private internalAnswerIds: string[] = [];
+ private internalSpoiled = false;
+
+ /**
+ * The provided answers for the poll. Note that this may be falsy/unpredictable if
+ * the `spoiled` property is true.
+ */
+ public get answerIds(): string[] {
+ return this.internalAnswerIds;
+ }
+
+ /**
+ * The poll start event ID referenced by the response.
+ */
+ public readonly pollEventId: string;
+
+ /**
+ * Whether the vote is spoiled.
+ */
+ public get spoiled(): boolean {
+ return this.internalSpoiled;
+ }
+
+ /**
+ * Creates a new PollResponseEvent from a pure format. Note that the event is *not*
+ * parsed here: it will be treated as a literal m.poll.response primary typed event.
+ *
+ * To validate the response against a poll, call `validateAgainst` after creation.
+ * @param wireFormat - The event.
+ */
+ public constructor(wireFormat: IPartialEvent<PollResponseEventContent>) {
+ super(wireFormat);
+
+ const rel = this.wireContent["m.relates_to"];
+ if (!REFERENCE_RELATION.matches(rel?.rel_type) || typeof rel?.event_id !== "string") {
+ throw new InvalidEventError("Relationship must be a reference to an event");
+ }
+
+ this.pollEventId = rel.event_id;
+ this.validateAgainst(null);
+ }
+
+ /**
+ * Validates the poll response using the poll start event as a frame of reference. This
+ * is used to determine if the vote is spoiled, whether the answers are valid, etc.
+ * @param poll - The poll start event.
+ */
+ public validateAgainst(poll: PollStartEvent | null): void {
+ const response = M_POLL_RESPONSE.findIn<PollResponseSubtype>(this.wireContent);
+ if (!Array.isArray(response?.answers)) {
+ this.internalSpoiled = true;
+ this.internalAnswerIds = [];
+ return;
+ }
+
+ let answers = response?.answers ?? [];
+ if (answers.some((a) => typeof a !== "string") || answers.length === 0) {
+ this.internalSpoiled = true;
+ this.internalAnswerIds = [];
+ return;
+ }
+
+ if (poll) {
+ if (answers.some((a) => !poll.answers.some((pa) => pa.id === a))) {
+ this.internalSpoiled = true;
+ this.internalAnswerIds = [];
+ return;
+ }
+
+ answers = answers.slice(0, poll.maxSelections);
+ }
+
+ this.internalAnswerIds = answers;
+ this.internalSpoiled = false;
+ }
+
+ public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean {
+ return isEventTypeSame(primaryEventType, M_POLL_RESPONSE);
+ }
+
+ public serialize(): IPartialEvent<object> {
+ return {
+ type: M_POLL_RESPONSE.name,
+ content: {
+ "m.relates_to": {
+ rel_type: REFERENCE_RELATION.name,
+ event_id: this.pollEventId,
+ },
+ [M_POLL_RESPONSE.name]: {
+ answers: this.spoiled ? undefined : this.answerIds,
+ },
+ },
+ };
+ }
+
+ /**
+ * Creates a new PollResponseEvent from a set of answers. To spoil the vote, pass an empty
+ * answers array.
+ * @param answers - The user's answers. Should be valid from a poll's answer IDs.
+ * @param pollEventId - The poll start event ID.
+ * @returns The representative poll response event.
+ */
+ public static from(answers: string[], pollEventId: string): PollResponseEvent {
+ return new PollResponseEvent({
+ type: M_POLL_RESPONSE.name,
+ content: {
+ "m.relates_to": {
+ rel_type: REFERENCE_RELATION.name,
+ event_id: pollEventId,
+ },
+ [M_POLL_RESPONSE.name]: {
+ answers: answers,
+ },
+ },
+ });
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollStartEvent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollStartEvent.ts
new file mode 100644
index 0000000..8584bf9
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/PollStartEvent.ts
@@ -0,0 +1,207 @@
+/*
+Copyright 2022 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { NamespacedValue } from "matrix-events-sdk";
+
+import { MessageEvent } from "./MessageEvent";
+import { ExtensibleEventType, IPartialEvent, isEventTypeSame, M_TEXT } from "../@types/extensible_events";
+import {
+ KnownPollKind,
+ M_POLL_KIND_DISCLOSED,
+ M_POLL_KIND_UNDISCLOSED,
+ M_POLL_START,
+ PollStartEventContent,
+ PollStartSubtype,
+ PollAnswer,
+} from "../@types/polls";
+import { InvalidEventError } from "./InvalidEventError";
+import { ExtensibleEvent } from "./ExtensibleEvent";
+
+/**
+ * Represents a poll answer. Note that this is represented as a subtype and is
+ * not registered as a parsable event - it is implied for usage exclusively
+ * within the PollStartEvent parsing.
+ */
+export class PollAnswerSubevent extends MessageEvent {
+ /**
+ * The answer ID.
+ */
+ public readonly id: string;
+
+ public constructor(wireFormat: IPartialEvent<PollAnswer>) {
+ super(wireFormat);
+
+ const id = wireFormat.content.id;
+ if (!id || typeof id !== "string") {
+ throw new InvalidEventError("Answer ID must be a non-empty string");
+ }
+ this.id = id;
+ }
+
+ public serialize(): IPartialEvent<object> {
+ return {
+ type: "org.matrix.sdk.poll.answer",
+ content: {
+ id: this.id,
+ ...this.serializeMMessageOnly(),
+ },
+ };
+ }
+
+ /**
+ * Creates a new PollAnswerSubevent from ID and text.
+ * @param id - The answer ID (unique within the poll).
+ * @param text - The text.
+ * @returns The representative answer.
+ */
+ public static from(id: string, text: string): PollAnswerSubevent {
+ return new PollAnswerSubevent({
+ type: "org.matrix.sdk.poll.answer",
+ content: {
+ id: id,
+ [M_TEXT.name]: text,
+ },
+ });
+ }
+}
+
+/**
+ * Represents a poll start event.
+ */
+export class PollStartEvent extends ExtensibleEvent<PollStartEventContent> {
+ /**
+ * The question being asked, as a MessageEvent node.
+ */
+ public readonly question: MessageEvent;
+
+ /**
+ * The interpreted kind of poll. Note that this will infer a value that is known to the
+ * SDK rather than verbatim - this means unknown types will be represented as undisclosed
+ * polls.
+ *
+ * To get the raw kind, use rawKind.
+ */
+ public readonly kind: KnownPollKind;
+
+ /**
+ * The true kind as provided by the event sender. Might not be valid.
+ */
+ public readonly rawKind: string;
+
+ /**
+ * The maximum number of selections a user is allowed to make.
+ */
+ public readonly maxSelections: number;
+
+ /**
+ * The possible answers for the poll.
+ */
+ public readonly answers: PollAnswerSubevent[];
+
+ /**
+ * Creates a new PollStartEvent from a pure format. Note that the event is *not*
+ * parsed here: it will be treated as a literal m.poll.start primary typed event.
+ * @param wireFormat - The event.
+ */
+ public constructor(wireFormat: IPartialEvent<PollStartEventContent>) {
+ super(wireFormat);
+
+ const poll = M_POLL_START.findIn<PollStartSubtype>(this.wireContent);
+
+ if (!poll?.question) {
+ throw new InvalidEventError("A question is required");
+ }
+
+ this.question = new MessageEvent({ type: "org.matrix.sdk.poll.question", content: poll.question });
+
+ this.rawKind = poll.kind;
+ if (M_POLL_KIND_DISCLOSED.matches(this.rawKind)) {
+ this.kind = M_POLL_KIND_DISCLOSED;
+ } else {
+ this.kind = M_POLL_KIND_UNDISCLOSED; // default & assumed value
+ }
+
+ this.maxSelections =
+ Number.isFinite(poll.max_selections) && poll.max_selections! > 0 ? poll.max_selections! : 1;
+
+ if (!Array.isArray(poll.answers)) {
+ throw new InvalidEventError("Poll answers must be an array");
+ }
+ const answers = poll.answers.slice(0, 20).map(
+ (a) =>
+ new PollAnswerSubevent({
+ type: "org.matrix.sdk.poll.answer",
+ content: a,
+ }),
+ );
+ if (answers.length <= 0) {
+ throw new InvalidEventError("No answers available");
+ }
+ this.answers = answers;
+ }
+
+ public isEquivalentTo(primaryEventType: ExtensibleEventType): boolean {
+ return isEventTypeSame(primaryEventType, M_POLL_START);
+ }
+
+ public serialize(): IPartialEvent<object> {
+ return {
+ type: M_POLL_START.name,
+ content: {
+ [M_POLL_START.name]: {
+ question: this.question.serialize().content,
+ kind: this.rawKind,
+ max_selections: this.maxSelections,
+ answers: this.answers.map((a) => a.serialize().content),
+ },
+ [M_TEXT.name]: `${this.question.text}\n${this.answers.map((a, i) => `${i + 1}. ${a.text}`).join("\n")}`,
+ },
+ };
+ }
+
+ /**
+ * Creates a new PollStartEvent from question, answers, and metadata.
+ * @param question - The question to ask.
+ * @param answers - The answers. Should be unique within each other.
+ * @param kind - The kind of poll.
+ * @param maxSelections - The maximum number of selections. Must be 1 or higher.
+ * @returns The representative poll start event.
+ */
+ public static from(
+ question: string,
+ answers: string[],
+ kind: KnownPollKind | string,
+ maxSelections = 1,
+ ): PollStartEvent {
+ return new PollStartEvent({
+ type: M_POLL_START.name,
+ content: {
+ [M_TEXT.name]: question, // unused by parsing
+ [M_POLL_START.name]: {
+ question: { [M_TEXT.name]: question },
+ kind: kind instanceof NamespacedValue ? kind.name : kind,
+ max_selections: maxSelections,
+ answers: answers.map((a) => ({ id: makeId(), [M_TEXT.name]: a })),
+ },
+ },
+ });
+ }
+}
+
+const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+function makeId(): string {
+ return [...Array(16)].map(() => LETTERS.charAt(Math.floor(Math.random() * LETTERS.length))).join("");
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/utilities.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/utilities.ts
new file mode 100644
index 0000000..0660442
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/extensible_events_v1/utilities.ts
@@ -0,0 +1,35 @@
+/*
+Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Optional } from "matrix-events-sdk";
+
+/**
+ * Determines if the given optional was provided a value.
+ * @param s - The optional to test.
+ * @returns True if the value is defined.
+ */
+export function isProvided<T>(s: Optional<T>): boolean {
+ return s !== null && s !== undefined;
+}
+
+/**
+ * Determines if the given optional string is a defined string.
+ * @param s - The input string.
+ * @returns True if the input is a defined string.
+ */
+export function isOptionalAString(s: Optional<string>): s is string {
+ return isProvided(s) && typeof s === "string";
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/feature.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/feature.ts
new file mode 100644
index 0000000..9141e81
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/feature.ts
@@ -0,0 +1,75 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IServerVersions } from "./client";
+
+export enum ServerSupport {
+ Stable,
+ Unstable,
+ Unsupported,
+}
+
+export enum Feature {
+ Thread = "Thread",
+ ThreadUnreadNotifications = "ThreadUnreadNotifications",
+ LoginTokenRequest = "LoginTokenRequest",
+ RelationBasedRedactions = "RelationBasedRedactions",
+ AccountDataDeletion = "AccountDataDeletion",
+}
+
+type FeatureSupportCondition = {
+ unstablePrefixes?: string[];
+ matrixVersion?: string;
+};
+
+const featureSupportResolver: Record<string, FeatureSupportCondition> = {
+ [Feature.Thread]: {
+ unstablePrefixes: ["org.matrix.msc3440"],
+ matrixVersion: "v1.3",
+ },
+ [Feature.ThreadUnreadNotifications]: {
+ unstablePrefixes: ["org.matrix.msc3771", "org.matrix.msc3773"],
+ matrixVersion: "v1.4",
+ },
+ [Feature.LoginTokenRequest]: {
+ unstablePrefixes: ["org.matrix.msc3882"],
+ },
+ [Feature.RelationBasedRedactions]: {
+ unstablePrefixes: ["org.matrix.msc3912"],
+ },
+ [Feature.AccountDataDeletion]: {
+ unstablePrefixes: ["org.matrix.msc3391"],
+ },
+};
+
+export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
+ const supportMap = new Map<Feature, ServerSupport>();
+ for (const [feature, supportCondition] of Object.entries(featureSupportResolver)) {
+ const supportMatrixVersion = versions.versions?.includes(supportCondition.matrixVersion || "") ?? false;
+ const supportUnstablePrefixes =
+ supportCondition.unstablePrefixes?.every((unstablePrefix) => {
+ return versions.unstable_features?.[unstablePrefix] === true;
+ }) ?? false;
+ if (supportMatrixVersion) {
+ supportMap.set(feature as Feature, ServerSupport.Stable);
+ } else if (supportUnstablePrefixes) {
+ supportMap.set(feature as Feature, ServerSupport.Unstable);
+ } else {
+ supportMap.set(feature as Feature, ServerSupport.Unsupported);
+ }
+ }
+ return supportMap;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/filter-component.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/filter-component.ts
new file mode 100644
index 0000000..e28571d
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/filter-component.ts
@@ -0,0 +1,204 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RelationType } from "./@types/event";
+import { MatrixEvent } from "./models/event";
+import { FILTER_RELATED_BY_REL_TYPES, FILTER_RELATED_BY_SENDERS, THREAD_RELATION_TYPE } from "./models/thread";
+
+/**
+ * Checks if a value matches a given field value, which may be a * terminated
+ * wildcard pattern.
+ * @param actualValue - The value to be compared
+ * @param filterValue - The filter pattern to be compared
+ * @returns true if the actualValue matches the filterValue
+ */
+function matchesWildcard(actualValue: string, filterValue: string): boolean {
+ if (filterValue.endsWith("*")) {
+ const typePrefix = filterValue.slice(0, -1);
+ return actualValue.slice(0, typePrefix.length) === typePrefix;
+ } else {
+ return actualValue === filterValue;
+ }
+}
+
+/* eslint-disable camelcase */
+export interface IFilterComponent {
+ "types"?: string[];
+ "not_types"?: string[];
+ "rooms"?: string[];
+ "not_rooms"?: string[];
+ "senders"?: string[];
+ "not_senders"?: string[];
+ "contains_url"?: boolean;
+ "limit"?: number;
+ "related_by_senders"?: Array<RelationType | string>;
+ "related_by_rel_types"?: string[];
+
+ // Unstable values
+ "io.element.relation_senders"?: Array<RelationType | string>;
+ "io.element.relation_types"?: string[];
+}
+/* eslint-enable camelcase */
+
+/**
+ * FilterComponent is a section of a Filter definition which defines the
+ * types, rooms, senders filters etc to be applied to a particular type of resource.
+ * This is all ported over from synapse's Filter object.
+ *
+ * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as
+ * 'Filters' are referred to as 'FilterCollections'.
+ */
+export class FilterComponent {
+ public constructor(private filterJson: IFilterComponent, public readonly userId?: string | undefined | null) {}
+
+ /**
+ * Checks with the filter component matches the given event
+ * @param event - event to be checked against the filter
+ * @returns true if the event matches the filter
+ */
+ public check(event: MatrixEvent): boolean {
+ const bundledRelationships = event.getUnsigned()?.["m.relations"] || {};
+ const relations: Array<string | RelationType> = Object.keys(bundledRelationships);
+ // Relation senders allows in theory a look-up of any senders
+ // however clients can only know about the current user participation status
+ // as sending a whole list of participants could be proven problematic in terms
+ // of performance
+ // This should be improved when bundled relationships solve that problem
+ const relationSenders: string[] = [];
+ if (this.userId && bundledRelationships?.[THREAD_RELATION_TYPE.name]?.current_user_participated) {
+ relationSenders.push(this.userId);
+ }
+
+ return this.checkFields(
+ event.getRoomId(),
+ event.getSender(),
+ event.getType(),
+ event.getContent() ? event.getContent().url !== undefined : false,
+ relations,
+ relationSenders,
+ );
+ }
+
+ /**
+ * Converts the filter component into the form expected over the wire
+ */
+ public toJSON(): object {
+ return {
+ types: this.filterJson.types || null,
+ not_types: this.filterJson.not_types || [],
+ rooms: this.filterJson.rooms || null,
+ not_rooms: this.filterJson.not_rooms || [],
+ senders: this.filterJson.senders || null,
+ not_senders: this.filterJson.not_senders || [],
+ contains_url: this.filterJson.contains_url || null,
+ [FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name] || [],
+ [FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name] || [],
+ };
+ }
+
+ /**
+ * Checks whether the filter component matches the given event fields.
+ * @param roomId - the roomId for the event being checked
+ * @param sender - the sender of the event being checked
+ * @param eventType - the type of the event being checked
+ * @param containsUrl - whether the event contains a content.url field
+ * @param relationTypes - whether has aggregated relation of the given type
+ * @param relationSenders - whether one of the relation is sent by the user listed
+ * @returns true if the event fields match the filter
+ */
+ private checkFields(
+ roomId: string | undefined,
+ sender: string | undefined,
+ eventType: string,
+ containsUrl: boolean,
+ relationTypes: Array<RelationType | string>,
+ relationSenders: string[],
+ ): boolean {
+ const literalKeys = {
+ rooms: function (v: string): boolean {
+ return roomId === v;
+ },
+ senders: function (v: string): boolean {
+ return sender === v;
+ },
+ types: function (v: string): boolean {
+ return matchesWildcard(eventType, v);
+ },
+ } as const;
+
+ for (const name in literalKeys) {
+ const matchFunc = literalKeys[<keyof typeof literalKeys>name];
+ const notName = "not_" + name;
+ const disallowedValues = this.filterJson[<`not_${keyof typeof literalKeys}`>notName];
+ if (disallowedValues?.some(matchFunc)) {
+ return false;
+ }
+
+ const allowedValues = this.filterJson[name as keyof typeof literalKeys];
+ if (allowedValues && !allowedValues.some(matchFunc)) {
+ return false;
+ }
+ }
+
+ const containsUrlFilter = this.filterJson.contains_url;
+ if (containsUrlFilter !== undefined && containsUrlFilter !== containsUrl) {
+ return false;
+ }
+
+ const relationTypesFilter = this.filterJson[FILTER_RELATED_BY_REL_TYPES.name];
+ if (relationTypesFilter !== undefined) {
+ if (!this.arrayMatchesFilter(relationTypesFilter, relationTypes)) {
+ return false;
+ }
+ }
+
+ const relationSendersFilter = this.filterJson[FILTER_RELATED_BY_SENDERS.name];
+ if (relationSendersFilter !== undefined) {
+ if (!this.arrayMatchesFilter(relationSendersFilter, relationSenders)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private arrayMatchesFilter(filter: any[], values: any[]): boolean {
+ return (
+ values.length > 0 &&
+ filter.every((value) => {
+ return values.includes(value);
+ })
+ );
+ }
+
+ /**
+ * Filters a list of events down to those which match this filter component
+ * @param events - Events to be checked against the filter component
+ * @returns events which matched the filter component
+ */
+ public filter(events: MatrixEvent[]): MatrixEvent[] {
+ return events.filter(this.check, this);
+ }
+
+ /**
+ * Returns the limit field for a given filter component, providing a default of
+ * 10 if none is otherwise specified. Cargo-culted from Synapse.
+ * @returns the limit for this filter component.
+ */
+ public limit(): number {
+ return this.filterJson.limit !== undefined ? this.filterJson.limit : 10;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/filter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/filter.ts
new file mode 100644
index 0000000..4d74c8c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/filter.ts
@@ -0,0 +1,242 @@
+/*
+Copyright 2015 - 2021 Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventType, RelationType } from "./@types/event";
+import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
+import { FilterComponent, IFilterComponent } from "./filter-component";
+import { MatrixEvent } from "./models/event";
+
+/**
+ */
+function setProp(obj: Record<string, any>, keyNesting: string, val: any): void {
+ const nestedKeys = keyNesting.split(".") as [keyof typeof obj];
+ let currentObj = obj;
+ for (let i = 0; i < nestedKeys.length - 1; i++) {
+ if (!currentObj[nestedKeys[i]]) {
+ currentObj[nestedKeys[i]] = {};
+ }
+ currentObj = currentObj[nestedKeys[i]];
+ }
+ currentObj[nestedKeys[nestedKeys.length - 1]] = val;
+}
+
+/* eslint-disable camelcase */
+export interface IFilterDefinition {
+ event_fields?: string[];
+ event_format?: "client" | "federation";
+ presence?: IFilterComponent;
+ account_data?: IFilterComponent;
+ room?: IRoomFilter;
+}
+
+export interface IRoomEventFilter extends IFilterComponent {
+ "lazy_load_members"?: boolean;
+ "include_redundant_members"?: boolean;
+ "types"?: Array<EventType | string>;
+ "related_by_senders"?: Array<RelationType | string>;
+ "related_by_rel_types"?: string[];
+ "unread_thread_notifications"?: boolean;
+ "org.matrix.msc3773.unread_thread_notifications"?: boolean;
+
+ // Unstable values
+ "io.element.relation_senders"?: Array<RelationType | string>;
+ "io.element.relation_types"?: string[];
+}
+
+interface IStateFilter extends IRoomEventFilter {}
+
+interface IRoomFilter {
+ not_rooms?: string[];
+ rooms?: string[];
+ ephemeral?: IRoomEventFilter;
+ include_leave?: boolean;
+ state?: IStateFilter;
+ timeline?: IRoomEventFilter;
+ account_data?: IRoomEventFilter;
+}
+/* eslint-enable camelcase */
+
+export class Filter {
+ public static LAZY_LOADING_MESSAGES_FILTER = {
+ lazy_load_members: true,
+ };
+
+ /**
+ * Create a filter from existing data.
+ */
+ public static fromJson(userId: string | undefined | null, filterId: string, jsonObj: IFilterDefinition): Filter {
+ const filter = new Filter(userId, filterId);
+ filter.setDefinition(jsonObj);
+ return filter;
+ }
+
+ private definition: IFilterDefinition = {};
+ private roomFilter?: FilterComponent;
+ private roomTimelineFilter?: FilterComponent;
+
+ /**
+ * Construct a new Filter.
+ * @param userId - The user ID for this filter.
+ * @param filterId - The filter ID if known.
+ */
+ public constructor(public readonly userId: string | undefined | null, public filterId?: string) {}
+
+ /**
+ * Get the ID of this filter on your homeserver (if known)
+ * @returns The filter ID
+ */
+ public getFilterId(): string | undefined {
+ return this.filterId;
+ }
+
+ /**
+ * Get the JSON body of the filter.
+ * @returns The filter definition
+ */
+ public getDefinition(): IFilterDefinition {
+ return this.definition;
+ }
+
+ /**
+ * Set the JSON body of the filter
+ * @param definition - The filter definition
+ */
+ public setDefinition(definition: IFilterDefinition): void {
+ this.definition = definition;
+
+ // This is all ported from synapse's FilterCollection()
+
+ // definitions look something like:
+ // {
+ // "room": {
+ // "rooms": ["!abcde:example.com"],
+ // "not_rooms": ["!123456:example.com"],
+ // "state": {
+ // "types": ["m.room.*"],
+ // "not_rooms": ["!726s6s6q:example.com"],
+ // "lazy_load_members": true,
+ // },
+ // "timeline": {
+ // "limit": 10,
+ // "types": ["m.room.message"],
+ // "not_rooms": ["!726s6s6q:example.com"],
+ // "not_senders": ["@spam:example.com"]
+ // "contains_url": true
+ // },
+ // "ephemeral": {
+ // "types": ["m.receipt", "m.typing"],
+ // "not_rooms": ["!726s6s6q:example.com"],
+ // "not_senders": ["@spam:example.com"]
+ // }
+ // },
+ // "presence": {
+ // "types": ["m.presence"],
+ // "not_senders": ["@alice:example.com"]
+ // },
+ // "event_format": "client",
+ // "event_fields": ["type", "content", "sender"]
+ // }
+
+ const roomFilterJson = definition.room;
+
+ // consider the top level rooms/not_rooms filter
+ const roomFilterFields: IRoomFilter = {};
+ if (roomFilterJson) {
+ if (roomFilterJson.rooms) {
+ roomFilterFields.rooms = roomFilterJson.rooms;
+ }
+ if (roomFilterJson.rooms) {
+ roomFilterFields.not_rooms = roomFilterJson.not_rooms;
+ }
+ }
+
+ this.roomFilter = new FilterComponent(roomFilterFields, this.userId);
+ this.roomTimelineFilter = new FilterComponent(roomFilterJson?.timeline || {}, this.userId);
+
+ // don't bother porting this from synapse yet:
+ // this._room_state_filter =
+ // new FilterComponent(roomFilterJson.state || {});
+ // this._room_ephemeral_filter =
+ // new FilterComponent(roomFilterJson.ephemeral || {});
+ // this._room_account_data_filter =
+ // new FilterComponent(roomFilterJson.account_data || {});
+ // this._presence_filter =
+ // new FilterComponent(definition.presence || {});
+ // this._account_data_filter =
+ // new FilterComponent(definition.account_data || {});
+ }
+
+ /**
+ * Get the room.timeline filter component of the filter
+ * @returns room timeline filter component
+ */
+ public getRoomTimelineFilterComponent(): FilterComponent | undefined {
+ return this.roomTimelineFilter;
+ }
+
+ /**
+ * Filter the list of events based on whether they are allowed in a timeline
+ * based on this filter
+ * @param events - the list of events being filtered
+ * @returns the list of events which match the filter
+ */
+ public filterRoomTimeline(events: MatrixEvent[]): MatrixEvent[] {
+ if (this.roomFilter) {
+ events = this.roomFilter.filter(events);
+ }
+ if (this.roomTimelineFilter) {
+ events = this.roomTimelineFilter.filter(events);
+ }
+ return events;
+ }
+
+ /**
+ * Set the max number of events to return for each room's timeline.
+ * @param limit - The max number of events to return for each room.
+ */
+ public setTimelineLimit(limit: number): void {
+ setProp(this.definition, "room.timeline.limit", limit);
+ }
+
+ /**
+ * Enable threads unread notification
+ */
+ public setUnreadThreadNotifications(enabled: boolean): void {
+ this.definition = {
+ ...this.definition,
+ room: {
+ ...this.definition?.room,
+ timeline: {
+ ...this.definition?.room?.timeline,
+ [UNREAD_THREAD_NOTIFICATIONS.name]: enabled,
+ },
+ },
+ };
+ }
+
+ public setLazyLoadMembers(enabled: boolean): void {
+ setProp(this.definition, "room.state.lazy_load_members", enabled);
+ }
+
+ /**
+ * Control whether left rooms should be included in responses.
+ * @param includeLeave - True to make rooms the user has left appear
+ * in responses.
+ */
+ public setIncludeLeaveRooms(includeLeave: boolean): void {
+ setProp(this.definition, "room.include_leave", includeLeave);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/errors.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/errors.ts
new file mode 100644
index 0000000..e48fc02
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/errors.ts
@@ -0,0 +1,84 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IUsageLimit } from "../@types/partials";
+import { MatrixEvent } from "../models/event";
+
+interface IErrorJson extends Partial<IUsageLimit> {
+ [key: string]: any; // extensible
+ errcode?: string;
+ error?: string;
+}
+
+/**
+ * Construct a generic HTTP error. This is a JavaScript Error with additional information
+ * specific to HTTP responses.
+ * @param msg - The error message to include.
+ * @param httpStatus - The HTTP response status code.
+ */
+export class HTTPError extends Error {
+ public constructor(msg: string, public readonly httpStatus?: number) {
+ super(msg);
+ }
+}
+
+export class MatrixError extends HTTPError {
+ // The Matrix 'errcode' value, e.g. "M_FORBIDDEN".
+ public readonly errcode?: string;
+ // The raw Matrix error JSON used to construct this object.
+ public data: IErrorJson;
+
+ /**
+ * Construct a Matrix error. This is a JavaScript Error with additional
+ * information specific to the standard Matrix error response.
+ * @param errorJson - The Matrix error JSON returned from the homeserver.
+ * @param httpStatus - The numeric HTTP status code given
+ */
+ public constructor(
+ errorJson: IErrorJson = {},
+ public readonly httpStatus?: number,
+ public url?: string,
+ public event?: MatrixEvent,
+ ) {
+ let message = errorJson.error || "Unknown message";
+ if (httpStatus) {
+ message = `[${httpStatus}] ${message}`;
+ }
+ if (url) {
+ message = `${message} (${url})`;
+ }
+ super(`MatrixError: ${message}`, httpStatus);
+ this.errcode = errorJson.errcode;
+ this.name = errorJson.errcode || "Unknown error code";
+ this.data = errorJson;
+ }
+}
+
+/**
+ * Construct a ConnectionError. This is a JavaScript Error indicating
+ * that a request failed because of some error with the connection, either
+ * CORS was not correctly configured on the server, the server didn't response,
+ * the request timed out, or the internet connection on the client side went down.
+ */
+export class ConnectionError extends Error {
+ public constructor(message: string, cause?: Error) {
+ super(message + (cause ? `: ${cause.message}` : ""));
+ }
+
+ public get name(): string {
+ return "ConnectionError";
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/fetch.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/fetch.ts
new file mode 100644
index 0000000..ecb0908
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/fetch.ts
@@ -0,0 +1,311 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module. See {@link MatrixHttpApi} for the public class.
+ */
+
+import * as utils from "../utils";
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+import { Method } from "./method";
+import { ConnectionError, MatrixError } from "./errors";
+import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, IRequestOpts } from "./interface";
+import { anySignal, parseErrorResponse, timeoutSignal } from "./utils";
+import { QueryDict } from "../utils";
+
+type Body = Record<string, any> | BodyInit;
+
+interface TypedResponse<T> extends Response {
+ json(): Promise<T>;
+}
+
+export type ResponseType<T, O extends IHttpOpts> = O extends undefined
+ ? T
+ : O extends { onlyData: true }
+ ? T
+ : TypedResponse<T>;
+
+export class FetchHttpApi<O extends IHttpOpts> {
+ private abortController = new AbortController();
+
+ public constructor(
+ private eventEmitter: TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>,
+ public readonly opts: O,
+ ) {
+ utils.checkObjectHasKeys(opts, ["baseUrl", "prefix"]);
+ opts.onlyData = !!opts.onlyData;
+ opts.useAuthorizationHeader = opts.useAuthorizationHeader ?? true;
+ }
+
+ public abort(): void {
+ this.abortController.abort();
+ this.abortController = new AbortController();
+ }
+
+ public fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> {
+ if (this.opts.fetchFn) {
+ return this.opts.fetchFn(resource, options);
+ }
+ return global.fetch(resource, options);
+ }
+
+ /**
+ * Sets the base URL for the identity server
+ * @param url - The new base url
+ */
+ public setIdBaseUrl(url: string): void {
+ this.opts.idBaseUrl = url;
+ }
+
+ public idServerRequest<T extends {} = Record<string, unknown>>(
+ method: Method,
+ path: string,
+ params: Record<string, string | string[]> | undefined,
+ prefix: string,
+ accessToken?: string,
+ ): Promise<ResponseType<T, O>> {
+ if (!this.opts.idBaseUrl) {
+ throw new Error("No identity server base URL set");
+ }
+
+ let queryParams: QueryDict | undefined = undefined;
+ let body: Record<string, string | string[]> | undefined = undefined;
+ if (method === Method.Get) {
+ queryParams = params;
+ } else {
+ body = params;
+ }
+
+ const fullUri = this.getUrl(path, queryParams, prefix, this.opts.idBaseUrl);
+
+ const opts: IRequestOpts = {
+ json: true,
+ headers: {},
+ };
+ if (accessToken) {
+ opts.headers!.Authorization = `Bearer ${accessToken}`;
+ }
+
+ return this.requestOtherUrl(method, fullUri, body, opts);
+ }
+
+ /**
+ * Perform an authorised request to the homeserver.
+ * @param method - The HTTP method e.g. "GET".
+ * @param path - The HTTP path <b>after</b> the supplied prefix e.g.
+ * "/createRoom".
+ *
+ * @param queryParams - A dict of query params (these will NOT be
+ * urlencoded). If unspecified, there will be no query params.
+ *
+ * @param body - The HTTP JSON body.
+ *
+ * @param opts - additional options. If a number is specified,
+ * this is treated as `opts.localTimeoutMs`.
+ *
+ * @returns Promise which resolves to
+ * ```
+ * {
+ * data: {Object},
+ * headers: {Object},
+ * code: {Number},
+ * }
+ * ```
+ * If `onlyData` is set, this will resolve to the `data` object only.
+ * @returns Rejects with an error if a problem occurred.
+ * This includes network problems and Matrix-specific error JSON.
+ */
+ public authedRequest<T>(
+ method: Method,
+ path: string,
+ queryParams?: QueryDict,
+ body?: Body,
+ opts: IRequestOpts = {},
+ ): Promise<ResponseType<T, O>> {
+ if (!queryParams) queryParams = {};
+
+ if (this.opts.accessToken) {
+ if (this.opts.useAuthorizationHeader) {
+ if (!opts.headers) {
+ opts.headers = {};
+ }
+ if (!opts.headers.Authorization) {
+ opts.headers.Authorization = "Bearer " + this.opts.accessToken;
+ }
+ if (queryParams.access_token) {
+ delete queryParams.access_token;
+ }
+ } else if (!queryParams.access_token) {
+ queryParams.access_token = this.opts.accessToken;
+ }
+ }
+
+ const requestPromise = this.request<T>(method, path, queryParams, body, opts);
+
+ requestPromise.catch((err: MatrixError) => {
+ if (err.errcode == "M_UNKNOWN_TOKEN" && !opts?.inhibitLogoutEmit) {
+ this.eventEmitter.emit(HttpApiEvent.SessionLoggedOut, err);
+ } else if (err.errcode == "M_CONSENT_NOT_GIVEN") {
+ this.eventEmitter.emit(HttpApiEvent.NoConsent, err.message, err.data.consent_uri);
+ }
+ });
+
+ // return the original promise, otherwise tests break due to it having to
+ // go around the event loop one more time to process the result of the request
+ return requestPromise;
+ }
+
+ /**
+ * Perform a request to the homeserver without any credentials.
+ * @param method - The HTTP method e.g. "GET".
+ * @param path - The HTTP path <b>after</b> the supplied prefix e.g.
+ * "/createRoom".
+ *
+ * @param queryParams - A dict of query params (these will NOT be
+ * urlencoded). If unspecified, there will be no query params.
+ *
+ * @param body - The HTTP JSON body.
+ *
+ * @param opts - additional options
+ *
+ * @returns Promise which resolves to
+ * ```
+ * {
+ * data: {Object},
+ * headers: {Object},
+ * code: {Number},
+ * }
+ * ```
+ * If `onlyData</code> is set, this will resolve to the <code>data`
+ * object only.
+ * @returns Rejects with an error if a problem
+ * occurred. This includes network problems and Matrix-specific error JSON.
+ */
+ public request<T>(
+ method: Method,
+ path: string,
+ queryParams?: QueryDict,
+ body?: Body,
+ opts?: IRequestOpts,
+ ): Promise<ResponseType<T, O>> {
+ const fullUri = this.getUrl(path, queryParams, opts?.prefix, opts?.baseUrl);
+ return this.requestOtherUrl<T>(method, fullUri, body, opts);
+ }
+
+ /**
+ * Perform a request to an arbitrary URL.
+ * @param method - The HTTP method e.g. "GET".
+ * @param url - The HTTP URL object.
+ *
+ * @param body - The HTTP JSON body.
+ *
+ * @param opts - additional options
+ *
+ * @returns Promise which resolves to data unless `onlyData` is specified as false,
+ * where the resolved value will be a fetch Response object.
+ * @returns Rejects with an error if a problem
+ * occurred. This includes network problems and Matrix-specific error JSON.
+ */
+ public async requestOtherUrl<T>(
+ method: Method,
+ url: URL | string,
+ body?: Body,
+ opts: Pick<IRequestOpts, "headers" | "json" | "localTimeoutMs" | "keepAlive" | "abortSignal"> = {},
+ ): Promise<ResponseType<T, O>> {
+ const headers = Object.assign({}, opts.headers || {});
+ const json = opts.json ?? true;
+ // We can't use getPrototypeOf here as objects made in other contexts e.g. over postMessage won't have same ref
+ const jsonBody = json && body?.constructor?.name === Object.name;
+
+ if (json) {
+ if (jsonBody && !headers["Content-Type"]) {
+ headers["Content-Type"] = "application/json";
+ }
+
+ if (!headers["Accept"]) {
+ headers["Accept"] = "application/json";
+ }
+ }
+
+ const timeout = opts.localTimeoutMs ?? this.opts.localTimeoutMs;
+ const keepAlive = opts.keepAlive ?? false;
+ const signals = [this.abortController.signal];
+ if (timeout !== undefined) {
+ signals.push(timeoutSignal(timeout));
+ }
+ if (opts.abortSignal) {
+ signals.push(opts.abortSignal);
+ }
+
+ let data: BodyInit;
+ if (jsonBody) {
+ data = JSON.stringify(body);
+ } else {
+ data = body as BodyInit;
+ }
+
+ const { signal, cleanup } = anySignal(signals);
+
+ let res: Response;
+ try {
+ res = await this.fetch(url, {
+ signal,
+ method,
+ body: data,
+ headers,
+ mode: "cors",
+ redirect: "follow",
+ referrer: "",
+ referrerPolicy: "no-referrer",
+ cache: "no-cache",
+ credentials: "omit", // we send credentials via headers
+ keepalive: keepAlive,
+ });
+ } catch (e) {
+ if ((<Error>e).name === "AbortError") {
+ throw e;
+ }
+ throw new ConnectionError("fetch failed", <Error>e);
+ } finally {
+ cleanup();
+ }
+
+ if (!res.ok) {
+ throw parseErrorResponse(res, await res.text());
+ }
+
+ if (this.opts.onlyData) {
+ return json ? res.json() : res.text();
+ }
+ return res as ResponseType<T, O>;
+ }
+
+ /**
+ * Form and return a homeserver request URL based on the given path params and prefix.
+ * @param path - The HTTP path <b>after</b> the supplied prefix e.g. "/createRoom".
+ * @param queryParams - A dict of query params (these will NOT be urlencoded).
+ * @param prefix - The full prefix to use e.g. "/_matrix/client/v2_alpha", defaulting to this.opts.prefix.
+ * @param baseUrl - The baseUrl to use e.g. "https://matrix.org/", defaulting to this.opts.baseUrl.
+ * @returns URL
+ */
+ public getUrl(path: string, queryParams?: QueryDict, prefix?: string, baseUrl?: string): URL {
+ const url = new URL((baseUrl ?? this.opts.baseUrl) + (prefix ?? this.opts.prefix) + path);
+ if (queryParams) {
+ utils.encodeParams(queryParams, url.searchParams);
+ }
+ return url;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/index.ts
new file mode 100644
index 0000000..c5e8e2a
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/index.ts
@@ -0,0 +1,191 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { FetchHttpApi } from "./fetch";
+import { FileType, IContentUri, IHttpOpts, Upload, UploadOpts, UploadResponse } from "./interface";
+import { MediaPrefix } from "./prefix";
+import * as utils from "../utils";
+import * as callbacks from "../realtime-callbacks";
+import { Method } from "./method";
+import { ConnectionError } from "./errors";
+import { parseErrorResponse } from "./utils";
+
+export * from "./interface";
+export * from "./prefix";
+export * from "./errors";
+export * from "./method";
+export * from "./utils";
+
+export class MatrixHttpApi<O extends IHttpOpts> extends FetchHttpApi<O> {
+ private uploads: Upload[] = [];
+
+ /**
+ * Upload content to the homeserver
+ *
+ * @param file - The object to upload. On a browser, something that
+ * can be sent to XMLHttpRequest.send (typically a File). Under node.js,
+ * a Buffer, String or ReadStream.
+ *
+ * @param opts - options object
+ *
+ * @returns Promise which resolves to response object, as
+ * determined by this.opts.onlyData, opts.rawResponse, and
+ * opts.onlyContentUri. Rejects with an error (usually a MatrixError).
+ */
+ public uploadContent(file: FileType, opts: UploadOpts = {}): Promise<UploadResponse> {
+ const includeFilename = opts.includeFilename ?? true;
+ const abortController = opts.abortController ?? new AbortController();
+
+ // If the file doesn't have a mime type, use a default since the HS errors if we don't supply one.
+ const contentType = opts.type ?? (file as File).type ?? "application/octet-stream";
+ const fileName = opts.name ?? (file as File).name;
+
+ const upload = {
+ loaded: 0,
+ total: 0,
+ abortController,
+ } as Upload;
+ const defer = utils.defer<UploadResponse>();
+
+ if (global.XMLHttpRequest) {
+ const xhr = new global.XMLHttpRequest();
+
+ const timeoutFn = function (): void {
+ xhr.abort();
+ defer.reject(new Error("Timeout"));
+ };
+
+ // set an initial timeout of 30s; we'll advance it each time we get a progress notification
+ let timeoutTimer = callbacks.setTimeout(timeoutFn, 30000);
+
+ xhr.onreadystatechange = function (): void {
+ switch (xhr.readyState) {
+ case global.XMLHttpRequest.DONE:
+ callbacks.clearTimeout(timeoutTimer);
+ try {
+ if (xhr.status === 0) {
+ throw new DOMException(xhr.statusText, "AbortError"); // mimic fetch API
+ }
+ if (!xhr.responseText) {
+ throw new Error("No response body.");
+ }
+
+ if (xhr.status >= 400) {
+ defer.reject(parseErrorResponse(xhr, xhr.responseText));
+ } else {
+ defer.resolve(JSON.parse(xhr.responseText));
+ }
+ } catch (err) {
+ if ((<Error>err).name === "AbortError") {
+ defer.reject(err);
+ return;
+ }
+ defer.reject(new ConnectionError("request failed", <Error>err));
+ }
+ break;
+ }
+ };
+
+ xhr.upload.onprogress = (ev: ProgressEvent): void => {
+ callbacks.clearTimeout(timeoutTimer);
+ upload.loaded = ev.loaded;
+ upload.total = ev.total;
+ timeoutTimer = callbacks.setTimeout(timeoutFn, 30000);
+ opts.progressHandler?.({
+ loaded: ev.loaded,
+ total: ev.total,
+ });
+ };
+
+ const url = this.getUrl("/upload", undefined, MediaPrefix.R0);
+
+ if (includeFilename && fileName) {
+ url.searchParams.set("filename", encodeURIComponent(fileName));
+ }
+
+ if (!this.opts.useAuthorizationHeader && this.opts.accessToken) {
+ url.searchParams.set("access_token", encodeURIComponent(this.opts.accessToken));
+ }
+
+ xhr.open(Method.Post, url.href);
+ if (this.opts.useAuthorizationHeader && this.opts.accessToken) {
+ xhr.setRequestHeader("Authorization", "Bearer " + this.opts.accessToken);
+ }
+ xhr.setRequestHeader("Content-Type", contentType);
+ xhr.send(file);
+
+ abortController.signal.addEventListener("abort", () => {
+ xhr.abort();
+ });
+ } else {
+ const queryParams: utils.QueryDict = {};
+ if (includeFilename && fileName) {
+ queryParams.filename = fileName;
+ }
+
+ const headers: Record<string, string> = { "Content-Type": contentType };
+
+ this.authedRequest<UploadResponse>(Method.Post, "/upload", queryParams, file, {
+ prefix: MediaPrefix.R0,
+ headers,
+ abortSignal: abortController.signal,
+ })
+ .then((response) => {
+ return this.opts.onlyData ? <UploadResponse>response : response.json();
+ })
+ .then(defer.resolve, defer.reject);
+ }
+
+ // remove the upload from the list on completion
+ upload.promise = defer.promise.finally(() => {
+ utils.removeElement(this.uploads, (elem) => elem === upload);
+ });
+ abortController.signal.addEventListener("abort", () => {
+ utils.removeElement(this.uploads, (elem) => elem === upload);
+ defer.reject(new DOMException("Aborted", "AbortError"));
+ });
+ this.uploads.push(upload);
+ return upload.promise;
+ }
+
+ public cancelUpload(promise: Promise<UploadResponse>): boolean {
+ const upload = this.uploads.find((u) => u.promise === promise);
+ if (upload) {
+ upload.abortController.abort();
+ return true;
+ }
+ return false;
+ }
+
+ public getCurrentUploads(): Upload[] {
+ return this.uploads;
+ }
+
+ /**
+ * Get the content repository url with query parameters.
+ * @returns An object with a 'base', 'path' and 'params' for base URL,
+ * path and query parameters respectively.
+ */
+ public getContentUri(): IContentUri {
+ return {
+ base: this.opts.baseUrl,
+ path: MediaPrefix.R0 + "/upload",
+ params: {
+ access_token: this.opts.accessToken!,
+ },
+ };
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/interface.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/interface.ts
new file mode 100644
index 0000000..9946aa3
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/interface.ts
@@ -0,0 +1,147 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixError } from "./errors";
+
+export interface IHttpOpts {
+ fetchFn?: typeof global.fetch;
+
+ baseUrl: string;
+ idBaseUrl?: string;
+ prefix: string;
+ extraParams?: Record<string, string>;
+
+ accessToken?: string;
+ useAuthorizationHeader?: boolean; // defaults to true
+
+ onlyData?: boolean;
+ localTimeoutMs?: number;
+}
+
+export interface IRequestOpts {
+ /**
+ * The alternative base url to use.
+ * If not specified, uses this.opts.baseUrl
+ */
+ baseUrl?: string;
+ /**
+ * The full prefix to use e.g.
+ * "/_matrix/client/v2_alpha". If not specified, uses this.opts.prefix.
+ */
+ prefix?: string;
+ /**
+ * map of additional request headers
+ */
+ headers?: Record<string, string>;
+ abortSignal?: AbortSignal;
+ /**
+ * The maximum amount of time to wait before
+ * timing out the request. If not specified, there is no timeout.
+ */
+ localTimeoutMs?: number;
+ keepAlive?: boolean; // defaults to false
+ json?: boolean; // defaults to true
+
+ // Set to true to prevent the request function from emitting a Session.logged_out event.
+ // This is intended for use on endpoints where M_UNKNOWN_TOKEN is a valid/notable error response,
+ // such as with token refreshes.
+ inhibitLogoutEmit?: boolean;
+}
+
+export interface IContentUri {
+ base: string;
+ path: string;
+ params: {
+ // eslint-disable-next-line camelcase
+ access_token: string;
+ };
+}
+
+export enum HttpApiEvent {
+ SessionLoggedOut = "Session.logged_out",
+ NoConsent = "no_consent",
+}
+
+export type HttpApiEventHandlerMap = {
+ /**
+ * Fires whenever the login session the JS SDK is using is no
+ * longer valid and the user must log in again.
+ * NB. This only fires when action is required from the user, not
+ * when then login session can be renewed by using a refresh token.
+ * @example
+ * ```
+ * matrixClient.on("Session.logged_out", function(errorObj){
+ * // show the login screen
+ * });
+ * ```
+ */
+ [HttpApiEvent.SessionLoggedOut]: (err: MatrixError) => void;
+ /**
+ * Fires when the JS SDK receives a M_CONSENT_NOT_GIVEN error in response
+ * to a HTTP request.
+ * @example
+ * ```
+ * matrixClient.on("no_consent", function(message, contentUri) {
+ * console.info(message + ' Go to ' + contentUri);
+ * });
+ * ```
+ */
+ [HttpApiEvent.NoConsent]: (message: string, consentUri: string) => void;
+};
+
+export interface UploadProgress {
+ loaded: number;
+ total: number;
+}
+
+export interface UploadOpts {
+ /**
+ * Name to give the file on the server. Defaults to <tt>file.name</tt>.
+ */
+ name?: string;
+ /**
+ * Content-type for the upload. Defaults to
+ * <tt>file.type</tt>, or <tt>applicaton/octet-stream</tt>.
+ */
+ type?: string;
+ /**
+ * if false will not send the filename,
+ * e.g for encrypted file uploads where filename leaks are undesirable.
+ * Defaults to true.
+ */
+ includeFilename?: boolean;
+ /**
+ * Optional. Called when a chunk of
+ * data has been uploaded, with an object containing the fields `loaded`
+ * (number of bytes transferred) and `total` (total size, if known).
+ */
+ progressHandler?(progress: UploadProgress): void;
+ abortController?: AbortController;
+}
+
+export interface Upload {
+ loaded: number;
+ total: number;
+ promise: Promise<UploadResponse>;
+ abortController: AbortController;
+}
+
+export interface UploadResponse {
+ // eslint-disable-next-line camelcase
+ content_uri: string;
+}
+
+export type FileType = XMLHttpRequestBodyInit;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/method.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/method.ts
new file mode 100644
index 0000000..1914360
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/method.ts
@@ -0,0 +1,22 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export enum Method {
+ Get = "GET",
+ Put = "PUT",
+ Post = "POST",
+ Delete = "DELETE",
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/prefix.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/prefix.ts
new file mode 100644
index 0000000..f15b1ac
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/prefix.ts
@@ -0,0 +1,48 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export enum ClientPrefix {
+ /**
+ * A constant representing the URI path for release 0 of the Client-Server HTTP API.
+ */
+ R0 = "/_matrix/client/r0",
+ /**
+ * A constant representing the URI path for the legacy release v1 of the Client-Server HTTP API.
+ */
+ V1 = "/_matrix/client/v1",
+ /**
+ * A constant representing the URI path for Client-Server API endpoints versioned at v3.
+ */
+ V3 = "/_matrix/client/v3",
+ /**
+ * A constant representing the URI path for as-yet unspecified Client-Server HTTP APIs.
+ */
+ Unstable = "/_matrix/client/unstable",
+}
+
+export enum IdentityPrefix {
+ /**
+ * URI path for the v2 identity API
+ */
+ V2 = "/_matrix/identity/v2",
+}
+
+export enum MediaPrefix {
+ /**
+ * URI path for the media repo API
+ */
+ R0 = "/_matrix/media/r0",
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/utils.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/utils.ts
new file mode 100644
index 0000000..c49be74
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/http-api/utils.ts
@@ -0,0 +1,153 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { parse as parseContentType, ParsedMediaType } from "content-type";
+
+import { logger } from "../logger";
+import { sleep } from "../utils";
+import { ConnectionError, HTTPError, MatrixError } from "./errors";
+
+// Ponyfill for https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout
+export function timeoutSignal(ms: number): AbortSignal {
+ const controller = new AbortController();
+ setTimeout(() => {
+ controller.abort();
+ }, ms);
+
+ return controller.signal;
+}
+
+export function anySignal(signals: AbortSignal[]): {
+ signal: AbortSignal;
+ cleanup(): void;
+} {
+ const controller = new AbortController();
+
+ function cleanup(): void {
+ for (const signal of signals) {
+ signal.removeEventListener("abort", onAbort);
+ }
+ }
+
+ function onAbort(): void {
+ controller.abort();
+ cleanup();
+ }
+
+ for (const signal of signals) {
+ if (signal.aborted) {
+ onAbort();
+ break;
+ }
+ signal.addEventListener("abort", onAbort);
+ }
+
+ return {
+ signal: controller.signal,
+ cleanup,
+ };
+}
+
+/**
+ * Attempt to turn an HTTP error response into a Javascript Error.
+ *
+ * If it is a JSON response, we will parse it into a MatrixError. Otherwise
+ * we return a generic Error.
+ *
+ * @param response - response object
+ * @param body - raw body of the response
+ * @returns
+ */
+export function parseErrorResponse(response: XMLHttpRequest | Response, body?: string): Error {
+ let contentType: ParsedMediaType | null;
+ try {
+ contentType = getResponseContentType(response);
+ } catch (e) {
+ return <Error>e;
+ }
+
+ if (contentType?.type === "application/json" && body) {
+ return new MatrixError(
+ JSON.parse(body),
+ response.status,
+ isXhr(response) ? response.responseURL : response.url,
+ );
+ }
+ if (contentType?.type === "text/plain") {
+ return new HTTPError(`Server returned ${response.status} error: ${body}`, response.status);
+ }
+ return new HTTPError(`Server returned ${response.status} error`, response.status);
+}
+
+function isXhr(response: XMLHttpRequest | Response): response is XMLHttpRequest {
+ return "getResponseHeader" in response;
+}
+
+/**
+ * extract the Content-Type header from the response object, and
+ * parse it to a `{type, parameters}` object.
+ *
+ * returns null if no content-type header could be found.
+ *
+ * @param response - response object
+ * @returns parsed content-type header, or null if not found
+ */
+function getResponseContentType(response: XMLHttpRequest | Response): ParsedMediaType | null {
+ let contentType: string | null;
+ if (isXhr(response)) {
+ contentType = response.getResponseHeader("Content-Type");
+ } else {
+ contentType = response.headers.get("Content-Type");
+ }
+
+ if (!contentType) return null;
+
+ try {
+ return parseContentType(contentType);
+ } catch (e) {
+ throw new Error(`Error parsing Content-Type '${contentType}': ${e}`);
+ }
+}
+
+/**
+ * Retries a network operation run in a callback.
+ * @param maxAttempts - maximum attempts to try
+ * @param callback - callback that returns a promise of the network operation. If rejected with ConnectionError, it will be retried by calling the callback again.
+ * @returns the result of the network operation
+ * @throws {@link ConnectionError} If after maxAttempts the callback still throws ConnectionError
+ */
+export async function retryNetworkOperation<T>(maxAttempts: number, callback: () => Promise<T>): Promise<T> {
+ let attempts = 0;
+ let lastConnectionError: ConnectionError | null = null;
+ while (attempts < maxAttempts) {
+ try {
+ if (attempts > 0) {
+ const timeout = 1000 * Math.pow(2, attempts);
+ logger.log(`network operation failed ${attempts} times, retrying in ${timeout}ms...`);
+ await sleep(timeout);
+ }
+ return await callback();
+ } catch (err) {
+ if (err instanceof ConnectionError) {
+ attempts += 1;
+ lastConnectionError = err;
+ } else {
+ throw err;
+ }
+ }
+ }
+ throw lastConnectionError;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/index.ts
new file mode 100644
index 0000000..c9a5dcf
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/index.ts
@@ -0,0 +1,25 @@
+/*
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import * as matrixcs from "./matrix";
+
+if (global.__js_sdk_entrypoint) {
+ throw new Error("Multiple matrix-js-sdk entrypoints detected!");
+}
+global.__js_sdk_entrypoint = true;
+
+export * from "./matrix";
+export default matrixcs;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-helpers.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-helpers.ts
new file mode 100644
index 0000000..6f99ae5
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-helpers.ts
@@ -0,0 +1,50 @@
+/*
+Copyright 2019 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Check if an IndexedDB database exists. The only way to do so is to try opening it, so
+ * we do that and then delete it did not exist before.
+ *
+ * @param indexedDB - The `indexedDB` interface
+ * @param dbName - The database name to test for
+ * @returns Whether the database exists
+ */
+export function exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> {
+ return new Promise<boolean>((resolve, reject) => {
+ let exists = true;
+ const req = indexedDB.open(dbName);
+ req.onupgradeneeded = (): void => {
+ // Since we did not provide an explicit version when opening, this event
+ // should only fire if the DB did not exist before at any version.
+ exists = false;
+ };
+ req.onblocked = (): void => reject(req.error);
+ req.onsuccess = (): void => {
+ const db = req.result;
+ db.close();
+ if (!exists) {
+ // The DB did not exist before, but has been created as part of this
+ // existence check. Delete it now to restore previous state. Delete can
+ // actually take a while to complete in some browsers, so don't wait for
+ // it. This won't block future open calls that a store might issue next to
+ // properly set up the DB.
+ indexedDB.deleteDatabase(dbName);
+ }
+ resolve(exists);
+ };
+ req.onerror = (): void => reject(req.error);
+ });
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-worker.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-worker.ts
new file mode 100644
index 0000000..68dcf0f
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/indexeddb-worker.ts
@@ -0,0 +1,24 @@
+/*
+Copyright 2017 Vector Creations Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Separate exports file for the indexeddb web worker, which is designed
+ * to be used separately
+ */
+
+/** The {@link IndexedDBStoreWorker} class. */
+export { IndexedDBStoreWorker } from "./store/indexeddb-store-worker";
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/interactive-auth.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/interactive-auth.ts
new file mode 100644
index 0000000..7d9c183
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/interactive-auth.ts
@@ -0,0 +1,617 @@
+/*
+Copyright 2016 OpenMarket Ltd
+Copyright 2017 Vector Creations Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "./logger";
+import { MatrixClient } from "./client";
+import { defer, IDeferred } from "./utils";
+import { MatrixError } from "./http-api";
+
+const EMAIL_STAGE_TYPE = "m.login.email.identity";
+const MSISDN_STAGE_TYPE = "m.login.msisdn";
+
+export interface UIAFlow {
+ stages: AuthType[];
+}
+
+export interface IInputs {
+ // An email address. If supplied, a flow using email verification will be chosen.
+ emailAddress?: string;
+ // An ISO two letter country code. Gives the country that opts.phoneNumber should be resolved relative to.
+ phoneCountry?: string;
+ // A phone number. If supplied, a flow using phone number validation will be chosen.
+ phoneNumber?: string;
+ registrationToken?: string;
+}
+
+export interface IStageStatus {
+ emailSid?: string;
+ errcode?: string;
+ error?: string;
+}
+
+export interface IAuthData {
+ session?: string;
+ type?: string;
+ completed?: string[];
+ flows?: UIAFlow[];
+ available_flows?: UIAFlow[];
+ stages?: string[];
+ required_stages?: AuthType[];
+ params?: Record<string, Record<string, any>>;
+ data?: Record<string, string>;
+ errcode?: string;
+ error?: string;
+ user_id?: string;
+ device_id?: string;
+ access_token?: string;
+}
+
+export enum AuthType {
+ Password = "m.login.password",
+ Recaptcha = "m.login.recaptcha",
+ Terms = "m.login.terms",
+ Email = "m.login.email.identity",
+ Msisdn = "m.login.msisdn",
+ Sso = "m.login.sso",
+ SsoUnstable = "org.matrix.login.sso",
+ Dummy = "m.login.dummy",
+ RegistrationToken = "m.login.registration_token",
+ // For backwards compatability with servers that have not yet updated to
+ // use the stable "m.login.registration_token" type.
+ // The authentication flow is the same in both cases.
+ UnstableRegistrationToken = "org.matrix.msc3231.login.registration_token",
+}
+
+export interface IAuthDict {
+ // [key: string]: any;
+ type?: string;
+ session?: string;
+ // TODO: Remove `user` once servers support proper UIA
+ // See https://github.com/vector-im/element-web/issues/10312
+ user?: string;
+ identifier?: any;
+ password?: string;
+ response?: string;
+ // TODO: Remove `threepid_creds` once servers support proper UIA
+ // See https://github.com/vector-im/element-web/issues/10312
+ // See https://github.com/matrix-org/matrix-doc/issues/2220
+ // eslint-disable-next-line camelcase
+ threepid_creds?: any;
+ threepidCreds?: any;
+ // For m.login.registration_token type
+ token?: string;
+}
+
+class NoAuthFlowFoundError extends Error {
+ public name = "NoAuthFlowFoundError";
+
+ // eslint-disable-next-line @typescript-eslint/naming-convention, camelcase
+ public constructor(m: string, public readonly required_stages: string[], public readonly flows: UIAFlow[]) {
+ super(m);
+ }
+}
+
+interface IOpts {
+ /**
+ * A matrix client to use for the auth process
+ */
+ matrixClient: MatrixClient;
+ /**
+ * Error response from the last request. If null, a request will be made with no auth before starting.
+ */
+ authData?: IAuthData;
+ /**
+ * Inputs provided by the user and used by different stages of the auto process.
+ * The inputs provided will affect what flow is chosen.
+ */
+ inputs?: IInputs;
+ /**
+ * If resuming an existing interactive auth session, the sessionId of that session.
+ */
+ sessionId?: string;
+ /**
+ * If resuming an existing interactive auth session, the client secret for that session
+ */
+ clientSecret?: string;
+ /**
+ * If returning from having completed m.login.email.identity auth, the sid for the email verification session.
+ */
+ emailSid?: string;
+
+ /**
+ * Called with the new auth dict to submit the request.
+ * Also passes a second deprecated arg which is a flag set to true if this request is a background request.
+ * The busyChanged callback should be used instead of the background flag.
+ * Should return a promise which resolves to the successful response or rejects with a MatrixError.
+ */
+ doRequest(auth: IAuthData | null, background: boolean): Promise<IAuthData>;
+ /**
+ * Called when the status of the UI auth changes,
+ * ie. when the state of an auth stage changes of when the auth flow moves to a new stage.
+ * The arguments are: the login type (eg m.login.password); and an object which is either an error or an
+ * informational object specific to the login type.
+ * If the 'errcode' key is defined, the object is an error, and has keys:
+ * errcode: string, the textual error code, eg. M_UNKNOWN
+ * error: string, human readable string describing the error
+ *
+ * The login type specific objects are as follows:
+ * m.login.email.identity:
+ * * emailSid: string, the sid of the active email auth session
+ */
+ stateUpdated(nextStage: AuthType, status: IStageStatus): void;
+
+ /**
+ * A function that takes the email address (string), clientSecret (string), attempt number (int) and
+ * sessionId (string) and calls the relevant requestToken function and returns the promise returned by that
+ * function.
+ * If the resulting promise rejects, the rejection will propagate through to the attemptAuth promise.
+ */
+ requestEmailToken(email: string, secret: string, attempt: number, session: string): Promise<{ sid: string }>;
+ /**
+ * Called whenever the interactive auth logic becomes busy submitting information provided by the user or finishes.
+ * After this has been called with true the UI should indicate that a request is in progress
+ * until it is called again with false.
+ */
+ busyChanged?(busy: boolean): void;
+ startAuthStage?(nextStage: string): Promise<void>; // LEGACY
+}
+
+/**
+ * Abstracts the logic used to drive the interactive auth process.
+ *
+ * <p>Components implementing an interactive auth flow should instantiate one of
+ * these, passing in the necessary callbacks to the constructor. They should
+ * then call attemptAuth, which will return a promise which will resolve or
+ * reject when the interactive-auth process completes.
+ *
+ * <p>Meanwhile, calls will be made to the startAuthStage and doRequest
+ * callbacks, and information gathered from the user can be submitted with
+ * submitAuthDict.
+ *
+ * @param opts - options object
+ */
+export class InteractiveAuth {
+ private readonly matrixClient: MatrixClient;
+ private readonly inputs: IInputs;
+ private readonly clientSecret: string;
+ private readonly requestCallback: IOpts["doRequest"];
+ private readonly busyChangedCallback?: IOpts["busyChanged"];
+ private readonly stateUpdatedCallback: IOpts["stateUpdated"];
+ private readonly requestEmailTokenCallback: IOpts["requestEmailToken"];
+
+ private data: IAuthData;
+ private emailSid?: string;
+ private requestingEmailToken = false;
+ private attemptAuthDeferred: IDeferred<IAuthData> | null = null;
+ private chosenFlow: UIAFlow | null = null;
+ private currentStage: string | null = null;
+
+ private emailAttempt = 1;
+
+ // if we are currently trying to submit an auth dict (which includes polling)
+ // the promise the will resolve/reject when it completes
+ private submitPromise: Promise<void> | null = null;
+
+ public constructor(opts: IOpts) {
+ this.matrixClient = opts.matrixClient;
+ this.data = opts.authData || {};
+ this.requestCallback = opts.doRequest;
+ this.busyChangedCallback = opts.busyChanged;
+ // startAuthStage included for backwards compat
+ this.stateUpdatedCallback = opts.stateUpdated || opts.startAuthStage;
+ this.requestEmailTokenCallback = opts.requestEmailToken;
+ this.inputs = opts.inputs || {};
+
+ if (opts.sessionId) this.data.session = opts.sessionId;
+ this.clientSecret = opts.clientSecret || this.matrixClient.generateClientSecret();
+ this.emailSid = opts.emailSid;
+ }
+
+ /**
+ * begin the authentication process.
+ *
+ * @returns which resolves to the response on success,
+ * or rejects with the error on failure. Rejects with NoAuthFlowFoundError if
+ * no suitable authentication flow can be found
+ */
+ public attemptAuth(): Promise<IAuthData> {
+ // This promise will be quite long-lived and will resolve when the
+ // request is authenticated and completes successfully.
+ this.attemptAuthDeferred = defer();
+ // pluck the promise out now, as doRequest may clear before we return
+ const promise = this.attemptAuthDeferred.promise;
+
+ // if we have no flows, try a request to acquire the flows
+ if (!this.data?.flows) {
+ this.busyChangedCallback?.(true);
+ // use the existing sessionId, if one is present.
+ const auth = this.data.session ? { session: this.data.session } : null;
+ this.doRequest(auth).finally(() => {
+ this.busyChangedCallback?.(false);
+ });
+ } else {
+ this.startNextAuthStage();
+ }
+
+ return promise;
+ }
+
+ /**
+ * Poll to check if the auth session or current stage has been
+ * completed out-of-band. If so, the attemptAuth promise will
+ * be resolved.
+ */
+ public async poll(): Promise<void> {
+ if (!this.data.session) return;
+ // likewise don't poll if there is no auth session in progress
+ if (!this.attemptAuthDeferred) return;
+ // if we currently have a request in flight, there's no point making
+ // another just to check what the status is
+ if (this.submitPromise) return;
+
+ let authDict: IAuthDict = {};
+ if (this.currentStage == EMAIL_STAGE_TYPE) {
+ // The email can be validated out-of-band, but we need to provide the
+ // creds so the HS can go & check it.
+ if (this.emailSid) {
+ const creds: Record<string, string> = {
+ sid: this.emailSid,
+ client_secret: this.clientSecret,
+ };
+ if (await this.matrixClient.doesServerRequireIdServerParam()) {
+ const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()!);
+ creds.id_server = idServerParsedUrl.host;
+ }
+ authDict = {
+ type: EMAIL_STAGE_TYPE,
+ // TODO: Remove `threepid_creds` once servers support proper UIA
+ // See https://github.com/matrix-org/synapse/issues/5665
+ // See https://github.com/matrix-org/matrix-doc/issues/2220
+ threepid_creds: creds,
+ threepidCreds: creds,
+ };
+ }
+ }
+
+ this.submitAuthDict(authDict, true);
+ }
+
+ /**
+ * get the auth session ID
+ *
+ * @returns session id
+ */
+ public getSessionId(): string | undefined {
+ return this.data?.session;
+ }
+
+ /**
+ * get the client secret used for validation sessions
+ * with the identity server.
+ *
+ * @returns client secret
+ */
+ public getClientSecret(): string {
+ return this.clientSecret;
+ }
+
+ /**
+ * get the server params for a given stage
+ *
+ * @param loginType - login type for the stage
+ * @returns any parameters from the server for this stage
+ */
+ public getStageParams(loginType: string): Record<string, any> | undefined {
+ return this.data.params?.[loginType];
+ }
+
+ public getChosenFlow(): UIAFlow | null {
+ return this.chosenFlow;
+ }
+
+ /**
+ * submit a new auth dict and fire off the request. This will either
+ * make attemptAuth resolve/reject, or cause the startAuthStage callback
+ * to be called for a new stage.
+ *
+ * @param authData - new auth dict to send to the server. Should
+ * include a `type` property denoting the login type, as well as any
+ * other params for that stage.
+ * @param background - If true, this request failing will not result
+ * in the attemptAuth promise being rejected. This can be set to true
+ * for requests that just poll to see if auth has been completed elsewhere.
+ */
+ public async submitAuthDict(authData: IAuthDict, background = false): Promise<void> {
+ if (!this.attemptAuthDeferred) {
+ throw new Error("submitAuthDict() called before attemptAuth()");
+ }
+
+ if (!background) {
+ this.busyChangedCallback?.(true);
+ }
+
+ // if we're currently trying a request, wait for it to finish
+ // as otherwise we can get multiple 200 responses which can mean
+ // things like multiple logins for register requests.
+ // (but discard any exceptions as we only care when its done,
+ // not whether it worked or not)
+ while (this.submitPromise) {
+ try {
+ await this.submitPromise;
+ } catch (e) {}
+ }
+
+ // use the sessionid from the last request, if one is present.
+ let auth: IAuthDict;
+ if (this.data.session) {
+ auth = {
+ session: this.data.session,
+ };
+ Object.assign(auth, authData);
+ } else {
+ auth = authData;
+ }
+
+ try {
+ // NB. the 'background' flag is deprecated by the busyChanged
+ // callback and is here for backwards compat
+ this.submitPromise = this.doRequest(auth, background);
+ await this.submitPromise;
+ } finally {
+ this.submitPromise = null;
+ if (!background) {
+ this.busyChangedCallback?.(false);
+ }
+ }
+ }
+
+ /**
+ * Gets the sid for the email validation session
+ * Specific to m.login.email.identity
+ *
+ * @returns The sid of the email auth session
+ */
+ public getEmailSid(): string | undefined {
+ return this.emailSid;
+ }
+
+ /**
+ * Sets the sid for the email validation session
+ * This must be set in order to successfully poll for completion
+ * of the email validation.
+ * Specific to m.login.email.identity
+ *
+ * @param sid - The sid for the email validation session
+ */
+ public setEmailSid(sid: string): void {
+ this.emailSid = sid;
+ }
+
+ /**
+ * Requests a new email token and sets the email sid for the validation session
+ */
+ public requestEmailToken = async (): Promise<void> => {
+ if (!this.requestingEmailToken) {
+ logger.trace("Requesting email token. Attempt: " + this.emailAttempt);
+ // If we've picked a flow with email auth, we send the email
+ // now because we want the request to fail as soon as possible
+ // if the email address is not valid (ie. already taken or not
+ // registered, depending on what the operation is).
+ this.requestingEmailToken = true;
+ try {
+ const requestTokenResult = await this.requestEmailTokenCallback(
+ this.inputs.emailAddress!,
+ this.clientSecret,
+ this.emailAttempt++,
+ this.data.session!,
+ );
+ this.emailSid = requestTokenResult.sid;
+ logger.trace("Email token request succeeded");
+ } finally {
+ this.requestingEmailToken = false;
+ }
+ } else {
+ logger.warn("Could not request email token: Already requesting");
+ }
+ };
+
+ /**
+ * Fire off a request, and either resolve the promise, or call
+ * startAuthStage.
+ *
+ * @internal
+ * @param auth - new auth dict, including session id
+ * @param background - If true, this request is a background poll, so it
+ * failing will not result in the attemptAuth promise being rejected.
+ * This can be set to true for requests that just poll to see if auth has
+ * been completed elsewhere.
+ */
+ private async doRequest(auth: IAuthData | null, background = false): Promise<void> {
+ try {
+ const result = await this.requestCallback(auth, background);
+ this.attemptAuthDeferred!.resolve(result);
+ this.attemptAuthDeferred = null;
+ } catch (error) {
+ // sometimes UI auth errors don't come with flows
+ const errorFlows = (<MatrixError>error).data?.flows ?? null;
+ const haveFlows = this.data.flows || Boolean(errorFlows);
+ if ((<MatrixError>error).httpStatus !== 401 || !(<MatrixError>error).data || !haveFlows) {
+ // doesn't look like an interactive-auth failure.
+ if (!background) {
+ this.attemptAuthDeferred?.reject(error);
+ } else {
+ // We ignore all failures here (even non-UI auth related ones)
+ // since we don't want to suddenly fail if the internet connection
+ // had a blip whilst we were polling
+ logger.log("Background poll request failed doing UI auth: ignoring", error);
+ }
+ }
+ if (!(<MatrixError>error).data) {
+ (<MatrixError>error).data = {};
+ }
+ // if the error didn't come with flows, completed flows or session ID,
+ // copy over the ones we have. Synapse sometimes sends responses without
+ // any UI auth data (eg. when polling for email validation, if the email
+ // has not yet been validated). This appears to be a Synapse bug, which
+ // we workaround here.
+ if (
+ !(<MatrixError>error).data.flows &&
+ !(<MatrixError>error).data.completed &&
+ !(<MatrixError>error).data.session
+ ) {
+ (<MatrixError>error).data.flows = this.data.flows;
+ (<MatrixError>error).data.completed = this.data.completed;
+ (<MatrixError>error).data.session = this.data.session;
+ }
+ this.data = (<MatrixError>error).data;
+ try {
+ this.startNextAuthStage();
+ } catch (e) {
+ this.attemptAuthDeferred!.reject(e);
+ this.attemptAuthDeferred = null;
+ return;
+ }
+
+ if (!this.emailSid && this.chosenFlow?.stages.includes(AuthType.Email)) {
+ try {
+ await this.requestEmailToken();
+ // NB. promise is not resolved here - at some point, doRequest
+ // will be called again and if the user has jumped through all
+ // the hoops correctly, auth will be complete and the request
+ // will succeed.
+ // Also, we should expose the fact that this request has compledted
+ // so clients can know that the email has actually been sent.
+ } catch (e) {
+ // we failed to request an email token, so fail the request.
+ // This could be due to the email already beeing registered
+ // (or not being registered, depending on what we're trying
+ // to do) or it could be a network failure. Either way, pass
+ // the failure up as the user can't complete auth if we can't
+ // send the email, for whatever reason.
+ this.attemptAuthDeferred!.reject(e);
+ this.attemptAuthDeferred = null;
+ }
+ }
+ }
+ }
+
+ /**
+ * Pick the next stage and call the callback
+ *
+ * @internal
+ * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
+ */
+ private startNextAuthStage(): void {
+ const nextStage = this.chooseStage();
+ if (!nextStage) {
+ throw new Error("No incomplete flows from the server");
+ }
+ this.currentStage = nextStage;
+
+ if (nextStage === AuthType.Dummy) {
+ this.submitAuthDict({
+ type: "m.login.dummy",
+ });
+ return;
+ }
+
+ if (this.data?.errcode || this.data?.error) {
+ this.stateUpdatedCallback(nextStage, {
+ errcode: this.data?.errcode || "",
+ error: this.data?.error || "",
+ });
+ return;
+ }
+
+ this.stateUpdatedCallback(nextStage, nextStage === EMAIL_STAGE_TYPE ? { emailSid: this.emailSid } : {});
+ }
+
+ /**
+ * Pick the next auth stage
+ *
+ * @internal
+ * @returns login type
+ * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
+ */
+ private chooseStage(): AuthType | undefined {
+ if (this.chosenFlow === null) {
+ this.chosenFlow = this.chooseFlow();
+ }
+ logger.log("Active flow => %s", JSON.stringify(this.chosenFlow));
+ const nextStage = this.firstUncompletedStage(this.chosenFlow);
+ logger.log("Next stage: %s", nextStage);
+ return nextStage;
+ }
+
+ /**
+ * Pick one of the flows from the returned list
+ * If a flow using all of the inputs is found, it will
+ * be returned, otherwise, null will be returned.
+ *
+ * Only flows using all given inputs are chosen because it
+ * is likely to be surprising if the user provides a
+ * credential and it is not used. For example, for registration,
+ * this could result in the email not being used which would leave
+ * the account with no means to reset a password.
+ *
+ * @internal
+ * @returns flow
+ * @throws {@link NoAuthFlowFoundError} If no suitable authentication flow can be found
+ */
+ private chooseFlow(): UIAFlow {
+ const flows = this.data.flows || [];
+
+ // we've been given an email or we've already done an email part
+ const haveEmail = Boolean(this.inputs.emailAddress) || Boolean(this.emailSid);
+ const haveMsisdn = Boolean(this.inputs.phoneCountry) && Boolean(this.inputs.phoneNumber);
+
+ for (const flow of flows) {
+ let flowHasEmail = false;
+ let flowHasMsisdn = false;
+ for (const stage of flow.stages) {
+ if (stage === EMAIL_STAGE_TYPE) {
+ flowHasEmail = true;
+ } else if (stage == MSISDN_STAGE_TYPE) {
+ flowHasMsisdn = true;
+ }
+ }
+
+ if (flowHasEmail == haveEmail && flowHasMsisdn == haveMsisdn) {
+ return flow;
+ }
+ }
+
+ const requiredStages: string[] = [];
+ if (haveEmail) requiredStages.push(EMAIL_STAGE_TYPE);
+ if (haveMsisdn) requiredStages.push(MSISDN_STAGE_TYPE);
+ // Throw an error with a fairly generic description, but with more
+ // information such that the app can give a better one if so desired.
+ throw new NoAuthFlowFoundError("No appropriate authentication flow found", requiredStages, flows);
+ }
+
+ /**
+ * Get the first uncompleted stage in the given flow
+ *
+ * @internal
+ * @returns login type
+ */
+ private firstUncompletedStage(flow: UIAFlow): AuthType | undefined {
+ const completed = this.data.completed || [];
+ return flow.stages.find((stageType) => !completed.includes(stageType));
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/logger.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/logger.ts
new file mode 100644
index 0000000..ba7f742
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/logger.ts
@@ -0,0 +1,82 @@
+/*
+Copyright 2018 André Jaenisch
+Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import log, { Logger } from "loglevel";
+
+// This is to demonstrate, that you can use any namespace you want.
+// Namespaces allow you to turn on/off the logging for specific parts of the
+// application.
+// An idea would be to control this via an environment variable (on Node.js).
+// See https://www.npmjs.com/package/debug to see how this could be implemented
+// Part of #332 is introducing a logging library in the first place.
+const DEFAULT_NAMESPACE = "matrix";
+
+// because rageshakes in react-sdk hijack the console log, also at module load time,
+// initializing the logger here races with the initialization of rageshakes.
+// to avoid the issue, we override the methodFactory of loglevel that binds to the
+// console methods at initialization time by a factory that looks up the console methods
+// when logging so we always get the current value of console methods.
+log.methodFactory = function (methodName, logLevel, loggerName) {
+ return function (this: PrefixedLogger, ...args): void {
+ /* eslint-disable @typescript-eslint/no-invalid-this */
+ if (this.prefix) {
+ args.unshift(this.prefix);
+ }
+ /* eslint-enable @typescript-eslint/no-invalid-this */
+ const supportedByConsole =
+ methodName === "error" || methodName === "warn" || methodName === "trace" || methodName === "info";
+ /* eslint-disable no-console */
+ if (supportedByConsole) {
+ return console[methodName](...args);
+ } else {
+ return console.log(...args);
+ }
+ /* eslint-enable no-console */
+ };
+};
+
+/**
+ * Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
+ * Can be tailored down to specific use cases if needed.
+ */
+export const logger = log.getLogger(DEFAULT_NAMESPACE) as PrefixedLogger;
+logger.setLevel(log.levels.DEBUG, false);
+
+export interface PrefixedLogger extends Logger {
+ withPrefix: (prefix: string) => PrefixedLogger;
+ prefix: string;
+}
+
+function extendLogger(logger: Logger): void {
+ (<PrefixedLogger>logger).withPrefix = function (prefix: string): PrefixedLogger {
+ const existingPrefix = this.prefix || "";
+ return getPrefixedLogger(existingPrefix + prefix);
+ };
+}
+
+extendLogger(logger);
+
+function getPrefixedLogger(prefix: string): PrefixedLogger {
+ const prefixLogger = log.getLogger(`${DEFAULT_NAMESPACE}-${prefix}`) as PrefixedLogger;
+ if (prefixLogger.prefix !== prefix) {
+ // Only do this setup work the first time through, as loggers are saved by name.
+ extendLogger(prefixLogger);
+ prefixLogger.prefix = prefix;
+ prefixLogger.setLevel(log.levels.DEBUG, false);
+ }
+ return prefixLogger;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/matrix.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/matrix.ts
new file mode 100644
index 0000000..591c5e3
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/matrix.ts
@@ -0,0 +1,110 @@
+/*
+Copyright 2015-2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { WidgetApi } from "matrix-widget-api";
+
+import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store";
+import { MemoryStore } from "./store/memory";
+import { MatrixScheduler } from "./scheduler";
+import { MatrixClient, ICreateClientOpts } from "./client";
+import { RoomWidgetClient, ICapabilities } from "./embedded";
+import { CryptoStore } from "./crypto/store/base";
+
+export * from "./client";
+export * from "./embedded";
+export * from "./http-api";
+export * from "./autodiscovery";
+export * from "./sync-accumulator";
+export * from "./errors";
+export * from "./models/beacon";
+export * from "./models/event";
+export * from "./models/room";
+export * from "./models/event-timeline";
+export * from "./models/event-timeline-set";
+export * from "./models/poll";
+export * from "./models/room-member";
+export * from "./models/room-state";
+export * from "./models/user";
+export * from "./scheduler";
+export * from "./filter";
+export * from "./timeline-window";
+export * from "./interactive-auth";
+export * from "./service-types";
+export * from "./store/memory";
+export * from "./store/indexeddb";
+export * from "./crypto/store/memory-crypto-store";
+export * from "./crypto/store/indexeddb-crypto-store";
+export * from "./content-repo";
+export * from "./@types/event";
+export * from "./@types/PushRules";
+export * from "./@types/partials";
+export * from "./@types/requests";
+export * from "./@types/search";
+export * from "./models/room-summary";
+export * as ContentHelpers from "./content-helpers";
+export * as SecretStorage from "./secret-storage";
+export type { ICryptoCallbacks } from "./crypto"; // used to be located here
+export { createNewMatrixCall } from "./webrtc/call";
+export type { MatrixCall } from "./webrtc/call";
+export { GroupCallEvent, GroupCallIntent, GroupCallState, GroupCallType } from "./webrtc/groupCall";
+export type { GroupCall } from "./webrtc/groupCall";
+export type { CryptoApi } from "./crypto-api";
+
+let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
+
+/**
+ * Configure a different factory to be used for creating crypto stores
+ *
+ * @param fac - a function which will return a new {@link CryptoStore}
+ */
+export function setCryptoStoreFactory(fac: () => CryptoStore): void {
+ cryptoStoreFactory = fac;
+}
+
+function amendClientOpts(opts: ICreateClientOpts): ICreateClientOpts {
+ opts.store =
+ opts.store ??
+ new MemoryStore({
+ localStorage: global.localStorage,
+ });
+ opts.scheduler = opts.scheduler ?? new MatrixScheduler();
+ opts.cryptoStore = opts.cryptoStore ?? cryptoStoreFactory();
+
+ return opts;
+}
+
+/**
+ * Construct a Matrix Client. Similar to {@link MatrixClient}
+ * except that the 'request', 'store' and 'scheduler' dependencies are satisfied.
+ * @param opts - The configuration options for this client. These configuration
+ * options will be passed directly to {@link MatrixClient}.
+ *
+ * @returns A new matrix client.
+ * @see {@link MatrixClient} for the full list of options for
+ * `opts`.
+ */
+export function createClient(opts: ICreateClientOpts): MatrixClient {
+ return new MatrixClient(amendClientOpts(opts));
+}
+
+export function createRoomWidgetClient(
+ widgetApi: WidgetApi,
+ capabilities: ICapabilities,
+ roomId: string,
+ opts: ICreateClientOpts,
+): MatrixClient {
+ return new RoomWidgetClient(widgetApi, capabilities, roomId, amendClientOpts(opts));
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089Branch.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089Branch.ts
new file mode 100644
index 0000000..27be4b8
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089Branch.ts
@@ -0,0 +1,258 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixClient } from "../client";
+import { IEncryptedFile, RelationType, UNSTABLE_MSC3089_BRANCH } from "../@types/event";
+import { IContent, MatrixEvent } from "./event";
+import { MSC3089TreeSpace } from "./MSC3089TreeSpace";
+import { EventTimeline } from "./event-timeline";
+import { FileType } from "../http-api";
+import type { ISendEventResponse } from "../@types/requests";
+
+/**
+ * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference
+ * to a file (leaf) in the tree. Note that this is UNSTABLE and subject to breaking changes
+ * without notice.
+ */
+export class MSC3089Branch {
+ public constructor(
+ private client: MatrixClient,
+ public readonly indexEvent: MatrixEvent,
+ public readonly directory: MSC3089TreeSpace,
+ ) {
+ // Nothing to do
+ }
+
+ /**
+ * The file ID.
+ */
+ public get id(): string {
+ const stateKey = this.indexEvent.getStateKey();
+ if (!stateKey) {
+ throw new Error("State key not found for branch");
+ }
+ return stateKey;
+ }
+
+ /**
+ * Whether this branch is active/valid.
+ */
+ public get isActive(): boolean {
+ return this.indexEvent.getContent()["active"] === true;
+ }
+
+ /**
+ * Version for the file, one-indexed.
+ */
+ public get version(): number {
+ return this.indexEvent.getContent()["version"] ?? 1;
+ }
+
+ private get roomId(): string {
+ return this.indexEvent.getRoomId()!;
+ }
+
+ /**
+ * Deletes the file from the tree, including all prior edits/versions.
+ * @returns Promise which resolves when complete.
+ */
+ public async delete(): Promise<void> {
+ await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {}, this.id);
+ await this.client.redactEvent(this.roomId, this.id);
+
+ const nextVersion = (await this.getVersionHistory())[1]; // [0] will be us
+ if (nextVersion) await nextVersion.delete(); // implicit recursion
+ }
+
+ /**
+ * Gets the name for this file.
+ * @returns The name, or "Unnamed File" if unknown.
+ */
+ public getName(): string {
+ return this.indexEvent.getContent()["name"] || "Unnamed File";
+ }
+
+ /**
+ * Sets the name for this file.
+ * @param name - The new name for this file.
+ * @returns Promise which resolves when complete.
+ */
+ public async setName(name: string): Promise<void> {
+ await this.client.sendStateEvent(
+ this.roomId,
+ UNSTABLE_MSC3089_BRANCH.name,
+ {
+ ...this.indexEvent.getContent(),
+ name: name,
+ },
+ this.id,
+ );
+ }
+
+ /**
+ * Gets whether or not a file is locked.
+ * @returns True if locked, false otherwise.
+ */
+ public isLocked(): boolean {
+ return this.indexEvent.getContent()["locked"] || false;
+ }
+
+ /**
+ * Sets a file as locked or unlocked.
+ * @param locked - True to lock the file, false otherwise.
+ * @returns Promise which resolves when complete.
+ */
+ public async setLocked(locked: boolean): Promise<void> {
+ await this.client.sendStateEvent(
+ this.roomId,
+ UNSTABLE_MSC3089_BRANCH.name,
+ {
+ ...this.indexEvent.getContent(),
+ locked: locked,
+ },
+ this.id,
+ );
+ }
+
+ /**
+ * Gets information about the file needed to download it.
+ * @returns Information about the file.
+ */
+ public async getFileInfo(): Promise<{ info: IEncryptedFile; httpUrl: string }> {
+ const event = await this.getFileEvent();
+
+ const file = event.getOriginalContent()["file"];
+ const httpUrl = this.client.mxcUrlToHttp(file["url"]);
+
+ if (!httpUrl) {
+ throw new Error(`No HTTP URL available for ${file["url"]}`);
+ }
+
+ return { info: file, httpUrl: httpUrl };
+ }
+
+ /**
+ * Gets the event the file points to.
+ * @returns Promise which resolves to the file's event.
+ */
+ public async getFileEvent(): Promise<MatrixEvent> {
+ const room = this.client.getRoom(this.roomId);
+ if (!room) throw new Error("Unknown room");
+
+ let event: MatrixEvent | undefined = room.getUnfilteredTimelineSet().findEventById(this.id);
+
+ // keep scrolling back if needed until we find the event or reach the start of the room:
+ while (!event && room.getLiveTimeline().getState(EventTimeline.BACKWARDS)!.paginationToken) {
+ await this.client.scrollback(room, 100);
+ event = room.getUnfilteredTimelineSet().findEventById(this.id);
+ }
+
+ if (!event) throw new Error("Failed to find event");
+
+ // Sometimes the event isn't decrypted for us, so do that. We specifically set `emit: true`
+ // to ensure that the relations system in the sdk will function.
+ await this.client.decryptEventIfNeeded(event, { emit: true, isRetry: true });
+
+ return event;
+ }
+
+ /**
+ * Creates a new version of this file with contents in a type that is compatible with MatrixClient.uploadContent().
+ * @param name - The name of the file.
+ * @param encryptedContents - The encrypted contents.
+ * @param info - The encrypted file information.
+ * @param additionalContent - Optional event content fields to include in the message.
+ * @returns Promise which resolves to the file event's sent response.
+ */
+ public async createNewVersion(
+ name: string,
+ encryptedContents: FileType,
+ info: Partial<IEncryptedFile>,
+ additionalContent?: IContent,
+ ): Promise<ISendEventResponse> {
+ const fileEventResponse = await this.directory.createFile(name, encryptedContents, info, {
+ ...(additionalContent ?? {}),
+ "m.new_content": true,
+ "m.relates_to": {
+ rel_type: RelationType.Replace,
+ event_id: this.id,
+ },
+ });
+
+ // Update the version of the new event
+ await this.client.sendStateEvent(
+ this.roomId,
+ UNSTABLE_MSC3089_BRANCH.name,
+ {
+ active: true,
+ name: name,
+ version: this.version + 1,
+ },
+ fileEventResponse["event_id"],
+ );
+
+ // Deprecate ourselves
+ await this.client.sendStateEvent(
+ this.roomId,
+ UNSTABLE_MSC3089_BRANCH.name,
+ {
+ ...this.indexEvent.getContent(),
+ active: false,
+ },
+ this.id,
+ );
+
+ return fileEventResponse;
+ }
+
+ /**
+ * Gets the file's version history, starting at this file.
+ * @returns Promise which resolves to the file's version history, with the
+ * first element being the current version and the last element being the first version.
+ */
+ public async getVersionHistory(): Promise<MSC3089Branch[]> {
+ const fileHistory: MSC3089Branch[] = [];
+ fileHistory.push(this); // start with ourselves
+
+ const room = this.client.getRoom(this.roomId);
+ if (!room) throw new Error("Invalid or unknown room");
+
+ // Clone the timeline to reverse it, getting most-recent-first ordering, hopefully
+ // shortening the awful loop below. Without the clone, we can unintentionally mutate
+ // the timeline.
+ const timelineEvents = [...room.getLiveTimeline().getEvents()].reverse();
+
+ // XXX: This is a very inefficient search, but it's the best we can do with the
+ // relations structure we have in the SDK. As of writing, it is not worth the
+ // investment in improving the structure.
+ let childEvent: MatrixEvent | undefined;
+ let parentEvent = await this.getFileEvent();
+ do {
+ childEvent = timelineEvents.find((e) => e.replacingEventId() === parentEvent.getId());
+ if (childEvent) {
+ const branch = this.directory.getFile(childEvent.getId()!);
+ if (branch) {
+ fileHistory.push(branch);
+ parentEvent = childEvent;
+ } else {
+ break; // prevent infinite loop
+ }
+ }
+ } while (childEvent);
+
+ return fileHistory;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089TreeSpace.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089TreeSpace.ts
new file mode 100644
index 0000000..b0e71d9
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/MSC3089TreeSpace.ts
@@ -0,0 +1,566 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import promiseRetry from "p-retry";
+
+import { MatrixClient } from "../client";
+import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event";
+import { Room } from "./room";
+import { logger } from "../logger";
+import { IContent, MatrixEvent } from "./event";
+import {
+ averageBetweenStrings,
+ DEFAULT_ALPHABET,
+ lexicographicCompare,
+ nextString,
+ prevString,
+ simpleRetryOperation,
+} from "../utils";
+import { MSC3089Branch } from "./MSC3089Branch";
+import { isRoomSharedHistory } from "../crypto/algorithms/megolm";
+import { ISendEventResponse } from "../@types/requests";
+import { FileType } from "../http-api";
+
+/**
+ * The recommended defaults for a tree space's power levels. Note that this
+ * is UNSTABLE and subject to breaking changes without notice.
+ */
+export const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = {
+ // Owner
+ invite: 100,
+ kick: 100,
+ ban: 100,
+
+ // Editor
+ redact: 50,
+ state_default: 50,
+ events_default: 50,
+
+ // Viewer
+ users_default: 0,
+
+ // Mixed
+ events: {
+ [EventType.RoomPowerLevels]: 100,
+ [EventType.RoomHistoryVisibility]: 100,
+ [EventType.RoomTombstone]: 100,
+ [EventType.RoomEncryption]: 100,
+ [EventType.RoomName]: 50,
+ [EventType.RoomMessage]: 50,
+ [EventType.RoomMessageEncrypted]: 50,
+ [EventType.Sticker]: 50,
+ },
+
+ users: {}, // defined by calling code
+};
+
+/**
+ * Ease-of-use representation for power levels represented as simple roles.
+ * Note that this is UNSTABLE and subject to breaking changes without notice.
+ */
+export enum TreePermissions {
+ Viewer = "viewer", // Default
+ Editor = "editor", // "Moderator" or ~PL50
+ Owner = "owner", // "Admin" or PL100
+}
+
+/**
+ * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089)
+ * file tree Space. Note that this is UNSTABLE and subject to breaking changes
+ * without notice.
+ */
+export class MSC3089TreeSpace {
+ public readonly room: Room;
+
+ public constructor(private client: MatrixClient, public readonly roomId: string) {
+ this.room = this.client.getRoom(this.roomId)!;
+
+ if (!this.room) throw new Error("Unknown room");
+ }
+
+ /**
+ * Syntactic sugar for room ID of the Space.
+ */
+ public get id(): string {
+ return this.roomId;
+ }
+
+ /**
+ * Whether or not this is a top level space.
+ */
+ public get isTopLevel(): boolean {
+ // XXX: This is absolutely not how you find out if the space is top level
+ // but is safe for a managed usecase like we offer in the SDK.
+ const parentEvents = this.room.currentState.getStateEvents(EventType.SpaceParent);
+ if (!parentEvents?.length) return true;
+ return parentEvents.every((e) => !e.getContent()?.["via"]);
+ }
+
+ /**
+ * Sets the name of the tree space.
+ * @param name - The new name for the space.
+ * @returns Promise which resolves when complete.
+ */
+ public async setName(name: string): Promise<void> {
+ await this.client.sendStateEvent(this.roomId, EventType.RoomName, { name }, "");
+ }
+
+ /**
+ * Invites a user to the tree space. They will be given the default Viewer
+ * permission level unless specified elsewhere.
+ * @param userId - The user ID to invite.
+ * @param andSubspaces - True (default) to invite the user to all
+ * directories/subspaces too, recursively.
+ * @param shareHistoryKeys - True (default) to share encryption keys
+ * with the invited user. This will allow them to decrypt the events (files)
+ * in the tree. Keys will not be shared if the room is lacking appropriate
+ * history visibility (by default, history visibility is "shared" in trees,
+ * which is an appropriate visibility for these purposes).
+ * @returns Promise which resolves when complete.
+ */
+ public async invite(userId: string, andSubspaces = true, shareHistoryKeys = true): Promise<void> {
+ const promises: Promise<void>[] = [this.retryInvite(userId)];
+ if (andSubspaces) {
+ promises.push(...this.getDirectories().map((d) => d.invite(userId, andSubspaces, shareHistoryKeys)));
+ }
+ return Promise.all(promises).then(() => {
+ // Note: key sharing is default on because for file trees it is relatively important that the invite
+ // target can actually decrypt the files. The implied use case is that by inviting a user to the tree
+ // it means the sender would like the receiver to view/download the files contained within, much like
+ // sharing a folder in other circles.
+ if (shareHistoryKeys && isRoomSharedHistory(this.room)) {
+ // noinspection JSIgnoredPromiseFromCall - we aren't concerned as much if this fails.
+ this.client.sendSharedHistoryKeys(this.roomId, [userId]);
+ }
+ });
+ }
+
+ private retryInvite(userId: string): Promise<void> {
+ return simpleRetryOperation(async () => {
+ await this.client.invite(this.roomId, userId).catch((e) => {
+ // We don't want to retry permission errors forever...
+ if (e?.errcode === "M_FORBIDDEN") {
+ throw new promiseRetry.AbortError(e);
+ }
+ throw e;
+ });
+ });
+ }
+
+ /**
+ * Sets the permissions of a user to the given role. Note that if setting a user
+ * to Owner then they will NOT be able to be demoted. If the user does not have
+ * permission to change the power level of the target, an error will be thrown.
+ * @param userId - The user ID to change the role of.
+ * @param role - The role to assign.
+ * @returns Promise which resolves when complete.
+ */
+ public async setPermissions(userId: string, role: TreePermissions): Promise<void> {
+ const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
+ if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels");
+
+ const pls = currentPls?.getContent() || {};
+ const viewLevel = pls["users_default"] || 0;
+ const editLevel = pls["events_default"] || 50;
+ const adminLevel = pls["events"]?.[EventType.RoomPowerLevels] || 100;
+
+ const users = pls["users"] || {};
+ switch (role) {
+ case TreePermissions.Viewer:
+ users[userId] = viewLevel;
+ break;
+ case TreePermissions.Editor:
+ users[userId] = editLevel;
+ break;
+ case TreePermissions.Owner:
+ users[userId] = adminLevel;
+ break;
+ default:
+ throw new Error("Invalid role: " + role);
+ }
+ pls["users"] = users;
+
+ await this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, "");
+ }
+
+ /**
+ * Gets the current permissions of a user. Note that any users missing explicit permissions (or not
+ * in the space) will be considered Viewers. Appropriate membership checks need to be performed
+ * elsewhere.
+ * @param userId - The user ID to check permissions of.
+ * @returns The permissions for the user, defaulting to Viewer.
+ */
+ public getPermissions(userId: string): TreePermissions {
+ const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, "");
+ if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels");
+
+ const pls = currentPls?.getContent() || {};
+ const viewLevel = pls["users_default"] || 0;
+ const editLevel = pls["events_default"] || 50;
+ const adminLevel = pls["events"]?.[EventType.RoomPowerLevels] || 100;
+
+ const userLevel = pls["users"]?.[userId] || viewLevel;
+ if (userLevel >= adminLevel) return TreePermissions.Owner;
+ if (userLevel >= editLevel) return TreePermissions.Editor;
+ return TreePermissions.Viewer;
+ }
+
+ /**
+ * Creates a directory under this tree space, represented as another tree space.
+ * @param name - The name for the directory.
+ * @returns Promise which resolves to the created directory.
+ */
+ public async createDirectory(name: string): Promise<MSC3089TreeSpace> {
+ const directory = await this.client.unstableCreateFileTree(name);
+
+ await this.client.sendStateEvent(
+ this.roomId,
+ EventType.SpaceChild,
+ {
+ via: [this.client.getDomain()],
+ },
+ directory.roomId,
+ );
+
+ await this.client.sendStateEvent(
+ directory.roomId,
+ EventType.SpaceParent,
+ {
+ via: [this.client.getDomain()],
+ },
+ this.roomId,
+ );
+
+ return directory;
+ }
+
+ /**
+ * Gets a list of all known immediate subdirectories to this tree space.
+ * @returns The tree spaces (directories). May be empty, but not null.
+ */
+ public getDirectories(): MSC3089TreeSpace[] {
+ const trees: MSC3089TreeSpace[] = [];
+ const children = this.room.currentState.getStateEvents(EventType.SpaceChild);
+ for (const child of children) {
+ try {
+ const stateKey = child.getStateKey();
+ if (stateKey) {
+ const tree = this.client.unstableGetFileTreeSpace(stateKey);
+ if (tree) trees.push(tree);
+ }
+ } catch (e) {
+ logger.warn("Unable to create tree space instance for listing. Are we joined?", e);
+ }
+ }
+ return trees;
+ }
+
+ /**
+ * Gets a subdirectory of a given ID under this tree space. Note that this will not recurse
+ * into children and instead only look one level deep.
+ * @param roomId - The room ID (directory ID) to find.
+ * @returns The directory, or undefined if not found.
+ */
+ public getDirectory(roomId: string): MSC3089TreeSpace | undefined {
+ return this.getDirectories().find((r) => r.roomId === roomId);
+ }
+
+ /**
+ * Deletes the tree, kicking all members and deleting **all subdirectories**.
+ * @returns Promise which resolves when complete.
+ */
+ public async delete(): Promise<void> {
+ const subdirectories = this.getDirectories();
+ for (const dir of subdirectories) {
+ await dir.delete();
+ }
+
+ const kickMemberships = ["invite", "knock", "join"];
+ const members = this.room.currentState.getStateEvents(EventType.RoomMember);
+ for (const member of members) {
+ const isNotUs = member.getStateKey() !== this.client.getUserId();
+ if (isNotUs && kickMemberships.includes(member.getContent().membership!)) {
+ const stateKey = member.getStateKey();
+ if (!stateKey) {
+ throw new Error("State key not found for branch");
+ }
+ await this.client.kick(this.roomId, stateKey, "Room deleted");
+ }
+ }
+
+ await this.client.leave(this.roomId);
+ }
+
+ private getOrderedChildren(children: MatrixEvent[]): { roomId: string; order: string }[] {
+ const ordered: { roomId: string; order: string }[] = children
+ .map((c) => ({ roomId: c.getStateKey(), order: c.getContent()["order"] }))
+ .filter((c) => c.roomId) as { roomId: string; order: string }[];
+ ordered.sort((a, b) => {
+ if (a.order && !b.order) {
+ return -1;
+ } else if (!a.order && b.order) {
+ return 1;
+ } else if (!a.order && !b.order) {
+ const roomA = this.client.getRoom(a.roomId);
+ const roomB = this.client.getRoom(b.roomId);
+ if (!roomA || !roomB) {
+ // just don't bother trying to do more partial sorting
+ return lexicographicCompare(a.roomId, b.roomId);
+ }
+
+ const createTsA = roomA.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
+ const createTsB = roomB.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0;
+ if (createTsA === createTsB) {
+ return lexicographicCompare(a.roomId, b.roomId);
+ }
+ return createTsA - createTsB;
+ } else {
+ // both not-null orders
+ return lexicographicCompare(a.order, b.order);
+ }
+ });
+ return ordered;
+ }
+
+ private getParentRoom(): Room {
+ const parents = this.room.currentState.getStateEvents(EventType.SpaceParent);
+ const parent = parents[0]; // XXX: Wild assumption
+ if (!parent) throw new Error("Expected to have a parent in a non-top level space");
+
+ // XXX: We are assuming the parent is a valid tree space.
+ // We probably don't need to validate the parent room state for this usecase though.
+ const stateKey = parent.getStateKey();
+ if (!stateKey) throw new Error("No state key found for parent");
+ const parentRoom = this.client.getRoom(stateKey);
+ if (!parentRoom) throw new Error("Unable to locate room for parent");
+
+ return parentRoom;
+ }
+
+ /**
+ * Gets the current order index for this directory. Note that if this is the top level space
+ * then -1 will be returned.
+ * @returns The order index of this space.
+ */
+ public getOrder(): number {
+ if (this.isTopLevel) return -1;
+
+ const parentRoom = this.getParentRoom();
+ const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
+ const ordered = this.getOrderedChildren(children);
+
+ return ordered.findIndex((c) => c.roomId === this.roomId);
+ }
+
+ /**
+ * Sets the order index for this directory within its parent. Note that if this is a top level
+ * space then an error will be thrown. -1 can be used to move the child to the start, and numbers
+ * larger than the number of children can be used to move the child to the end.
+ * @param index - The new order index for this space.
+ * @returns Promise which resolves when complete.
+ * @throws Throws if this is a top level space.
+ */
+ public async setOrder(index: number): Promise<void> {
+ if (this.isTopLevel) throw new Error("Cannot set order of top level spaces currently");
+
+ const parentRoom = this.getParentRoom();
+ const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild);
+ const ordered = this.getOrderedChildren(children);
+ index = Math.max(Math.min(index, ordered.length - 1), 0);
+
+ const currentIndex = this.getOrder();
+ const movingUp = currentIndex < index;
+ if (movingUp && index === ordered.length - 1) {
+ index--;
+ } else if (!movingUp && index === 0) {
+ index++;
+ }
+
+ const prev = ordered[movingUp ? index : index - 1];
+ const next = ordered[movingUp ? index + 1 : index];
+
+ let newOrder = DEFAULT_ALPHABET[0];
+ let ensureBeforeIsSane = false;
+ if (!prev) {
+ // Move to front
+ if (next?.order) {
+ newOrder = prevString(next.order);
+ }
+ } else if (index === ordered.length - 1) {
+ // Move to back
+ if (next?.order) {
+ newOrder = nextString(next.order);
+ }
+ } else {
+ // Move somewhere in the middle
+ const startOrder = prev?.order;
+ const endOrder = next?.order;
+ if (startOrder && endOrder) {
+ if (startOrder === endOrder) {
+ // Error case: just move +1 to break out of awful math
+ newOrder = nextString(startOrder);
+ } else {
+ newOrder = averageBetweenStrings(startOrder, endOrder);
+ }
+ } else {
+ if (startOrder) {
+ // We're at the end (endOrder is null, so no explicit order)
+ newOrder = nextString(startOrder);
+ } else if (endOrder) {
+ // We're at the start (startOrder is null, so nothing before us)
+ newOrder = prevString(endOrder);
+ } else {
+ // Both points are unknown. We're likely in a range where all the children
+ // don't have particular order values, so we may need to update them too.
+ // The other possibility is there's only us as a child, but we should have
+ // shown up in the other states.
+ ensureBeforeIsSane = true;
+ }
+ }
+ }
+
+ if (ensureBeforeIsSane) {
+ // We were asked by the order algorithm to prepare the moving space for a landing
+ // in the undefined order part of the order array, which means we need to update the
+ // spaces that come before it with a stable order value.
+ let lastOrder: string | undefined;
+ for (let i = 0; i <= index; i++) {
+ const target = ordered[i];
+ if (i === 0) {
+ lastOrder = target.order;
+ }
+ if (!target.order) {
+ // XXX: We should be creating gaps to avoid conflicts
+ lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0];
+ const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId);
+ const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
+ await this.client.sendStateEvent(
+ parentRoom.roomId,
+ EventType.SpaceChild,
+ {
+ ...content,
+ order: lastOrder,
+ },
+ target.roomId,
+ );
+ } else {
+ lastOrder = target.order;
+ }
+ }
+ if (lastOrder) {
+ newOrder = nextString(lastOrder);
+ }
+ }
+
+ // TODO: Deal with order conflicts by reordering
+
+ // Now we can finally update our own order state
+ const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId);
+ const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] };
+ await this.client.sendStateEvent(
+ parentRoom.roomId,
+ EventType.SpaceChild,
+ {
+ ...content,
+
+ // TODO: Safely constrain to 50 character limit required by spaces.
+ order: newOrder,
+ },
+ this.roomId,
+ );
+ }
+
+ /**
+ * Creates (uploads) a new file to this tree. The file must have already been encrypted for the room.
+ * The file contents are in a type that is compatible with MatrixClient.uploadContent().
+ * @param name - The name of the file.
+ * @param encryptedContents - The encrypted contents.
+ * @param info - The encrypted file information.
+ * @param additionalContent - Optional event content fields to include in the message.
+ * @returns Promise which resolves to the file event's sent response.
+ */
+ public async createFile(
+ name: string,
+ encryptedContents: FileType,
+ info: Partial<IEncryptedFile>,
+ additionalContent?: IContent,
+ ): Promise<ISendEventResponse> {
+ const { content_uri: mxc } = await this.client.uploadContent(encryptedContents, {
+ includeFilename: false,
+ });
+ info.url = mxc;
+
+ const fileContent = {
+ msgtype: MsgType.File,
+ body: name,
+ url: mxc,
+ file: info,
+ };
+
+ additionalContent = additionalContent ?? {};
+ if (additionalContent["m.new_content"]) {
+ // We do the right thing according to the spec, but due to how relations are
+ // handled we also end up duplicating this information to the regular `content`
+ // as well.
+ additionalContent["m.new_content"] = fileContent;
+ }
+
+ const res = await this.client.sendMessage(this.roomId, {
+ ...additionalContent,
+ ...fileContent,
+ [UNSTABLE_MSC3089_LEAF.name]: {},
+ });
+
+ await this.client.sendStateEvent(
+ this.roomId,
+ UNSTABLE_MSC3089_BRANCH.name,
+ {
+ active: true,
+ name: name,
+ },
+ res["event_id"],
+ );
+
+ return res;
+ }
+
+ /**
+ * Retrieves a file from the tree.
+ * @param fileEventId - The event ID of the file.
+ * @returns The file, or null if not found.
+ */
+ public getFile(fileEventId: string): MSC3089Branch | null {
+ const branch = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name, fileEventId);
+ return branch ? new MSC3089Branch(this.client, branch, this) : null;
+ }
+
+ /**
+ * Gets an array of all known files for the tree.
+ * @returns The known files. May be empty, but not null.
+ */
+ public listFiles(): MSC3089Branch[] {
+ return this.listAllFiles().filter((b) => b.isActive);
+ }
+
+ /**
+ * Gets an array of all known files for the tree, including inactive/invalid ones.
+ * @returns The known files. May be empty, but not null.
+ */
+ public listAllFiles(): MSC3089Branch[] {
+ const branches = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name) ?? [];
+ return branches.map((e) => new MSC3089Branch(this.client, e, this));
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/ToDeviceMessage.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/ToDeviceMessage.ts
new file mode 100644
index 0000000..8efc3ed
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/ToDeviceMessage.ts
@@ -0,0 +1,38 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export type ToDevicePayload = Record<string, any>;
+
+export interface ToDeviceMessage {
+ userId: string;
+ deviceId: string;
+ payload: ToDevicePayload;
+}
+
+export interface ToDeviceBatch {
+ eventType: string;
+ batch: ToDeviceMessage[];
+}
+
+// Only used internally
+export interface ToDeviceBatchWithTxnId extends ToDeviceBatch {
+ txnId: string;
+}
+
+// Only used internally
+export interface IndexedToDeviceBatch extends ToDeviceBatchWithTxnId {
+ id: number;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/beacon.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/beacon.ts
new file mode 100644
index 0000000..3801831
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/beacon.ts
@@ -0,0 +1,209 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MBeaconEventContent } from "../@types/beacon";
+import { BeaconInfoState, BeaconLocationState, parseBeaconContent, parseBeaconInfoContent } from "../content-helpers";
+import { MatrixEvent } from "./event";
+import { sortEventsByLatestContentTimestamp } from "../utils";
+import { TypedEventEmitter } from "./typed-event-emitter";
+
+export enum BeaconEvent {
+ New = "Beacon.new",
+ Update = "Beacon.update",
+ LivenessChange = "Beacon.LivenessChange",
+ Destroy = "Beacon.Destroy",
+ LocationUpdate = "Beacon.LocationUpdate",
+}
+
+export type BeaconEventHandlerMap = {
+ [BeaconEvent.Update]: (event: MatrixEvent, beacon: Beacon) => void;
+ [BeaconEvent.LivenessChange]: (isLive: boolean, beacon: Beacon) => void;
+ [BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
+ [BeaconEvent.LocationUpdate]: (locationState: BeaconLocationState) => void;
+ [BeaconEvent.Destroy]: (beaconIdentifier: string) => void;
+};
+
+export const isTimestampInDuration = (startTimestamp: number, durationMs: number, timestamp: number): boolean =>
+ timestamp >= startTimestamp && startTimestamp + durationMs >= timestamp;
+
+// beacon info events are uniquely identified by
+// `<roomId>_<state_key>`
+export type BeaconIdentifier = string;
+export const getBeaconInfoIdentifier = (event: MatrixEvent): BeaconIdentifier =>
+ `${event.getRoomId()}_${event.getStateKey()}`;
+
+// https://github.com/matrix-org/matrix-spec-proposals/pull/3672
+export class Beacon extends TypedEventEmitter<Exclude<BeaconEvent, BeaconEvent.New>, BeaconEventHandlerMap> {
+ public readonly roomId: string;
+ // beaconInfo is assigned by setBeaconInfo in the constructor
+ // ! to make tsc believe it is definitely assigned
+ private _beaconInfo!: BeaconInfoState;
+ private _isLive?: boolean;
+ private livenessWatchTimeout?: ReturnType<typeof setTimeout>;
+ private _latestLocationEvent?: MatrixEvent;
+
+ public constructor(private rootEvent: MatrixEvent) {
+ super();
+ this.roomId = this.rootEvent.getRoomId()!;
+ this.setBeaconInfo(this.rootEvent);
+ }
+
+ public get isLive(): boolean {
+ return !!this._isLive;
+ }
+
+ public get identifier(): BeaconIdentifier {
+ return getBeaconInfoIdentifier(this.rootEvent);
+ }
+
+ public get beaconInfoId(): string {
+ return this.rootEvent.getId()!;
+ }
+
+ public get beaconInfoOwner(): string {
+ return this.rootEvent.getStateKey()!;
+ }
+
+ public get beaconInfoEventType(): string {
+ return this.rootEvent.getType();
+ }
+
+ public get beaconInfo(): BeaconInfoState {
+ return this._beaconInfo;
+ }
+
+ public get latestLocationState(): BeaconLocationState | undefined {
+ return this._latestLocationEvent && parseBeaconContent(this._latestLocationEvent.getContent());
+ }
+
+ public get latestLocationEvent(): MatrixEvent | undefined {
+ return this._latestLocationEvent;
+ }
+
+ public update(beaconInfoEvent: MatrixEvent): void {
+ if (getBeaconInfoIdentifier(beaconInfoEvent) !== this.identifier) {
+ throw new Error("Invalid updating event");
+ }
+ // don't update beacon with an older event
+ if (beaconInfoEvent.getTs() < this.rootEvent.getTs()) {
+ return;
+ }
+ this.rootEvent = beaconInfoEvent;
+ this.setBeaconInfo(this.rootEvent);
+
+ this.emit(BeaconEvent.Update, beaconInfoEvent, this);
+ this.clearLatestLocation();
+ }
+
+ public destroy(): void {
+ if (this.livenessWatchTimeout) {
+ clearTimeout(this.livenessWatchTimeout);
+ }
+
+ this._isLive = false;
+ this.emit(BeaconEvent.Destroy, this.identifier);
+ }
+
+ /**
+ * Monitor liveness of a beacon
+ * Emits BeaconEvent.LivenessChange when beacon expires
+ */
+ public monitorLiveness(): void {
+ if (this.livenessWatchTimeout) {
+ clearTimeout(this.livenessWatchTimeout);
+ }
+
+ this.checkLiveness();
+ if (!this.beaconInfo) return;
+ if (this.isLive) {
+ const expiryInMs = this.beaconInfo.timestamp! + this.beaconInfo.timeout - Date.now();
+ if (expiryInMs > 1) {
+ this.livenessWatchTimeout = setTimeout(() => {
+ this.monitorLiveness();
+ }, expiryInMs);
+ }
+ } else if (this.beaconInfo.timestamp! > Date.now()) {
+ // beacon start timestamp is in the future
+ // check liveness again then
+ this.livenessWatchTimeout = setTimeout(() => {
+ this.monitorLiveness();
+ }, this.beaconInfo.timestamp! - Date.now());
+ }
+ }
+
+ /**
+ * Process Beacon locations
+ * Emits BeaconEvent.LocationUpdate
+ */
+ public addLocations(beaconLocationEvents: MatrixEvent[]): void {
+ // discard locations for beacons that are not live
+ if (!this.isLive) {
+ return;
+ }
+
+ const validLocationEvents = beaconLocationEvents.filter((event) => {
+ const content = event.getContent<MBeaconEventContent>();
+ const parsed = parseBeaconContent(content);
+ if (!parsed.uri || !parsed.timestamp) return false; // we won't be able to process these
+ const { timestamp } = parsed;
+ return (
+ this._beaconInfo.timestamp &&
+ // only include positions that were taken inside the beacon's live period
+ isTimestampInDuration(this._beaconInfo.timestamp, this._beaconInfo.timeout, timestamp) &&
+ // ignore positions older than our current latest location
+ (!this.latestLocationState || timestamp > this.latestLocationState.timestamp!)
+ );
+ });
+ const latestLocationEvent = validLocationEvents.sort(sortEventsByLatestContentTimestamp)?.[0];
+
+ if (latestLocationEvent) {
+ this._latestLocationEvent = latestLocationEvent;
+ this.emit(BeaconEvent.LocationUpdate, this.latestLocationState!);
+ }
+ }
+
+ private clearLatestLocation = (): void => {
+ this._latestLocationEvent = undefined;
+ this.emit(BeaconEvent.LocationUpdate, this.latestLocationState!);
+ };
+
+ private setBeaconInfo(event: MatrixEvent): void {
+ this._beaconInfo = parseBeaconInfoContent(event.getContent());
+ this.checkLiveness();
+ }
+
+ private checkLiveness(): void {
+ const prevLiveness = this.isLive;
+
+ // element web sets a beacon's start timestamp to the senders local current time
+ // when Alice's system clock deviates slightly from Bob's a beacon Alice intended to be live
+ // may have a start timestamp in the future from Bob's POV
+ // handle this by adding 6min of leniency to the start timestamp when it is in the future
+ if (!this.beaconInfo) return;
+ const startTimestamp =
+ this.beaconInfo.timestamp! > Date.now()
+ ? this.beaconInfo.timestamp! - 360000 /* 6min */
+ : this.beaconInfo.timestamp;
+ this._isLive =
+ !!this._beaconInfo.live &&
+ !!startTimestamp &&
+ isTimestampInDuration(startTimestamp, this._beaconInfo.timeout, Date.now());
+
+ if (prevLiveness !== this.isLive) {
+ this.emit(BeaconEvent.LivenessChange, this.isLive, this);
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-context.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-context.ts
new file mode 100644
index 0000000..0401cd5
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-context.ts
@@ -0,0 +1,110 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixEvent } from "./event";
+import { Direction } from "./event-timeline";
+
+export class EventContext {
+ private timeline: MatrixEvent[];
+ private ourEventIndex = 0;
+ private paginateTokens: Record<Direction, string | null> = {
+ [Direction.Backward]: null,
+ [Direction.Forward]: null,
+ };
+
+ /**
+ * Construct a new EventContext
+ *
+ * An eventcontext is used for circumstances such as search results, when we
+ * have a particular event of interest, and a bunch of events before and after
+ * it.
+ *
+ * It also stores pagination tokens for going backwards and forwards in the
+ * timeline.
+ *
+ * @param ourEvent - the event at the centre of this context
+ */
+ public constructor(public readonly ourEvent: MatrixEvent) {
+ this.timeline = [ourEvent];
+ }
+
+ /**
+ * Get the main event of interest
+ *
+ * This is a convenience function for getTimeline()[getOurEventIndex()].
+ *
+ * @returns The event at the centre of this context.
+ */
+ public getEvent(): MatrixEvent {
+ return this.timeline[this.ourEventIndex];
+ }
+
+ /**
+ * Get the list of events in this context
+ *
+ * @returns An array of MatrixEvents
+ */
+ public getTimeline(): MatrixEvent[] {
+ return this.timeline;
+ }
+
+ /**
+ * Get the index in the timeline of our event
+ */
+ public getOurEventIndex(): number {
+ return this.ourEventIndex;
+ }
+
+ /**
+ * Get a pagination token.
+ *
+ * @param backwards - true to get the pagination token for going
+ */
+ public getPaginateToken(backwards = false): string | null {
+ return this.paginateTokens[backwards ? Direction.Backward : Direction.Forward];
+ }
+
+ /**
+ * Set a pagination token.
+ *
+ * Generally this will be used only by the matrix js sdk.
+ *
+ * @param token - pagination token
+ * @param backwards - true to set the pagination token for going
+ * backwards in time
+ */
+ public setPaginateToken(token?: string, backwards = false): void {
+ this.paginateTokens[backwards ? Direction.Backward : Direction.Forward] = token ?? null;
+ }
+
+ /**
+ * Add more events to the timeline
+ *
+ * @param events - new events, in timeline order
+ * @param atStart - true to insert new events at the start
+ */
+ public addEvents(events: MatrixEvent[], atStart = false): void {
+ // TODO: should we share logic with Room.addEventsToTimeline?
+ // Should Room even use EventContext?
+
+ if (atStart) {
+ this.timeline = events.concat(this.timeline);
+ this.ourEventIndex += events.length;
+ } else {
+ this.timeline = this.timeline.concat(events);
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-status.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-status.ts
new file mode 100644
index 0000000..a5113e0
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-status.ts
@@ -0,0 +1,39 @@
+/*
+Copyright 2015 - 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Enum for event statuses.
+ * @readonly
+ */
+export enum EventStatus {
+ /** The event was not sent and will no longer be retried. */
+ NOT_SENT = "not_sent",
+
+ /** The message is being encrypted */
+ ENCRYPTING = "encrypting",
+
+ /** The event is in the process of being sent. */
+ SENDING = "sending",
+
+ /** The event is in a queue waiting to be sent. */
+ QUEUED = "queued",
+
+ /** The event has been sent to the server, but we have not yet received the echo. */
+ SENT = "sent",
+
+ /** The event was cancelled before it was successfully sent. */
+ CANCELLED = "cancelled",
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts
new file mode 100644
index 0000000..5cb0499
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline-set.ts
@@ -0,0 +1,906 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventTimeline, IAddEventOptions } from "./event-timeline";
+import { MatrixEvent } from "./event";
+import { logger } from "../logger";
+import { Room, RoomEvent } from "./room";
+import { Filter } from "../filter";
+import { RoomState } from "./room-state";
+import { TypedEventEmitter } from "./typed-event-emitter";
+import { RelationsContainer } from "./relations-container";
+import { MatrixClient } from "../client";
+import { Thread, ThreadFilterType } from "./thread";
+
+const DEBUG = true;
+
+/* istanbul ignore next */
+let debuglog: (...args: any[]) => void;
+if (DEBUG) {
+ // using bind means that we get to keep useful line numbers in the console
+ debuglog = logger.log.bind(logger);
+} else {
+ /* istanbul ignore next */
+ debuglog = function (): void {};
+}
+
+interface IOpts {
+ // Set to true to enable improved timeline support.
+ timelineSupport?: boolean;
+ // The filter object, if any, for this timelineSet.
+ filter?: Filter;
+ pendingEvents?: boolean;
+}
+
+export enum DuplicateStrategy {
+ Ignore = "ignore",
+ Replace = "replace",
+}
+
+export interface IRoomTimelineData {
+ // the timeline the event was added to/removed from
+ timeline: EventTimeline;
+ // true if the event was a real-time event added to the end of the live timeline
+ liveEvent?: boolean;
+}
+
+export interface IAddEventToTimelineOptions
+ extends Pick<IAddEventOptions, "toStartOfTimeline" | "roomState" | "timelineWasEmpty"> {
+ /** Whether the sync response came from cache */
+ fromCache?: boolean;
+}
+
+export interface IAddLiveEventOptions
+ extends Pick<IAddEventToTimelineOptions, "fromCache" | "roomState" | "timelineWasEmpty"> {
+ /** Applies to events in the timeline only. If this is 'replace' then if a
+ * duplicate is encountered, the event passed to this function will replace
+ * the existing event in the timeline. If this is not specified, or is
+ * 'ignore', then the event passed to this function will be ignored
+ * entirely, preserving the existing event in the timeline. Events are
+ * identical based on their event ID <b>only</b>. */
+ duplicateStrategy?: DuplicateStrategy;
+}
+
+type EmittedEvents = RoomEvent.Timeline | RoomEvent.TimelineReset;
+
+export type EventTimelineSetHandlerMap = {
+ /**
+ * Fires whenever the timeline in a room is updated.
+ * @param event - The matrix event which caused this event to fire.
+ * @param room - The room, if any, whose timeline was updated.
+ * @param toStartOfTimeline - True if this event was added to the start
+ * @param removed - True if this event has just been removed from the timeline
+ * (beginning; oldest) of the timeline e.g. due to pagination.
+ *
+ * @param data - more data about the event
+ *
+ * @example
+ * ```
+ * matrixClient.on("Room.timeline",
+ * function(event, room, toStartOfTimeline, removed, data) {
+ * if (!toStartOfTimeline && data.liveEvent) {
+ * var messageToAppend = room.timeline.[room.timeline.length - 1];
+ * }
+ * });
+ * ```
+ */
+ [RoomEvent.Timeline]: (
+ event: MatrixEvent,
+ room: Room | undefined,
+ toStartOfTimeline: boolean | undefined,
+ removed: boolean,
+ data: IRoomTimelineData,
+ ) => void;
+ /**
+ * Fires whenever the live timeline in a room is reset.
+ *
+ * When we get a 'limited' sync (for example, after a network outage), we reset
+ * the live timeline to be empty before adding the recent events to the new
+ * timeline. This event is fired after the timeline is reset, and before the
+ * new events are added.
+ *
+ * @param room - The room whose live timeline was reset, if any
+ * @param timelineSet - timelineSet room whose live timeline was reset
+ * @param resetAllTimelines - True if all timelines were reset.
+ */
+ [RoomEvent.TimelineReset]: (
+ room: Room | undefined,
+ eventTimelineSet: EventTimelineSet,
+ resetAllTimelines: boolean,
+ ) => void;
+};
+
+export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTimelineSetHandlerMap> {
+ public readonly relations: RelationsContainer;
+ private readonly timelineSupport: boolean;
+ private readonly displayPendingEvents: boolean;
+ private liveTimeline: EventTimeline;
+ private timelines: EventTimeline[];
+ private _eventIdToTimeline = new Map<string, EventTimeline>();
+ private filter?: Filter;
+
+ /**
+ * Construct a set of EventTimeline objects, typically on behalf of a given
+ * room. A room may have multiple EventTimelineSets for different levels
+ * of filtering. The global notification list is also an EventTimelineSet, but
+ * lacks a room.
+ *
+ * <p>This is an ordered sequence of timelines, which may or may not
+ * be continuous. Each timeline lists a series of events, as well as tracking
+ * the room state at the start and the end of the timeline (if appropriate).
+ * It also tracks forward and backward pagination tokens, as well as containing
+ * links to the next timeline in the sequence.
+ *
+ * <p>There is one special timeline - the 'live' timeline, which represents the
+ * timeline to which events are being added in real-time as they are received
+ * from the /sync API. Note that you should not retain references to this
+ * timeline - even if it is the current timeline right now, it may not remain
+ * so if the server gives us a timeline gap in /sync.
+ *
+ * <p>In order that we can find events from their ids later, we also maintain a
+ * map from event_id to timeline and index.
+ *
+ * @param room - Room for this timelineSet. May be null for non-room cases, such as the
+ * notification timeline.
+ * @param opts - Options inherited from Room.
+ * @param client - the Matrix client which owns this EventTimelineSet,
+ * can be omitted if room is specified.
+ * @param thread - the thread to which this timeline set relates.
+ * @param isThreadTimeline - Whether this timeline set relates to a thread list timeline
+ * (e.g., All threads or My threads)
+ */
+ public constructor(
+ public readonly room: Room | undefined,
+ opts: IOpts = {},
+ client?: MatrixClient,
+ public readonly thread?: Thread,
+ public readonly threadListType: ThreadFilterType | null = null,
+ ) {
+ super();
+
+ this.timelineSupport = Boolean(opts.timelineSupport);
+ this.liveTimeline = new EventTimeline(this);
+ this.displayPendingEvents = opts.pendingEvents !== false;
+
+ // just a list - *not* ordered.
+ this.timelines = [this.liveTimeline];
+ this._eventIdToTimeline = new Map<string, EventTimeline>();
+
+ this.filter = opts.filter;
+
+ this.relations = this.room?.relations ?? new RelationsContainer(room?.client ?? client!);
+ }
+
+ /**
+ * Get all the timelines in this set
+ * @returns the timelines in this set
+ */
+ public getTimelines(): EventTimeline[] {
+ return this.timelines;
+ }
+
+ /**
+ * Get the filter object this timeline set is filtered on, if any
+ * @returns the optional filter for this timelineSet
+ */
+ public getFilter(): Filter | undefined {
+ return this.filter;
+ }
+
+ /**
+ * Set the filter object this timeline set is filtered on
+ * (passed to the server when paginating via /messages).
+ * @param filter - the filter for this timelineSet
+ */
+ public setFilter(filter?: Filter): void {
+ this.filter = filter;
+ }
+
+ /**
+ * Get the list of pending sent events for this timelineSet's room, filtered
+ * by the timelineSet's filter if appropriate.
+ *
+ * @returns A list of the sent events
+ * waiting for remote echo.
+ *
+ * @throws If `opts.pendingEventOrdering` was not 'detached'
+ */
+ public getPendingEvents(): MatrixEvent[] {
+ if (!this.room || !this.displayPendingEvents) {
+ return [];
+ }
+
+ return this.room.getPendingEvents();
+ }
+ /**
+ * Get the live timeline for this room.
+ *
+ * @returns live timeline
+ */
+ public getLiveTimeline(): EventTimeline {
+ return this.liveTimeline;
+ }
+
+ /**
+ * Set the live timeline for this room.
+ *
+ * @returns live timeline
+ */
+ public setLiveTimeline(timeline: EventTimeline): void {
+ this.liveTimeline = timeline;
+ }
+
+ /**
+ * Return the timeline (if any) this event is in.
+ * @param eventId - the eventId being sought
+ * @returns timeline
+ */
+ public eventIdToTimeline(eventId: string): EventTimeline | undefined {
+ return this._eventIdToTimeline.get(eventId);
+ }
+
+ /**
+ * Track a new event as if it were in the same timeline as an old event,
+ * replacing it.
+ * @param oldEventId - event ID of the original event
+ * @param newEventId - event ID of the replacement event
+ */
+ public replaceEventId(oldEventId: string, newEventId: string): void {
+ const existingTimeline = this._eventIdToTimeline.get(oldEventId);
+ if (existingTimeline) {
+ this._eventIdToTimeline.delete(oldEventId);
+ this._eventIdToTimeline.set(newEventId, existingTimeline);
+ }
+ }
+
+ /**
+ * Reset the live timeline, and start a new one.
+ *
+ * <p>This is used when /sync returns a 'limited' timeline.
+ *
+ * @param backPaginationToken - token for back-paginating the new timeline
+ * @param forwardPaginationToken - token for forward-paginating the old live timeline,
+ * if absent or null, all timelines are reset.
+ *
+ * @remarks
+ * Fires {@link RoomEvent.TimelineReset}
+ */
+ public resetLiveTimeline(backPaginationToken?: string, forwardPaginationToken?: string): void {
+ // Each EventTimeline has RoomState objects tracking the state at the start
+ // and end of that timeline. The copies at the end of the live timeline are
+ // special because they will have listeners attached to monitor changes to
+ // the current room state, so we move this RoomState from the end of the
+ // current live timeline to the end of the new one and, if necessary,
+ // replace it with a newly created one. We also make a copy for the start
+ // of the new timeline.
+
+ // if timeline support is disabled, forget about the old timelines
+ const resetAllTimelines = !this.timelineSupport || !forwardPaginationToken;
+
+ const oldTimeline = this.liveTimeline;
+ const newTimeline = resetAllTimelines
+ ? oldTimeline.forkLive(EventTimeline.FORWARDS)
+ : oldTimeline.fork(EventTimeline.FORWARDS);
+
+ if (resetAllTimelines) {
+ this.timelines = [newTimeline];
+ this._eventIdToTimeline = new Map<string, EventTimeline>();
+ } else {
+ this.timelines.push(newTimeline);
+ }
+
+ if (forwardPaginationToken) {
+ // Now set the forward pagination token on the old live timeline
+ // so it can be forward-paginated.
+ oldTimeline.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS);
+ }
+
+ // make sure we set the pagination token before firing timelineReset,
+ // otherwise clients which start back-paginating will fail, and then get
+ // stuck without realising that they *can* back-paginate.
+ newTimeline.setPaginationToken(backPaginationToken ?? null, EventTimeline.BACKWARDS);
+
+ // Now we can swap the live timeline to the new one.
+ this.liveTimeline = newTimeline;
+ this.emit(RoomEvent.TimelineReset, this.room, this, resetAllTimelines);
+ }
+
+ /**
+ * Get the timeline which contains the given event, if any
+ *
+ * @param eventId - event ID to look for
+ * @returns timeline containing
+ * the given event, or null if unknown
+ */
+ public getTimelineForEvent(eventId?: string): EventTimeline | null {
+ if (eventId === null || eventId === undefined) {
+ return null;
+ }
+ const res = this._eventIdToTimeline.get(eventId);
+ return res === undefined ? null : res;
+ }
+
+ /**
+ * Get an event which is stored in our timelines
+ *
+ * @param eventId - event ID to look for
+ * @returns the given event, or undefined if unknown
+ */
+ public findEventById(eventId: string): MatrixEvent | undefined {
+ const tl = this.getTimelineForEvent(eventId);
+ if (!tl) {
+ return undefined;
+ }
+ return tl.getEvents().find(function (ev) {
+ return ev.getId() == eventId;
+ });
+ }
+
+ /**
+ * Add a new timeline to this timeline list
+ *
+ * @returns newly-created timeline
+ */
+ public addTimeline(): EventTimeline {
+ if (!this.timelineSupport) {
+ throw new Error(
+ "timeline support is disabled. Set the 'timelineSupport'" +
+ " parameter to true when creating MatrixClient to enable" +
+ " it.",
+ );
+ }
+
+ const timeline = new EventTimeline(this);
+ this.timelines.push(timeline);
+ return timeline;
+ }
+
+ /**
+ * Add events to a timeline
+ *
+ * <p>Will fire "Room.timeline" for each event added.
+ *
+ * @param events - A list of events to add.
+ *
+ * @param toStartOfTimeline - True to add these events to the start
+ * (oldest) instead of the end (newest) of the timeline. If true, the oldest
+ * event will be the <b>last</b> element of 'events'.
+ *
+ * @param timeline - timeline to
+ * add events to.
+ *
+ * @param paginationToken - token for the next batch of events
+ *
+ * @remarks
+ * Fires {@link RoomEvent.Timeline}
+ *
+ */
+ public addEventsToTimeline(
+ events: MatrixEvent[],
+ toStartOfTimeline: boolean,
+ timeline: EventTimeline,
+ paginationToken?: string | null,
+ ): void {
+ if (!timeline) {
+ throw new Error("'timeline' not specified for EventTimelineSet.addEventsToTimeline");
+ }
+
+ if (!toStartOfTimeline && timeline == this.liveTimeline) {
+ throw new Error(
+ "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " +
+ "the live timeline - use Room.addLiveEvents instead",
+ );
+ }
+
+ if (this.filter) {
+ events = this.filter.filterRoomTimeline(events);
+ if (!events.length) {
+ return;
+ }
+ }
+
+ const direction = toStartOfTimeline ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS;
+ const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : EventTimeline.BACKWARDS;
+
+ // Adding events to timelines can be quite complicated. The following
+ // illustrates some of the corner-cases.
+ //
+ // Let's say we start by knowing about four timelines. timeline3 and
+ // timeline4 are neighbours:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M] [P] [S] <------> [T]
+ //
+ // Now we paginate timeline1, and get the following events from the server:
+ // [M, N, P, R, S, T, U].
+ //
+ // 1. First, we ignore event M, since we already know about it.
+ //
+ // 2. Next, we append N to timeline 1.
+ //
+ // 3. Next, we don't add event P, since we already know about it,
+ // but we do link together the timelines. We now have:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P] [S] <------> [T]
+ //
+ // 4. Now we add event R to timeline2:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P, R] [S] <------> [T]
+ //
+ // Note that we have switched the timeline we are working on from
+ // timeline1 to timeline2.
+ //
+ // 5. We ignore event S, but again join the timelines:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P, R] <---> [S] <------> [T]
+ //
+ // 6. We ignore event T, and the timelines are already joined, so there
+ // is nothing to do.
+ //
+ // 7. Finally, we add event U to timeline4:
+ //
+ // timeline1 timeline2 timeline3 timeline4
+ // [M, N] <---> [P, R] <---> [S] <------> [T, U]
+ //
+ // The important thing to note in the above is what happened when we
+ // already knew about a given event:
+ //
+ // - if it was appropriate, we joined up the timelines (steps 3, 5).
+ // - in any case, we started adding further events to the timeline which
+ // contained the event we knew about (steps 3, 5, 6).
+ //
+ //
+ // So much for adding events to the timeline. But what do we want to do
+ // with the pagination token?
+ //
+ // In the case above, we will be given a pagination token which tells us how to
+ // get events beyond 'U' - in this case, it makes sense to store this
+ // against timeline4. But what if timeline4 already had 'U' and beyond? in
+ // that case, our best bet is to throw away the pagination token we were
+ // given and stick with whatever token timeline4 had previously. In short,
+ // we want to only store the pagination token if the last event we receive
+ // is one we didn't previously know about.
+ //
+ // We make an exception for this if it turns out that we already knew about
+ // *all* of the events, and we weren't able to join up any timelines. When
+ // that happens, it means our existing pagination token is faulty, since it
+ // is only telling us what we already know. Rather than repeatedly
+ // paginating with the same token, we might as well use the new pagination
+ // token in the hope that we eventually work our way out of the mess.
+
+ let didUpdate = false;
+ let lastEventWasNew = false;
+ for (const event of events) {
+ const eventId = event.getId()!;
+
+ const existingTimeline = this._eventIdToTimeline.get(eventId);
+
+ if (!existingTimeline) {
+ // we don't know about this event yet. Just add it to the timeline.
+ this.addEventToTimeline(event, timeline, {
+ toStartOfTimeline,
+ });
+ lastEventWasNew = true;
+ didUpdate = true;
+ continue;
+ }
+
+ lastEventWasNew = false;
+
+ if (existingTimeline == timeline) {
+ debuglog("Event " + eventId + " already in timeline " + timeline);
+ continue;
+ }
+
+ const neighbour = timeline.getNeighbouringTimeline(direction);
+ if (neighbour) {
+ // this timeline already has a neighbour in the relevant direction;
+ // let's assume the timelines are already correctly linked up, and
+ // skip over to it.
+ //
+ // there's probably some edge-case here where we end up with an
+ // event which is in a timeline a way down the chain, and there is
+ // a break in the chain somewhere. But I can't really imagine how
+ // that would happen, so I'm going to ignore it for now.
+ //
+ if (existingTimeline == neighbour) {
+ debuglog("Event " + eventId + " in neighbouring timeline - " + "switching to " + existingTimeline);
+ } else {
+ debuglog("Event " + eventId + " already in a different " + "timeline " + existingTimeline);
+ }
+ timeline = existingTimeline;
+ continue;
+ }
+
+ // time to join the timelines.
+ logger.info(
+ "Already have timeline for " + eventId + " - joining timeline " + timeline + " to " + existingTimeline,
+ );
+
+ // Variables to keep the line length limited below.
+ const existingIsLive = existingTimeline === this.liveTimeline;
+ const timelineIsLive = timeline === this.liveTimeline;
+
+ const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive;
+ const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive;
+
+ if (backwardsIsLive || forwardsIsLive) {
+ // The live timeline should never be spliced into a non-live position.
+ // We use independent logging to better discover the problem at a glance.
+ if (backwardsIsLive) {
+ logger.warn(
+ "Refusing to set a preceding existingTimeLine on our " +
+ "timeline as the existingTimeLine is live (" +
+ existingTimeline +
+ ")",
+ );
+ }
+ if (forwardsIsLive) {
+ logger.warn(
+ "Refusing to set our preceding timeline on a existingTimeLine " +
+ "as our timeline is live (" +
+ timeline +
+ ")",
+ );
+ }
+ continue; // abort splicing - try next event
+ }
+
+ timeline.setNeighbouringTimeline(existingTimeline, direction);
+ existingTimeline.setNeighbouringTimeline(timeline, inverseDirection);
+
+ timeline = existingTimeline;
+ didUpdate = true;
+ }
+
+ // see above - if the last event was new to us, or if we didn't find any
+ // new information, we update the pagination token for whatever
+ // timeline we ended up on.
+ if (lastEventWasNew || !didUpdate) {
+ if (direction === EventTimeline.FORWARDS && timeline === this.liveTimeline) {
+ logger.warn({ lastEventWasNew, didUpdate }); // for debugging
+ logger.warn(
+ `Refusing to set forwards pagination token of live timeline ` + `${timeline} to ${paginationToken}`,
+ );
+ return;
+ }
+ timeline.setPaginationToken(paginationToken ?? null, direction);
+ }
+ }
+
+ /**
+ * Add an event to the end of this live timeline.
+ *
+ * @param event - Event to be added
+ * @param options - addLiveEvent options
+ */
+ public addLiveEvent(
+ event: MatrixEvent,
+ { duplicateStrategy, fromCache, roomState, timelineWasEmpty }: IAddLiveEventOptions,
+ ): void;
+ /**
+ * @deprecated In favor of the overload with `IAddLiveEventOptions`
+ */
+ public addLiveEvent(
+ event: MatrixEvent,
+ duplicateStrategy?: DuplicateStrategy,
+ fromCache?: boolean,
+ roomState?: RoomState,
+ ): void;
+ public addLiveEvent(
+ event: MatrixEvent,
+ duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
+ fromCache = false,
+ roomState?: RoomState,
+ ): void {
+ let duplicateStrategy = (duplicateStrategyOrOpts as DuplicateStrategy) || DuplicateStrategy.Ignore;
+ let timelineWasEmpty: boolean | undefined;
+ if (typeof duplicateStrategyOrOpts === "object") {
+ ({
+ duplicateStrategy = DuplicateStrategy.Ignore,
+ fromCache = false,
+ roomState,
+ timelineWasEmpty,
+ } = duplicateStrategyOrOpts);
+ } else if (duplicateStrategyOrOpts !== undefined) {
+ // Deprecation warning
+ // FIXME: Remove after 2023-06-01 (technical debt)
+ logger.warn(
+ "Overload deprecated: " +
+ "`EventTimelineSet.addLiveEvent(event, duplicateStrategy?, fromCache?, roomState?)` " +
+ "is deprecated in favor of the overload with " +
+ "`EventTimelineSet.addLiveEvent(event, IAddLiveEventOptions)`",
+ );
+ }
+
+ if (this.filter) {
+ const events = this.filter.filterRoomTimeline([event]);
+ if (!events.length) {
+ return;
+ }
+ }
+
+ const timeline = this._eventIdToTimeline.get(event.getId()!);
+ if (timeline) {
+ if (duplicateStrategy === DuplicateStrategy.Replace) {
+ debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + event.getId());
+ const tlEvents = timeline.getEvents();
+ for (let j = 0; j < tlEvents.length; j++) {
+ if (tlEvents[j].getId() === event.getId()) {
+ // still need to set the right metadata on this event
+ if (!roomState) {
+ roomState = timeline.getState(EventTimeline.FORWARDS);
+ }
+ EventTimeline.setEventMetadata(event, roomState!, false);
+ tlEvents[j] = event;
+
+ // XXX: we need to fire an event when this happens.
+ break;
+ }
+ }
+ } else {
+ debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + event.getId());
+ }
+ return;
+ }
+
+ this.addEventToTimeline(event, this.liveTimeline, {
+ toStartOfTimeline: false,
+ fromCache,
+ roomState,
+ timelineWasEmpty,
+ });
+ }
+
+ /**
+ * Add event to the given timeline, and emit Room.timeline. Assumes
+ * we have already checked we don't know about this event.
+ *
+ * Will fire "Room.timeline" for each event added.
+ *
+ * @param options - addEventToTimeline options
+ *
+ * @remarks
+ * Fires {@link RoomEvent.Timeline}
+ */
+ public addEventToTimeline(
+ event: MatrixEvent,
+ timeline: EventTimeline,
+ { toStartOfTimeline, fromCache, roomState, timelineWasEmpty }: IAddEventToTimelineOptions,
+ ): void;
+ /**
+ * @deprecated In favor of the overload with `IAddEventToTimelineOptions`
+ */
+ public addEventToTimeline(
+ event: MatrixEvent,
+ timeline: EventTimeline,
+ toStartOfTimeline: boolean,
+ fromCache?: boolean,
+ roomState?: RoomState,
+ ): void;
+ public addEventToTimeline(
+ event: MatrixEvent,
+ timeline: EventTimeline,
+ toStartOfTimelineOrOpts: boolean | IAddEventToTimelineOptions,
+ fromCache = false,
+ roomState?: RoomState,
+ ): void {
+ let toStartOfTimeline = !!toStartOfTimelineOrOpts;
+ let timelineWasEmpty: boolean | undefined;
+ if (typeof toStartOfTimelineOrOpts === "object") {
+ ({ toStartOfTimeline, fromCache = false, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts);
+ } else if (toStartOfTimelineOrOpts !== undefined) {
+ // Deprecation warning
+ // FIXME: Remove after 2023-06-01 (technical debt)
+ logger.warn(
+ "Overload deprecated: " +
+ "`EventTimelineSet.addEventToTimeline(event, timeline, toStartOfTimeline, fromCache?, roomState?)` " +
+ "is deprecated in favor of the overload with " +
+ "`EventTimelineSet.addEventToTimeline(event, timeline, IAddEventToTimelineOptions)`",
+ );
+ }
+
+ if (timeline.getTimelineSet() !== this) {
+ throw new Error(`EventTimelineSet.addEventToTimeline: Timeline=${timeline.toString()} does not belong " +
+ "in timelineSet(threadId=${this.thread?.id})`);
+ }
+
+ // Make sure events don't get mixed in timelines they shouldn't be in (e.g. a
+ // threaded message should not be in the main timeline).
+ //
+ // We can only run this check for timelines with a `room` because `canContain`
+ // requires it
+ if (this.room && !this.canContain(event)) {
+ let eventDebugString = `event=${event.getId()}`;
+ if (event.threadRootId) {
+ eventDebugString += `(belongs to thread=${event.threadRootId})`;
+ }
+ logger.warn(
+ `EventTimelineSet.addEventToTimeline: Ignoring ${eventDebugString} that does not belong ` +
+ `in timeline=${timeline.toString()} timelineSet(threadId=${this.thread?.id})`,
+ );
+ return;
+ }
+
+ const eventId = event.getId()!;
+ timeline.addEvent(event, {
+ toStartOfTimeline,
+ roomState,
+ timelineWasEmpty,
+ });
+ this._eventIdToTimeline.set(eventId, timeline);
+
+ this.relations.aggregateParentEvent(event);
+ this.relations.aggregateChildEvent(event, this);
+
+ const data: IRoomTimelineData = {
+ timeline: timeline,
+ liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache,
+ };
+ this.emit(RoomEvent.Timeline, event, this.room, Boolean(toStartOfTimeline), false, data);
+ }
+
+ /**
+ * Replaces event with ID oldEventId with one with newEventId, if oldEventId is
+ * recognised. Otherwise, add to the live timeline. Used to handle remote echos.
+ *
+ * @param localEvent - the new event to be added to the timeline
+ * @param oldEventId - the ID of the original event
+ * @param newEventId - the ID of the replacement event
+ *
+ * @remarks
+ * Fires {@link RoomEvent.Timeline}
+ */
+ public handleRemoteEcho(localEvent: MatrixEvent, oldEventId: string, newEventId: string): void {
+ // XXX: why don't we infer newEventId from localEvent?
+ const existingTimeline = this._eventIdToTimeline.get(oldEventId);
+ if (existingTimeline) {
+ this._eventIdToTimeline.delete(oldEventId);
+ this._eventIdToTimeline.set(newEventId, existingTimeline);
+ } else if (!this.filter || this.filter.filterRoomTimeline([localEvent]).length) {
+ this.addEventToTimeline(localEvent, this.liveTimeline, {
+ toStartOfTimeline: false,
+ });
+ }
+ }
+
+ /**
+ * Removes a single event from this room.
+ *
+ * @param eventId - The id of the event to remove
+ *
+ * @returns the removed event, or null if the event was not found
+ * in this room.
+ */
+ public removeEvent(eventId: string): MatrixEvent | null {
+ const timeline = this._eventIdToTimeline.get(eventId);
+ if (!timeline) {
+ return null;
+ }
+
+ const removed = timeline.removeEvent(eventId);
+ if (removed) {
+ this._eventIdToTimeline.delete(eventId);
+ const data = {
+ timeline: timeline,
+ };
+ this.emit(RoomEvent.Timeline, removed, this.room, undefined, true, data);
+ }
+ return removed;
+ }
+
+ /**
+ * Determine where two events appear in the timeline relative to one another
+ *
+ * @param eventId1 - The id of the first event
+ * @param eventId2 - The id of the second event
+
+ * @returns a number less than zero if eventId1 precedes eventId2, and
+ * greater than zero if eventId1 succeeds eventId2. zero if they are the
+ * same event; null if we can't tell (either because we don't know about one
+ * of the events, or because they are in separate timelines which don't join
+ * up).
+ */
+ public compareEventOrdering(eventId1: string, eventId2: string): number | null {
+ if (eventId1 == eventId2) {
+ // optimise this case
+ return 0;
+ }
+
+ const timeline1 = this._eventIdToTimeline.get(eventId1);
+ const timeline2 = this._eventIdToTimeline.get(eventId2);
+
+ if (timeline1 === undefined) {
+ return null;
+ }
+ if (timeline2 === undefined) {
+ return null;
+ }
+
+ if (timeline1 === timeline2) {
+ // both events are in the same timeline - figure out their relative indices
+ let idx1: number | undefined = undefined;
+ let idx2: number | undefined = undefined;
+ const events = timeline1.getEvents();
+ for (let idx = 0; idx < events.length && (idx1 === undefined || idx2 === undefined); idx++) {
+ const evId = events[idx].getId();
+ if (evId == eventId1) {
+ idx1 = idx;
+ }
+ if (evId == eventId2) {
+ idx2 = idx;
+ }
+ }
+ return idx1! - idx2!;
+ }
+
+ // the events are in different timelines. Iterate through the
+ // linkedlist to see which comes first.
+
+ // first work forwards from timeline1
+ let tl: EventTimeline | null = timeline1;
+ while (tl) {
+ if (tl === timeline2) {
+ // timeline1 is before timeline2
+ return -1;
+ }
+ tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS);
+ }
+
+ // now try backwards from timeline1
+ tl = timeline1;
+ while (tl) {
+ if (tl === timeline2) {
+ // timeline2 is before timeline1
+ return 1;
+ }
+ tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS);
+ }
+
+ // the timelines are not contiguous.
+ return null;
+ }
+
+ /**
+ * Determine whether a given event can sanely be added to this event timeline set,
+ * for timeline sets relating to a thread, only return true for events in the same
+ * thread timeline, for timeline sets not relating to a thread only return true
+ * for events which should be shown in the main room timeline.
+ * Requires the `room` property to have been set at EventTimelineSet construction time.
+ *
+ * @param event - the event to check whether it belongs to this timeline set.
+ * @throws Error if `room` was not set when constructing this timeline set.
+ * @returns whether the event belongs to this timeline set.
+ */
+ public canContain(event: MatrixEvent): boolean {
+ if (!this.room) {
+ throw new Error(
+ "Cannot call `EventTimelineSet::canContain without a `room` set. " +
+ "Set the room when creating the EventTimelineSet to call this method.",
+ );
+ }
+
+ const { threadId, shouldLiveInRoom } = this.room.eventShouldLiveIn(event);
+
+ if (this.thread) {
+ return this.thread.id === threadId;
+ }
+ return shouldLiveInRoom;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline.ts
new file mode 100644
index 0000000..d1ba321
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event-timeline.ts
@@ -0,0 +1,458 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "../logger";
+import { IMarkerFoundOptions, RoomState } from "./room-state";
+import { EventTimelineSet } from "./event-timeline-set";
+import { MatrixEvent } from "./event";
+import { Filter } from "../filter";
+import { EventType } from "../@types/event";
+
+export interface IInitialiseStateOptions extends Pick<IMarkerFoundOptions, "timelineWasEmpty"> {
+ // This is a separate interface without any extra stuff currently added on
+ // top of `IMarkerFoundOptions` just because it feels like they have
+ // different concerns. One shouldn't necessarily look to add to
+ // `IMarkerFoundOptions` just because they want to add an extra option to
+ // `initialiseState`.
+}
+
+export interface IAddEventOptions extends Pick<IMarkerFoundOptions, "timelineWasEmpty"> {
+ /** Whether to insert the new event at the start of the timeline where the
+ * oldest events are (timeline is in chronological order, oldest to most
+ * recent) */
+ toStartOfTimeline: boolean;
+ /** The state events to reconcile metadata from */
+ roomState?: RoomState;
+}
+
+export enum Direction {
+ Backward = "b",
+ Forward = "f",
+}
+
+export class EventTimeline {
+ /**
+ * Symbolic constant for methods which take a 'direction' argument:
+ * refers to the start of the timeline, or backwards in time.
+ */
+ public static readonly BACKWARDS = Direction.Backward;
+
+ /**
+ * Symbolic constant for methods which take a 'direction' argument:
+ * refers to the end of the timeline, or forwards in time.
+ */
+ public static readonly FORWARDS = Direction.Forward;
+
+ /**
+ * Static helper method to set sender and target properties
+ *
+ * @param event - the event whose metadata is to be set
+ * @param stateContext - the room state to be queried
+ * @param toStartOfTimeline - if true the event's forwardLooking flag is set false
+ */
+ public static setEventMetadata(event: MatrixEvent, stateContext: RoomState, toStartOfTimeline: boolean): void {
+ // When we try to generate a sentinel member before we have that member
+ // in the members object, we still generate a sentinel but it doesn't
+ // have a membership event, so test to see if events.member is set. We
+ // check this to avoid overriding non-sentinel members by sentinel ones
+ // when adding the event to a filtered timeline
+ if (!event.sender?.events?.member) {
+ event.sender = stateContext.getSentinelMember(event.getSender()!);
+ }
+ if (!event.target?.events?.member && event.getType() === EventType.RoomMember) {
+ event.target = stateContext.getSentinelMember(event.getStateKey()!);
+ }
+
+ if (event.isState()) {
+ // room state has no concept of 'old' or 'current', but we want the
+ // room state to regress back to previous values if toStartOfTimeline
+ // is set, which means inspecting prev_content if it exists. This
+ // is done by toggling the forwardLooking flag.
+ if (toStartOfTimeline) {
+ event.forwardLooking = false;
+ }
+ }
+ }
+
+ private readonly roomId: string | null;
+ private readonly name: string;
+ private events: MatrixEvent[] = [];
+ private baseIndex = 0;
+
+ private startState?: RoomState;
+ private endState?: RoomState;
+ // If we have a roomId then we delegate pagination token storage to the room state objects `startState` and
+ // `endState`, but for things like the notification timeline which mix multiple rooms we store the tokens ourselves.
+ private startToken: string | null = null;
+ private endToken: string | null = null;
+
+ private prevTimeline: EventTimeline | null = null;
+ private nextTimeline: EventTimeline | null = null;
+ public paginationRequests: Record<Direction, Promise<boolean> | null> = {
+ [Direction.Backward]: null,
+ [Direction.Forward]: null,
+ };
+
+ /**
+ * Construct a new EventTimeline
+ *
+ * <p>An EventTimeline represents a contiguous sequence of events in a room.
+ *
+ * <p>As well as keeping track of the events themselves, it stores the state of
+ * the room at the beginning and end of the timeline, and pagination tokens for
+ * going backwards and forwards in the timeline.
+ *
+ * <p>In order that clients can meaningfully maintain an index into a timeline,
+ * the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is
+ * incremented when events are prepended to the timeline. The index of an event
+ * relative to baseIndex therefore remains constant.
+ *
+ * <p>Once a timeline joins up with its neighbour, they are linked together into a
+ * doubly-linked list.
+ *
+ * @param eventTimelineSet - the set of timelines this is part of
+ */
+ public constructor(private readonly eventTimelineSet: EventTimelineSet) {
+ this.roomId = eventTimelineSet.room?.roomId ?? null;
+ if (this.roomId) {
+ this.startState = new RoomState(this.roomId);
+ this.endState = new RoomState(this.roomId);
+ }
+
+ // this is used by client.js
+ this.paginationRequests = { b: null, f: null };
+
+ this.name = this.roomId + ":" + new Date().toISOString();
+ }
+
+ /**
+ * Initialise the start and end state with the given events
+ *
+ * <p>This can only be called before any events are added.
+ *
+ * @param stateEvents - list of state events to initialise the
+ * state with.
+ * @throws Error if an attempt is made to call this after addEvent is called.
+ */
+ public initialiseState(stateEvents: MatrixEvent[], { timelineWasEmpty }: IInitialiseStateOptions = {}): void {
+ if (this.events.length > 0) {
+ throw new Error("Cannot initialise state after events are added");
+ }
+
+ this.startState?.setStateEvents(stateEvents, { timelineWasEmpty });
+ this.endState?.setStateEvents(stateEvents, { timelineWasEmpty });
+ }
+
+ /**
+ * Forks the (live) timeline, taking ownership of the existing directional state of this timeline.
+ * All attached listeners will keep receiving state updates from the new live timeline state.
+ * The end state of this timeline gets replaced with an independent copy of the current RoomState,
+ * and will need a new pagination token if it ever needs to paginate forwards.
+
+ * @param direction - EventTimeline.BACKWARDS to get the state at the
+ * start of the timeline; EventTimeline.FORWARDS to get the state at the end
+ * of the timeline.
+ *
+ * @returns the new timeline
+ */
+ public forkLive(direction: Direction): EventTimeline {
+ const forkState = this.getState(direction);
+ const timeline = new EventTimeline(this.eventTimelineSet);
+ timeline.startState = forkState?.clone();
+ // Now clobber the end state of the new live timeline with that from the
+ // previous live timeline. It will be identical except that we'll keep
+ // using the same RoomMember objects for the 'live' set of members with any
+ // listeners still attached
+ timeline.endState = forkState;
+ // Firstly, we just stole the current timeline's end state, so it needs a new one.
+ // Make an immutable copy of the state so back pagination will get the correct sentinels.
+ this.endState = forkState?.clone();
+ return timeline;
+ }
+
+ /**
+ * Creates an independent timeline, inheriting the directional state from this timeline.
+ *
+ * @param direction - EventTimeline.BACKWARDS to get the state at the
+ * start of the timeline; EventTimeline.FORWARDS to get the state at the end
+ * of the timeline.
+ *
+ * @returns the new timeline
+ */
+ public fork(direction: Direction): EventTimeline {
+ const forkState = this.getState(direction);
+ const timeline = new EventTimeline(this.eventTimelineSet);
+ timeline.startState = forkState?.clone();
+ timeline.endState = forkState?.clone();
+ return timeline;
+ }
+
+ /**
+ * Get the ID of the room for this timeline
+ * @returns room ID
+ */
+ public getRoomId(): string | null {
+ return this.roomId;
+ }
+
+ /**
+ * Get the filter for this timeline's timelineSet (if any)
+ * @returns filter
+ */
+ public getFilter(): Filter | undefined {
+ return this.eventTimelineSet.getFilter();
+ }
+
+ /**
+ * Get the timelineSet for this timeline
+ * @returns timelineSet
+ */
+ public getTimelineSet(): EventTimelineSet {
+ return this.eventTimelineSet;
+ }
+
+ /**
+ * Get the base index.
+ *
+ * <p>This is an index which is incremented when events are prepended to the
+ * timeline. An individual event therefore stays at the same index in the array
+ * relative to the base index (although note that a given event's index may
+ * well be less than the base index, thus giving that event a negative relative
+ * index).
+ */
+ public getBaseIndex(): number {
+ return this.baseIndex;
+ }
+
+ /**
+ * Get the list of events in this context
+ *
+ * @returns An array of MatrixEvents
+ */
+ public getEvents(): MatrixEvent[] {
+ return this.events;
+ }
+
+ /**
+ * Get the room state at the start/end of the timeline
+ *
+ * @param direction - EventTimeline.BACKWARDS to get the state at the
+ * start of the timeline; EventTimeline.FORWARDS to get the state at the end
+ * of the timeline.
+ *
+ * @returns state at the start/end of the timeline
+ */
+ public getState(direction: Direction): RoomState | undefined {
+ if (direction == EventTimeline.BACKWARDS) {
+ return this.startState;
+ } else if (direction == EventTimeline.FORWARDS) {
+ return this.endState;
+ } else {
+ throw new Error("Invalid direction '" + direction + "'");
+ }
+ }
+
+ /**
+ * Get a pagination token
+ *
+ * @param direction - EventTimeline.BACKWARDS to get the pagination
+ * token for going backwards in time; EventTimeline.FORWARDS to get the
+ * pagination token for going forwards in time.
+ *
+ * @returns pagination token
+ */
+ public getPaginationToken(direction: Direction): string | null {
+ if (this.roomId) {
+ return this.getState(direction)!.paginationToken;
+ } else if (direction === Direction.Backward) {
+ return this.startToken;
+ } else {
+ return this.endToken;
+ }
+ }
+
+ /**
+ * Set a pagination token
+ *
+ * @param token - pagination token
+ *
+ * @param direction - EventTimeline.BACKWARDS to set the pagination
+ * token for going backwards in time; EventTimeline.FORWARDS to set the
+ * pagination token for going forwards in time.
+ */
+ public setPaginationToken(token: string | null, direction: Direction): void {
+ if (this.roomId) {
+ this.getState(direction)!.paginationToken = token;
+ } else if (direction === Direction.Backward) {
+ this.startToken = token;
+ } else {
+ this.endToken = token;
+ }
+ }
+
+ /**
+ * Get the next timeline in the series
+ *
+ * @param direction - EventTimeline.BACKWARDS to get the previous
+ * timeline; EventTimeline.FORWARDS to get the next timeline.
+ *
+ * @returns previous or following timeline, if they have been
+ * joined up.
+ */
+ public getNeighbouringTimeline(direction: Direction): EventTimeline | null {
+ if (direction == EventTimeline.BACKWARDS) {
+ return this.prevTimeline;
+ } else if (direction == EventTimeline.FORWARDS) {
+ return this.nextTimeline;
+ } else {
+ throw new Error("Invalid direction '" + direction + "'");
+ }
+ }
+
+ /**
+ * Set the next timeline in the series
+ *
+ * @param neighbour - previous/following timeline
+ *
+ * @param direction - EventTimeline.BACKWARDS to set the previous
+ * timeline; EventTimeline.FORWARDS to set the next timeline.
+ *
+ * @throws Error if an attempt is made to set the neighbouring timeline when
+ * it is already set.
+ */
+ public setNeighbouringTimeline(neighbour: EventTimeline, direction: Direction): void {
+ if (this.getNeighbouringTimeline(direction)) {
+ throw new Error(
+ "timeline already has a neighbouring timeline - " +
+ "cannot reset neighbour (direction: " +
+ direction +
+ ")",
+ );
+ }
+
+ if (direction == EventTimeline.BACKWARDS) {
+ this.prevTimeline = neighbour;
+ } else if (direction == EventTimeline.FORWARDS) {
+ this.nextTimeline = neighbour;
+ } else {
+ throw new Error("Invalid direction '" + direction + "'");
+ }
+
+ // make sure we don't try to paginate this timeline
+ this.setPaginationToken(null, direction);
+ }
+
+ /**
+ * Add a new event to the timeline, and update the state
+ *
+ * @param event - new event
+ * @param options - addEvent options
+ */
+ public addEvent(event: MatrixEvent, { toStartOfTimeline, roomState, timelineWasEmpty }: IAddEventOptions): void;
+ /**
+ * @deprecated In favor of the overload with `IAddEventOptions`
+ */
+ public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, roomState?: RoomState): void;
+ public addEvent(
+ event: MatrixEvent,
+ toStartOfTimelineOrOpts: boolean | IAddEventOptions,
+ roomState?: RoomState,
+ ): void {
+ let toStartOfTimeline = !!toStartOfTimelineOrOpts;
+ let timelineWasEmpty: boolean | undefined;
+ if (typeof toStartOfTimelineOrOpts === "object") {
+ ({ toStartOfTimeline, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts);
+ } else if (toStartOfTimelineOrOpts !== undefined) {
+ // Deprecation warning
+ // FIXME: Remove after 2023-06-01 (technical debt)
+ logger.warn(
+ "Overload deprecated: " +
+ "`EventTimeline.addEvent(event, toStartOfTimeline, roomState?)` " +
+ "is deprecated in favor of the overload with `EventTimeline.addEvent(event, IAddEventOptions)`",
+ );
+ }
+
+ if (!roomState) {
+ roomState = toStartOfTimeline ? this.startState : this.endState;
+ }
+
+ const timelineSet = this.getTimelineSet();
+
+ if (timelineSet.room) {
+ EventTimeline.setEventMetadata(event, roomState!, toStartOfTimeline);
+
+ // modify state but only on unfiltered timelineSets
+ if (event.isState() && timelineSet.room.getUnfilteredTimelineSet() === timelineSet) {
+ roomState?.setStateEvents([event], { timelineWasEmpty });
+ // it is possible that the act of setting the state event means we
+ // can set more metadata (specifically sender/target props), so try
+ // it again if the prop wasn't previously set. It may also mean that
+ // the sender/target is updated (if the event set was a room member event)
+ // so we want to use the *updated* member (new avatar/name) instead.
+ //
+ // However, we do NOT want to do this on member events if we're going
+ // back in time, else we'll set the .sender value for BEFORE the given
+ // member event, whereas we want to set the .sender value for the ACTUAL
+ // member event itself.
+ if (!event.sender || (event.getType() === EventType.RoomMember && !toStartOfTimeline)) {
+ EventTimeline.setEventMetadata(event, roomState!, toStartOfTimeline);
+ }
+ }
+ }
+
+ let insertIndex: number;
+
+ if (toStartOfTimeline) {
+ insertIndex = 0;
+ } else {
+ insertIndex = this.events.length;
+ }
+
+ this.events.splice(insertIndex, 0, event); // insert element
+ if (toStartOfTimeline) {
+ this.baseIndex++;
+ }
+ }
+
+ /**
+ * Remove an event from the timeline
+ *
+ * @param eventId - ID of event to be removed
+ * @returns removed event, or null if not found
+ */
+ public removeEvent(eventId: string): MatrixEvent | null {
+ for (let i = this.events.length - 1; i >= 0; i--) {
+ const ev = this.events[i];
+ if (ev.getId() == eventId) {
+ this.events.splice(i, 1);
+ if (i < this.baseIndex) {
+ this.baseIndex--;
+ }
+ return ev;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Return a string to identify this timeline, for debugging
+ *
+ * @returns name for this timeline
+ */
+ public toString(): string {
+ return this.name;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event.ts
new file mode 100644
index 0000000..2db3479
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/event.ts
@@ -0,0 +1,1631 @@
+/*
+Copyright 2015 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module. See {@link MatrixEvent} and {@link RoomEvent} for
+ * the public classes.
+ */
+
+import { ExtensibleEvent, ExtensibleEvents, Optional } from "matrix-events-sdk";
+
+import type { IEventDecryptionResult } from "../@types/crypto";
+import { logger } from "../logger";
+import { VerificationRequest } from "../crypto/verification/request/VerificationRequest";
+import { EVENT_VISIBILITY_CHANGE_TYPE, EventType, MsgType, RelationType } from "../@types/event";
+import { Crypto } from "../crypto";
+import { deepSortedObjectEntries, internaliseString } from "../utils";
+import { RoomMember } from "./room-member";
+import { Thread, ThreadEvent, EventHandlerMap as ThreadEventHandlerMap, THREAD_RELATION_TYPE } from "./thread";
+import { IActionsObject } from "../pushprocessor";
+import { TypedReEmitter } from "../ReEmitter";
+import { MatrixError } from "../http-api";
+import { TypedEventEmitter } from "./typed-event-emitter";
+import { EventStatus } from "./event-status";
+import { DecryptionError } from "../crypto/algorithms";
+import { CryptoBackend } from "../common-crypto/CryptoBackend";
+import { WITHHELD_MESSAGES } from "../crypto/OlmDevice";
+
+export { EventStatus } from "./event-status";
+
+/* eslint-disable camelcase */
+export interface IContent {
+ [key: string]: any;
+ "msgtype"?: MsgType | string;
+ "membership"?: string;
+ "avatar_url"?: string;
+ "displayname"?: string;
+ "m.relates_to"?: IEventRelation;
+
+ "org.matrix.msc3952.mentions"?: IMentions;
+}
+
+type StrippedState = Required<Pick<IEvent, "content" | "state_key" | "type" | "sender">>;
+
+export interface IUnsigned {
+ "age"?: number;
+ "prev_sender"?: string;
+ "prev_content"?: IContent;
+ "redacted_because"?: IEvent;
+ "transaction_id"?: string;
+ "invite_room_state"?: StrippedState[];
+ "m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
+}
+
+export interface IThreadBundledRelationship {
+ latest_event: IEvent;
+ count: number;
+ current_user_participated?: boolean;
+}
+
+export interface IEvent {
+ event_id: string;
+ type: string;
+ content: IContent;
+ sender: string;
+ room_id?: string;
+ origin_server_ts: number;
+ txn_id?: string;
+ state_key?: string;
+ membership?: string;
+ unsigned: IUnsigned;
+ redacts?: string;
+
+ /**
+ * @deprecated in favour of `sender`
+ */
+ user_id?: string;
+ /**
+ * @deprecated in favour of `unsigned.prev_content`
+ */
+ prev_content?: IContent;
+ /**
+ * @deprecated in favour of `origin_server_ts`
+ */
+ age?: number;
+}
+
+export interface IAggregatedRelation {
+ origin_server_ts: number;
+ event_id?: string;
+ sender?: string;
+ type?: string;
+ count?: number;
+ key?: string;
+}
+
+export interface IEventRelation {
+ "rel_type"?: RelationType | string;
+ "event_id"?: string;
+ "is_falling_back"?: boolean;
+ "m.in_reply_to"?: {
+ event_id?: string;
+ };
+ "key"?: string;
+}
+
+export interface IMentions {
+ user_ids?: string[];
+ room?: boolean;
+}
+
+/**
+ * When an event is a visibility change event, as per MSC3531,
+ * the visibility change implied by the event.
+ */
+export interface IVisibilityChange {
+ /**
+ * If `true`, the target event should be made visible.
+ * Otherwise, it should be hidden.
+ */
+ visible: boolean;
+
+ /**
+ * The event id affected.
+ */
+ eventId: string;
+
+ /**
+ * Optionally, a human-readable reason explaining why
+ * the event was hidden. Ignored if the event was made
+ * visible.
+ */
+ reason: string | null;
+}
+
+export interface IClearEvent {
+ room_id?: string;
+ type: string;
+ content: Omit<IContent, "membership" | "avatar_url" | "displayname" | "m.relates_to">;
+ unsigned?: IUnsigned;
+}
+/* eslint-enable camelcase */
+
+interface IKeyRequestRecipient {
+ userId: string;
+ deviceId: "*" | string;
+}
+
+export interface IDecryptOptions {
+ // Emits "event.decrypted" if set to true
+ emit?: boolean;
+ // True if this is a retry (enables more logging)
+ isRetry?: boolean;
+ // whether the message should be re-decrypted if it was previously successfully decrypted with an untrusted key
+ forceRedecryptIfUntrusted?: boolean;
+}
+
+/**
+ * Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531.
+ */
+export type MessageVisibility = IMessageVisibilityHidden | IMessageVisibilityVisible;
+/**
+ * Variant of `MessageVisibility` for the case in which the message should be displayed.
+ */
+export interface IMessageVisibilityVisible {
+ readonly visible: true;
+}
+/**
+ * Variant of `MessageVisibility` for the case in which the message should be hidden.
+ */
+export interface IMessageVisibilityHidden {
+ readonly visible: false;
+ /**
+ * Optionally, a human-readable reason to show to the user indicating why the
+ * message has been hidden (e.g. "Message Pending Moderation").
+ */
+ readonly reason: string | null;
+}
+// A singleton implementing `IMessageVisibilityVisible`.
+const MESSAGE_VISIBLE: IMessageVisibilityVisible = Object.freeze({ visible: true });
+
+export enum MatrixEventEvent {
+ Decrypted = "Event.decrypted",
+ BeforeRedaction = "Event.beforeRedaction",
+ VisibilityChange = "Event.visibilityChange",
+ LocalEventIdReplaced = "Event.localEventIdReplaced",
+ Status = "Event.status",
+ Replaced = "Event.replaced",
+ RelationsCreated = "Event.relationsCreated",
+}
+
+export type MatrixEventEmittedEvents = MatrixEventEvent | ThreadEvent.Update;
+
+export type MatrixEventHandlerMap = {
+ /**
+ * Fires when an event is decrypted
+ *
+ * @param event - The matrix event which has been decrypted
+ * @param err - The error that occurred during decryption, or `undefined` if no error occurred.
+ */
+ [MatrixEventEvent.Decrypted]: (event: MatrixEvent, err?: Error) => void;
+ [MatrixEventEvent.BeforeRedaction]: (event: MatrixEvent, redactionEvent: MatrixEvent) => void;
+ [MatrixEventEvent.VisibilityChange]: (event: MatrixEvent, visible: boolean) => void;
+ [MatrixEventEvent.LocalEventIdReplaced]: (event: MatrixEvent) => void;
+ [MatrixEventEvent.Status]: (event: MatrixEvent, status: EventStatus | null) => void;
+ [MatrixEventEvent.Replaced]: (event: MatrixEvent) => void;
+ [MatrixEventEvent.RelationsCreated]: (relationType: string, eventType: string) => void;
+} & Pick<ThreadEventHandlerMap, ThreadEvent.Update>;
+
+export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap> {
+ private pushActions: IActionsObject | null = null;
+ private _replacingEvent: MatrixEvent | null = null;
+ private _localRedactionEvent: MatrixEvent | null = null;
+ private _isCancelled = false;
+ private clearEvent?: IClearEvent;
+
+ /* Message hiding, as specified by https://github.com/matrix-org/matrix-doc/pull/3531.
+
+ Note: We're returning this object, so any value stored here MUST be frozen.
+ */
+ private visibility: MessageVisibility = MESSAGE_VISIBLE;
+
+ // Not all events will be extensible-event compatible, so cache a flag in
+ // addition to a falsy cached event value. We check the flag later on in
+ // a public getter to decide if the cache is valid.
+ private _hasCachedExtEv = false;
+ private _cachedExtEv: Optional<ExtensibleEvent> = undefined;
+
+ /* curve25519 key which we believe belongs to the sender of the event. See
+ * getSenderKey()
+ */
+ private senderCurve25519Key: string | null = null;
+
+ /* ed25519 key which the sender of this event (for olm) or the creator of
+ * the megolm session (for megolm) claims to own. See getClaimedEd25519Key()
+ */
+ private claimedEd25519Key: string | null = null;
+
+ /* curve25519 keys of devices involved in telling us about the
+ * senderCurve25519Key and claimedEd25519Key.
+ * See getForwardingCurve25519KeyChain().
+ */
+ private forwardingCurve25519KeyChain: string[] = [];
+
+ /* where the decryption key is untrusted
+ */
+ private untrusted: boolean | null = null;
+
+ /* if we have a process decrypting this event, a Promise which resolves
+ * when it is finished. Normally null.
+ */
+ private decryptionPromise: Promise<void> | null = null;
+
+ /* flag to indicate if we should retry decrypting this event after the
+ * first attempt (eg, we have received new data which means that a second
+ * attempt may succeed)
+ */
+ private retryDecryption = false;
+
+ /* The txnId with which this event was sent if it was during this session,
+ * allows for a unique ID which does not change when the event comes back down sync.
+ */
+ private txnId?: string;
+
+ /**
+ * A reference to the thread this event belongs to
+ */
+ private thread?: Thread;
+ private threadId?: string;
+
+ /*
+ * True if this event is an encrypted event which we failed to decrypt, the receiver's device is unverified and
+ * the sender has disabled encrypting to unverified devices.
+ */
+ private encryptedDisabledForUnverifiedDevices = false;
+
+ /* Set an approximate timestamp for the event relative the local clock.
+ * This will inherently be approximate because it doesn't take into account
+ * the time between the server putting the 'age' field on the event as it sent
+ * it to us and the time we're now constructing this event, but that's better
+ * than assuming the local clock is in sync with the origin HS's clock.
+ */
+ public localTimestamp: number;
+
+ /**
+ * The room member who sent this event, or null e.g.
+ * this is a presence event. This is only guaranteed to be set for events that
+ * appear in a timeline, ie. do not guarantee that it will be set on state
+ * events.
+ * @privateRemarks
+ * Should be read-only
+ */
+ public sender: RoomMember | null = null;
+ /**
+ * The room member who is the target of this event, e.g.
+ * the invitee, the person being banned, etc.
+ * @privateRemarks
+ * Should be read-only
+ */
+ public target: RoomMember | null = null;
+ /**
+ * The sending status of the event.
+ * @privateRemarks
+ * Should be read-only
+ */
+ public status: EventStatus | null = null;
+ /**
+ * most recent error associated with sending the event, if any
+ * @privateRemarks
+ * Should be read-only
+ */
+ public error: MatrixError | null = null;
+ /**
+ * True if this event is 'forward looking', meaning
+ * that getDirectionalContent() will return event.content and not event.prev_content.
+ * Only state events may be backwards looking
+ * Default: true. <strong>This property is experimental and may change.</strong>
+ * @privateRemarks
+ * Should be read-only
+ */
+ public forwardLooking = true;
+
+ /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event,
+ * `Crypto` will set this the `VerificationRequest` for the event
+ * so it can be easily accessed from the timeline.
+ */
+ public verificationRequest?: VerificationRequest;
+
+ private readonly reEmitter: TypedReEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap>;
+
+ /**
+ * Construct a Matrix Event object
+ *
+ * @param event - The raw (possibly encrypted) event. <b>Do not access
+ * this property</b> directly unless you absolutely have to. Prefer the getter
+ * methods defined on this class. Using the getter methods shields your app
+ * from changes to event JSON between Matrix versions.
+ */
+ public constructor(public event: Partial<IEvent> = {}) {
+ super();
+
+ // intern the values of matrix events to force share strings and reduce the
+ // amount of needless string duplication. This can save moderate amounts of
+ // memory (~10% on a 350MB heap).
+ // 'membership' at the event level (rather than the content level) is a legacy
+ // field that Element never otherwise looks at, but it will still take up a lot
+ // of space if we don't intern it.
+ (["state_key", "type", "sender", "room_id", "membership"] as const).forEach((prop) => {
+ if (typeof event[prop] !== "string") return;
+ event[prop] = internaliseString(event[prop]!);
+ });
+
+ (["membership", "avatar_url", "displayname"] as const).forEach((prop) => {
+ if (typeof event.content?.[prop] !== "string") return;
+ event.content[prop] = internaliseString(event.content[prop]!);
+ });
+
+ (["rel_type"] as const).forEach((prop) => {
+ if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return;
+ event.content["m.relates_to"][prop] = internaliseString(event.content["m.relates_to"][prop]!);
+ });
+
+ this.txnId = event.txn_id;
+ this.localTimestamp = Date.now() - (this.getAge() ?? 0);
+ this.reEmitter = new TypedReEmitter(this);
+ }
+
+ /**
+ * Unstable getter to try and get an extensible event. Note that this might
+ * return a falsy value if the event could not be parsed as an extensible
+ * event.
+ *
+ * @deprecated Use stable functions where possible.
+ */
+ public get unstableExtensibleEvent(): Optional<ExtensibleEvent> {
+ if (!this._hasCachedExtEv) {
+ this._cachedExtEv = ExtensibleEvents.parse(this.getEffectiveEvent());
+ }
+ return this._cachedExtEv;
+ }
+
+ private invalidateExtensibleEvent(): void {
+ // just reset the flag - that'll trick the getter into parsing a new event
+ this._hasCachedExtEv = false;
+ }
+
+ /**
+ * Gets the event as though it would appear unencrypted. If the event is already not
+ * encrypted, it is simply returned as-is.
+ * @returns The event in wire format.
+ */
+ public getEffectiveEvent(): IEvent {
+ const content = Object.assign({}, this.getContent()); // clone for mutation
+
+ if (this.getWireType() === EventType.RoomMessageEncrypted) {
+ // Encrypted events sometimes aren't symmetrical on the `content` so we'll copy
+ // that over too, but only for missing properties. We don't copy over mismatches
+ // between the plain and decrypted copies of `content` because we assume that the
+ // app is relying on the decrypted version, so we want to expose that as a source
+ // of truth here too.
+ for (const [key, value] of Object.entries(this.getWireContent())) {
+ // Skip fields from the encrypted event schema though - we don't want to leak
+ // these.
+ if (["algorithm", "ciphertext", "device_id", "sender_key", "session_id"].includes(key)) {
+ continue;
+ }
+
+ if (content[key] === undefined) content[key] = value;
+ }
+ }
+
+ // clearEvent doesn't have all the fields, so we'll copy what we can from this.event.
+ // We also copy over our "fixed" content key.
+ return Object.assign({}, this.event, this.clearEvent, { content }) as IEvent;
+ }
+
+ /**
+ * Get the event_id for this event.
+ * @returns The event ID, e.g. <code>$143350589368169JsLZx:localhost
+ * </code>
+ */
+ public getId(): string | undefined {
+ return this.event.event_id;
+ }
+
+ /**
+ * Get the user_id for this event.
+ * @returns The user ID, e.g. `@alice:matrix.org`
+ */
+ public getSender(): string | undefined {
+ return this.event.sender || this.event.user_id; // v2 / v1
+ }
+
+ /**
+ * Get the (decrypted, if necessary) type of event.
+ *
+ * @returns The event type, e.g. `m.room.message`
+ */
+ public getType(): EventType | string {
+ if (this.clearEvent) {
+ return this.clearEvent.type;
+ }
+ return this.event.type!;
+ }
+
+ /**
+ * Get the (possibly encrypted) type of the event that will be sent to the
+ * homeserver.
+ *
+ * @returns The event type.
+ */
+ public getWireType(): EventType | string {
+ return this.event.type!;
+ }
+
+ /**
+ * Get the room_id for this event. This will return `undefined`
+ * for `m.presence` events.
+ * @returns The room ID, e.g. <code>!cURbafjkfsMDVwdRDQ:matrix.org
+ * </code>
+ */
+ public getRoomId(): string | undefined {
+ return this.event.room_id;
+ }
+
+ /**
+ * Get the timestamp of this event.
+ * @returns The event timestamp, e.g. `1433502692297`
+ */
+ public getTs(): number {
+ return this.event.origin_server_ts!;
+ }
+
+ /**
+ * Get the timestamp of this event, as a Date object.
+ * @returns The event date, e.g. `new Date(1433502692297)`
+ */
+ public getDate(): Date | null {
+ return this.event.origin_server_ts ? new Date(this.event.origin_server_ts) : null;
+ }
+
+ /**
+ * Get a string containing details of this event
+ *
+ * This is intended for logging, to help trace errors. Example output:
+ *
+ * @example
+ * ```
+ * id=$HjnOHV646n0SjLDAqFrgIjim7RCpB7cdMXFrekWYAn type=m.room.encrypted
+ * sender=@user:example.com room=!room:example.com ts=2022-10-25T17:30:28.404Z
+ * ```
+ */
+ public getDetails(): string {
+ let details = `id=${this.getId()} type=${this.getWireType()} sender=${this.getSender()}`;
+ const room = this.getRoomId();
+ if (room) {
+ details += ` room=${room}`;
+ }
+ const date = this.getDate();
+ if (date) {
+ details += ` ts=${date.toISOString()}`;
+ }
+ return details;
+ }
+
+ /**
+ * Get the (decrypted, if necessary) event content JSON, even if the event
+ * was replaced by another event.
+ *
+ * @returns The event content JSON, or an empty object.
+ */
+ public getOriginalContent<T = IContent>(): T {
+ if (this._localRedactionEvent) {
+ return {} as T;
+ }
+ if (this.clearEvent) {
+ return (this.clearEvent.content || {}) as T;
+ }
+ return (this.event.content || {}) as T;
+ }
+
+ /**
+ * Get the (decrypted, if necessary) event content JSON,
+ * or the content from the replacing event, if any.
+ * See `makeReplaced`.
+ *
+ * @returns The event content JSON, or an empty object.
+ */
+ public getContent<T extends IContent = IContent>(): T {
+ if (this._localRedactionEvent) {
+ return {} as T;
+ } else if (this._replacingEvent) {
+ return this._replacingEvent.getContent()["m.new_content"] || {};
+ } else {
+ return this.getOriginalContent();
+ }
+ }
+
+ /**
+ * Get the (possibly encrypted) event content JSON that will be sent to the
+ * homeserver.
+ *
+ * @returns The event content JSON, or an empty object.
+ */
+ public getWireContent(): IContent {
+ return this.event.content || {};
+ }
+
+ /**
+ * Get the event ID of the thread head
+ */
+ public get threadRootId(): string | undefined {
+ const relatesTo = this.getWireContent()?.["m.relates_to"];
+ if (relatesTo?.rel_type === THREAD_RELATION_TYPE.name) {
+ return relatesTo.event_id;
+ } else {
+ return this.getThread()?.id || this.threadId;
+ }
+ }
+
+ /**
+ * A helper to check if an event is a thread's head or not
+ */
+ public get isThreadRoot(): boolean {
+ const threadDetails = this.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
+
+ // Bundled relationships only returned when the sync response is limited
+ // hence us having to check both bundled relation and inspect the thread
+ // model
+ return !!threadDetails || this.getThread()?.id === this.getId();
+ }
+
+ public get replyEventId(): string | undefined {
+ return this.getWireContent()["m.relates_to"]?.["m.in_reply_to"]?.event_id;
+ }
+
+ public get relationEventId(): string | undefined {
+ return this.getWireContent()?.["m.relates_to"]?.event_id;
+ }
+
+ /**
+ * Get the previous event content JSON. This will only return something for
+ * state events which exist in the timeline.
+ * @returns The previous event content JSON, or an empty object.
+ */
+ public getPrevContent(): IContent {
+ // v2 then v1 then default
+ return this.getUnsigned().prev_content || this.event.prev_content || {};
+ }
+
+ /**
+ * Get either 'content' or 'prev_content' depending on if this event is
+ * 'forward-looking' or not. This can be modified via event.forwardLooking.
+ * In practice, this means we get the chronologically earlier content value
+ * for this event (this method should surely be called getEarlierContent)
+ * <strong>This method is experimental and may change.</strong>
+ * @returns event.content if this event is forward-looking, else
+ * event.prev_content.
+ */
+ public getDirectionalContent(): IContent {
+ return this.forwardLooking ? this.getContent() : this.getPrevContent();
+ }
+
+ /**
+ * Get the age of this event. This represents the age of the event when the
+ * event arrived at the device, and not the age of the event when this
+ * function was called.
+ * Can only be returned once the server has echo'ed back
+ * @returns The age of this event in milliseconds.
+ */
+ public getAge(): number | undefined {
+ return this.getUnsigned().age || this.event.age; // v2 / v1
+ }
+
+ /**
+ * Get the age of the event when this function was called.
+ * This is the 'age' field adjusted according to how long this client has
+ * had the event.
+ * @returns The age of this event in milliseconds.
+ */
+ public getLocalAge(): number {
+ return Date.now() - this.localTimestamp;
+ }
+
+ /**
+ * Get the event state_key if it has one. This will return <code>undefined
+ * </code> for message events.
+ * @returns The event's `state_key`.
+ */
+ public getStateKey(): string | undefined {
+ return this.event.state_key;
+ }
+
+ /**
+ * Check if this event is a state event.
+ * @returns True if this is a state event.
+ */
+ public isState(): boolean {
+ return this.event.state_key !== undefined;
+ }
+
+ /**
+ * Replace the content of this event with encrypted versions.
+ * (This is used when sending an event; it should not be used by applications).
+ *
+ * @internal
+ *
+ * @param cryptoType - type of the encrypted event - typically
+ * <tt>"m.room.encrypted"</tt>
+ *
+ * @param cryptoContent - raw 'content' for the encrypted event.
+ *
+ * @param senderCurve25519Key - curve25519 key to record for the
+ * sender of this event.
+ * See {@link MatrixEvent#getSenderKey}.
+ *
+ * @param claimedEd25519Key - claimed ed25519 key to record for the
+ * sender if this event.
+ * See {@link MatrixEvent#getClaimedEd25519Key}
+ */
+ public makeEncrypted(
+ cryptoType: string,
+ cryptoContent: object,
+ senderCurve25519Key: string,
+ claimedEd25519Key: string,
+ ): void {
+ // keep the plain-text data for 'view source'
+ this.clearEvent = {
+ type: this.event.type!,
+ content: this.event.content!,
+ };
+ this.event.type = cryptoType;
+ this.event.content = cryptoContent;
+ this.senderCurve25519Key = senderCurve25519Key;
+ this.claimedEd25519Key = claimedEd25519Key;
+ }
+
+ /**
+ * Check if this event is currently being decrypted.
+ *
+ * @returns True if this event is currently being decrypted, else false.
+ */
+ public isBeingDecrypted(): boolean {
+ return this.decryptionPromise != null;
+ }
+
+ public getDecryptionPromise(): Promise<void> | null {
+ return this.decryptionPromise;
+ }
+
+ /**
+ * Check if this event is an encrypted event which we failed to decrypt
+ *
+ * (This implies that we might retry decryption at some point in the future)
+ *
+ * @returns True if this event is an encrypted event which we
+ * couldn't decrypt.
+ */
+ public isDecryptionFailure(): boolean {
+ return this.clearEvent?.content?.msgtype === "m.bad.encrypted";
+ }
+
+ /*
+ * True if this event is an encrypted event which we failed to decrypt, the receiver's device is unverified and
+ * the sender has disabled encrypting to unverified devices.
+ */
+ public get isEncryptedDisabledForUnverifiedDevices(): boolean {
+ return this.isDecryptionFailure() && this.encryptedDisabledForUnverifiedDevices;
+ }
+
+ public shouldAttemptDecryption(): boolean {
+ if (this.isRedacted()) return false;
+ if (this.isBeingDecrypted()) return false;
+ if (this.clearEvent) return false;
+ if (!this.isEncrypted()) return false;
+
+ return true;
+ }
+
+ /**
+ * Start the process of trying to decrypt this event.
+ *
+ * (This is used within the SDK: it isn't intended for use by applications)
+ *
+ * @internal
+ *
+ * @param crypto - crypto module
+ *
+ * @returns promise which resolves (to undefined) when the decryption
+ * attempt is completed.
+ */
+ public async attemptDecryption(crypto: CryptoBackend, options: IDecryptOptions = {}): Promise<void> {
+ // start with a couple of sanity checks.
+ if (!this.isEncrypted()) {
+ throw new Error("Attempt to decrypt event which isn't encrypted");
+ }
+
+ const alreadyDecrypted = this.clearEvent && !this.isDecryptionFailure();
+ const forceRedecrypt = options.forceRedecryptIfUntrusted && this.isKeySourceUntrusted();
+ if (alreadyDecrypted && !forceRedecrypt) {
+ // we may want to just ignore this? let's start with rejecting it.
+ throw new Error("Attempt to decrypt event which has already been decrypted");
+ }
+
+ // if we already have a decryption attempt in progress, then it may
+ // fail because it was using outdated info. We now have reason to
+ // succeed where it failed before, but we don't want to have multiple
+ // attempts going at the same time, so just set a flag that says we have
+ // new info.
+ //
+ if (this.decryptionPromise) {
+ logger.log(`Event ${this.getId()} already being decrypted; queueing a retry`);
+ this.retryDecryption = true;
+ return this.decryptionPromise;
+ }
+
+ this.decryptionPromise = this.decryptionLoop(crypto, options);
+ return this.decryptionPromise;
+ }
+
+ /**
+ * Cancel any room key request for this event and resend another.
+ *
+ * @param crypto - crypto module
+ * @param userId - the user who received this event
+ *
+ * @returns a promise that resolves when the request is queued
+ */
+ public cancelAndResendKeyRequest(crypto: Crypto, userId: string): Promise<void> {
+ const wireContent = this.getWireContent();
+ return crypto.requestRoomKey(
+ {
+ algorithm: wireContent.algorithm,
+ room_id: this.getRoomId()!,
+ session_id: wireContent.session_id,
+ sender_key: wireContent.sender_key,
+ },
+ this.getKeyRequestRecipients(userId),
+ true,
+ );
+ }
+
+ /**
+ * Calculate the recipients for keyshare requests.
+ *
+ * @param userId - the user who received this event.
+ *
+ * @returns array of recipients
+ */
+ public getKeyRequestRecipients(userId: string): IKeyRequestRecipient[] {
+ // send the request to all of our own devices
+ const recipients = [
+ {
+ userId,
+ deviceId: "*",
+ },
+ ];
+
+ return recipients;
+ }
+
+ private async decryptionLoop(crypto: CryptoBackend, options: IDecryptOptions = {}): Promise<void> {
+ // make sure that this method never runs completely synchronously.
+ // (doing so would mean that we would clear decryptionPromise *before*
+ // it is set in attemptDecryption - and hence end up with a stuck
+ // `decryptionPromise`).
+ await Promise.resolve();
+
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ this.retryDecryption = false;
+
+ let res: IEventDecryptionResult;
+ let err: Error | undefined = undefined;
+ try {
+ if (!crypto) {
+ res = this.badEncryptedMessage("Encryption not enabled");
+ } else {
+ res = await crypto.decryptEvent(this);
+ if (options.isRetry === true) {
+ logger.info(`Decrypted event on retry (${this.getDetails()})`);
+ }
+ }
+ } catch (e) {
+ const detailedError = e instanceof DecryptionError ? (<DecryptionError>e).detailedString : String(e);
+
+ err = e as Error;
+
+ // see if we have a retry queued.
+ //
+ // NB: make sure to keep this check in the same tick of the
+ // event loop as `decryptionPromise = null` below - otherwise we
+ // risk a race:
+ //
+ // * A: we check retryDecryption here and see that it is
+ // false
+ // * B: we get a second call to attemptDecryption, which sees
+ // that decryptionPromise is set so sets
+ // retryDecryption
+ // * A: we continue below, clear decryptionPromise, and
+ // never do the retry.
+ //
+ if (this.retryDecryption) {
+ // decryption error, but we have a retry queued.
+ logger.log(`Error decrypting event (${this.getDetails()}), but retrying: ${detailedError}`);
+ continue;
+ }
+
+ // decryption error, no retries queued. Warn about the error and
+ // set it to m.bad.encrypted.
+ //
+ // the detailedString already includes the name and message of the error, and the stack isn't much use,
+ // so we don't bother to log `e` separately.
+ logger.warn(`Error decrypting event (${this.getDetails()}): ${detailedError}`);
+
+ res = this.badEncryptedMessage(String(e));
+ }
+
+ // at this point, we've either successfully decrypted the event, or have given up
+ // (and set res to a 'badEncryptedMessage'). Either way, we can now set the
+ // cleartext of the event and raise Event.decrypted.
+ //
+ // make sure we clear 'decryptionPromise' before sending the 'Event.decrypted' event,
+ // otherwise the app will be confused to see `isBeingDecrypted` still set when
+ // there isn't an `Event.decrypted` on the way.
+ //
+ // see also notes on retryDecryption above.
+ //
+ this.decryptionPromise = null;
+ this.retryDecryption = false;
+ this.setClearData(res);
+
+ // Before we emit the event, clear the push actions so that they can be recalculated
+ // by relevant code. We do this because the clear event has now changed, making it
+ // so that existing rules can be re-run over the applicable properties. Stuff like
+ // highlighting when the user's name is mentioned rely on this happening. We also want
+ // to set the push actions before emitting so that any notification listeners don't
+ // pick up the wrong contents.
+ this.setPushActions(null);
+
+ if (options.emit !== false) {
+ this.emit(MatrixEventEvent.Decrypted, this, err);
+ }
+
+ return;
+ }
+ }
+
+ private badEncryptedMessage(reason: string): IEventDecryptionResult {
+ return {
+ clearEvent: {
+ type: EventType.RoomMessage,
+ content: {
+ msgtype: "m.bad.encrypted",
+ body: "** Unable to decrypt: " + reason + " **",
+ },
+ },
+ encryptedDisabledForUnverifiedDevices: reason === `DecryptionError: ${WITHHELD_MESSAGES["m.unverified"]}`,
+ };
+ }
+
+ /**
+ * Update the cleartext data on this event.
+ *
+ * (This is used after decrypting an event; it should not be used by applications).
+ *
+ * @internal
+ *
+ * @param decryptionResult - the decryption result, including the plaintext and some key info
+ *
+ * @remarks
+ * Fires {@link MatrixEventEvent.Decrypted}
+ */
+ private setClearData(decryptionResult: IEventDecryptionResult): void {
+ this.clearEvent = decryptionResult.clearEvent;
+ this.senderCurve25519Key = decryptionResult.senderCurve25519Key ?? null;
+ this.claimedEd25519Key = decryptionResult.claimedEd25519Key ?? null;
+ this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || [];
+ this.untrusted = decryptionResult.untrusted || false;
+ this.encryptedDisabledForUnverifiedDevices = decryptionResult.encryptedDisabledForUnverifiedDevices || false;
+ this.invalidateExtensibleEvent();
+ }
+
+ /**
+ * Gets the cleartext content for this event. If the event is not encrypted,
+ * or encryption has not been completed, this will return null.
+ *
+ * @returns The cleartext (decrypted) content for the event
+ */
+ public getClearContent(): IContent | null {
+ return this.clearEvent ? this.clearEvent.content : null;
+ }
+
+ /**
+ * Check if the event is encrypted.
+ * @returns True if this event is encrypted.
+ */
+ public isEncrypted(): boolean {
+ return !this.isState() && this.event.type === EventType.RoomMessageEncrypted;
+ }
+
+ /**
+ * The curve25519 key for the device that we think sent this event
+ *
+ * For an Olm-encrypted event, this is inferred directly from the DH
+ * exchange at the start of the session: the curve25519 key is involved in
+ * the DH exchange, so only a device which holds the private part of that
+ * key can establish such a session.
+ *
+ * For a megolm-encrypted event, it is inferred from the Olm message which
+ * established the megolm session
+ */
+ public getSenderKey(): string | null {
+ return this.senderCurve25519Key;
+ }
+
+ /**
+ * The additional keys the sender of this encrypted event claims to possess.
+ *
+ * Just a wrapper for #getClaimedEd25519Key (q.v.)
+ */
+ public getKeysClaimed(): Partial<Record<"ed25519", string>> {
+ if (!this.claimedEd25519Key) return {};
+
+ return {
+ ed25519: this.claimedEd25519Key,
+ };
+ }
+
+ /**
+ * Get the ed25519 the sender of this event claims to own.
+ *
+ * For Olm messages, this claim is encoded directly in the plaintext of the
+ * event itself. For megolm messages, it is implied by the m.room_key event
+ * which established the megolm session.
+ *
+ * Until we download the device list of the sender, it's just a claim: the
+ * device list gives a proof that the owner of the curve25519 key used for
+ * this event (and returned by #getSenderKey) also owns the ed25519 key by
+ * signing the public curve25519 key with the ed25519 key.
+ *
+ * In general, applications should not use this method directly, but should
+ * instead use MatrixClient.getEventSenderDeviceInfo.
+ */
+ public getClaimedEd25519Key(): string | null {
+ return this.claimedEd25519Key;
+ }
+
+ /**
+ * Get the curve25519 keys of the devices which were involved in telling us
+ * about the claimedEd25519Key and sender curve25519 key.
+ *
+ * Normally this will be empty, but in the case of a forwarded megolm
+ * session, the sender keys are sent to us by another device (the forwarding
+ * device), which we need to trust to do this. In that case, the result will
+ * be a list consisting of one entry.
+ *
+ * If the device that sent us the key (A) got it from another device which
+ * it wasn't prepared to vouch for (B), the result will be [A, B]. And so on.
+ *
+ * @returns base64-encoded curve25519 keys, from oldest to newest.
+ */
+ public getForwardingCurve25519KeyChain(): string[] {
+ return this.forwardingCurve25519KeyChain;
+ }
+
+ /**
+ * Whether the decryption key was obtained from an untrusted source. If so,
+ * we cannot verify the authenticity of the message.
+ */
+ public isKeySourceUntrusted(): boolean | undefined {
+ return !!this.untrusted;
+ }
+
+ public getUnsigned(): IUnsigned {
+ return this.event.unsigned || {};
+ }
+
+ public setUnsigned(unsigned: IUnsigned): void {
+ this.event.unsigned = unsigned;
+ }
+
+ public unmarkLocallyRedacted(): boolean {
+ const value = this._localRedactionEvent;
+ this._localRedactionEvent = null;
+ if (this.event.unsigned) {
+ this.event.unsigned.redacted_because = undefined;
+ }
+ return !!value;
+ }
+
+ public markLocallyRedacted(redactionEvent: MatrixEvent): void {
+ if (this._localRedactionEvent) return;
+ this.emit(MatrixEventEvent.BeforeRedaction, this, redactionEvent);
+ this._localRedactionEvent = redactionEvent;
+ if (!this.event.unsigned) {
+ this.event.unsigned = {};
+ }
+ this.event.unsigned.redacted_because = redactionEvent.event as IEvent;
+ }
+
+ /**
+ * Change the visibility of an event, as per https://github.com/matrix-org/matrix-doc/pull/3531 .
+ *
+ * @param visibilityChange - event holding a hide/unhide payload, or nothing
+ * if the event is being reset to its original visibility (presumably
+ * by a visibility event being redacted).
+ *
+ * @remarks
+ * Fires {@link MatrixEventEvent.VisibilityChange} if `visibilityEvent`
+ * caused a change in the actual visibility of this event, either by making it
+ * visible (if it was hidden), by making it hidden (if it was visible) or by
+ * changing the reason (if it was hidden).
+ */
+ public applyVisibilityEvent(visibilityChange?: IVisibilityChange): void {
+ const visible = visibilityChange?.visible ?? true;
+ const reason = visibilityChange?.reason ?? null;
+ let change = false;
+ if (this.visibility.visible !== visible) {
+ change = true;
+ } else if (!this.visibility.visible && this.visibility["reason"] !== reason) {
+ change = true;
+ }
+ if (change) {
+ if (visible) {
+ this.visibility = MESSAGE_VISIBLE;
+ } else {
+ this.visibility = Object.freeze({
+ visible: false,
+ reason,
+ });
+ }
+ this.emit(MatrixEventEvent.VisibilityChange, this, visible);
+ }
+ }
+
+ /**
+ * Return instructions to display or hide the message.
+ *
+ * @returns Instructions determining whether the message
+ * should be displayed.
+ */
+ public messageVisibility(): MessageVisibility {
+ // Note: We may return `this.visibility` without fear, as
+ // this is a shallow frozen object.
+ return this.visibility;
+ }
+
+ /**
+ * Update the content of an event in the same way it would be by the server
+ * if it were redacted before it was sent to us
+ *
+ * @param redactionEvent - event causing the redaction
+ */
+ public makeRedacted(redactionEvent: MatrixEvent): void {
+ // quick sanity-check
+ if (!redactionEvent.event) {
+ throw new Error("invalid redactionEvent in makeRedacted");
+ }
+
+ this._localRedactionEvent = null;
+
+ this.emit(MatrixEventEvent.BeforeRedaction, this, redactionEvent);
+
+ this._replacingEvent = null;
+ // we attempt to replicate what we would see from the server if
+ // the event had been redacted before we saw it.
+ //
+ // The server removes (most of) the content of the event, and adds a
+ // "redacted_because" key to the unsigned section containing the
+ // redacted event.
+ if (!this.event.unsigned) {
+ this.event.unsigned = {};
+ }
+ this.event.unsigned.redacted_because = redactionEvent.event as IEvent;
+
+ for (const key in this.event) {
+ if (this.event.hasOwnProperty(key) && !REDACT_KEEP_KEYS.has(key)) {
+ delete this.event[key as keyof IEvent];
+ }
+ }
+
+ // If the event is encrypted prune the decrypted bits
+ if (this.isEncrypted()) {
+ this.clearEvent = undefined;
+ }
+
+ const keeps =
+ this.getType() in REDACT_KEEP_CONTENT_MAP
+ ? REDACT_KEEP_CONTENT_MAP[this.getType() as keyof typeof REDACT_KEEP_CONTENT_MAP]
+ : {};
+ const content = this.getContent();
+ for (const key in content) {
+ if (content.hasOwnProperty(key) && !keeps[key]) {
+ delete content[key];
+ }
+ }
+
+ this.invalidateExtensibleEvent();
+ }
+
+ /**
+ * Check if this event has been redacted
+ *
+ * @returns True if this event has been redacted
+ */
+ public isRedacted(): boolean {
+ return Boolean(this.getUnsigned().redacted_because);
+ }
+
+ /**
+ * Check if this event is a redaction of another event
+ *
+ * @returns True if this event is a redaction
+ */
+ public isRedaction(): boolean {
+ return this.getType() === EventType.RoomRedaction;
+ }
+
+ /**
+ * Return the visibility change caused by this event,
+ * as per https://github.com/matrix-org/matrix-doc/pull/3531.
+ *
+ * @returns If the event is a well-formed visibility change event,
+ * an instance of `IVisibilityChange`, otherwise `null`.
+ */
+ public asVisibilityChange(): IVisibilityChange | null {
+ if (!EVENT_VISIBILITY_CHANGE_TYPE.matches(this.getType())) {
+ // Not a visibility change event.
+ return null;
+ }
+ const relation = this.getRelation();
+ if (!relation || relation.rel_type != "m.reference") {
+ // Ill-formed, ignore this event.
+ return null;
+ }
+ const eventId = relation.event_id;
+ if (!eventId) {
+ // Ill-formed, ignore this event.
+ return null;
+ }
+ const content = this.getWireContent();
+ const visible = !!content.visible;
+ const reason = content.reason;
+ if (reason && typeof reason != "string") {
+ // Ill-formed, ignore this event.
+ return null;
+ }
+ // Well-formed visibility change event.
+ return {
+ visible,
+ reason,
+ eventId,
+ };
+ }
+
+ /**
+ * Check if this event alters the visibility of another event,
+ * as per https://github.com/matrix-org/matrix-doc/pull/3531.
+ *
+ * @returns True if this event alters the visibility
+ * of another event.
+ */
+ public isVisibilityEvent(): boolean {
+ return EVENT_VISIBILITY_CHANGE_TYPE.matches(this.getType());
+ }
+
+ /**
+ * Get the (decrypted, if necessary) redaction event JSON
+ * if event was redacted
+ *
+ * @returns The redaction event JSON, or an empty object
+ */
+ public getRedactionEvent(): IEvent | {} | null {
+ if (!this.isRedacted()) return null;
+
+ if (this.clearEvent?.unsigned) {
+ return this.clearEvent?.unsigned.redacted_because ?? null;
+ } else if (this.event.unsigned?.redacted_because) {
+ return this.event.unsigned.redacted_because;
+ } else {
+ return {};
+ }
+ }
+
+ /**
+ * Get the push actions, if known, for this event
+ *
+ * @returns push actions
+ */
+ public getPushActions(): IActionsObject | null {
+ return this.pushActions;
+ }
+
+ /**
+ * Set the push actions for this event.
+ *
+ * @param pushActions - push actions
+ */
+ public setPushActions(pushActions: IActionsObject | null): void {
+ this.pushActions = pushActions;
+ }
+
+ /**
+ * Replace the `event` property and recalculate any properties based on it.
+ * @param event - the object to assign to the `event` property
+ */
+ public handleRemoteEcho(event: object): void {
+ const oldUnsigned = this.getUnsigned();
+ const oldId = this.getId();
+ this.event = event;
+ // if this event was redacted before it was sent, it's locally marked as redacted.
+ // At this point, we've received the remote echo for the event, but not yet for
+ // the redaction that we are sending ourselves. Preserve the locally redacted
+ // state by copying over redacted_because so we don't get a flash of
+ // redacted, not-redacted, redacted as remote echos come in
+ if (oldUnsigned.redacted_because) {
+ if (!this.event.unsigned) {
+ this.event.unsigned = {};
+ }
+ this.event.unsigned.redacted_because = oldUnsigned.redacted_because;
+ }
+ // successfully sent.
+ this.setStatus(null);
+ if (this.getId() !== oldId) {
+ // emit the event if it changed
+ this.emit(MatrixEventEvent.LocalEventIdReplaced, this);
+ }
+
+ this.localTimestamp = Date.now() - this.getAge()!;
+ }
+
+ /**
+ * Whether the event is in any phase of sending, send failure, waiting for
+ * remote echo, etc.
+ */
+ public isSending(): boolean {
+ return !!this.status;
+ }
+
+ /**
+ * Update the event's sending status and emit an event as well.
+ *
+ * @param status - The new status
+ */
+ public setStatus(status: EventStatus | null): void {
+ this.status = status;
+ this.emit(MatrixEventEvent.Status, this, status);
+ }
+
+ public replaceLocalEventId(eventId: string): void {
+ this.event.event_id = eventId;
+ this.emit(MatrixEventEvent.LocalEventIdReplaced, this);
+ }
+
+ /**
+ * Get whether the event is a relation event, and of a given type if
+ * `relType` is passed in. State events cannot be relation events
+ *
+ * @param relType - if given, checks that the relation is of the
+ * given type
+ */
+ public isRelation(relType?: string): boolean {
+ // Relation info is lifted out of the encrypted content when sent to
+ // encrypted rooms, so we have to check `getWireContent` for this.
+ const relation = this.getWireContent()?.["m.relates_to"];
+ if (this.isState() && relation?.rel_type === RelationType.Replace) {
+ // State events cannot be m.replace relations
+ return false;
+ }
+ return !!(relation?.rel_type && relation.event_id && (relType ? relation.rel_type === relType : true));
+ }
+
+ /**
+ * Get relation info for the event, if any.
+ */
+ public getRelation(): IEventRelation | null {
+ if (!this.isRelation()) {
+ return null;
+ }
+ return this.getWireContent()["m.relates_to"] ?? null;
+ }
+
+ /**
+ * Set an event that replaces the content of this event, through an m.replace relation.
+ *
+ * @param newEvent - the event with the replacing content, if any.
+ *
+ * @remarks
+ * Fires {@link MatrixEventEvent.Replaced}
+ */
+ public makeReplaced(newEvent?: MatrixEvent): void {
+ // don't allow redacted events to be replaced.
+ // if newEvent is null we allow to go through though,
+ // as with local redaction, the replacing event might get
+ // cancelled, which should be reflected on the target event.
+ if (this.isRedacted() && newEvent) {
+ return;
+ }
+ // don't allow state events to be replaced using this mechanism as per MSC2676
+ if (this.isState()) {
+ return;
+ }
+ if (this._replacingEvent !== newEvent) {
+ this._replacingEvent = newEvent ?? null;
+ this.emit(MatrixEventEvent.Replaced, this);
+ this.invalidateExtensibleEvent();
+ }
+ }
+
+ /**
+ * Returns the status of any associated edit or redaction
+ * (not for reactions/annotations as their local echo doesn't affect the original event),
+ * or else the status of the event.
+ */
+ public getAssociatedStatus(): EventStatus | null {
+ if (this._replacingEvent) {
+ return this._replacingEvent.status;
+ } else if (this._localRedactionEvent) {
+ return this._localRedactionEvent.status;
+ }
+ return this.status;
+ }
+
+ public getServerAggregatedRelation<T>(relType: RelationType | string): T | undefined {
+ return this.getUnsigned()["m.relations"]?.[relType];
+ }
+
+ /**
+ * Returns the event ID of the event replacing the content of this event, if any.
+ */
+ public replacingEventId(): string | undefined {
+ const replaceRelation = this.getServerAggregatedRelation<IAggregatedRelation>(RelationType.Replace);
+ if (replaceRelation) {
+ return replaceRelation.event_id;
+ } else if (this._replacingEvent) {
+ return this._replacingEvent.getId();
+ }
+ }
+
+ /**
+ * Returns the event replacing the content of this event, if any.
+ * Replacements are aggregated on the server, so this would only
+ * return an event in case it came down the sync, or for local echo of edits.
+ */
+ public replacingEvent(): MatrixEvent | null {
+ return this._replacingEvent;
+ }
+
+ /**
+ * Returns the origin_server_ts of the event replacing the content of this event, if any.
+ */
+ public replacingEventDate(): Date | undefined {
+ const replaceRelation = this.getServerAggregatedRelation<IAggregatedRelation>(RelationType.Replace);
+ if (replaceRelation) {
+ const ts = replaceRelation.origin_server_ts;
+ if (Number.isFinite(ts)) {
+ return new Date(ts);
+ }
+ } else if (this._replacingEvent) {
+ return this._replacingEvent.getDate() ?? undefined;
+ }
+ }
+
+ /**
+ * Returns the event that wants to redact this event, but hasn't been sent yet.
+ * @returns the event
+ */
+ public localRedactionEvent(): MatrixEvent | null {
+ return this._localRedactionEvent;
+ }
+
+ /**
+ * For relations and redactions, returns the event_id this event is referring to.
+ */
+ public getAssociatedId(): string | undefined {
+ const relation = this.getRelation();
+ if (this.replyEventId) {
+ return this.replyEventId;
+ } else if (relation) {
+ return relation.event_id;
+ } else if (this.isRedaction()) {
+ return this.event.redacts;
+ }
+ }
+
+ /**
+ * Checks if this event is associated with another event. See `getAssociatedId`.
+ * @deprecated use hasAssociation instead.
+ */
+ public hasAssocation(): boolean {
+ return !!this.getAssociatedId();
+ }
+
+ /**
+ * Checks if this event is associated with another event. See `getAssociatedId`.
+ */
+ public hasAssociation(): boolean {
+ return !!this.getAssociatedId();
+ }
+
+ /**
+ * Update the related id with a new one.
+ *
+ * Used to replace a local id with remote one before sending
+ * an event with a related id.
+ *
+ * @param eventId - the new event id
+ */
+ public updateAssociatedId(eventId: string): void {
+ const relation = this.getRelation();
+ if (relation) {
+ relation.event_id = eventId;
+ } else if (this.isRedaction()) {
+ this.event.redacts = eventId;
+ }
+ }
+
+ /**
+ * Flags an event as cancelled due to future conditions. For example, a verification
+ * request event in the same sync transaction may be flagged as cancelled to warn
+ * listeners that a cancellation event is coming down the same pipe shortly.
+ * @param cancelled - Whether the event is to be cancelled or not.
+ */
+ public flagCancelled(cancelled = true): void {
+ this._isCancelled = cancelled;
+ }
+
+ /**
+ * Gets whether or not the event is flagged as cancelled. See flagCancelled() for
+ * more information.
+ * @returns True if the event is cancelled, false otherwise.
+ */
+ public isCancelled(): boolean {
+ return this._isCancelled;
+ }
+
+ /**
+ * Get a copy/snapshot of this event. The returned copy will be loosely linked
+ * back to this instance, though will have "frozen" event information. Other
+ * properties of this MatrixEvent instance will be copied verbatim, which can
+ * mean they are in reference to this instance despite being on the copy too.
+ * The reference the snapshot uses does not change, however members aside from
+ * the underlying event will not be deeply cloned, thus may be mutated internally.
+ * For example, the sender profile will be copied over at snapshot time, and
+ * the sender profile internally may mutate without notice to the consumer.
+ *
+ * This is meant to be used to snapshot the event details themselves, not the
+ * features (such as sender) surrounding the event.
+ * @returns A snapshot of this event.
+ */
+ public toSnapshot(): MatrixEvent {
+ const ev = new MatrixEvent(JSON.parse(JSON.stringify(this.event)));
+ for (const [p, v] of Object.entries(this)) {
+ if (p !== "event") {
+ // exclude the thing we just cloned
+ // @ts-ignore - XXX: this is just nasty
+ ev[p as keyof MatrixEvent] = v;
+ }
+ }
+ return ev;
+ }
+
+ /**
+ * Determines if this event is equivalent to the given event. This only checks
+ * the event object itself, not the other properties of the event. Intended for
+ * use with toSnapshot() to identify events changing.
+ * @param otherEvent - The other event to check against.
+ * @returns True if the events are the same, false otherwise.
+ */
+ public isEquivalentTo(otherEvent: MatrixEvent): boolean {
+ if (!otherEvent) return false;
+ if (otherEvent === this) return true;
+ const myProps = deepSortedObjectEntries(this.event);
+ const theirProps = deepSortedObjectEntries(otherEvent.event);
+ return JSON.stringify(myProps) === JSON.stringify(theirProps);
+ }
+
+ /**
+ * Summarise the event as JSON. This is currently used by React SDK's view
+ * event source feature and Seshat's event indexing, so take care when
+ * adjusting the output here.
+ *
+ * If encrypted, include both the decrypted and encrypted view of the event.
+ *
+ * This is named `toJSON` for use with `JSON.stringify` which checks objects
+ * for functions named `toJSON` and will call them to customise the output
+ * if they are defined.
+ */
+ public toJSON(): object {
+ const event = this.getEffectiveEvent();
+
+ if (!this.isEncrypted()) {
+ return event;
+ }
+
+ return {
+ decrypted: event,
+ encrypted: this.event,
+ };
+ }
+
+ public setVerificationRequest(request: VerificationRequest): void {
+ this.verificationRequest = request;
+ }
+
+ public setTxnId(txnId: string): void {
+ this.txnId = txnId;
+ }
+
+ public getTxnId(): string | undefined {
+ return this.txnId;
+ }
+
+ /**
+ * Set the instance of a thread associated with the current event
+ * @param thread - the thread
+ */
+ public setThread(thread?: Thread): void {
+ if (this.thread) {
+ this.reEmitter.stopReEmitting(this.thread, [ThreadEvent.Update]);
+ }
+ this.thread = thread;
+ this.setThreadId(thread?.id);
+ if (thread) {
+ this.reEmitter.reEmit(thread, [ThreadEvent.Update]);
+ }
+ }
+
+ /**
+ * Get the instance of the thread associated with the current event
+ */
+ public getThread(): Thread | undefined {
+ return this.thread;
+ }
+
+ public setThreadId(threadId?: string): void {
+ this.threadId = threadId;
+ }
+}
+
+/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted
+ *
+ * This is specified here:
+ * http://matrix.org/speculator/spec/HEAD/client_server/latest.html#redactions
+ *
+ * Also:
+ * - We keep 'unsigned' since that is created by the local server
+ * - We keep user_id for backwards-compat with v1
+ */
+const REDACT_KEEP_KEYS = new Set([
+ "event_id",
+ "type",
+ "room_id",
+ "user_id",
+ "sender",
+ "state_key",
+ "prev_state",
+ "content",
+ "unsigned",
+ "origin_server_ts",
+]);
+
+// a map from state event type to the .content keys we keep when an event is redacted
+const REDACT_KEEP_CONTENT_MAP: Record<string, Record<string, 1>> = {
+ [EventType.RoomMember]: { membership: 1 },
+ [EventType.RoomCreate]: { creator: 1 },
+ [EventType.RoomJoinRules]: { join_rule: 1 },
+ [EventType.RoomPowerLevels]: {
+ ban: 1,
+ events: 1,
+ events_default: 1,
+ kick: 1,
+ redact: 1,
+ state_default: 1,
+ users: 1,
+ users_default: 1,
+ },
+} as const;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/invites-ignorer.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/invites-ignorer.ts
new file mode 100644
index 0000000..173ba62
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/invites-ignorer.ts
@@ -0,0 +1,368 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { UnstableValue } from "matrix-events-sdk";
+
+import { MatrixClient } from "../client";
+import { IContent, MatrixEvent } from "./event";
+import { EventTimeline } from "./event-timeline";
+import { Preset } from "../@types/partials";
+import { globToRegexp } from "../utils";
+import { Room } from "./room";
+
+/// The event type storing the user's individual policies.
+///
+/// Exported for testing purposes.
+export const POLICIES_ACCOUNT_EVENT_TYPE = new UnstableValue("m.policies", "org.matrix.msc3847.policies");
+
+/// The key within the user's individual policies storing the user's ignored invites.
+///
+/// Exported for testing purposes.
+export const IGNORE_INVITES_ACCOUNT_EVENT_KEY = new UnstableValue(
+ "m.ignore.invites",
+ "org.matrix.msc3847.ignore.invites",
+);
+
+/// The types of recommendations understood.
+enum PolicyRecommendation {
+ Ban = "m.ban",
+}
+
+/**
+ * The various scopes for policies.
+ */
+export enum PolicyScope {
+ /**
+ * The policy deals with an individual user, e.g. reject invites
+ * from this user.
+ */
+ User = "m.policy.user",
+
+ /**
+ * The policy deals with a room, e.g. reject invites towards
+ * a specific room.
+ */
+ Room = "m.policy.room",
+
+ /**
+ * The policy deals with a server, e.g. reject invites from
+ * this server.
+ */
+ Server = "m.policy.server",
+}
+
+/**
+ * A container for ignored invites.
+ *
+ * # Performance
+ *
+ * This implementation is extremely naive. It expects that we are dealing
+ * with a very short list of sources (e.g. only one). If real-world
+ * applications turn out to require longer lists, we may need to rework
+ * our data structures.
+ */
+export class IgnoredInvites {
+ public constructor(private readonly client: MatrixClient) {}
+
+ /**
+ * Add a new rule.
+ *
+ * @param scope - The scope for this rule.
+ * @param entity - The entity covered by this rule. Globs are supported.
+ * @param reason - A human-readable reason for introducing this new rule.
+ * @returns The event id for the new rule.
+ */
+ public async addRule(scope: PolicyScope, entity: string, reason: string): Promise<string> {
+ const target = await this.getOrCreateTargetRoom();
+ const response = await this.client.sendStateEvent(target.roomId, scope, {
+ entity,
+ reason,
+ recommendation: PolicyRecommendation.Ban,
+ });
+ return response.event_id;
+ }
+
+ /**
+ * Remove a rule.
+ */
+ public async removeRule(event: MatrixEvent): Promise<void> {
+ await this.client.redactEvent(event.getRoomId()!, event.getId()!);
+ }
+
+ /**
+ * Add a new room to the list of sources. If the user isn't a member of the
+ * room, attempt to join it.
+ *
+ * @param roomId - A valid room id. If this room is already in the list
+ * of sources, it will not be duplicated.
+ * @returns `true` if the source was added, `false` if it was already present.
+ * @throws If `roomId` isn't the id of a room that the current user is already
+ * member of or can join.
+ *
+ * # Safety
+ *
+ * This method will rewrite the `Policies` object in the user's account data.
+ * This rewrite is inherently racy and could overwrite or be overwritten by
+ * other concurrent rewrites of the same object.
+ */
+ public async addSource(roomId: string): Promise<boolean> {
+ // We attempt to join the room *before* calling
+ // `await this.getOrCreateSourceRooms()` to decrease the duration
+ // of the racy section.
+ await this.client.joinRoom(roomId);
+ // Race starts.
+ const sources = (await this.getOrCreateSourceRooms()).map((room) => room.roomId);
+ if (sources.includes(roomId)) {
+ return false;
+ }
+ sources.push(roomId);
+ await this.withIgnoreInvitesPolicies((ignoreInvitesPolicies) => {
+ ignoreInvitesPolicies.sources = sources;
+ });
+
+ // Race ends.
+ return true;
+ }
+
+ /**
+ * Find out whether an invite should be ignored.
+ *
+ * @param sender - The user id for the user who issued the invite.
+ * @param roomId - The room to which the user is invited.
+ * @returns A rule matching the entity, if any was found, `null` otherwise.
+ */
+ public async getRuleForInvite({
+ sender,
+ roomId,
+ }: {
+ sender: string;
+ roomId: string;
+ }): Promise<Readonly<MatrixEvent | null>> {
+ // In this implementation, we perform a very naive lookup:
+ // - search in each policy room;
+ // - turn each (potentially glob) rule entity into a regexp.
+ //
+ // Real-world testing will tell us whether this is performant enough.
+ // In the (unfortunately likely) case it isn't, there are several manners
+ // in which we could optimize this:
+ // - match several entities per go;
+ // - pre-compile each rule entity into a regexp;
+ // - pre-compile entire rooms into a single regexp.
+ const policyRooms = await this.getOrCreateSourceRooms();
+ const senderServer = sender.split(":")[1];
+ const roomServer = roomId.split(":")[1];
+ for (const room of policyRooms) {
+ const state = room.getUnfilteredTimelineSet().getLiveTimeline().getState(EventTimeline.FORWARDS)!;
+
+ for (const { scope, entities } of [
+ { scope: PolicyScope.Room, entities: [roomId] },
+ { scope: PolicyScope.User, entities: [sender] },
+ { scope: PolicyScope.Server, entities: [senderServer, roomServer] },
+ ]) {
+ const events = state.getStateEvents(scope);
+ for (const event of events) {
+ const content = event.getContent();
+ if (content?.recommendation != PolicyRecommendation.Ban) {
+ // Ignoring invites only looks at `m.ban` recommendations.
+ continue;
+ }
+ const glob = content?.entity;
+ if (!glob) {
+ // Invalid event.
+ continue;
+ }
+ let regexp: RegExp;
+ try {
+ regexp = new RegExp(globToRegexp(glob, false));
+ } catch (ex) {
+ // Assume invalid event.
+ continue;
+ }
+ for (const entity of entities) {
+ if (entity && regexp.test(entity)) {
+ return event;
+ }
+ }
+ // No match.
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get the target room, i.e. the room in which any new rule should be written.
+ *
+ * If there is no target room setup, a target room is created.
+ *
+ * Note: This method is public for testing reasons. Most clients should not need
+ * to call it directly.
+ *
+ * # Safety
+ *
+ * This method will rewrite the `Policies` object in the user's account data.
+ * This rewrite is inherently racy and could overwrite or be overwritten by
+ * other concurrent rewrites of the same object.
+ */
+ public async getOrCreateTargetRoom(): Promise<Room> {
+ const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
+ let target = ignoreInvitesPolicies.target;
+ // Validate `target`. If it is invalid, trash out the current `target`
+ // and create a new room.
+ if (typeof target !== "string") {
+ target = null;
+ }
+ if (target) {
+ // Check that the room exists and is valid.
+ const room = this.client.getRoom(target);
+ if (room) {
+ return room;
+ } else {
+ target = null;
+ }
+ }
+ // We need to create our own policy room for ignoring invites.
+ target = (
+ await this.client.createRoom({
+ name: "Individual Policy Room",
+ preset: Preset.PrivateChat,
+ })
+ ).room_id;
+ await this.withIgnoreInvitesPolicies((ignoreInvitesPolicies) => {
+ ignoreInvitesPolicies.target = target;
+ });
+
+ // Since we have just called `createRoom`, `getRoom` should not be `null`.
+ return this.client.getRoom(target)!;
+ }
+
+ /**
+ * Get the list of source rooms, i.e. the rooms from which rules need to be read.
+ *
+ * If no source rooms are setup, the target room is used as sole source room.
+ *
+ * Note: This method is public for testing reasons. Most clients should not need
+ * to call it directly.
+ *
+ * # Safety
+ *
+ * This method will rewrite the `Policies` object in the user's account data.
+ * This rewrite is inherently racy and could overwrite or be overwritten by
+ * other concurrent rewrites of the same object.
+ */
+ public async getOrCreateSourceRooms(): Promise<Room[]> {
+ const ignoreInvitesPolicies = this.getIgnoreInvitesPolicies();
+ let sources: string[] = ignoreInvitesPolicies.sources;
+
+ // Validate `sources`. If it is invalid, trash out the current `sources`
+ // and create a new list of sources from `target`.
+ let hasChanges = false;
+ if (!Array.isArray(sources)) {
+ // `sources` could not be an array.
+ hasChanges = true;
+ sources = [];
+ }
+ let sourceRooms = sources
+ // `sources` could contain non-string / invalid room ids
+ .filter((roomId) => typeof roomId === "string")
+ .map((roomId) => this.client.getRoom(roomId))
+ .filter((room) => !!room) as Room[];
+ if (sourceRooms.length != sources.length) {
+ hasChanges = true;
+ }
+ if (sourceRooms.length == 0) {
+ // `sources` could be empty (possibly because we've removed
+ // invalid content)
+ const target = await this.getOrCreateTargetRoom();
+ hasChanges = true;
+ sourceRooms = [target];
+ }
+ if (hasChanges) {
+ // Reload `policies`/`ignoreInvitesPolicies` in case it has been changed
+ // during or by our call to `this.getTargetRoom()`.
+ await this.withIgnoreInvitesPolicies((ignoreInvitesPolicies) => {
+ ignoreInvitesPolicies.sources = sources;
+ });
+ }
+ return sourceRooms;
+ }
+
+ /**
+ * Fetch the `IGNORE_INVITES_POLICIES` object from account data.
+ *
+ * If both an unstable prefix version and a stable prefix version are available,
+ * it will return the stable prefix version preferentially.
+ *
+ * The result is *not* validated but is guaranteed to be a non-null object.
+ *
+ * @returns A non-null object.
+ */
+ private getIgnoreInvitesPolicies(): { [key: string]: any } {
+ return this.getPoliciesAndIgnoreInvitesPolicies().ignoreInvitesPolicies;
+ }
+
+ /**
+ * Modify in place the `IGNORE_INVITES_POLICIES` object from account data.
+ */
+ private async withIgnoreInvitesPolicies(
+ cb: (ignoreInvitesPolicies: { [key: string]: any }) => void,
+ ): Promise<void> {
+ const { policies, ignoreInvitesPolicies } = this.getPoliciesAndIgnoreInvitesPolicies();
+ cb(ignoreInvitesPolicies);
+ policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
+ await this.client.setAccountData(POLICIES_ACCOUNT_EVENT_TYPE.name, policies);
+ }
+
+ /**
+ * As `getIgnoreInvitesPolicies` but also return the `POLICIES_ACCOUNT_EVENT_TYPE`
+ * object.
+ */
+ private getPoliciesAndIgnoreInvitesPolicies(): {
+ policies: { [key: string]: any };
+ ignoreInvitesPolicies: { [key: string]: any };
+ } {
+ let policies: IContent = {};
+ for (const key of [POLICIES_ACCOUNT_EVENT_TYPE.name, POLICIES_ACCOUNT_EVENT_TYPE.altName]) {
+ if (!key) {
+ continue;
+ }
+ const value = this.client.getAccountData(key)?.getContent();
+ if (value) {
+ policies = value;
+ break;
+ }
+ }
+
+ let ignoreInvitesPolicies = {};
+ let hasIgnoreInvitesPolicies = false;
+ for (const key of [IGNORE_INVITES_ACCOUNT_EVENT_KEY.name, IGNORE_INVITES_ACCOUNT_EVENT_KEY.altName]) {
+ if (!key) {
+ continue;
+ }
+ const value = policies[key];
+ if (value && typeof value == "object") {
+ ignoreInvitesPolicies = value;
+ hasIgnoreInvitesPolicies = true;
+ break;
+ }
+ }
+ if (!hasIgnoreInvitesPolicies) {
+ policies[IGNORE_INVITES_ACCOUNT_EVENT_KEY.name] = ignoreInvitesPolicies;
+ }
+
+ return { policies, ignoreInvitesPolicies };
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/poll.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/poll.ts
new file mode 100644
index 0000000..1d4344a
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/poll.ts
@@ -0,0 +1,268 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { M_POLL_END, M_POLL_RESPONSE } from "../@types/polls";
+import { MatrixClient } from "../client";
+import { PollStartEvent } from "../extensible_events_v1/PollStartEvent";
+import { MatrixEvent } from "./event";
+import { Relations } from "./relations";
+import { Room } from "./room";
+import { TypedEventEmitter } from "./typed-event-emitter";
+
+export enum PollEvent {
+ New = "Poll.new",
+ End = "Poll.end",
+ Update = "Poll.update",
+ Responses = "Poll.Responses",
+ Destroy = "Poll.Destroy",
+ UndecryptableRelations = "Poll.UndecryptableRelations",
+}
+
+export type PollEventHandlerMap = {
+ [PollEvent.Update]: (event: MatrixEvent, poll: Poll) => void;
+ [PollEvent.Destroy]: (pollIdentifier: string) => void;
+ [PollEvent.End]: () => void;
+ [PollEvent.Responses]: (responses: Relations) => void;
+ [PollEvent.UndecryptableRelations]: (count: number) => void;
+};
+
+const filterResponseRelations = (
+ relationEvents: MatrixEvent[],
+ pollEndTimestamp: number,
+): {
+ responseEvents: MatrixEvent[];
+} => {
+ const responseEvents = relationEvents.filter((event) => {
+ if (event.isDecryptionFailure()) {
+ return;
+ }
+ return (
+ M_POLL_RESPONSE.matches(event.getType()) &&
+ // From MSC3381:
+ // "Votes sent on or before the end event's timestamp are valid votes"
+ event.getTs() <= pollEndTimestamp
+ );
+ });
+
+ return { responseEvents };
+};
+
+export class Poll extends TypedEventEmitter<Exclude<PollEvent, PollEvent.New>, PollEventHandlerMap> {
+ public readonly roomId: string;
+ public readonly pollEvent: PollStartEvent;
+ private _isFetchingResponses = false;
+ private relationsNextBatch: string | undefined;
+ private responses: null | Relations = null;
+ private endEvent: MatrixEvent | undefined;
+ /**
+ * Keep track of undecryptable relations
+ * As incomplete result sets affect poll results
+ */
+ private undecryptableRelationEventIds = new Set<string>();
+
+ public constructor(public readonly rootEvent: MatrixEvent, private matrixClient: MatrixClient, private room: Room) {
+ super();
+ if (!this.rootEvent.getRoomId() || !this.rootEvent.getId()) {
+ throw new Error("Invalid poll start event.");
+ }
+ this.roomId = this.rootEvent.getRoomId()!;
+ this.pollEvent = this.rootEvent.unstableExtensibleEvent as unknown as PollStartEvent;
+ }
+
+ public get pollId(): string {
+ return this.rootEvent.getId()!;
+ }
+
+ public get endEventId(): string | undefined {
+ return this.endEvent?.getId();
+ }
+
+ public get isEnded(): boolean {
+ return !!this.endEvent;
+ }
+
+ public get isFetchingResponses(): boolean {
+ return this._isFetchingResponses;
+ }
+
+ public get undecryptableRelationsCount(): number {
+ return this.undecryptableRelationEventIds.size;
+ }
+
+ public async getResponses(): Promise<Relations> {
+ // if we have already fetched some responses
+ // just return them
+ if (this.responses) {
+ return this.responses;
+ }
+
+ // if there is no fetching in progress
+ // start fetching
+ if (!this.isFetchingResponses) {
+ await this.fetchResponses();
+ }
+ // return whatever responses we got from the first page
+ return this.responses!;
+ }
+
+ /**
+ *
+ * @param event - event with a relation to the rootEvent
+ * @returns void
+ */
+ public onNewRelation(event: MatrixEvent): void {
+ if (M_POLL_END.matches(event.getType()) && this.validateEndEvent(event)) {
+ this.endEvent = event;
+ this.refilterResponsesOnEnd();
+ this.emit(PollEvent.End);
+ }
+
+ // wait for poll responses to be initialised
+ if (!this.responses) {
+ return;
+ }
+
+ const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;
+ const { responseEvents } = filterResponseRelations([event], pollEndTimestamp);
+
+ this.countUndecryptableEvents([event]);
+
+ if (responseEvents.length) {
+ responseEvents.forEach((event) => {
+ this.responses!.addEvent(event);
+ });
+
+ this.emit(PollEvent.Responses, this.responses);
+ }
+ }
+
+ private async fetchResponses(): Promise<void> {
+ this._isFetchingResponses = true;
+
+ // we want:
+ // - stable and unstable M_POLL_RESPONSE
+ // - stable and unstable M_POLL_END
+ // so make one api call and filter by event type client side
+ const allRelations = await this.matrixClient.relations(
+ this.roomId,
+ this.rootEvent.getId()!,
+ "m.reference",
+ undefined,
+ {
+ from: this.relationsNextBatch || undefined,
+ },
+ );
+
+ await Promise.all(allRelations.events.map((event) => this.matrixClient.decryptEventIfNeeded(event)));
+
+ const responses =
+ this.responses ||
+ new Relations("m.reference", M_POLL_RESPONSE.name, this.matrixClient, [M_POLL_RESPONSE.altName!]);
+
+ const pollEndEvent = allRelations.events.find((event) => M_POLL_END.matches(event.getType()));
+
+ if (this.validateEndEvent(pollEndEvent)) {
+ this.endEvent = pollEndEvent;
+ this.refilterResponsesOnEnd();
+ this.emit(PollEvent.End);
+ }
+
+ const pollCloseTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;
+
+ const { responseEvents } = filterResponseRelations(allRelations.events, pollCloseTimestamp);
+
+ responseEvents.forEach((event) => {
+ responses.addEvent(event);
+ });
+
+ this.relationsNextBatch = allRelations.nextBatch ?? undefined;
+ this.responses = responses;
+ this.countUndecryptableEvents(allRelations.events);
+
+ // while there are more pages of relations
+ // fetch them
+ if (this.relationsNextBatch) {
+ // don't await
+ // we want to return the first page as soon as possible
+ this.fetchResponses();
+ } else {
+ // no more pages
+ this._isFetchingResponses = false;
+ }
+
+ // emit after updating _isFetchingResponses state
+ this.emit(PollEvent.Responses, this.responses);
+ }
+
+ /**
+ * Only responses made before the poll ended are valid
+ * Refilter after an end event is recieved
+ * To ensure responses are valid
+ */
+ private refilterResponsesOnEnd(): void {
+ if (!this.responses) {
+ return;
+ }
+
+ const pollEndTimestamp = this.endEvent?.getTs() || Number.MAX_SAFE_INTEGER;
+ this.responses.getRelations().forEach((event) => {
+ if (event.getTs() > pollEndTimestamp) {
+ this.responses?.removeEvent(event);
+ }
+ });
+
+ this.emit(PollEvent.Responses, this.responses);
+ }
+
+ private countUndecryptableEvents = (events: MatrixEvent[]): void => {
+ const undecryptableEventIds = events
+ .filter((event) => event.isDecryptionFailure())
+ .map((event) => event.getId()!);
+
+ const previousCount = this.undecryptableRelationsCount;
+ this.undecryptableRelationEventIds = new Set([...this.undecryptableRelationEventIds, ...undecryptableEventIds]);
+
+ if (this.undecryptableRelationsCount !== previousCount) {
+ this.emit(PollEvent.UndecryptableRelations, this.undecryptableRelationsCount);
+ }
+ };
+
+ private validateEndEvent(endEvent?: MatrixEvent): boolean {
+ if (!endEvent) {
+ return false;
+ }
+ /**
+ * Repeated end events are ignored -
+ * only the first (valid) closure event by origin_server_ts is counted.
+ */
+ if (this.endEvent && this.endEvent.getTs() < endEvent.getTs()) {
+ return false;
+ }
+
+ /**
+ * MSC3381
+ * If a m.poll.end event is received from someone other than the poll creator or user with permission to redact
+ * others' messages in the room, the event must be ignored by clients due to being invalid.
+ */
+ const roomCurrentState = this.room.currentState;
+ const endEventSender = endEvent.getSender();
+ return (
+ !!endEventSender &&
+ (endEventSender === this.rootEvent.getSender() ||
+ roomCurrentState.maySendRedactionForEvent(this.rootEvent, endEventSender))
+ );
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/read-receipt.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/read-receipt.ts
new file mode 100644
index 0000000..5858fe5
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/read-receipt.ts
@@ -0,0 +1,312 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+ http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {
+ CachedReceipt,
+ MAIN_ROOM_TIMELINE,
+ Receipt,
+ ReceiptCache,
+ ReceiptType,
+ WrappedReceipt,
+} from "../@types/read_receipts";
+import { ListenerMap, TypedEventEmitter } from "./typed-event-emitter";
+import * as utils from "../utils";
+import { MatrixEvent } from "./event";
+import { EventType } from "../@types/event";
+import { EventTimelineSet } from "./event-timeline-set";
+import { MapWithDefault } from "../utils";
+import { NotificationCountType } from "./room";
+
+export function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: ReceiptType): MatrixEvent {
+ return new MatrixEvent({
+ content: {
+ [event.getId()!]: {
+ [receiptType]: {
+ [userId]: {
+ ts: event.getTs(),
+ thread_id: event.threadRootId ?? MAIN_ROOM_TIMELINE,
+ },
+ },
+ },
+ },
+ type: EventType.Receipt,
+ room_id: event.getRoomId(),
+ });
+}
+
+const ReceiptPairRealIndex = 0;
+const ReceiptPairSyntheticIndex = 1;
+
+export abstract class ReadReceipt<
+ Events extends string,
+ Arguments extends ListenerMap<Events>,
+ SuperclassArguments extends ListenerMap<any> = Arguments,
+> extends TypedEventEmitter<Events, Arguments, SuperclassArguments> {
+ // receipts should clobber based on receipt_type and user_id pairs hence
+ // the form of this structure. This is sub-optimal for the exposed APIs
+ // which pass in an event ID and get back some receipts, so we also store
+ // a pre-cached list for this purpose.
+ // Map: receipt type → user Id → receipt
+ private receipts = new MapWithDefault<string, Map<string, [WrappedReceipt | null, WrappedReceipt | null]>>(
+ () => new Map(),
+ );
+ private receiptCacheByEventId: ReceiptCache = new Map();
+
+ public abstract getUnfilteredTimelineSet(): EventTimelineSet;
+ public abstract timeline: MatrixEvent[];
+
+ /**
+ * Gets the latest receipt for a given user in the room
+ * @param userId - The id of the user for which we want the receipt
+ * @param ignoreSynthesized - Whether to ignore synthesized receipts or not
+ * @param receiptType - Optional. The type of the receipt we want to get
+ * @returns the latest receipts of the chosen type for the chosen user
+ */
+ public getReadReceiptForUserId(
+ userId: string,
+ ignoreSynthesized = false,
+ receiptType = ReceiptType.Read,
+ ): WrappedReceipt | null {
+ const [realReceipt, syntheticReceipt] = this.receipts.get(receiptType)?.get(userId) ?? [null, null];
+ if (ignoreSynthesized) {
+ return realReceipt;
+ }
+
+ return syntheticReceipt ?? realReceipt;
+ }
+
+ /**
+ * Get the ID of the event that a given user has read up to, or null if we
+ * have received no read receipts from them.
+ * @param userId - The user ID to get read receipt event ID for
+ * @param ignoreSynthesized - If true, return only receipts that have been
+ * sent by the server, not implicit ones generated
+ * by the JS SDK.
+ * @returns ID of the latest event that the given user has read, or null.
+ */
+ public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null {
+ // XXX: This is very very ugly and I hope I won't have to ever add a new
+ // receipt type here again. IMHO this should be done by the server in
+ // some more intelligent manner or the client should just use timestamps
+
+ const timelineSet = this.getUnfilteredTimelineSet();
+ const publicReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.Read);
+ const privateReadReceipt = this.getReadReceiptForUserId(userId, ignoreSynthesized, ReceiptType.ReadPrivate);
+
+ // If we have both, compare them
+ let comparison: number | null | undefined;
+ if (publicReadReceipt?.eventId && privateReadReceipt?.eventId) {
+ comparison = timelineSet.compareEventOrdering(publicReadReceipt?.eventId, privateReadReceipt?.eventId);
+ }
+
+ // If we didn't get a comparison try to compare the ts of the receipts
+ if (!comparison && publicReadReceipt?.data?.ts && privateReadReceipt?.data?.ts) {
+ comparison = publicReadReceipt?.data?.ts - privateReadReceipt?.data?.ts;
+ }
+
+ // The public receipt is more likely to drift out of date so the private
+ // one has precedence
+ if (!comparison) return privateReadReceipt?.eventId ?? publicReadReceipt?.eventId ?? null;
+
+ // If public read receipt is older, return the private one
+ return (comparison < 0 ? privateReadReceipt?.eventId : publicReadReceipt?.eventId) ?? null;
+ }
+
+ public addReceiptToStructure(
+ eventId: string,
+ receiptType: ReceiptType,
+ userId: string,
+ receipt: Receipt,
+ synthetic: boolean,
+ ): void {
+ const receiptTypesMap = this.receipts.getOrCreate(receiptType);
+ let pair = receiptTypesMap.get(userId);
+
+ if (!pair) {
+ pair = [null, null];
+ receiptTypesMap.set(userId, pair);
+ }
+
+ let existingReceipt = pair[ReceiptPairRealIndex];
+ if (synthetic) {
+ existingReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
+ }
+
+ if (existingReceipt) {
+ // we only want to add this receipt if we think it is later than the one we already have.
+ // This is managed server-side, but because we synthesize RRs locally we have to do it here too.
+ const ordering = this.getUnfilteredTimelineSet().compareEventOrdering(existingReceipt.eventId, eventId);
+ if (ordering !== null && ordering >= 0) {
+ return;
+ }
+ }
+
+ const wrappedReceipt: WrappedReceipt = {
+ eventId,
+ data: receipt,
+ };
+
+ const realReceipt = synthetic ? pair[ReceiptPairRealIndex] : wrappedReceipt;
+ const syntheticReceipt = synthetic ? wrappedReceipt : pair[ReceiptPairSyntheticIndex];
+
+ let ordering: number | null = null;
+ if (realReceipt && syntheticReceipt) {
+ ordering = this.getUnfilteredTimelineSet().compareEventOrdering(
+ realReceipt.eventId,
+ syntheticReceipt.eventId,
+ );
+ }
+
+ const preferSynthetic = ordering === null || ordering < 0;
+
+ // we don't bother caching just real receipts by event ID as there's nothing that would read it.
+ // Take the current cached receipt before we overwrite the pair elements.
+ const cachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
+
+ if (synthetic && preferSynthetic) {
+ pair[ReceiptPairSyntheticIndex] = wrappedReceipt;
+ } else if (!synthetic) {
+ pair[ReceiptPairRealIndex] = wrappedReceipt;
+
+ if (!preferSynthetic) {
+ pair[ReceiptPairSyntheticIndex] = null;
+ }
+ }
+
+ const newCachedReceipt = pair[ReceiptPairSyntheticIndex] ?? pair[ReceiptPairRealIndex];
+ if (cachedReceipt === newCachedReceipt) return;
+
+ // clean up any previous cache entry
+ if (cachedReceipt && this.receiptCacheByEventId.get(cachedReceipt.eventId)) {
+ const previousEventId = cachedReceipt.eventId;
+ // Remove the receipt we're about to clobber out of existence from the cache
+ this.receiptCacheByEventId.set(
+ previousEventId,
+ this.receiptCacheByEventId.get(previousEventId)!.filter((r) => {
+ return r.type !== receiptType || r.userId !== userId;
+ }),
+ );
+
+ if (this.receiptCacheByEventId.get(previousEventId)!.length < 1) {
+ this.receiptCacheByEventId.delete(previousEventId); // clean up the cache keys
+ }
+ }
+
+ // cache the new one
+ if (!this.receiptCacheByEventId.get(eventId)) {
+ this.receiptCacheByEventId.set(eventId, []);
+ }
+ this.receiptCacheByEventId.get(eventId)!.push({
+ userId: userId,
+ type: receiptType as ReceiptType,
+ data: receipt,
+ });
+ }
+
+ /**
+ * Get a list of receipts for the given event.
+ * @param event - the event to get receipts for
+ * @returns A list of receipts with a userId, type and data keys or
+ * an empty list.
+ */
+ public getReceiptsForEvent(event: MatrixEvent): CachedReceipt[] {
+ return this.receiptCacheByEventId.get(event.getId()!) || [];
+ }
+
+ public abstract addReceipt(event: MatrixEvent, synthetic: boolean): void;
+
+ public abstract setUnread(type: NotificationCountType, count: number): void;
+
+ /**
+ * This issue should also be addressed on synapse's side and is tracked as part
+ * of https://github.com/matrix-org/synapse/issues/14837
+ *
+ * Retrieves the read receipt for the logged in user and checks if it matches
+ * the last event in the room and whether that event originated from the logged
+ * in user.
+ * Under those conditions we can consider the context as read. This is useful
+ * because we never send read receipts against our own events
+ * @param userId - the logged in user
+ */
+ public fixupNotifications(userId: string): void {
+ const receipt = this.getReadReceiptForUserId(userId, false);
+
+ const lastEvent = this.timeline[this.timeline.length - 1];
+ if (lastEvent && receipt?.eventId === lastEvent.getId() && userId === lastEvent.getSender()) {
+ this.setUnread(NotificationCountType.Total, 0);
+ this.setUnread(NotificationCountType.Highlight, 0);
+ }
+ }
+
+ /**
+ * Add a temporary local-echo receipt to the room to reflect in the
+ * client the fact that we've sent one.
+ * @param userId - The user ID if the receipt sender
+ * @param e - The event that is to be acknowledged
+ * @param receiptType - The type of receipt
+ */
+ public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: ReceiptType): void {
+ this.addReceipt(synthesizeReceipt(userId, e, receiptType), true);
+ }
+
+ /**
+ * Get a list of user IDs who have <b>read up to</b> the given event.
+ * @param event - the event to get read receipts for.
+ * @returns A list of user IDs.
+ */
+ public getUsersReadUpTo(event: MatrixEvent): string[] {
+ return this.getReceiptsForEvent(event)
+ .filter(function (receipt) {
+ return utils.isSupportedReceiptType(receipt.type);
+ })
+ .map(function (receipt) {
+ return receipt.userId;
+ });
+ }
+
+ /**
+ * Determines if the given user has read a particular event ID with the known
+ * history of the room. This is not a definitive check as it relies only on
+ * what is available to the room at the time of execution.
+ * @param userId - The user ID to check the read state of.
+ * @param eventId - The event ID to check if the user read.
+ * @returns True if the user has read the event, false otherwise.
+ */
+ public hasUserReadEvent(userId: string, eventId: string): boolean {
+ const readUpToId = this.getEventReadUpTo(userId, false);
+ if (readUpToId === eventId) return true;
+
+ if (
+ this.timeline?.length &&
+ this.timeline[this.timeline.length - 1].getSender() &&
+ this.timeline[this.timeline.length - 1].getSender() === userId
+ ) {
+ // It doesn't matter where the event is in the timeline, the user has read
+ // it because they've sent the latest event.
+ return true;
+ }
+
+ for (let i = this.timeline?.length - 1; i >= 0; --i) {
+ const ev = this.timeline[i];
+
+ // If we encounter the target event first, the user hasn't read it
+ // however if we encounter the readUpToId first then the user has read
+ // it. These rules apply because we're iterating bottom-up.
+ if (ev.getId() === eventId) return false;
+ if (ev.getId() === readUpToId) return true;
+ }
+
+ // We don't know if the user has read it, so assume not.
+ return false;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/related-relations.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/related-relations.ts
new file mode 100644
index 0000000..a005169
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/related-relations.ts
@@ -0,0 +1,39 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Relations, RelationsEvent, EventHandlerMap } from "./relations";
+import { MatrixEvent } from "./event";
+import { Listener } from "./typed-event-emitter";
+
+export class RelatedRelations {
+ private relations: Relations[];
+
+ public constructor(relations: Relations[]) {
+ this.relations = relations.filter((r) => !!r);
+ }
+
+ public getRelations(): MatrixEvent[] {
+ return this.relations.reduce<MatrixEvent[]>((c, p) => [...c, ...p.getRelations()], []);
+ }
+
+ public on<T extends RelationsEvent>(ev: T, fn: Listener<RelationsEvent, EventHandlerMap, T>): void {
+ this.relations.forEach((r) => r.on(ev, fn));
+ }
+
+ public off<T extends RelationsEvent>(ev: T, fn: Listener<RelationsEvent, EventHandlerMap, T>): void {
+ this.relations.forEach((r) => r.off(ev, fn));
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations-container.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations-container.ts
new file mode 100644
index 0000000..d328b1c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations-container.ts
@@ -0,0 +1,146 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Relations } from "./relations";
+import { EventType, RelationType } from "../@types/event";
+import { EventStatus, MatrixEvent, MatrixEventEvent } from "./event";
+import { EventTimelineSet } from "./event-timeline-set";
+import { MatrixClient } from "../client";
+import { Room } from "./room";
+
+export class RelationsContainer {
+ // A tree of objects to access a set of related children for an event, as in:
+ // this.relations.get(parentEventId).get(relationType).get(relationEventType)
+ private relations = new Map<string, Map<RelationType | string, Map<EventType | string, Relations>>>();
+
+ public constructor(private readonly client: MatrixClient, private readonly room?: Room) {}
+
+ /**
+ * Get a collection of child events to a given event in this timeline set.
+ *
+ * @param eventId - The ID of the event that you'd like to access child events for.
+ * For example, with annotations, this would be the ID of the event being annotated.
+ * @param relationType - The type of relationship involved, such as "m.annotation", "m.reference", "m.replace", etc.
+ * @param eventType - The relation event's type, such as "m.reaction", etc.
+ * @throws If `eventId</code>, <code>relationType</code> or <code>eventType`
+ * are not valid.
+ *
+ * @returns
+ * A container for relation events or undefined if there are no relation events for
+ * the relationType.
+ */
+ public getChildEventsForEvent(
+ eventId: string,
+ relationType: RelationType | string,
+ eventType: EventType | string,
+ ): Relations | undefined {
+ return this.relations.get(eventId)?.get(relationType)?.get(eventType);
+ }
+
+ public getAllChildEventsForEvent(parentEventId: string): MatrixEvent[] {
+ const relationsForEvent =
+ this.relations.get(parentEventId) ?? new Map<RelationType | string, Map<EventType | string, Relations>>();
+ const events: MatrixEvent[] = [];
+ for (const relationsRecord of relationsForEvent.values()) {
+ for (const relations of relationsRecord.values()) {
+ events.push(...relations.getRelations());
+ }
+ }
+ return events;
+ }
+
+ /**
+ * Set an event as the target event if any Relations exist for it already.
+ * Child events can point to other child events as their parent, so this method may be
+ * called for events which are also logically child events.
+ *
+ * @param event - The event to check as relation target.
+ */
+ public aggregateParentEvent(event: MatrixEvent): void {
+ const relationsForEvent = this.relations.get(event.getId()!);
+ if (!relationsForEvent) return;
+
+ for (const relationsWithRelType of relationsForEvent.values()) {
+ for (const relationsWithEventType of relationsWithRelType.values()) {
+ relationsWithEventType.setTargetEvent(event);
+ }
+ }
+ }
+
+ /**
+ * Add relation events to the relevant relation collection.
+ *
+ * @param event - The new child event to be aggregated.
+ * @param timelineSet - The event timeline set within which to search for the related event if any.
+ */
+ public aggregateChildEvent(event: MatrixEvent, timelineSet?: EventTimelineSet): void {
+ if (event.isRedacted() || event.status === EventStatus.CANCELLED) {
+ return;
+ }
+
+ const relation = event.getRelation();
+ if (!relation) return;
+
+ const onEventDecrypted = (): void => {
+ if (event.isDecryptionFailure()) {
+ // This could for example happen if the encryption keys are not yet available.
+ // The event may still be decrypted later. Register the listener again.
+ event.once(MatrixEventEvent.Decrypted, onEventDecrypted);
+ return;
+ }
+
+ this.aggregateChildEvent(event, timelineSet);
+ };
+
+ // If the event is currently encrypted, wait until it has been decrypted.
+ if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) {
+ event.once(MatrixEventEvent.Decrypted, onEventDecrypted);
+ return;
+ }
+
+ const { event_id: relatesToEventId, rel_type: relationType } = relation;
+ const eventType = event.getType();
+
+ let relationsForEvent = this.relations.get(relatesToEventId!);
+ if (!relationsForEvent) {
+ relationsForEvent = new Map<RelationType | string, Map<EventType | string, Relations>>();
+ this.relations.set(relatesToEventId!, relationsForEvent);
+ }
+
+ let relationsWithRelType = relationsForEvent.get(relationType!);
+ if (!relationsWithRelType) {
+ relationsWithRelType = new Map<EventType | string, Relations>();
+ relationsForEvent.set(relationType!, relationsWithRelType);
+ }
+
+ let relationsWithEventType = relationsWithRelType.get(eventType);
+ if (!relationsWithEventType) {
+ relationsWithEventType = new Relations(relationType!, eventType, this.client);
+ relationsWithRelType.set(eventType, relationsWithEventType);
+
+ const room = this.room ?? timelineSet?.room;
+ const relatesToEvent =
+ timelineSet?.findEventById(relatesToEventId!) ??
+ room?.findEventById(relatesToEventId!) ??
+ room?.getPendingEvent(relatesToEventId!);
+ if (relatesToEvent) {
+ relationsWithEventType.setTargetEvent(relatesToEvent);
+ }
+ }
+
+ relationsWithEventType.addEvent(event);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations.ts
new file mode 100644
index 0000000..d2b637c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/relations.ts
@@ -0,0 +1,368 @@
+/*
+Copyright 2019, 2021, 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventStatus, IAggregatedRelation, MatrixEvent, MatrixEventEvent } from "./event";
+import { logger } from "../logger";
+import { RelationType } from "../@types/event";
+import { TypedEventEmitter } from "./typed-event-emitter";
+import { MatrixClient } from "../client";
+import { Room } from "./room";
+
+export enum RelationsEvent {
+ Add = "Relations.add",
+ Remove = "Relations.remove",
+ Redaction = "Relations.redaction",
+}
+
+export type EventHandlerMap = {
+ [RelationsEvent.Add]: (event: MatrixEvent) => void;
+ [RelationsEvent.Remove]: (event: MatrixEvent) => void;
+ [RelationsEvent.Redaction]: (event: MatrixEvent) => void;
+};
+
+const matchesEventType = (eventType: string, targetEventType: string, altTargetEventTypes: string[] = []): boolean =>
+ [targetEventType, ...altTargetEventTypes].includes(eventType);
+
+/**
+ * A container for relation events that supports easy access to common ways of
+ * aggregating such events. Each instance holds events that of a single relation
+ * type and event type. All of the events also relate to the same original event.
+ *
+ * The typical way to get one of these containers is via
+ * EventTimelineSet#getRelationsForEvent.
+ */
+export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap> {
+ private relationEventIds = new Set<string>();
+ private relations = new Set<MatrixEvent>();
+ private annotationsByKey: Record<string, Set<MatrixEvent>> = {};
+ private annotationsBySender: Record<string, Set<MatrixEvent>> = {};
+ private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = [];
+ private targetEvent: MatrixEvent | null = null;
+ private creationEmitted = false;
+ private readonly client: MatrixClient;
+
+ /**
+ * @param relationType - The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc.
+ * @param eventType - The relation event's type, such as "m.reaction", etc.
+ * @param client - The client which created this instance. For backwards compatibility also accepts a Room.
+ * @param altEventTypes - alt event types for relation events, for example to support unstable prefixed event types
+ */
+ public constructor(
+ public readonly relationType: RelationType | string,
+ public readonly eventType: string,
+ client: MatrixClient | Room,
+ public readonly altEventTypes?: string[],
+ ) {
+ super();
+ this.client = client instanceof Room ? client.client : client;
+ }
+
+ /**
+ * Add relation events to this collection.
+ *
+ * @param event - The new relation event to be added.
+ */
+ public async addEvent(event: MatrixEvent): Promise<void> {
+ if (this.relationEventIds.has(event.getId()!)) {
+ return;
+ }
+
+ const relation = event.getRelation();
+ if (!relation) {
+ logger.error("Event must have relation info");
+ return;
+ }
+
+ const relationType = relation.rel_type;
+ const eventType = event.getType();
+
+ if (this.relationType !== relationType || !matchesEventType(eventType, this.eventType, this.altEventTypes)) {
+ logger.error("Event relation info doesn't match this container");
+ return;
+ }
+
+ // If the event is in the process of being sent, listen for cancellation
+ // so we can remove the event from the collection.
+ if (event.isSending()) {
+ event.on(MatrixEventEvent.Status, this.onEventStatus);
+ }
+
+ this.relations.add(event);
+ this.relationEventIds.add(event.getId()!);
+
+ if (this.relationType === RelationType.Annotation) {
+ this.addAnnotationToAggregation(event);
+ } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
+ const lastReplacement = await this.getLastReplacement();
+ this.targetEvent.makeReplaced(lastReplacement!);
+ }
+
+ event.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
+
+ this.emit(RelationsEvent.Add, event);
+
+ this.maybeEmitCreated();
+ }
+
+ /**
+ * Remove relation event from this collection.
+ *
+ * @param event - The relation event to remove.
+ */
+ public async removeEvent(event: MatrixEvent): Promise<void> {
+ if (!this.relations.has(event)) {
+ return;
+ }
+
+ this.relations.delete(event);
+
+ if (this.relationType === RelationType.Annotation) {
+ this.removeAnnotationFromAggregation(event);
+ } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
+ const lastReplacement = await this.getLastReplacement();
+ this.targetEvent.makeReplaced(lastReplacement!);
+ }
+
+ this.emit(RelationsEvent.Remove, event);
+ }
+
+ /**
+ * Listens for event status changes to remove cancelled events.
+ *
+ * @param event - The event whose status has changed
+ * @param status - The new status
+ */
+ private onEventStatus = (event: MatrixEvent, status: EventStatus | null): void => {
+ if (!event.isSending()) {
+ // Sending is done, so we don't need to listen anymore
+ event.removeListener(MatrixEventEvent.Status, this.onEventStatus);
+ return;
+ }
+ if (status !== EventStatus.CANCELLED) {
+ return;
+ }
+ // Event was cancelled, remove from the collection
+ event.removeListener(MatrixEventEvent.Status, this.onEventStatus);
+ this.removeEvent(event);
+ };
+
+ /**
+ * Get all relation events in this collection.
+ *
+ * These are currently in the order of insertion to this collection, which
+ * won't match timeline order in the case of scrollback.
+ * TODO: Tweak `addEvent` to insert correctly for scrollback.
+ *
+ * Relation events in insertion order.
+ */
+ public getRelations(): MatrixEvent[] {
+ return [...this.relations];
+ }
+
+ private addAnnotationToAggregation(event: MatrixEvent): void {
+ const { key } = event.getRelation() ?? {};
+ if (!key) return;
+
+ let eventsForKey = this.annotationsByKey[key];
+ if (!eventsForKey) {
+ eventsForKey = this.annotationsByKey[key] = new Set();
+ this.sortedAnnotationsByKey.push([key, eventsForKey]);
+ }
+ // Add the new event to the set for this key
+ eventsForKey.add(event);
+ // Re-sort the [key, events] pairs in descending order of event count
+ this.sortedAnnotationsByKey.sort((a, b) => {
+ const aEvents = a[1];
+ const bEvents = b[1];
+ return bEvents.size - aEvents.size;
+ });
+
+ const sender = event.getSender()!;
+ let eventsFromSender = this.annotationsBySender[sender];
+ if (!eventsFromSender) {
+ eventsFromSender = this.annotationsBySender[sender] = new Set();
+ }
+ // Add the new event to the set for this sender
+ eventsFromSender.add(event);
+ }
+
+ private removeAnnotationFromAggregation(event: MatrixEvent): void {
+ const { key } = event.getRelation() ?? {};
+ if (!key) return;
+
+ const eventsForKey = this.annotationsByKey[key];
+ if (eventsForKey) {
+ eventsForKey.delete(event);
+
+ // Re-sort the [key, events] pairs in descending order of event count
+ this.sortedAnnotationsByKey.sort((a, b) => {
+ const aEvents = a[1];
+ const bEvents = b[1];
+ return bEvents.size - aEvents.size;
+ });
+ }
+
+ const sender = event.getSender()!;
+ const eventsFromSender = this.annotationsBySender[sender];
+ if (eventsFromSender) {
+ eventsFromSender.delete(event);
+ }
+ }
+
+ /**
+ * For relations that have been redacted, we want to remove them from
+ * aggregation data sets and emit an update event.
+ *
+ * To do so, we listen for `Event.beforeRedaction`, which happens:
+ * - after the server accepted the redaction and remote echoed back to us
+ * - before the original event has been marked redacted in the client
+ *
+ * @param redactedEvent - The original relation event that is about to be redacted.
+ */
+ private onBeforeRedaction = async (redactedEvent: MatrixEvent): Promise<void> => {
+ if (!this.relations.has(redactedEvent)) {
+ return;
+ }
+
+ this.relations.delete(redactedEvent);
+
+ if (this.relationType === RelationType.Annotation) {
+ // Remove the redacted annotation from aggregation by key
+ this.removeAnnotationFromAggregation(redactedEvent);
+ } else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
+ const lastReplacement = await this.getLastReplacement();
+ this.targetEvent.makeReplaced(lastReplacement!);
+ }
+
+ redactedEvent.removeListener(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
+
+ this.emit(RelationsEvent.Redaction, redactedEvent);
+ };
+
+ /**
+ * Get all events in this collection grouped by key and sorted by descending
+ * event count in each group.
+ *
+ * This is currently only supported for the annotation relation type.
+ *
+ * An array of [key, events] pairs sorted by descending event count.
+ * The events are stored in a Set (which preserves insertion order).
+ */
+ public getSortedAnnotationsByKey(): [string, Set<MatrixEvent>][] | null {
+ if (this.relationType !== RelationType.Annotation) {
+ // Other relation types are not grouped currently.
+ return null;
+ }
+
+ return this.sortedAnnotationsByKey;
+ }
+
+ /**
+ * Get all events in this collection grouped by sender.
+ *
+ * This is currently only supported for the annotation relation type.
+ *
+ * An object with each relation sender as a key and the matching Set of
+ * events for that sender as a value.
+ */
+ public getAnnotationsBySender(): Record<string, Set<MatrixEvent>> | null {
+ if (this.relationType !== RelationType.Annotation) {
+ // Other relation types are not grouped currently.
+ return null;
+ }
+
+ return this.annotationsBySender;
+ }
+
+ /**
+ * Returns the most recent (and allowed) m.replace relation, if any.
+ *
+ * This is currently only supported for the m.replace relation type,
+ * once the target event is known, see `addEvent`.
+ */
+ public async getLastReplacement(): Promise<MatrixEvent | null> {
+ if (this.relationType !== RelationType.Replace) {
+ // Aggregating on last only makes sense for this relation type
+ return null;
+ }
+ if (!this.targetEvent) {
+ // Don't know which replacements to accept yet.
+ // This method shouldn't be called before the original
+ // event is known anyway.
+ return null;
+ }
+
+ // the all-knowning server tells us that the event at some point had
+ // this timestamp for its replacement, so any following replacement should definitely not be less
+ const replaceRelation = this.targetEvent.getServerAggregatedRelation<IAggregatedRelation>(RelationType.Replace);
+ const minTs = replaceRelation?.origin_server_ts;
+
+ const lastReplacement = this.getRelations().reduce<MatrixEvent | null>((last, event) => {
+ if (event.getSender() !== this.targetEvent!.getSender()) {
+ return last;
+ }
+ if (minTs && minTs > event.getTs()) {
+ return last;
+ }
+ if (last && last.getTs() > event.getTs()) {
+ return last;
+ }
+ return event;
+ }, null);
+
+ if (lastReplacement?.shouldAttemptDecryption() && this.client.isCryptoEnabled()) {
+ await lastReplacement.attemptDecryption(this.client.crypto!);
+ } else if (lastReplacement?.isBeingDecrypted()) {
+ await lastReplacement.getDecryptionPromise();
+ }
+
+ return lastReplacement;
+ }
+
+ /*
+ * @param targetEvent - the event the relations are related to.
+ */
+ public async setTargetEvent(event: MatrixEvent): Promise<void> {
+ if (this.targetEvent) {
+ return;
+ }
+ this.targetEvent = event;
+
+ if (this.relationType === RelationType.Replace && !this.targetEvent.isState()) {
+ const replacement = await this.getLastReplacement();
+ // this is the initial update, so only call it if we already have something
+ // to not emit Event.replaced needlessly
+ if (replacement) {
+ this.targetEvent.makeReplaced(replacement);
+ }
+ }
+
+ this.maybeEmitCreated();
+ }
+
+ private maybeEmitCreated(): void {
+ if (this.creationEmitted) {
+ return;
+ }
+ // Only emit we're "created" once we have a target event instance _and_
+ // at least one related event.
+ if (!this.targetEvent || !this.relations.size) {
+ return;
+ }
+ this.creationEmitted = true;
+ this.targetEvent.emit(MatrixEventEvent.RelationsCreated, this.relationType, this.eventType);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-member.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-member.ts
new file mode 100644
index 0000000..116a93b
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-member.ts
@@ -0,0 +1,453 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { getHttpUriForMxc } from "../content-repo";
+import * as utils from "../utils";
+import { User } from "./user";
+import { MatrixEvent } from "./event";
+import { RoomState } from "./room-state";
+import { logger } from "../logger";
+import { TypedEventEmitter } from "./typed-event-emitter";
+import { EventType } from "../@types/event";
+
+export enum RoomMemberEvent {
+ Membership = "RoomMember.membership",
+ Name = "RoomMember.name",
+ PowerLevel = "RoomMember.powerLevel",
+ Typing = "RoomMember.typing",
+}
+
+export type RoomMemberEventHandlerMap = {
+ /**
+ * Fires whenever any room member's membership state changes.
+ * @param event - The matrix event which caused this event to fire.
+ * @param member - The member whose RoomMember.membership changed.
+ * @param oldMembership - The previous membership state. Null if it's a new member.
+ * @example
+ * ```
+ * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){
+ * var newState = member.membership;
+ * });
+ * ```
+ */
+ [RoomMemberEvent.Membership]: (event: MatrixEvent, member: RoomMember, oldMembership?: string) => void;
+ /**
+ * Fires whenever any room member's name changes.
+ * @param event - The matrix event which caused this event to fire.
+ * @param member - The member whose RoomMember.name changed.
+ * @param oldName - The previous name. Null if the member didn't have a name previously.
+ * @example
+ * ```
+ * matrixClient.on("RoomMember.name", function(event, member){
+ * var newName = member.name;
+ * });
+ * ```
+ */
+ [RoomMemberEvent.Name]: (event: MatrixEvent, member: RoomMember, oldName: string | null) => void;
+ /**
+ * Fires whenever any room member's power level changes.
+ * @param event - The matrix event which caused this event to fire.
+ * @param member - The member whose RoomMember.powerLevel changed.
+ * @example
+ * ```
+ * matrixClient.on("RoomMember.powerLevel", function(event, member){
+ * var newPowerLevel = member.powerLevel;
+ * var newNormPowerLevel = member.powerLevelNorm;
+ * });
+ * ```
+ */
+ [RoomMemberEvent.PowerLevel]: (event: MatrixEvent, member: RoomMember) => void;
+ /**
+ * Fires whenever any room member's typing state changes.
+ * @param event - The matrix event which caused this event to fire.
+ * @param member - The member whose RoomMember.typing changed.
+ * @example
+ * ```
+ * matrixClient.on("RoomMember.typing", function(event, member){
+ * var isTyping = member.typing;
+ * });
+ * ```
+ */
+ [RoomMemberEvent.Typing]: (event: MatrixEvent, member: RoomMember) => void;
+};
+
+export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEventHandlerMap> {
+ private _isOutOfBand = false;
+ private modified = -1;
+ public requestedProfileInfo = false; // used by sync.ts
+
+ // XXX these should be read-only
+ /**
+ * True if the room member is currently typing.
+ */
+ public typing = false;
+ /**
+ * The human-readable name for this room member. This will be
+ * disambiguated with a suffix of " (\@user_id:matrix.org)" if another member shares the
+ * same displayname.
+ */
+ public name: string;
+ /**
+ * The ambiguous displayname of this room member.
+ */
+ public rawDisplayName: string;
+ /**
+ * The power level for this room member.
+ */
+ public powerLevel = 0;
+ /**
+ * The normalised power level (0-100) for this room member.
+ */
+ public powerLevelNorm = 0;
+ /**
+ * The User object for this room member, if one exists.
+ */
+ public user?: User;
+ /**
+ * The membership state for this room member e.g. 'join'.
+ */
+ public membership?: string;
+ /**
+ * True if the member's name is disambiguated.
+ */
+ public disambiguate = false;
+ /**
+ * The events describing this RoomMember.
+ */
+ public events: {
+ /**
+ * The m.room.member event for this RoomMember.
+ */
+ member?: MatrixEvent;
+ } = {};
+
+ /**
+ * Construct a new room member.
+ *
+ * @param roomId - The room ID of the member.
+ * @param userId - The user ID of the member.
+ */
+ public constructor(public readonly roomId: string, public readonly userId: string) {
+ super();
+
+ this.name = userId;
+ this.rawDisplayName = userId;
+ this.updateModifiedTime();
+ }
+
+ /**
+ * Mark the member as coming from a channel that is not sync
+ */
+ public markOutOfBand(): void {
+ this._isOutOfBand = true;
+ }
+
+ /**
+ * @returns does the member come from a channel that is not sync?
+ * This is used to store the member seperately
+ * from the sync state so it available across browser sessions.
+ */
+ public isOutOfBand(): boolean {
+ return this._isOutOfBand;
+ }
+
+ /**
+ * Update this room member's membership event. May fire "RoomMember.name" if
+ * this event updates this member's name.
+ * @param event - The `m.room.member` event
+ * @param roomState - Optional. The room state to take into account
+ * when calculating (e.g. for disambiguating users with the same name).
+ *
+ * @remarks
+ * Fires {@link RoomMemberEvent.Name}
+ * Fires {@link RoomMemberEvent.Membership}
+ */
+ public setMembershipEvent(event: MatrixEvent, roomState?: RoomState): void {
+ const displayName = event.getDirectionalContent().displayname ?? "";
+
+ if (event.getType() !== EventType.RoomMember) {
+ return;
+ }
+
+ this._isOutOfBand = false;
+
+ this.events.member = event;
+
+ const oldMembership = this.membership;
+ this.membership = event.getDirectionalContent().membership;
+ if (this.membership === undefined) {
+ // logging to diagnose https://github.com/vector-im/element-web/issues/20962
+ // (logs event content, although only of membership events)
+ logger.trace(
+ `membership event with membership undefined (forwardLooking: ${event.forwardLooking})!`,
+ event.getContent(),
+ `prevcontent is `,
+ event.getPrevContent(),
+ );
+ }
+
+ this.disambiguate = shouldDisambiguate(this.userId, displayName, roomState);
+
+ const oldName = this.name;
+ this.name = calculateDisplayName(this.userId, displayName, this.disambiguate);
+
+ // not quite raw: we strip direction override chars so it can safely be inserted into
+ // blocks of text without breaking the text direction
+ this.rawDisplayName = utils.removeDirectionOverrideChars(event.getDirectionalContent().displayname ?? "");
+ if (!this.rawDisplayName || !utils.removeHiddenChars(this.rawDisplayName)) {
+ this.rawDisplayName = this.userId;
+ }
+
+ if (oldMembership !== this.membership) {
+ this.updateModifiedTime();
+ this.emit(RoomMemberEvent.Membership, event, this, oldMembership);
+ }
+ if (oldName !== this.name) {
+ this.updateModifiedTime();
+ this.emit(RoomMemberEvent.Name, event, this, oldName);
+ }
+ }
+
+ /**
+ * Update this room member's power level event. May fire
+ * "RoomMember.powerLevel" if this event updates this member's power levels.
+ * @param powerLevelEvent - The `m.room.power_levels` event
+ *
+ * @remarks
+ * Fires {@link RoomMemberEvent.PowerLevel}
+ */
+ public setPowerLevelEvent(powerLevelEvent: MatrixEvent): void {
+ if (powerLevelEvent.getType() !== EventType.RoomPowerLevels || powerLevelEvent.getStateKey() !== "") {
+ return;
+ }
+
+ const evContent = powerLevelEvent.getDirectionalContent();
+
+ let maxLevel = evContent.users_default || 0;
+ const users: { [userId: string]: number } = evContent.users || {};
+ Object.values(users).forEach((lvl: number) => {
+ maxLevel = Math.max(maxLevel, lvl);
+ });
+ const oldPowerLevel = this.powerLevel;
+ const oldPowerLevelNorm = this.powerLevelNorm;
+
+ if (users[this.userId] !== undefined && Number.isInteger(users[this.userId])) {
+ this.powerLevel = users[this.userId];
+ } else if (evContent.users_default !== undefined) {
+ this.powerLevel = evContent.users_default;
+ } else {
+ this.powerLevel = 0;
+ }
+ this.powerLevelNorm = 0;
+ if (maxLevel > 0) {
+ this.powerLevelNorm = (this.powerLevel * 100) / maxLevel;
+ }
+
+ // emit for changes in powerLevelNorm as well (since the app will need to
+ // redraw everyone's level if the max has changed)
+ if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) {
+ this.updateModifiedTime();
+ this.emit(RoomMemberEvent.PowerLevel, powerLevelEvent, this);
+ }
+ }
+
+ /**
+ * Update this room member's typing event. May fire "RoomMember.typing" if
+ * this event changes this member's typing state.
+ * @param event - The typing event
+ *
+ * @remarks
+ * Fires {@link RoomMemberEvent.Typing}
+ */
+ public setTypingEvent(event: MatrixEvent): void {
+ if (event.getType() !== "m.typing") {
+ return;
+ }
+ const oldTyping = this.typing;
+ this.typing = false;
+ const typingList = event.getContent().user_ids;
+ if (!Array.isArray(typingList)) {
+ // malformed event :/ bail early. TODO: whine?
+ return;
+ }
+ if (typingList.indexOf(this.userId) !== -1) {
+ this.typing = true;
+ }
+ if (oldTyping !== this.typing) {
+ this.updateModifiedTime();
+ this.emit(RoomMemberEvent.Typing, event, this);
+ }
+ }
+
+ /**
+ * Update the last modified time to the current time.
+ */
+ private updateModifiedTime(): void {
+ this.modified = Date.now();
+ }
+
+ /**
+ * Get the timestamp when this RoomMember was last updated. This timestamp is
+ * updated when properties on this RoomMember are updated.
+ * It is updated <i>before</i> firing events.
+ * @returns The timestamp
+ */
+ public getLastModifiedTime(): number {
+ return this.modified;
+ }
+
+ public isKicked(): boolean {
+ return (
+ this.membership === "leave" &&
+ this.events.member !== undefined &&
+ this.events.member.getSender() !== this.events.member.getStateKey()
+ );
+ }
+
+ /**
+ * If this member was invited with the is_direct flag set, return
+ * the user that invited this member
+ * @returns user id of the inviter
+ */
+ public getDMInviter(): string | undefined {
+ // when not available because that room state hasn't been loaded in,
+ // we don't really know, but more likely to not be a direct chat
+ if (this.events.member) {
+ // TODO: persist the is_direct flag on the member as more member events
+ // come in caused by displayName changes.
+
+ // the is_direct flag is set on the invite member event.
+ // This is copied on the prev_content section of the join member event
+ // when the invite is accepted.
+
+ const memberEvent = this.events.member;
+ let memberContent = memberEvent.getContent();
+ let inviteSender: string | undefined = memberEvent.getSender();
+
+ if (memberContent.membership === "join") {
+ memberContent = memberEvent.getPrevContent();
+ inviteSender = memberEvent.getUnsigned().prev_sender;
+ }
+
+ if (memberContent.membership === "invite" && memberContent.is_direct) {
+ return inviteSender;
+ }
+ }
+ }
+
+ /**
+ * Get the avatar URL for a room member.
+ * @param baseUrl - The base homeserver URL See
+ * {@link MatrixClient#getHomeserverUrl}.
+ * @param width - The desired width of the thumbnail.
+ * @param height - The desired height of the thumbnail.
+ * @param resizeMethod - The thumbnail resize method to use, either
+ * "crop" or "scale".
+ * @param allowDefault - (optional) Passing false causes this method to
+ * return null if the user has no avatar image. Otherwise, a default image URL
+ * will be returned. Default: true. (Deprecated)
+ * @param allowDirectLinks - (optional) If true, the avatar URL will be
+ * returned even if it is a direct hyperlink rather than a matrix content URL.
+ * If false, any non-matrix content URLs will be ignored. Setting this option to
+ * true will expose URLs that, if fetched, will leak information about the user
+ * to anyone who they share a room with.
+ * @returns the avatar URL or null.
+ */
+ public getAvatarUrl(
+ baseUrl: string,
+ width: number,
+ height: number,
+ resizeMethod: string,
+ allowDefault = true,
+ allowDirectLinks: boolean,
+ ): string | null {
+ const rawUrl = this.getMxcAvatarUrl();
+
+ if (!rawUrl && !allowDefault) {
+ return null;
+ }
+ const httpUrl = getHttpUriForMxc(baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks);
+ if (httpUrl) {
+ return httpUrl;
+ }
+ return null;
+ }
+
+ /**
+ * get the mxc avatar url, either from a state event, or from a lazily loaded member
+ * @returns the mxc avatar url
+ */
+ public getMxcAvatarUrl(): string | undefined {
+ if (this.events.member) {
+ return this.events.member.getDirectionalContent().avatar_url;
+ } else if (this.user) {
+ return this.user.avatarUrl;
+ }
+ }
+}
+
+const MXID_PATTERN = /@.+:.+/;
+const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
+
+function shouldDisambiguate(selfUserId: string, displayName?: string, roomState?: RoomState): boolean {
+ if (!displayName || displayName === selfUserId) return false;
+
+ // First check if the displayname is something we consider truthy
+ // after stripping it of zero width characters and padding spaces
+ if (!utils.removeHiddenChars(displayName)) return false;
+
+ if (!roomState) return false;
+
+ // Next check if the name contains something that look like a mxid
+ // If it does, it may be someone trying to impersonate someone else
+ // Show full mxid in this case
+ if (MXID_PATTERN.test(displayName)) return true;
+
+ // Also show mxid if the display name contains any LTR/RTL characters as these
+ // make it very difficult for us to find similar *looking* display names
+ // E.g "Mark" could be cloned by writing "kraM" but in RTL.
+ if (LTR_RTL_PATTERN.test(displayName)) return true;
+
+ // Also show mxid if there are other people with the same or similar
+ // displayname, after hidden character removal.
+ const userIds = roomState.getUserIdsWithDisplayName(displayName);
+ if (userIds.some((u) => u !== selfUserId)) return true;
+
+ return false;
+}
+
+function calculateDisplayName(selfUserId: string, displayName: string | undefined, disambiguate: boolean): string {
+ if (!displayName || displayName === selfUserId) return selfUserId;
+
+ if (disambiguate) return utils.removeDirectionOverrideChars(displayName) + " (" + selfUserId + ")";
+
+ // First check if the displayname is something we consider truthy
+ // after stripping it of zero width characters and padding spaces
+ if (!utils.removeHiddenChars(displayName)) return selfUserId;
+
+ // We always strip the direction override characters (LRO and RLO).
+ // These override the text direction for all subsequent characters
+ // in the paragraph so if display names contained these, they'd
+ // need to be wrapped in something to prevent this from leaking out
+ // (which we can do in HTML but not text) or we'd need to add
+ // control characters to the string to reset any overrides (eg.
+ // adding PDF characters at the end). As far as we can see,
+ // there should be no reason these would be necessary - rtl display
+ // names should flip into the correct direction automatically based on
+ // the characters, and you can still embed rtl in ltr or vice versa
+ // with the embed chars or marker chars.
+ return utils.removeDirectionOverrideChars(displayName);
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-state.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-state.ts
new file mode 100644
index 0000000..f975b9c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-state.ts
@@ -0,0 +1,1081 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RoomMember } from "./room-member";
+import { logger } from "../logger";
+import * as utils from "../utils";
+import { EventType, UNSTABLE_MSC2716_MARKER } from "../@types/event";
+import { IEvent, MatrixEvent, MatrixEventEvent } from "./event";
+import { MatrixClient } from "../client";
+import { GuestAccess, HistoryVisibility, IJoinRuleEventContent, JoinRule } from "../@types/partials";
+import { TypedEventEmitter } from "./typed-event-emitter";
+import { Beacon, BeaconEvent, BeaconEventHandlerMap, getBeaconInfoIdentifier, BeaconIdentifier } from "./beacon";
+import { TypedReEmitter } from "../ReEmitter";
+import { M_BEACON, M_BEACON_INFO } from "../@types/beacon";
+
+export interface IMarkerFoundOptions {
+ /** Whether the timeline was empty before the marker event arrived in the
+ * room. This could be happen in a variety of cases:
+ * 1. From the initial sync
+ * 2. It's the first state we're seeing after joining the room
+ * 3. Or whether it's coming from `syncFromCache`
+ *
+ * A marker event refers to `UNSTABLE_MSC2716_MARKER` and indicates that
+ * history was imported somewhere back in time. It specifically points to an
+ * MSC2716 insertion event where the history was imported at. Marker events
+ * are sent as state events so they are easily discoverable by clients and
+ * homeservers and don't get lost in timeline gaps.
+ */
+ timelineWasEmpty?: boolean;
+}
+
+// possible statuses for out-of-band member loading
+enum OobStatus {
+ NotStarted,
+ InProgress,
+ Finished,
+}
+
+export interface IPowerLevelsContent {
+ users?: Record<string, number>;
+ events?: Record<string, number>;
+ // eslint-disable-next-line camelcase
+ users_default?: number;
+ // eslint-disable-next-line camelcase
+ events_default?: number;
+ // eslint-disable-next-line camelcase
+ state_default?: number;
+ ban?: number;
+ kick?: number;
+ redact?: number;
+}
+
+export enum RoomStateEvent {
+ Events = "RoomState.events",
+ Members = "RoomState.members",
+ NewMember = "RoomState.newMember",
+ Update = "RoomState.update", // signals batches of updates without specificity
+ BeaconLiveness = "RoomState.BeaconLiveness",
+ Marker = "RoomState.Marker",
+}
+
+export type RoomStateEventHandlerMap = {
+ /**
+ * Fires whenever the event dictionary in room state is updated.
+ * @param event - The matrix event which caused this event to fire.
+ * @param state - The room state whose RoomState.events dictionary
+ * was updated.
+ * @param prevEvent - The event being replaced by the new state, if
+ * known. Note that this can differ from `getPrevContent()` on the new state event
+ * as this is the store's view of the last state, not the previous state provided
+ * by the server.
+ * @example
+ * ```
+ * matrixClient.on("RoomState.events", function(event, state, prevEvent){
+ * var newStateEvent = event;
+ * });
+ * ```
+ */
+ [RoomStateEvent.Events]: (event: MatrixEvent, state: RoomState, lastStateEvent: MatrixEvent | null) => void;
+ /**
+ * Fires whenever a member in the members dictionary is updated in any way.
+ * @param event - The matrix event which caused this event to fire.
+ * @param state - The room state whose RoomState.members dictionary
+ * was updated.
+ * @param member - The room member that was updated.
+ * @example
+ * ```
+ * matrixClient.on("RoomState.members", function(event, state, member){
+ * var newMembershipState = member.membership;
+ * });
+ * ```
+ */
+ [RoomStateEvent.Members]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void;
+ /**
+ * Fires whenever a member is added to the members dictionary. The RoomMember
+ * will not be fully populated yet (e.g. no membership state) but will already
+ * be available in the members dictionary.
+ * @param event - The matrix event which caused this event to fire.
+ * @param state - The room state whose RoomState.members dictionary
+ * was updated with a new entry.
+ * @param member - The room member that was added.
+ * @example
+ * ```
+ * matrixClient.on("RoomState.newMember", function(event, state, member){
+ * // add event listeners on 'member'
+ * });
+ * ```
+ */
+ [RoomStateEvent.NewMember]: (event: MatrixEvent, state: RoomState, member: RoomMember) => void;
+ [RoomStateEvent.Update]: (state: RoomState) => void;
+ [RoomStateEvent.BeaconLiveness]: (state: RoomState, hasLiveBeacons: boolean) => void;
+ [RoomStateEvent.Marker]: (event: MatrixEvent, setStateOptions?: IMarkerFoundOptions) => void;
+ [BeaconEvent.New]: (event: MatrixEvent, beacon: Beacon) => void;
+};
+
+type EmittedEvents = RoomStateEvent | BeaconEvent;
+type EventHandlerMap = RoomStateEventHandlerMap & BeaconEventHandlerMap;
+
+export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap> {
+ public readonly reEmitter = new TypedReEmitter<EmittedEvents, EventHandlerMap>(this);
+ private sentinels: Record<string, RoomMember> = {}; // userId: RoomMember
+ // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys)
+ private displayNameToUserIds = new Map<string, string[]>();
+ private userIdsToDisplayNames: Record<string, string> = {};
+ private tokenToInvite: Record<string, MatrixEvent> = {}; // 3pid invite state_key to m.room.member invite
+ private joinedMemberCount: number | null = null; // cache of the number of joined members
+ // joined members count from summary api
+ // once set, we know the server supports the summary api
+ // and we should only trust that
+ // we could also only trust that before OOB members
+ // are loaded but doesn't seem worth the hassle atm
+ private summaryJoinedMemberCount: number | null = null;
+ // same for invited member count
+ private invitedMemberCount: number | null = null;
+ private summaryInvitedMemberCount: number | null = null;
+ private modified = -1;
+
+ // XXX: Should be read-only
+ // The room member dictionary, keyed on the user's ID.
+ public members: Record<string, RoomMember> = {}; // userId: RoomMember
+ // The state events dictionary, keyed on the event type and then the state_key value.
+ public events = new Map<string, Map<string, MatrixEvent>>(); // Map<eventType, Map<stateKey, MatrixEvent>>
+ // The pagination token for this state.
+ public paginationToken: string | null = null;
+
+ public readonly beacons = new Map<BeaconIdentifier, Beacon>();
+ private _liveBeaconIds: BeaconIdentifier[] = [];
+
+ /**
+ * Construct room state.
+ *
+ * Room State represents the state of the room at a given point.
+ * It can be mutated by adding state events to it.
+ * There are two types of room member associated with a state event:
+ * normal member objects (accessed via getMember/getMembers) which mutate
+ * with the state to represent the current state of that room/user, e.g.
+ * the object returned by `getMember('@bob:example.com')` will mutate to
+ * get a different display name if Bob later changes his display name
+ * in the room.
+ * There are also 'sentinel' members (accessed via getSentinelMember).
+ * These also represent the state of room members at the point in time
+ * represented by the RoomState object, but unlike objects from getMember,
+ * sentinel objects will always represent the room state as at the time
+ * getSentinelMember was called, so if Bob subsequently changes his display
+ * name, a room member object previously acquired with getSentinelMember
+ * will still have his old display name. Calling getSentinelMember again
+ * after the display name change will return a new RoomMember object
+ * with Bob's new display name.
+ *
+ * @param roomId - Optional. The ID of the room which has this state.
+ * If none is specified it just tracks paginationTokens, useful for notifTimelineSet
+ * @param oobMemberFlags - Optional. The state of loading out of bound members.
+ * As the timeline might get reset while they are loading, this state needs to be inherited
+ * and shared when the room state is cloned for the new timeline.
+ * This should only be passed from clone.
+ */
+ public constructor(public readonly roomId: string, private oobMemberFlags = { status: OobStatus.NotStarted }) {
+ super();
+ this.updateModifiedTime();
+ }
+
+ /**
+ * Returns the number of joined members in this room
+ * This method caches the result.
+ * @returns The number of members in this room whose membership is 'join'
+ */
+ public getJoinedMemberCount(): number {
+ if (this.summaryJoinedMemberCount !== null) {
+ return this.summaryJoinedMemberCount;
+ }
+ if (this.joinedMemberCount === null) {
+ this.joinedMemberCount = this.getMembers().reduce((count, m) => {
+ return m.membership === "join" ? count + 1 : count;
+ }, 0);
+ }
+ return this.joinedMemberCount;
+ }
+
+ /**
+ * Set the joined member count explicitly (like from summary part of the sync response)
+ * @param count - the amount of joined members
+ */
+ public setJoinedMemberCount(count: number): void {
+ this.summaryJoinedMemberCount = count;
+ }
+
+ /**
+ * Returns the number of invited members in this room
+ * @returns The number of members in this room whose membership is 'invite'
+ */
+ public getInvitedMemberCount(): number {
+ if (this.summaryInvitedMemberCount !== null) {
+ return this.summaryInvitedMemberCount;
+ }
+ if (this.invitedMemberCount === null) {
+ this.invitedMemberCount = this.getMembers().reduce((count, m) => {
+ return m.membership === "invite" ? count + 1 : count;
+ }, 0);
+ }
+ return this.invitedMemberCount;
+ }
+
+ /**
+ * Set the amount of invited members in this room
+ * @param count - the amount of invited members
+ */
+ public setInvitedMemberCount(count: number): void {
+ this.summaryInvitedMemberCount = count;
+ }
+
+ /**
+ * Get all RoomMembers in this room.
+ * @returns A list of RoomMembers.
+ */
+ public getMembers(): RoomMember[] {
+ return Object.values(this.members);
+ }
+
+ /**
+ * Get all RoomMembers in this room, excluding the user IDs provided.
+ * @param excludedIds - The user IDs to exclude.
+ * @returns A list of RoomMembers.
+ */
+ public getMembersExcept(excludedIds: string[]): RoomMember[] {
+ return this.getMembers().filter((m) => !excludedIds.includes(m.userId));
+ }
+
+ /**
+ * Get a room member by their user ID.
+ * @param userId - The room member's user ID.
+ * @returns The member or null if they do not exist.
+ */
+ public getMember(userId: string): RoomMember | null {
+ return this.members[userId] || null;
+ }
+
+ /**
+ * Get a room member whose properties will not change with this room state. You
+ * typically want this if you want to attach a RoomMember to a MatrixEvent which
+ * may no longer be represented correctly by Room.currentState or Room.oldState.
+ * The term 'sentinel' refers to the fact that this RoomMember is an unchanging
+ * guardian for state at this particular point in time.
+ * @param userId - The room member's user ID.
+ * @returns The member or null if they do not exist.
+ */
+ public getSentinelMember(userId: string): RoomMember | null {
+ if (!userId) return null;
+ let sentinel = this.sentinels[userId];
+
+ if (sentinel === undefined) {
+ sentinel = new RoomMember(this.roomId, userId);
+ const member = this.members[userId];
+ if (member?.events.member) {
+ sentinel.setMembershipEvent(member.events.member, this);
+ }
+ this.sentinels[userId] = sentinel;
+ }
+ return sentinel;
+ }
+
+ /**
+ * Get state events from the state of the room.
+ * @param eventType - The event type of the state event.
+ * @param stateKey - Optional. The state_key of the state event. If
+ * this is `undefined` then all matching state events will be
+ * returned.
+ * @returns A list of events if state_key was
+ * `undefined`, else a single event (or null if no match found).
+ */
+ public getStateEvents(eventType: EventType | string): MatrixEvent[];
+ public getStateEvents(eventType: EventType | string, stateKey: string): MatrixEvent | null;
+ public getStateEvents(eventType: EventType | string, stateKey?: string): MatrixEvent[] | MatrixEvent | null {
+ if (!this.events.has(eventType)) {
+ // no match
+ return stateKey === undefined ? [] : null;
+ }
+ if (stateKey === undefined) {
+ // return all values
+ return Array.from(this.events.get(eventType)!.values());
+ }
+ const event = this.events.get(eventType)!.get(stateKey);
+ return event ? event : null;
+ }
+
+ public get hasLiveBeacons(): boolean {
+ return !!this.liveBeaconIds?.length;
+ }
+
+ public get liveBeaconIds(): BeaconIdentifier[] {
+ return this._liveBeaconIds;
+ }
+
+ /**
+ * Creates a copy of this room state so that mutations to either won't affect the other.
+ * @returns the copy of the room state
+ */
+ public clone(): RoomState {
+ const copy = new RoomState(this.roomId, this.oobMemberFlags);
+
+ // Ugly hack: because setStateEvents will mark
+ // members as susperseding future out of bound members
+ // if loading is in progress (through oobMemberFlags)
+ // since these are not new members, we're merely copying them
+ // set the status to not started
+ // after copying, we set back the status
+ const status = this.oobMemberFlags.status;
+ this.oobMemberFlags.status = OobStatus.NotStarted;
+
+ Array.from(this.events.values()).forEach((eventsByStateKey) => {
+ copy.setStateEvents(Array.from(eventsByStateKey.values()));
+ });
+
+ // Ugly hack: see above
+ this.oobMemberFlags.status = status;
+
+ if (this.summaryInvitedMemberCount !== null) {
+ copy.setInvitedMemberCount(this.getInvitedMemberCount());
+ }
+ if (this.summaryJoinedMemberCount !== null) {
+ copy.setJoinedMemberCount(this.getJoinedMemberCount());
+ }
+
+ // copy out of band flags if needed
+ if (this.oobMemberFlags.status == OobStatus.Finished) {
+ // copy markOutOfBand flags
+ this.getMembers().forEach((member) => {
+ if (member.isOutOfBand()) {
+ copy.getMember(member.userId)?.markOutOfBand();
+ }
+ });
+ }
+
+ return copy;
+ }
+
+ /**
+ * Add previously unknown state events.
+ * When lazy loading members while back-paginating,
+ * the relevant room state for the timeline chunk at the end
+ * of the chunk can be set with this method.
+ * @param events - state events to prepend
+ */
+ public setUnknownStateEvents(events: MatrixEvent[]): void {
+ const unknownStateEvents = events.filter((event) => {
+ return !this.events.has(event.getType()) || !this.events.get(event.getType())!.has(event.getStateKey()!);
+ });
+
+ this.setStateEvents(unknownStateEvents);
+ }
+
+ /**
+ * Add an array of one or more state MatrixEvents, overwriting any existing
+ * state with the same `{type, stateKey}` tuple. Will fire "RoomState.events"
+ * for every event added. May fire "RoomState.members" if there are
+ * `m.room.member` events. May fire "RoomStateEvent.Marker" if there are
+ * `UNSTABLE_MSC2716_MARKER` events.
+ * @param stateEvents - a list of state events for this room.
+ *
+ * @remarks
+ * Fires {@link RoomStateEvent.Members}
+ * Fires {@link RoomStateEvent.NewMember}
+ * Fires {@link RoomStateEvent.Events}
+ * Fires {@link RoomStateEvent.Marker}
+ */
+ public setStateEvents(stateEvents: MatrixEvent[], markerFoundOptions?: IMarkerFoundOptions): void {
+ this.updateModifiedTime();
+
+ // update the core event dict
+ stateEvents.forEach((event) => {
+ if (event.getRoomId() !== this.roomId || !event.isState()) return;
+
+ if (M_BEACON_INFO.matches(event.getType())) {
+ this.setBeacon(event);
+ }
+
+ const lastStateEvent = this.getStateEventMatching(event);
+ this.setStateEvent(event);
+ if (event.getType() === EventType.RoomMember) {
+ this.updateDisplayNameCache(event.getStateKey()!, event.getContent().displayname ?? "");
+ this.updateThirdPartyTokenCache(event);
+ }
+ this.emit(RoomStateEvent.Events, event, this, lastStateEvent);
+ });
+
+ this.onBeaconLivenessChange();
+ // update higher level data structures. This needs to be done AFTER the
+ // core event dict as these structures may depend on other state events in
+ // the given array (e.g. disambiguating display names in one go to do both
+ // clashing names rather than progressively which only catches 1 of them).
+ stateEvents.forEach((event) => {
+ if (event.getRoomId() !== this.roomId || !event.isState()) return;
+
+ if (event.getType() === EventType.RoomMember) {
+ const userId = event.getStateKey()!;
+
+ // leave events apparently elide the displayname or avatar_url,
+ // so let's fake one up so that we don't leak user ids
+ // into the timeline
+ if (event.getContent().membership === "leave" || event.getContent().membership === "ban") {
+ event.getContent().avatar_url = event.getContent().avatar_url || event.getPrevContent().avatar_url;
+ event.getContent().displayname =
+ event.getContent().displayname || event.getPrevContent().displayname;
+ }
+
+ const member = this.getOrCreateMember(userId, event);
+ member.setMembershipEvent(event, this);
+ this.updateMember(member);
+ this.emit(RoomStateEvent.Members, event, this, member);
+ } else if (event.getType() === EventType.RoomPowerLevels) {
+ // events with unknown state keys should be ignored
+ // and should not aggregate onto members power levels
+ if (event.getStateKey() !== "") {
+ return;
+ }
+ const members = Object.values(this.members);
+ members.forEach((member) => {
+ // We only propagate `RoomState.members` event if the
+ // power levels has been changed
+ // large room suffer from large re-rendering especially when not needed
+ const oldLastModified = member.getLastModifiedTime();
+ member.setPowerLevelEvent(event);
+ if (oldLastModified !== member.getLastModifiedTime()) {
+ this.emit(RoomStateEvent.Members, event, this, member);
+ }
+ });
+
+ // assume all our sentinels are now out-of-date
+ this.sentinels = {};
+ } else if (UNSTABLE_MSC2716_MARKER.matches(event.getType())) {
+ this.emit(RoomStateEvent.Marker, event, markerFoundOptions);
+ }
+ });
+
+ this.emit(RoomStateEvent.Update, this);
+ }
+
+ public async processBeaconEvents(events: MatrixEvent[], matrixClient: MatrixClient): Promise<void> {
+ if (
+ !events.length ||
+ // discard locations if we have no beacons
+ !this.beacons.size
+ ) {
+ return;
+ }
+
+ const beaconByEventIdDict = [...this.beacons.values()].reduce<Record<string, Beacon>>((dict, beacon) => {
+ dict[beacon.beaconInfoId] = beacon;
+ return dict;
+ }, {});
+
+ const processBeaconRelation = (beaconInfoEventId: string, event: MatrixEvent): void => {
+ if (!M_BEACON.matches(event.getType())) {
+ return;
+ }
+
+ const beacon = beaconByEventIdDict[beaconInfoEventId];
+
+ if (beacon) {
+ beacon.addLocations([event]);
+ }
+ };
+
+ for (const event of events) {
+ const relatedToEventId = event.getRelation()?.event_id;
+ // not related to a beacon we know about; discard
+ if (!relatedToEventId || !beaconByEventIdDict[relatedToEventId]) return;
+ if (!M_BEACON.matches(event.getType()) && !event.isEncrypted()) return;
+
+ try {
+ await matrixClient.decryptEventIfNeeded(event);
+ processBeaconRelation(relatedToEventId, event);
+ } catch {
+ if (event.isDecryptionFailure()) {
+ // add an event listener for once the event is decrypted.
+ event.once(MatrixEventEvent.Decrypted, async () => {
+ processBeaconRelation(relatedToEventId, event);
+ });
+ }
+ }
+ }
+ }
+
+ /**
+ * Looks up a member by the given userId, and if it doesn't exist,
+ * create it and emit the `RoomState.newMember` event.
+ * This method makes sure the member is added to the members dictionary
+ * before emitting, as this is done from setStateEvents and setOutOfBandMember.
+ * @param userId - the id of the user to look up
+ * @param event - the membership event for the (new) member. Used to emit.
+ * @returns the member, existing or newly created.
+ *
+ * @remarks
+ * Fires {@link RoomStateEvent.NewMember}
+ */
+ private getOrCreateMember(userId: string, event: MatrixEvent): RoomMember {
+ let member = this.members[userId];
+ if (!member) {
+ member = new RoomMember(this.roomId, userId);
+ // add member to members before emitting any events,
+ // as event handlers often lookup the member
+ this.members[userId] = member;
+ this.emit(RoomStateEvent.NewMember, event, this, member);
+ }
+ return member;
+ }
+
+ private setStateEvent(event: MatrixEvent): void {
+ if (!this.events.has(event.getType())) {
+ this.events.set(event.getType(), new Map());
+ }
+ this.events.get(event.getType())!.set(event.getStateKey()!, event);
+ }
+
+ /**
+ * @experimental
+ */
+ private setBeacon(event: MatrixEvent): void {
+ const beaconIdentifier = getBeaconInfoIdentifier(event);
+
+ if (this.beacons.has(beaconIdentifier)) {
+ const beacon = this.beacons.get(beaconIdentifier)!;
+
+ if (event.isRedacted()) {
+ if (beacon.beaconInfoId === (<IEvent>event.getRedactionEvent())?.redacts) {
+ beacon.destroy();
+ this.beacons.delete(beaconIdentifier);
+ }
+ return;
+ }
+
+ return beacon.update(event);
+ }
+
+ if (event.isRedacted()) {
+ return;
+ }
+
+ const beacon = new Beacon(event);
+
+ this.reEmitter.reEmit<BeaconEvent, BeaconEvent>(beacon, [
+ BeaconEvent.New,
+ BeaconEvent.Update,
+ BeaconEvent.Destroy,
+ BeaconEvent.LivenessChange,
+ ]);
+
+ this.emit(BeaconEvent.New, event, beacon);
+ beacon.on(BeaconEvent.LivenessChange, this.onBeaconLivenessChange.bind(this));
+ beacon.on(BeaconEvent.Destroy, this.onBeaconLivenessChange.bind(this));
+
+ this.beacons.set(beacon.identifier, beacon);
+ }
+
+ /**
+ * @experimental
+ * Check liveness of room beacons
+ * emit RoomStateEvent.BeaconLiveness event
+ */
+ private onBeaconLivenessChange(): void {
+ this._liveBeaconIds = Array.from(this.beacons.values())
+ .filter((beacon) => beacon.isLive)
+ .map((beacon) => beacon.identifier);
+
+ this.emit(RoomStateEvent.BeaconLiveness, this, this.hasLiveBeacons);
+ }
+
+ private getStateEventMatching(event: MatrixEvent): MatrixEvent | null {
+ return this.events.get(event.getType())?.get(event.getStateKey()!) ?? null;
+ }
+
+ private updateMember(member: RoomMember): void {
+ // this member may have a power level already, so set it.
+ const pwrLvlEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
+ if (pwrLvlEvent) {
+ member.setPowerLevelEvent(pwrLvlEvent);
+ }
+
+ // blow away the sentinel which is now outdated
+ delete this.sentinels[member.userId];
+
+ this.members[member.userId] = member;
+ this.joinedMemberCount = null;
+ this.invitedMemberCount = null;
+ }
+
+ /**
+ * Get the out-of-band members loading state, whether loading is needed or not.
+ * Note that loading might be in progress and hence isn't needed.
+ * @returns whether or not the members of this room need to be loaded
+ */
+ public needsOutOfBandMembers(): boolean {
+ return this.oobMemberFlags.status === OobStatus.NotStarted;
+ }
+
+ /**
+ * Check if loading of out-of-band-members has completed
+ *
+ * @returns true if the full membership list of this room has been loaded. False if it is not started or is in
+ * progress.
+ */
+ public outOfBandMembersReady(): boolean {
+ return this.oobMemberFlags.status === OobStatus.Finished;
+ }
+
+ /**
+ * Mark this room state as waiting for out-of-band members,
+ * ensuring it doesn't ask for them to be requested again
+ * through needsOutOfBandMembers
+ */
+ public markOutOfBandMembersStarted(): void {
+ if (this.oobMemberFlags.status !== OobStatus.NotStarted) {
+ return;
+ }
+ this.oobMemberFlags.status = OobStatus.InProgress;
+ }
+
+ /**
+ * Mark this room state as having failed to fetch out-of-band members
+ */
+ public markOutOfBandMembersFailed(): void {
+ if (this.oobMemberFlags.status !== OobStatus.InProgress) {
+ return;
+ }
+ this.oobMemberFlags.status = OobStatus.NotStarted;
+ }
+
+ /**
+ * Clears the loaded out-of-band members
+ */
+ public clearOutOfBandMembers(): void {
+ let count = 0;
+ Object.keys(this.members).forEach((userId) => {
+ const member = this.members[userId];
+ if (member.isOutOfBand()) {
+ ++count;
+ delete this.members[userId];
+ }
+ });
+ logger.log(`LL: RoomState removed ${count} members...`);
+ this.oobMemberFlags.status = OobStatus.NotStarted;
+ }
+
+ /**
+ * Sets the loaded out-of-band members.
+ * @param stateEvents - array of membership state events
+ */
+ public setOutOfBandMembers(stateEvents: MatrixEvent[]): void {
+ logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`);
+ if (this.oobMemberFlags.status !== OobStatus.InProgress) {
+ return;
+ }
+ logger.log(`LL: RoomState put in finished state ...`);
+ this.oobMemberFlags.status = OobStatus.Finished;
+ stateEvents.forEach((e) => this.setOutOfBandMember(e));
+ this.emit(RoomStateEvent.Update, this);
+ }
+
+ /**
+ * Sets a single out of band member, used by both setOutOfBandMembers and clone
+ * @param stateEvent - membership state event
+ */
+ private setOutOfBandMember(stateEvent: MatrixEvent): void {
+ if (stateEvent.getType() !== EventType.RoomMember) {
+ return;
+ }
+ const userId = stateEvent.getStateKey()!;
+ const existingMember = this.getMember(userId);
+ // never replace members received as part of the sync
+ if (existingMember && !existingMember.isOutOfBand()) {
+ return;
+ }
+
+ const member = this.getOrCreateMember(userId, stateEvent);
+ member.setMembershipEvent(stateEvent, this);
+ // needed to know which members need to be stored seperately
+ // as they are not part of the sync accumulator
+ // this is cleared by setMembershipEvent so when it's updated through /sync
+ member.markOutOfBand();
+
+ this.updateDisplayNameCache(member.userId, member.name);
+
+ this.setStateEvent(stateEvent);
+ this.updateMember(member);
+ this.emit(RoomStateEvent.Members, stateEvent, this, member);
+ }
+
+ /**
+ * Set the current typing event for this room.
+ * @param event - The typing event
+ */
+ public setTypingEvent(event: MatrixEvent): void {
+ Object.values(this.members).forEach(function (member) {
+ member.setTypingEvent(event);
+ });
+ }
+
+ /**
+ * Get the m.room.member event which has the given third party invite token.
+ *
+ * @param token - The token
+ * @returns The m.room.member event or null
+ */
+ public getInviteForThreePidToken(token: string): MatrixEvent | null {
+ return this.tokenToInvite[token] || null;
+ }
+
+ /**
+ * Update the last modified time to the current time.
+ */
+ private updateModifiedTime(): void {
+ this.modified = Date.now();
+ }
+
+ /**
+ * Get the timestamp when this room state was last updated. This timestamp is
+ * updated when this object has received new state events.
+ * @returns The timestamp
+ */
+ public getLastModifiedTime(): number {
+ return this.modified;
+ }
+
+ /**
+ * Get user IDs with the specified or similar display names.
+ * @param displayName - The display name to get user IDs from.
+ * @returns An array of user IDs or an empty array.
+ */
+ public getUserIdsWithDisplayName(displayName: string): string[] {
+ return this.displayNameToUserIds.get(utils.removeHiddenChars(displayName)) ?? [];
+ }
+
+ /**
+ * Returns true if userId is in room, event is not redacted and either sender of
+ * mxEvent or has power level sufficient to redact events other than their own.
+ * @param mxEvent - The event to test permission for
+ * @param userId - The user ID of the user to test permission for
+ * @returns true if the given used ID can redact given event
+ */
+ public maySendRedactionForEvent(mxEvent: MatrixEvent, userId: string): boolean {
+ const member = this.getMember(userId);
+ if (!member || member.membership === "leave") return false;
+
+ if (mxEvent.status || mxEvent.isRedacted()) return false;
+
+ // The user may have been the sender, but they can't redact their own message
+ // if redactions are blocked.
+ const canRedact = this.maySendEvent(EventType.RoomRedaction, userId);
+ if (mxEvent.getSender() === userId) return canRedact;
+
+ return this.hasSufficientPowerLevelFor("redact", member.powerLevel);
+ }
+
+ /**
+ * Returns true if the given power level is sufficient for action
+ * @param action - The type of power level to check
+ * @param powerLevel - The power level of the member
+ * @returns true if the given power level is sufficient
+ */
+ public hasSufficientPowerLevelFor(action: "ban" | "kick" | "redact", powerLevel: number): boolean {
+ const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
+
+ let powerLevels: IPowerLevelsContent = {};
+ if (powerLevelsEvent) {
+ powerLevels = powerLevelsEvent.getContent();
+ }
+
+ let requiredLevel = 50;
+ if (utils.isNumber(powerLevels[action])) {
+ requiredLevel = powerLevels[action]!;
+ }
+
+ return powerLevel >= requiredLevel;
+ }
+
+ /**
+ * Short-form for maySendEvent('m.room.message', userId)
+ * @param userId - The user ID of the user to test permission for
+ * @returns true if the given user ID should be permitted to send
+ * message events into the given room.
+ */
+ public maySendMessage(userId: string): boolean {
+ return this.maySendEventOfType(EventType.RoomMessage, userId, false);
+ }
+
+ /**
+ * Returns true if the given user ID has permission to send a normal
+ * event of type `eventType` into this room.
+ * @param eventType - The type of event to test
+ * @param userId - The user ID of the user to test permission for
+ * @returns true if the given user ID should be permitted to send
+ * the given type of event into this room,
+ * according to the room's state.
+ */
+ public maySendEvent(eventType: EventType | string, userId: string): boolean {
+ return this.maySendEventOfType(eventType, userId, false);
+ }
+
+ /**
+ * Returns true if the given MatrixClient has permission to send a state
+ * event of type `stateEventType` into this room.
+ * @param stateEventType - The type of state events to test
+ * @param cli - The client to test permission for
+ * @returns true if the given client should be permitted to send
+ * the given type of state event into this room,
+ * according to the room's state.
+ */
+ public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean {
+ if (cli.isGuest() || !cli.credentials.userId) {
+ return false;
+ }
+ return this.maySendStateEvent(stateEventType, cli.credentials.userId);
+ }
+
+ /**
+ * Returns true if the given user ID has permission to send a state
+ * event of type `stateEventType` into this room.
+ * @param stateEventType - The type of state events to test
+ * @param userId - The user ID of the user to test permission for
+ * @returns true if the given user ID should be permitted to send
+ * the given type of state event into this room,
+ * according to the room's state.
+ */
+ public maySendStateEvent(stateEventType: EventType | string, userId: string): boolean {
+ return this.maySendEventOfType(stateEventType, userId, true);
+ }
+
+ /**
+ * Returns true if the given user ID has permission to send a normal or state
+ * event of type `eventType` into this room.
+ * @param eventType - The type of event to test
+ * @param userId - The user ID of the user to test permission for
+ * @param state - If true, tests if the user may send a state
+ event of this type. Otherwise tests whether
+ they may send a regular event.
+ * @returns true if the given user ID should be permitted to send
+ * the given type of event into this room,
+ * according to the room's state.
+ */
+ private maySendEventOfType(eventType: EventType | string, userId: string, state: boolean): boolean {
+ const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
+
+ let powerLevels: IPowerLevelsContent;
+ let eventsLevels: Record<EventType | string, number> = {};
+
+ let stateDefault = 0;
+ let eventsDefault = 0;
+ let powerLevel = 0;
+ if (powerLevelsEvent) {
+ powerLevels = powerLevelsEvent.getContent();
+ eventsLevels = powerLevels.events || {};
+
+ if (Number.isSafeInteger(powerLevels.state_default)) {
+ stateDefault = powerLevels.state_default!;
+ } else {
+ stateDefault = 50;
+ }
+
+ const userPowerLevel = powerLevels.users && powerLevels.users[userId];
+ if (Number.isSafeInteger(userPowerLevel)) {
+ powerLevel = userPowerLevel!;
+ } else if (Number.isSafeInteger(powerLevels.users_default)) {
+ powerLevel = powerLevels.users_default!;
+ }
+
+ if (Number.isSafeInteger(powerLevels.events_default)) {
+ eventsDefault = powerLevels.events_default!;
+ }
+ }
+
+ let requiredLevel = state ? stateDefault : eventsDefault;
+ if (Number.isSafeInteger(eventsLevels[eventType])) {
+ requiredLevel = eventsLevels[eventType];
+ }
+ return powerLevel >= requiredLevel;
+ }
+
+ /**
+ * Returns true if the given user ID has permission to trigger notification
+ * of type `notifLevelKey`
+ * @param notifLevelKey - The level of notification to test (eg. 'room')
+ * @param userId - The user ID of the user to test permission for
+ * @returns true if the given user ID has permission to trigger a
+ * notification of this type.
+ */
+ public mayTriggerNotifOfType(notifLevelKey: string, userId: string): boolean {
+ const member = this.getMember(userId);
+ if (!member) {
+ return false;
+ }
+
+ const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, "");
+
+ let notifLevel = 50;
+ if (
+ powerLevelsEvent &&
+ powerLevelsEvent.getContent() &&
+ powerLevelsEvent.getContent().notifications &&
+ utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey])
+ ) {
+ notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey];
+ }
+
+ return member.powerLevel >= notifLevel;
+ }
+
+ /**
+ * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`.
+ * @returns the join_rule applied to this room
+ */
+ public getJoinRule(): JoinRule {
+ const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, "");
+ const joinRuleContent: Partial<IJoinRuleEventContent> = joinRuleEvent?.getContent() ?? {};
+ return joinRuleContent["join_rule"] || JoinRule.Invite;
+ }
+
+ /**
+ * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`.
+ * @returns the history_visibility applied to this room
+ */
+ public getHistoryVisibility(): HistoryVisibility {
+ const historyVisibilityEvent = this.getStateEvents(EventType.RoomHistoryVisibility, "");
+ const historyVisibilityContent = historyVisibilityEvent?.getContent() ?? {};
+ return historyVisibilityContent["history_visibility"] || HistoryVisibility.Shared;
+ }
+
+ /**
+ * Returns the guest access based on the m.room.guest_access state event, defaulting to `shared`.
+ * @returns the guest_access applied to this room
+ */
+ public getGuestAccess(): GuestAccess {
+ const guestAccessEvent = this.getStateEvents(EventType.RoomGuestAccess, "");
+ const guestAccessContent = guestAccessEvent?.getContent() ?? {};
+ return guestAccessContent["guest_access"] || GuestAccess.Forbidden;
+ }
+
+ /**
+ * Find the predecessor room based on this room state.
+ *
+ * @param msc3946ProcessDynamicPredecessor - if true, look for an
+ * m.room.predecessor state event and use it if found (MSC3946).
+ * @returns null if this room has no predecessor. Otherwise, returns
+ * the roomId, last eventId and viaServers of the predecessor room.
+ *
+ * If msc3946ProcessDynamicPredecessor is true, use m.predecessor events
+ * as well as m.room.create events to find predecessors.
+ *
+ * Note: if an m.predecessor event is used, eventId may be undefined
+ * since last_known_event_id is optional.
+ *
+ * Note: viaServers may be undefined, and will definitely be undefined if
+ * this predecessor comes from a RoomCreate event (rather than a
+ * RoomPredecessor, which has the optional via_servers property).
+ */
+ public findPredecessor(
+ msc3946ProcessDynamicPredecessor = false,
+ ): { roomId: string; eventId?: string; viaServers?: string[] } | null {
+ // Note: the tests for this function are against Room.findPredecessor,
+ // which just calls through to here.
+
+ if (msc3946ProcessDynamicPredecessor) {
+ const predecessorEvent = this.getStateEvents(EventType.RoomPredecessor, "");
+ if (predecessorEvent) {
+ const content = predecessorEvent.getContent<{
+ predecessor_room_id: string;
+ last_known_event_id?: string;
+ via_servers?: string[];
+ }>();
+ const roomId = content.predecessor_room_id;
+ let eventId = content.last_known_event_id;
+ if (typeof eventId !== "string") {
+ eventId = undefined;
+ }
+ let viaServers = content.via_servers;
+ if (!Array.isArray(viaServers)) {
+ viaServers = undefined;
+ }
+ if (typeof roomId === "string") {
+ return { roomId, eventId, viaServers };
+ }
+ }
+ }
+
+ const createEvent = this.getStateEvents(EventType.RoomCreate, "");
+ if (createEvent) {
+ const predecessor = createEvent.getContent<{
+ predecessor?: Partial<{
+ room_id: string;
+ event_id: string;
+ }>;
+ }>()["predecessor"];
+ if (predecessor) {
+ const roomId = predecessor["room_id"];
+ if (typeof roomId === "string") {
+ let eventId = predecessor["event_id"];
+ if (typeof eventId !== "string" || eventId === "") {
+ eventId = undefined;
+ }
+ return { roomId, eventId };
+ }
+ }
+ }
+ return null;
+ }
+
+ private updateThirdPartyTokenCache(memberEvent: MatrixEvent): void {
+ if (!memberEvent.getContent().third_party_invite) {
+ return;
+ }
+ const token = (memberEvent.getContent().third_party_invite.signed || {}).token;
+ if (!token) {
+ return;
+ }
+ const threePidInvite = this.getStateEvents(EventType.RoomThirdPartyInvite, token);
+ if (!threePidInvite) {
+ return;
+ }
+ this.tokenToInvite[token] = memberEvent;
+ }
+
+ private updateDisplayNameCache(userId: string, displayName: string): void {
+ const oldName = this.userIdsToDisplayNames[userId];
+ delete this.userIdsToDisplayNames[userId];
+ if (oldName) {
+ // Remove the old name from the cache.
+ // We clobber the user_id > name lookup but the name -> [user_id] lookup
+ // means we need to remove that user ID from that array rather than nuking
+ // the lot.
+ const strippedOldName = utils.removeHiddenChars(oldName);
+
+ const existingUserIds = this.displayNameToUserIds.get(strippedOldName);
+ if (existingUserIds) {
+ // remove this user ID from this array
+ const filteredUserIDs = existingUserIds.filter((id) => id !== userId);
+ this.displayNameToUserIds.set(strippedOldName, filteredUserIDs);
+ }
+ }
+
+ this.userIdsToDisplayNames[userId] = displayName;
+
+ const strippedDisplayname = displayName && utils.removeHiddenChars(displayName);
+ // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js
+ if (strippedDisplayname) {
+ const arr = this.displayNameToUserIds.get(strippedDisplayname) ?? [];
+ arr.push(userId);
+ this.displayNameToUserIds.set(strippedDisplayname, arr);
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-summary.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-summary.ts
new file mode 100644
index 0000000..936ec1d
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room-summary.ts
@@ -0,0 +1,44 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export interface IRoomSummary {
+ "m.heroes": string[];
+ "m.joined_member_count"?: number;
+ "m.invited_member_count"?: number;
+}
+
+interface IInfo {
+ /** The title of the room (e.g. `m.room.name`) */
+ title: string;
+ /** The description of the room (e.g. `m.room.topic`) */
+ desc?: string;
+ /** The number of joined users. */
+ numMembers?: number;
+ /** The list of aliases for this room. */
+ aliases?: string[];
+ /** The timestamp for this room. */
+ timestamp?: number;
+}
+
+/**
+ * Construct a new Room Summary. A summary can be used for display on a recent
+ * list, without having to load the entire room list into memory.
+ * @param roomId - Required. The ID of this room.
+ * @param info - Optional. The summary info. Additional keys are supported.
+ */
+export class RoomSummary {
+ public constructor(public readonly roomId: string, info?: IInfo) {}
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts
new file mode 100644
index 0000000..133b210
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/room.ts
@@ -0,0 +1,3487 @@
+/*
+Copyright 2015 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { M_POLL_START, Optional } from "matrix-events-sdk";
+
+import {
+ EventTimelineSet,
+ DuplicateStrategy,
+ IAddLiveEventOptions,
+ EventTimelineSetHandlerMap,
+} from "./event-timeline-set";
+import { Direction, EventTimeline } from "./event-timeline";
+import { getHttpUriForMxc } from "../content-repo";
+import * as utils from "../utils";
+import { normalize, noUnsafeEventProps } from "../utils";
+import { IEvent, IThreadBundledRelationship, MatrixEvent, MatrixEventEvent, MatrixEventHandlerMap } from "./event";
+import { EventStatus } from "./event-status";
+import { RoomMember } from "./room-member";
+import { IRoomSummary, RoomSummary } from "./room-summary";
+import { logger } from "../logger";
+import { TypedReEmitter } from "../ReEmitter";
+import {
+ EventType,
+ RoomCreateTypeField,
+ RoomType,
+ UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
+ EVENT_VISIBILITY_CHANGE_TYPE,
+ RelationType,
+} from "../@types/event";
+import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
+import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
+import { Filter, IFilterDefinition } from "../filter";
+import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state";
+import { BeaconEvent, BeaconEventHandlerMap } from "./beacon";
+import {
+ Thread,
+ ThreadEvent,
+ EventHandlerMap as ThreadHandlerMap,
+ FILTER_RELATED_BY_REL_TYPES,
+ THREAD_RELATION_TYPE,
+ FILTER_RELATED_BY_SENDERS,
+ ThreadFilterType,
+} from "./thread";
+import {
+ CachedReceiptStructure,
+ MAIN_ROOM_TIMELINE,
+ Receipt,
+ ReceiptContent,
+ ReceiptType,
+} from "../@types/read_receipts";
+import { IStateEventWithRoomId } from "../@types/search";
+import { RelationsContainer } from "./relations-container";
+import { ReadReceipt, synthesizeReceipt } from "./read-receipt";
+import { Poll, PollEvent } from "./poll";
+
+// 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 same as the common default room version whereas SAFE_ROOM_VERSIONS are the
+// room versions which are considered okay for people to run without being asked
+// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers
+// return an m.room_versions capability.
+export const KNOWN_SAFE_ROOM_VERSION = "9";
+const SAFE_ROOM_VERSIONS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
+
+interface IOpts {
+ /**
+ * Controls where pending messages appear in a room's timeline.
+ * If "<b>chronological</b>", messages will appear in the timeline when the call to `sendEvent` was made.
+ * If "<b>detached</b>", pending messages will appear in a separate list,
+ * accessible via {@link Room#getPendingEvents}.
+ * Default: "chronological".
+ */
+ pendingEventOrdering?: PendingEventOrdering;
+ /**
+ * Set to true to enable improved timeline support.
+ */
+ timelineSupport?: boolean;
+ lazyLoadMembers?: boolean;
+}
+
+export interface IRecommendedVersion {
+ version: string;
+ needsUpgrade: boolean;
+ urgent: boolean;
+}
+
+// When inserting a visibility event affecting event `eventId`, we
+// need to scan through existing visibility events for `eventId`.
+// In theory, this could take an unlimited amount of time if:
+//
+// - the visibility event was sent by a moderator; and
+// - `eventId` already has many visibility changes (usually, it should
+// be 2 or less); and
+// - for some reason, the visibility changes are received out of order
+// (usually, this shouldn't happen at all).
+//
+// For this reason, we limit the number of events to scan through,
+// expecting that a broken visibility change for a single event in
+// an extremely uncommon case (possibly a DoS) is a small
+// price to pay to keep matrix-js-sdk responsive.
+const MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH = 30;
+
+export type NotificationCount = Partial<Record<NotificationCountType, number>>;
+
+export enum NotificationCountType {
+ Highlight = "highlight",
+ Total = "total",
+}
+
+export interface ICreateFilterOpts {
+ // Populate the filtered timeline with already loaded events in the room
+ // timeline. Useful to disable for some filters that can't be achieved by the
+ // client in an efficient manner
+ prepopulateTimeline?: boolean;
+ useSyncEvents?: boolean;
+ pendingEvents?: boolean;
+}
+
+export enum RoomEvent {
+ MyMembership = "Room.myMembership",
+ Tags = "Room.tags",
+ AccountData = "Room.accountData",
+ Receipt = "Room.receipt",
+ Name = "Room.name",
+ Redaction = "Room.redaction",
+ RedactionCancelled = "Room.redactionCancelled",
+ LocalEchoUpdated = "Room.localEchoUpdated",
+ Timeline = "Room.timeline",
+ TimelineReset = "Room.timelineReset",
+ TimelineRefresh = "Room.TimelineRefresh",
+ OldStateUpdated = "Room.OldStateUpdated",
+ CurrentStateUpdated = "Room.CurrentStateUpdated",
+ HistoryImportedWithinTimeline = "Room.historyImportedWithinTimeline",
+ UnreadNotifications = "Room.UnreadNotifications",
+}
+
+export type RoomEmittedEvents =
+ | RoomEvent
+ | RoomStateEvent.Events
+ | RoomStateEvent.Members
+ | RoomStateEvent.NewMember
+ | RoomStateEvent.Update
+ | RoomStateEvent.Marker
+ | ThreadEvent.New
+ | ThreadEvent.Update
+ | ThreadEvent.NewReply
+ | ThreadEvent.Delete
+ | MatrixEventEvent.BeforeRedaction
+ | BeaconEvent.New
+ | BeaconEvent.Update
+ | BeaconEvent.Destroy
+ | BeaconEvent.LivenessChange
+ | PollEvent.New;
+
+export type RoomEventHandlerMap = {
+ /**
+ * Fires when the logged in user's membership in the room is updated.
+ *
+ * @param room - The room in which the membership has been updated
+ * @param membership - The new membership value
+ * @param prevMembership - The previous membership value
+ */
+ [RoomEvent.MyMembership]: (room: Room, membership: string, prevMembership?: string) => void;
+ /**
+ * Fires whenever a room's tags are updated.
+ * @param event - The tags event
+ * @param room - The room whose Room.tags was updated.
+ * @example
+ * ```
+ * matrixClient.on("Room.tags", function(event, room){
+ * var newTags = event.getContent().tags;
+ * if (newTags["favourite"]) showStar(room);
+ * });
+ * ```
+ */
+ [RoomEvent.Tags]: (event: MatrixEvent, room: Room) => void;
+ /**
+ * Fires whenever a room's account_data is updated.
+ * @param event - The account_data event
+ * @param room - The room whose account_data was updated.
+ * @param prevEvent - The event being replaced by
+ * the new account data, if known.
+ * @example
+ * ```
+ * matrixClient.on("Room.accountData", function(event, room, oldEvent){
+ * if (event.getType() === "m.room.colorscheme") {
+ * applyColorScheme(event.getContents());
+ * }
+ * });
+ * ```
+ */
+ [RoomEvent.AccountData]: (event: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => void;
+ /**
+ * Fires whenever a receipt is received for a room
+ * @param event - The receipt event
+ * @param room - The room whose receipts was updated.
+ * @example
+ * ```
+ * matrixClient.on("Room.receipt", function(event, room){
+ * var receiptContent = event.getContent();
+ * });
+ * ```
+ */
+ [RoomEvent.Receipt]: (event: MatrixEvent, room: Room) => void;
+ /**
+ * Fires whenever the name of a room is updated.
+ * @param room - The room whose Room.name was updated.
+ * @example
+ * ```
+ * matrixClient.on("Room.name", function(room){
+ * var newName = room.name;
+ * });
+ * ```
+ */
+ [RoomEvent.Name]: (room: Room) => void;
+ /**
+ * Fires when an event we had previously received is redacted.
+ *
+ * (Note this is *not* fired when the redaction happens before we receive the
+ * event).
+ *
+ * @param event - The matrix redaction event
+ * @param room - The room containing the redacted event
+ */
+ [RoomEvent.Redaction]: (event: MatrixEvent, room: Room) => void;
+ /**
+ * Fires when an event that was previously redacted isn't anymore.
+ * This happens when the redaction couldn't be sent and
+ * was subsequently cancelled by the user. Redactions have a local echo
+ * which is undone in this scenario.
+ *
+ * @param event - The matrix redaction event that was cancelled.
+ * @param room - The room containing the unredacted event
+ */
+ [RoomEvent.RedactionCancelled]: (event: MatrixEvent, room: Room) => void;
+ /**
+ * Fires when the status of a transmitted event is updated.
+ *
+ * <p>When an event is first transmitted, a temporary copy of the event is
+ * inserted into the timeline, with a temporary event id, and a status of
+ * 'SENDING'.
+ *
+ * <p>Once the echo comes back from the server, the content of the event
+ * (MatrixEvent.event) is replaced by the complete event from the homeserver,
+ * thus updating its event id, as well as server-generated fields such as the
+ * timestamp. Its status is set to null.
+ *
+ * <p>Once the /send request completes, if the remote echo has not already
+ * arrived, the event is updated with a new event id and the status is set to
+ * 'SENT'. The server-generated fields are of course not updated yet.
+ *
+ * <p>If the /send fails, In this case, the event's status is set to
+ * 'NOT_SENT'. If it is later resent, the process starts again, setting the
+ * status to 'SENDING'. Alternatively, the message may be cancelled, which
+ * removes the event from the room, and sets the status to 'CANCELLED'.
+ *
+ * <p>This event is raised to reflect each of the transitions above.
+ *
+ * @param event - The matrix event which has been updated
+ *
+ * @param room - The room containing the redacted event
+ *
+ * @param oldEventId - The previous event id (the temporary event id,
+ * except when updating a successfully-sent event when its echo arrives)
+ *
+ * @param oldStatus - The previous event status.
+ */
+ [RoomEvent.LocalEchoUpdated]: (
+ event: MatrixEvent,
+ room: Room,
+ oldEventId?: string,
+ oldStatus?: EventStatus | null,
+ ) => void;
+ [RoomEvent.OldStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void;
+ [RoomEvent.CurrentStateUpdated]: (room: Room, previousRoomState: RoomState, roomState: RoomState) => void;
+ [RoomEvent.HistoryImportedWithinTimeline]: (markerEvent: MatrixEvent, room: Room) => void;
+ [RoomEvent.UnreadNotifications]: (unreadNotifications?: NotificationCount, threadId?: string) => void;
+ [RoomEvent.TimelineRefresh]: (room: Room, eventTimelineSet: EventTimelineSet) => void;
+ [ThreadEvent.New]: (thread: Thread, toStartOfTimeline: boolean) => void;
+ /**
+ * Fires when a new poll instance is added to the room state
+ * @param poll - the new poll
+ */
+ [PollEvent.New]: (poll: Poll) => void;
+} & Pick<ThreadHandlerMap, ThreadEvent.Update | ThreadEvent.NewReply | ThreadEvent.Delete> &
+ EventTimelineSetHandlerMap &
+ Pick<MatrixEventHandlerMap, MatrixEventEvent.BeforeRedaction> &
+ Pick<
+ RoomStateEventHandlerMap,
+ | RoomStateEvent.Events
+ | RoomStateEvent.Members
+ | RoomStateEvent.NewMember
+ | RoomStateEvent.Update
+ | RoomStateEvent.Marker
+ | BeaconEvent.New
+ > &
+ Pick<BeaconEventHandlerMap, BeaconEvent.Update | BeaconEvent.Destroy | BeaconEvent.LivenessChange>;
+
+export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
+ public readonly reEmitter: TypedReEmitter<RoomEmittedEvents, RoomEventHandlerMap>;
+ private txnToEvent: Map<string, MatrixEvent> = new Map(); // Pending in-flight requests { string: MatrixEvent }
+ private notificationCounts: NotificationCount = {};
+ private readonly threadNotifications = new Map<string, NotificationCount>();
+ public readonly cachedThreadReadReceipts = new Map<string, CachedReceiptStructure[]>();
+ // Useful to know at what point the current user has started using threads in this room
+ private oldestThreadedReceiptTs = Infinity;
+ /**
+ * A record of the latest unthread receipts per user
+ * This is useful in determining whether a user has read a thread or not
+ */
+ private unthreadedReceipts = new Map<string, Receipt>();
+ private readonly timelineSets: EventTimelineSet[];
+ public readonly polls: Map<string, Poll> = new Map<string, Poll>();
+ public readonly threadsTimelineSets: EventTimelineSet[] = [];
+ // any filtered timeline sets we're maintaining for this room
+ private readonly filteredTimelineSets: Record<string, EventTimelineSet> = {}; // filter_id: timelineSet
+ private timelineNeedsRefresh = false;
+ private readonly pendingEventList?: MatrixEvent[];
+ // read by megolm via getter; boolean value - null indicates "use global value"
+ private blacklistUnverifiedDevices?: boolean;
+ private selfMembership?: string;
+ private summaryHeroes: string[] | null = null;
+ // flags to stop logspam about missing m.room.create events
+ private getTypeWarning = false;
+ private getVersionWarning = false;
+ private membersPromise?: Promise<boolean>;
+
+ // XXX: These should be read-only
+ /**
+ * The human-readable display name for this room.
+ */
+ public name: string;
+ /**
+ * The un-homoglyphed name for this room.
+ */
+ public normalizedName: string;
+ /**
+ * Dict of room tags; the keys are the tag name and the values
+ * are any metadata associated with the tag - e.g. `{ "fav" : { order: 1 } }`
+ */
+ public tags: Record<string, Record<string, any>> = {}; // $tagName: { $metadata: $value }
+ /**
+ * accountData Dict of per-room account_data events; the keys are the
+ * event type and the values are the events.
+ */
+ public accountData: Map<string, MatrixEvent> = new Map(); // $eventType: $event
+ /**
+ * The room summary.
+ */
+ public summary: RoomSummary | null = null;
+ // legacy fields
+ /**
+ * The live event timeline for this room, with the oldest event at index 0.
+ * Present for backwards compatibility - prefer getLiveTimeline().getEvents()
+ */
+ public timeline!: MatrixEvent[];
+ /**
+ * oldState The state of the room at the time of the oldest
+ * event in the live timeline. Present for backwards compatibility -
+ * prefer getLiveTimeline().getState(EventTimeline.BACKWARDS).
+ */
+ public oldState!: RoomState;
+ /**
+ * currentState The state of the room at the time of the
+ * newest event in the timeline. Present for backwards compatibility -
+ * prefer getLiveTimeline().getState(EventTimeline.FORWARDS).
+ */
+ public currentState!: RoomState;
+ public readonly relations = new RelationsContainer(this.client, this);
+
+ /**
+ * A collection of events known by the client
+ * This is not a comprehensive list of the threads that exist in this room
+ */
+ private threads = new Map<string, Thread>();
+ public lastThread?: Thread;
+
+ /**
+ * A mapping of eventId to all visibility changes to apply
+ * to the event, by chronological order, as per
+ * https://github.com/matrix-org/matrix-doc/pull/3531
+ *
+ * # Invariants
+ *
+ * - within each list, all events are classed by
+ * chronological order;
+ * - all events are events such that
+ * `asVisibilityEvent()` returns a non-null `IVisibilityChange`;
+ * - within each list with key `eventId`, all events
+ * are in relation to `eventId`.
+ *
+ * @experimental
+ */
+ private visibilityEvents = new Map<string, MatrixEvent[]>();
+
+ /**
+ * Construct a new Room.
+ *
+ * <p>For a room, we store an ordered sequence of timelines, which may or may not
+ * be continuous. Each timeline lists a series of events, as well as tracking
+ * the room state at the start and the end of the timeline. It also tracks
+ * forward and backward pagination tokens, as well as containing links to the
+ * next timeline in the sequence.
+ *
+ * <p>There is one special timeline - the 'live' timeline, which represents the
+ * timeline to which events are being added in real-time as they are received
+ * from the /sync API. Note that you should not retain references to this
+ * timeline - even if it is the current timeline right now, it may not remain
+ * so if the server gives us a timeline gap in /sync.
+ *
+ * <p>In order that we can find events from their ids later, we also maintain a
+ * map from event_id to timeline and index.
+ *
+ * @param roomId - Required. The ID of this room.
+ * @param client - Required. The client, used to lazy load members.
+ * @param myUserId - Required. The ID of the syncing user.
+ * @param opts - Configuration options
+ */
+ public constructor(
+ public readonly roomId: string,
+ public readonly client: MatrixClient,
+ public readonly myUserId: string,
+ private readonly opts: IOpts = {},
+ ) {
+ super();
+ // In some cases, we add listeners for every displayed Matrix event, so it's
+ // common to have quite a few more than the default limit.
+ this.setMaxListeners(100);
+ this.reEmitter = new TypedReEmitter(this);
+
+ opts.pendingEventOrdering = opts.pendingEventOrdering || PendingEventOrdering.Chronological;
+
+ this.name = roomId;
+ this.normalizedName = roomId;
+
+ // all our per-room timeline sets. the first one is the unfiltered ones;
+ // the subsequent ones are the filtered ones in no particular order.
+ this.timelineSets = [new EventTimelineSet(this, opts)];
+ this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), [RoomEvent.Timeline, RoomEvent.TimelineReset]);
+
+ this.fixUpLegacyTimelineFields();
+
+ if (this.opts.pendingEventOrdering === PendingEventOrdering.Detached) {
+ this.pendingEventList = [];
+ this.client.store.getPendingEvents(this.roomId).then((events) => {
+ const mapper = this.client.getEventMapper({
+ toDevice: false,
+ decrypt: false,
+ });
+ events.forEach(async (serializedEvent: Partial<IEvent>) => {
+ const event = mapper(serializedEvent);
+ await client.decryptEventIfNeeded(event);
+ event.setStatus(EventStatus.NOT_SENT);
+ this.addPendingEvent(event, event.getTxnId()!);
+ });
+ });
+ }
+
+ // awaited by getEncryptionTargetMembers while room members are loading
+ if (!this.opts.lazyLoadMembers) {
+ this.membersPromise = Promise.resolve(false);
+ } else {
+ this.membersPromise = undefined;
+ }
+ }
+
+ private threadTimelineSetsPromise: Promise<[EventTimelineSet, EventTimelineSet]> | null = null;
+ public async createThreadsTimelineSets(): Promise<[EventTimelineSet, EventTimelineSet] | null> {
+ if (this.threadTimelineSetsPromise) {
+ return this.threadTimelineSetsPromise;
+ }
+
+ if (this.client?.supportsThreads()) {
+ try {
+ this.threadTimelineSetsPromise = Promise.all([
+ this.createThreadTimelineSet(),
+ this.createThreadTimelineSet(ThreadFilterType.My),
+ ]);
+ const timelineSets = await this.threadTimelineSetsPromise;
+ this.threadsTimelineSets.push(...timelineSets);
+ return timelineSets;
+ } catch (e) {
+ this.threadTimelineSetsPromise = null;
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Bulk decrypt critical events in a room
+ *
+ * Critical events represents the minimal set of events to decrypt
+ * for a typical UI to function properly
+ *
+ * - Last event of every room (to generate likely message preview)
+ * - All events up to the read receipt (to calculate an accurate notification count)
+ *
+ * @returns Signals when all events have been decrypted
+ */
+ public async decryptCriticalEvents(): Promise<void> {
+ if (!this.client.isCryptoEnabled()) return;
+
+ const readReceiptEventId = this.getEventReadUpTo(this.client.getUserId()!, true);
+ const events = this.getLiveTimeline().getEvents();
+ const readReceiptTimelineIndex = events.findIndex((matrixEvent) => {
+ return matrixEvent.event.event_id === readReceiptEventId;
+ });
+
+ const decryptionPromises = events
+ .slice(readReceiptTimelineIndex)
+ .reverse()
+ .map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true }));
+
+ await Promise.allSettled(decryptionPromises);
+ }
+
+ /**
+ * Bulk decrypt events in a room
+ *
+ * @returns Signals when all events have been decrypted
+ */
+ public async decryptAllEvents(): Promise<void> {
+ if (!this.client.isCryptoEnabled()) return;
+
+ const decryptionPromises = this.getUnfilteredTimelineSet()
+ .getLiveTimeline()
+ .getEvents()
+ .slice(0) // copy before reversing
+ .reverse()
+ .map((event) => this.client.decryptEventIfNeeded(event, { isRetry: true }));
+
+ await Promise.allSettled(decryptionPromises);
+ }
+
+ /**
+ * Gets the creator of the room
+ * @returns The creator of the room, or null if it could not be determined
+ */
+ public getCreator(): string | null {
+ const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
+ return createEvent?.getContent()["creator"] ?? null;
+ }
+
+ /**
+ * Gets the version of the room
+ * @returns The version of the room, or null if it could not be determined
+ */
+ public getVersion(): string {
+ const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
+ if (!createEvent) {
+ if (!this.getVersionWarning) {
+ logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event");
+ this.getVersionWarning = true;
+ }
+ return "1";
+ }
+ return createEvent.getContent()["room_version"] ?? "1";
+ }
+
+ /**
+ * Determines whether this room needs to be upgraded to a new version
+ * @returns What version the room should be upgraded to, or null if
+ * the room does not require upgrading at this time.
+ * @deprecated Use #getRecommendedVersion() instead
+ */
+ public shouldUpgradeToVersion(): string | null {
+ // TODO: Remove this function.
+ // This makes assumptions about which versions are safe, and can easily
+ // be wrong. Instead, people are encouraged to use getRecommendedVersion
+ // which determines a safer value. This function doesn't use that function
+ // because this is not async-capable, and to avoid breaking the contract
+ // we're deprecating this.
+
+ if (!SAFE_ROOM_VERSIONS.includes(this.getVersion())) {
+ return KNOWN_SAFE_ROOM_VERSION;
+ }
+
+ return null;
+ }
+
+ /**
+ * Determines the recommended room version for the room. This returns an
+ * object with 3 properties: `version` as the new version the
+ * room should be upgraded to (may be the same as the current version);
+ * `needsUpgrade` to indicate if the room actually can be
+ * upgraded (ie: does the current version not match?); and `urgent`
+ * to indicate if the new version patches a vulnerability in a previous
+ * version.
+ * @returns
+ * Resolves to the version the room should be upgraded to.
+ */
+ public async getRecommendedVersion(): Promise<IRecommendedVersion> {
+ const capabilities = await this.client.getCapabilities();
+ let versionCap = capabilities["m.room_versions"];
+ if (!versionCap) {
+ versionCap = {
+ default: KNOWN_SAFE_ROOM_VERSION,
+ available: {},
+ };
+ for (const safeVer of SAFE_ROOM_VERSIONS) {
+ versionCap.available[safeVer] = RoomVersionStability.Stable;
+ }
+ }
+
+ let result = this.checkVersionAgainstCapability(versionCap);
+ if (result.urgent && result.needsUpgrade) {
+ // Something doesn't feel right: we shouldn't need to update
+ // because the version we're on should be in the protocol's
+ // namespace. This usually means that the server was updated
+ // before the client was, making us think the newest possible
+ // room version is not stable. As a solution, we'll refresh
+ // the capability we're using to determine this.
+ logger.warn(
+ "Refreshing room version capability because the server looks " +
+ "to be supporting a newer room version we don't know about.",
+ );
+
+ const caps = await this.client.getCapabilities(true);
+ versionCap = caps["m.room_versions"];
+ if (!versionCap) {
+ logger.warn("No room version capability - assuming upgrade required.");
+ return result;
+ } else {
+ result = this.checkVersionAgainstCapability(versionCap);
+ }
+ }
+
+ return result;
+ }
+
+ private checkVersionAgainstCapability(versionCap: IRoomVersionsCapability): IRecommendedVersion {
+ const currentVersion = this.getVersion();
+ logger.log(`[${this.roomId}] Current version: ${currentVersion}`);
+ logger.log(`[${this.roomId}] Version capability: `, versionCap);
+
+ const result: IRecommendedVersion = {
+ version: currentVersion,
+ needsUpgrade: false,
+ urgent: false,
+ };
+
+ // If the room is on the default version then nothing needs to change
+ if (currentVersion === versionCap.default) return result;
+
+ const stableVersions = Object.keys(versionCap.available).filter((v) => versionCap.available[v] === "stable");
+
+ // Check if the room is on an unstable version. We determine urgency based
+ // off the version being in the Matrix spec namespace or not (if the version
+ // is in the current namespace and unstable, the room is probably vulnerable).
+ if (!stableVersions.includes(currentVersion)) {
+ result.version = versionCap.default;
+ result.needsUpgrade = true;
+ result.urgent = !!this.getVersion().match(/^[0-9]+[0-9.]*$/g);
+ if (result.urgent) {
+ logger.warn(`URGENT upgrade required on ${this.roomId}`);
+ } else {
+ logger.warn(`Non-urgent upgrade required on ${this.roomId}`);
+ }
+ return result;
+ }
+
+ // The room is on a stable, but non-default, version by this point.
+ // No upgrade needed.
+ return result;
+ }
+
+ /**
+ * Determines whether the given user is permitted to perform a room upgrade
+ * @param userId - The ID of the user to test against
+ * @returns True if the given user is permitted to upgrade the room
+ */
+ public userMayUpgradeRoom(userId: string): boolean {
+ return this.currentState.maySendStateEvent(EventType.RoomTombstone, userId);
+ }
+
+ /**
+ * Get the list of pending sent events for this room
+ *
+ * @returns A list of the sent events
+ * waiting for remote echo.
+ *
+ * @throws If `opts.pendingEventOrdering` was not 'detached'
+ */
+ public getPendingEvents(): MatrixEvent[] {
+ if (!this.pendingEventList) {
+ throw new Error(
+ "Cannot call getPendingEvents with pendingEventOrdering == " + this.opts.pendingEventOrdering,
+ );
+ }
+
+ return this.pendingEventList;
+ }
+
+ /**
+ * Removes a pending event for this room
+ *
+ * @returns True if an element was removed.
+ */
+ public removePendingEvent(eventId: string): boolean {
+ if (!this.pendingEventList) {
+ throw new Error(
+ "Cannot call removePendingEvent with pendingEventOrdering == " + this.opts.pendingEventOrdering,
+ );
+ }
+
+ const removed = utils.removeElement(
+ this.pendingEventList,
+ function (ev) {
+ return ev.getId() == eventId;
+ },
+ false,
+ );
+
+ this.savePendingEvents();
+
+ return removed;
+ }
+
+ /**
+ * Check whether the pending event list contains a given event by ID.
+ * If pending event ordering is not "detached" then this returns false.
+ *
+ * @param eventId - The event ID to check for.
+ */
+ public hasPendingEvent(eventId: string): boolean {
+ return this.pendingEventList?.some((event) => event.getId() === eventId) ?? false;
+ }
+
+ /**
+ * Get a specific event from the pending event list, if configured, null otherwise.
+ *
+ * @param eventId - The event ID to check for.
+ */
+ public getPendingEvent(eventId: string): MatrixEvent | null {
+ return this.pendingEventList?.find((event) => event.getId() === eventId) ?? null;
+ }
+
+ /**
+ * Get the live unfiltered timeline for this room.
+ *
+ * @returns live timeline
+ */
+ public getLiveTimeline(): EventTimeline {
+ return this.getUnfilteredTimelineSet().getLiveTimeline();
+ }
+
+ /**
+ * Get the timestamp of the last message in the room
+ *
+ * @returns the timestamp of the last message in the room
+ */
+ public getLastActiveTimestamp(): number {
+ const timeline = this.getLiveTimeline();
+ const events = timeline.getEvents();
+ if (events.length) {
+ const lastEvent = events[events.length - 1];
+ return lastEvent.getTs();
+ } else {
+ return Number.MIN_SAFE_INTEGER;
+ }
+ }
+
+ /**
+ * @returns the membership type (join | leave | invite) for the logged in user
+ */
+ public getMyMembership(): string {
+ return this.selfMembership ?? "leave";
+ }
+
+ /**
+ * If this room is a DM we're invited to,
+ * try to find out who invited us
+ * @returns user id of the inviter
+ */
+ public getDMInviter(): string | undefined {
+ const me = this.getMember(this.myUserId);
+ if (me) {
+ return me.getDMInviter();
+ }
+
+ if (this.selfMembership === "invite") {
+ // fall back to summary information
+ const memberCount = this.getInvitedAndJoinedMemberCount();
+ if (memberCount === 2) {
+ return this.summaryHeroes?.[0];
+ }
+ }
+ }
+
+ /**
+ * Assuming this room is a DM room, tries to guess with which user.
+ * @returns user id of the other member (could be syncing user)
+ */
+ public guessDMUserId(): string {
+ const me = this.getMember(this.myUserId);
+ if (me) {
+ const inviterId = me.getDMInviter();
+ if (inviterId) {
+ return inviterId;
+ }
+ }
+ // Remember, we're assuming this room is a DM, so returning the first member we find should be fine
+ if (Array.isArray(this.summaryHeroes) && this.summaryHeroes.length) {
+ return this.summaryHeroes[0];
+ }
+ const members = this.currentState.getMembers();
+ const anyMember = members.find((m) => m.userId !== this.myUserId);
+ if (anyMember) {
+ return anyMember.userId;
+ }
+ // it really seems like I'm the only user in the room
+ // so I probably created a room with just me in it
+ // and marked it as a DM. Ok then
+ return this.myUserId;
+ }
+
+ public getAvatarFallbackMember(): RoomMember | undefined {
+ const memberCount = this.getInvitedAndJoinedMemberCount();
+ if (memberCount > 2) {
+ return;
+ }
+ const hasHeroes = Array.isArray(this.summaryHeroes) && this.summaryHeroes.length;
+ if (hasHeroes) {
+ const availableMember = this.summaryHeroes!.map((userId) => {
+ return this.getMember(userId);
+ }).find((member) => !!member);
+ if (availableMember) {
+ return availableMember;
+ }
+ }
+ const members = this.currentState.getMembers();
+ // could be different than memberCount
+ // as this includes left members
+ if (members.length <= 2) {
+ const availableMember = members.find((m) => {
+ return m.userId !== this.myUserId;
+ });
+ if (availableMember) {
+ return availableMember;
+ }
+ }
+ // if all else fails, try falling back to a user,
+ // and create a one-off member for it
+ if (hasHeroes) {
+ const availableUser = this.summaryHeroes!.map((userId) => {
+ return this.client.getUser(userId);
+ }).find((user) => !!user);
+ if (availableUser) {
+ const member = new RoomMember(this.roomId, availableUser.userId);
+ member.user = availableUser;
+ return member;
+ }
+ }
+ }
+
+ /**
+ * Sets the membership this room was received as during sync
+ * @param membership - join | leave | invite
+ */
+ public updateMyMembership(membership: string): void {
+ const prevMembership = this.selfMembership;
+ this.selfMembership = membership;
+ if (prevMembership !== membership) {
+ if (membership === "leave") {
+ this.cleanupAfterLeaving();
+ }
+ this.emit(RoomEvent.MyMembership, this, membership, prevMembership);
+ }
+ }
+
+ private async loadMembersFromServer(): Promise<IStateEventWithRoomId[]> {
+ const lastSyncToken = this.client.store.getSyncToken();
+ const response = await this.client.members(this.roomId, undefined, "leave", lastSyncToken ?? undefined);
+ return response.chunk;
+ }
+
+ private async loadMembers(): Promise<{ memberEvents: MatrixEvent[]; fromServer: boolean }> {
+ // were the members loaded from the server?
+ let fromServer = false;
+ let rawMembersEvents = await this.client.store.getOutOfBandMembers(this.roomId);
+ // If the room is encrypted, we always fetch members from the server at
+ // least once, in case the latest state wasn't persisted properly. Note
+ // that this function is only called once (unless loading the members
+ // fails), since loadMembersIfNeeded always returns this.membersPromise
+ // if set, which will be the result of the first (successful) call.
+ if (rawMembersEvents === null || (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId))) {
+ fromServer = true;
+ rawMembersEvents = await this.loadMembersFromServer();
+ logger.log(`LL: got ${rawMembersEvents.length} ` + `members from server for room ${this.roomId}`);
+ }
+ const memberEvents = rawMembersEvents.filter(noUnsafeEventProps).map(this.client.getEventMapper());
+ return { memberEvents, fromServer };
+ }
+
+ /**
+ * Check if loading of out-of-band-members has completed
+ *
+ * @returns true if the full membership list of this room has been loaded (including if lazy-loading is disabled).
+ * False if the load is not started or is in progress.
+ */
+ public membersLoaded(): boolean {
+ if (!this.opts.lazyLoadMembers) {
+ return true;
+ }
+
+ return this.currentState.outOfBandMembersReady();
+ }
+
+ /**
+ * Preloads the member list in case lazy loading
+ * of memberships is in use. Can be called multiple times,
+ * it will only preload once.
+ * @returns when preloading is done and
+ * accessing the members on the room will take
+ * all members in the room into account
+ */
+ public loadMembersIfNeeded(): Promise<boolean> {
+ if (this.membersPromise) {
+ return this.membersPromise;
+ }
+
+ // mark the state so that incoming messages while
+ // the request is in flight get marked as superseding
+ // the OOB members
+ this.currentState.markOutOfBandMembersStarted();
+
+ const inMemoryUpdate = this.loadMembers()
+ .then((result) => {
+ this.currentState.setOutOfBandMembers(result.memberEvents);
+ return result.fromServer;
+ })
+ .catch((err) => {
+ // allow retries on fail
+ this.membersPromise = undefined;
+ this.currentState.markOutOfBandMembersFailed();
+ throw err;
+ });
+ // update members in storage, but don't wait for it
+ inMemoryUpdate
+ .then((fromServer) => {
+ if (fromServer) {
+ const oobMembers = this.currentState
+ .getMembers()
+ .filter((m) => m.isOutOfBand())
+ .map((m) => m.events.member?.event as IStateEventWithRoomId);
+ logger.log(`LL: telling store to write ${oobMembers.length}` + ` members for room ${this.roomId}`);
+ const store = this.client.store;
+ return (
+ store
+ .setOutOfBandMembers(this.roomId, oobMembers)
+ // swallow any IDB error as we don't want to fail
+ // because of this
+ .catch((err) => {
+ logger.log("LL: storing OOB room members failed, oh well", err);
+ })
+ );
+ }
+ })
+ .catch((err) => {
+ // as this is not awaited anywhere,
+ // at least show the error in the console
+ logger.error(err);
+ });
+
+ this.membersPromise = inMemoryUpdate;
+
+ return this.membersPromise;
+ }
+
+ /**
+ * Removes the lazily loaded members from storage if needed
+ */
+ public async clearLoadedMembersIfNeeded(): Promise<void> {
+ if (this.opts.lazyLoadMembers && this.membersPromise) {
+ await this.loadMembersIfNeeded();
+ await this.client.store.clearOutOfBandMembers(this.roomId);
+ this.currentState.clearOutOfBandMembers();
+ this.membersPromise = undefined;
+ }
+ }
+
+ /**
+ * called when sync receives this room in the leave section
+ * to do cleanup after leaving a room. Possibly called multiple times.
+ */
+ private cleanupAfterLeaving(): void {
+ this.clearLoadedMembersIfNeeded().catch((err) => {
+ logger.error(`error after clearing loaded members from ` + `room ${this.roomId} after leaving`);
+ logger.log(err);
+ });
+ }
+
+ /**
+ * Empty out the current live timeline and re-request it. This is used when
+ * historical messages are imported into the room via MSC2716 `/batch_send`
+ * because the client may already have that section of the timeline loaded.
+ * We need to force the client to throw away their current timeline so that
+ * when they back paginate over the area again with the historical messages
+ * in between, it grabs the newly imported messages. We can listen for
+ * `UNSTABLE_MSC2716_MARKER`, in order to tell when historical messages are ready
+ * to be discovered in the room and the timeline needs a refresh. The SDK
+ * emits a `RoomEvent.HistoryImportedWithinTimeline` event when we detect a
+ * valid marker and can check the needs refresh status via
+ * `room.getTimelineNeedsRefresh()`.
+ */
+ public async refreshLiveTimeline(): Promise<void> {
+ const liveTimelineBefore = this.getLiveTimeline();
+ const forwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.FORWARDS);
+ const backwardPaginationToken = liveTimelineBefore.getPaginationToken(EventTimeline.BACKWARDS);
+ const eventsBefore = liveTimelineBefore.getEvents();
+ const mostRecentEventInTimeline = eventsBefore[eventsBefore.length - 1];
+ logger.log(
+ `[refreshLiveTimeline for ${this.roomId}] at ` +
+ `mostRecentEventInTimeline=${mostRecentEventInTimeline && mostRecentEventInTimeline.getId()} ` +
+ `liveTimelineBefore=${liveTimelineBefore.toString()} ` +
+ `forwardPaginationToken=${forwardPaginationToken} ` +
+ `backwardPaginationToken=${backwardPaginationToken}`,
+ );
+
+ // Get the main TimelineSet
+ const timelineSet = this.getUnfilteredTimelineSet();
+
+ let newTimeline: Optional<EventTimeline>;
+ // If there isn't any event in the timeline, let's go fetch the latest
+ // event and construct a timeline from it.
+ //
+ // This should only really happen if the user ran into an error
+ // with refreshing the timeline before which left them in a blank
+ // timeline from `resetLiveTimeline`.
+ if (!mostRecentEventInTimeline) {
+ newTimeline = await this.client.getLatestTimeline(timelineSet);
+ } else {
+ // Empty out all of `this.timelineSets`. But we also need to keep the
+ // same `timelineSet` references around so the React code updates
+ // properly and doesn't ignore the room events we emit because it checks
+ // that the `timelineSet` references are the same. We need the
+ // `timelineSet` empty so that the `client.getEventTimeline(...)` call
+ // later, will call `/context` and create a new timeline instead of
+ // returning the same one.
+ this.resetLiveTimeline(null, null);
+
+ // Make the UI timeline show the new blank live timeline we just
+ // reset so that if the network fails below it's showing the
+ // accurate state of what we're working with instead of the
+ // disconnected one in the TimelineWindow which is just hanging
+ // around by reference.
+ this.emit(RoomEvent.TimelineRefresh, this, timelineSet);
+
+ // Use `client.getEventTimeline(...)` to construct a new timeline from a
+ // `/context` response state and events for the most recent event before
+ // we reset everything. The `timelineSet` we pass in needs to be empty
+ // in order for this function to call `/context` and generate a new
+ // timeline.
+ newTimeline = await this.client.getEventTimeline(timelineSet, mostRecentEventInTimeline.getId()!);
+ }
+
+ // If a racing `/sync` beat us to creating a new timeline, use that
+ // instead because it's the latest in the room and any new messages in
+ // the scrollback will include the history.
+ const liveTimeline = timelineSet.getLiveTimeline();
+ if (
+ !liveTimeline ||
+ (liveTimeline.getPaginationToken(Direction.Forward) === null &&
+ liveTimeline.getPaginationToken(Direction.Backward) === null &&
+ liveTimeline.getEvents().length === 0)
+ ) {
+ logger.log(`[refreshLiveTimeline for ${this.roomId}] using our new live timeline`);
+ // Set the pagination token back to the live sync token (`null`) instead
+ // of using the `/context` historical token (ex. `t12-13_0_0_0_0_0_0_0_0`)
+ // so that it matches the next response from `/sync` and we can properly
+ // continue the timeline.
+ newTimeline!.setPaginationToken(forwardPaginationToken, EventTimeline.FORWARDS);
+
+ // Set our new fresh timeline as the live timeline to continue syncing
+ // forwards and back paginating from.
+ timelineSet.setLiveTimeline(newTimeline!);
+ // Fixup `this.oldstate` so that `scrollback` has the pagination tokens
+ // available
+ this.fixUpLegacyTimelineFields();
+ } else {
+ logger.log(
+ `[refreshLiveTimeline for ${this.roomId}] \`/sync\` or some other request beat us to creating a new ` +
+ `live timeline after we reset it. We'll use that instead since any events in the scrollback from ` +
+ `this timeline will include the history.`,
+ );
+ }
+
+ // The timeline has now been refreshed ✅
+ this.setTimelineNeedsRefresh(false);
+
+ // Emit an event which clients can react to and re-load the timeline
+ // from the SDK
+ this.emit(RoomEvent.TimelineRefresh, this, timelineSet);
+ }
+
+ /**
+ * Reset the live timeline of all timelineSets, and start new ones.
+ *
+ * <p>This is used when /sync returns a 'limited' timeline.
+ *
+ * @param backPaginationToken - token for back-paginating the new timeline
+ * @param forwardPaginationToken - token for forward-paginating the old live timeline,
+ * if absent or null, all timelines are reset, removing old ones (including the previous live
+ * timeline which would otherwise be unable to paginate forwards without this token).
+ * Removing just the old live timeline whilst preserving previous ones is not supported.
+ */
+ public resetLiveTimeline(backPaginationToken?: string | null, forwardPaginationToken?: string | null): void {
+ for (const timelineSet of this.timelineSets) {
+ timelineSet.resetLiveTimeline(backPaginationToken ?? undefined, forwardPaginationToken ?? undefined);
+ }
+ for (const thread of this.threads.values()) {
+ thread.resetLiveTimeline(backPaginationToken, forwardPaginationToken);
+ }
+
+ this.fixUpLegacyTimelineFields();
+ }
+
+ /**
+ * Fix up this.timeline, this.oldState and this.currentState
+ *
+ * @internal
+ */
+ private fixUpLegacyTimelineFields(): void {
+ const previousOldState = this.oldState;
+ const previousCurrentState = this.currentState;
+
+ // maintain this.timeline as a reference to the live timeline,
+ // and this.oldState and this.currentState as references to the
+ // state at the start and end of that timeline. These are more
+ // for backwards-compatibility than anything else.
+ this.timeline = this.getLiveTimeline().getEvents();
+ this.oldState = this.getLiveTimeline().getState(EventTimeline.BACKWARDS)!;
+ this.currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
+
+ // Let people know to register new listeners for the new state
+ // references. The reference won't necessarily change every time so only
+ // emit when we see a change.
+ if (previousOldState !== this.oldState) {
+ this.emit(RoomEvent.OldStateUpdated, this, previousOldState, this.oldState);
+ }
+
+ if (previousCurrentState !== this.currentState) {
+ this.emit(RoomEvent.CurrentStateUpdated, this, previousCurrentState, this.currentState);
+
+ // Re-emit various events on the current room state
+ // TODO: If currentState really only exists for backwards
+ // compatibility, shouldn't we be doing this some other way?
+ this.reEmitter.stopReEmitting(previousCurrentState, [
+ RoomStateEvent.Events,
+ RoomStateEvent.Members,
+ RoomStateEvent.NewMember,
+ RoomStateEvent.Update,
+ RoomStateEvent.Marker,
+ BeaconEvent.New,
+ BeaconEvent.Update,
+ BeaconEvent.Destroy,
+ BeaconEvent.LivenessChange,
+ ]);
+ this.reEmitter.reEmit(this.currentState, [
+ RoomStateEvent.Events,
+ RoomStateEvent.Members,
+ RoomStateEvent.NewMember,
+ RoomStateEvent.Update,
+ RoomStateEvent.Marker,
+ BeaconEvent.New,
+ BeaconEvent.Update,
+ BeaconEvent.Destroy,
+ BeaconEvent.LivenessChange,
+ ]);
+ }
+ }
+
+ /**
+ * Returns whether there are any devices in the room that are unverified
+ *
+ * Note: Callers should first check if crypto is enabled on this device. If it is
+ * disabled, then we aren't tracking room devices at all, so we can't answer this, and an
+ * error will be thrown.
+ *
+ * @returns the result
+ */
+ public async hasUnverifiedDevices(): Promise<boolean> {
+ if (!this.client.isRoomEncrypted(this.roomId)) {
+ return false;
+ }
+ const e2eMembers = await this.getEncryptionTargetMembers();
+ for (const member of e2eMembers) {
+ const devices = this.client.getStoredDevicesForUser(member.userId);
+ if (devices.some((device) => device.isUnverified())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Return the timeline sets for this room.
+ * @returns array of timeline sets for this room
+ */
+ public getTimelineSets(): EventTimelineSet[] {
+ return this.timelineSets;
+ }
+
+ /**
+ * Helper to return the main unfiltered timeline set for this room
+ * @returns room's unfiltered timeline set
+ */
+ public getUnfilteredTimelineSet(): EventTimelineSet {
+ return this.timelineSets[0];
+ }
+
+ /**
+ * Get the timeline which contains the given event from the unfiltered set, if any
+ *
+ * @param eventId - event ID to look for
+ * @returns timeline containing
+ * the given event, or null if unknown
+ */
+ public getTimelineForEvent(eventId: string): EventTimeline | null {
+ const event = this.findEventById(eventId);
+ const thread = this.findThreadForEvent(event);
+ if (thread) {
+ return thread.timelineSet.getTimelineForEvent(eventId);
+ } else {
+ return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId);
+ }
+ }
+
+ /**
+ * Add a new timeline to this room's unfiltered timeline set
+ *
+ * @returns newly-created timeline
+ */
+ public addTimeline(): EventTimeline {
+ return this.getUnfilteredTimelineSet().addTimeline();
+ }
+
+ /**
+ * Whether the timeline needs to be refreshed in order to pull in new
+ * historical messages that were imported.
+ * @param value - The value to set
+ */
+ public setTimelineNeedsRefresh(value: boolean): void {
+ this.timelineNeedsRefresh = value;
+ }
+
+ /**
+ * Whether the timeline needs to be refreshed in order to pull in new
+ * historical messages that were imported.
+ * @returns .
+ */
+ public getTimelineNeedsRefresh(): boolean {
+ return this.timelineNeedsRefresh;
+ }
+
+ /**
+ * Get an event which is stored in our unfiltered timeline set, or in a thread
+ *
+ * @param eventId - event ID to look for
+ * @returns the given event, or undefined if unknown
+ */
+ public findEventById(eventId: string): MatrixEvent | undefined {
+ let event = this.getUnfilteredTimelineSet().findEventById(eventId);
+
+ if (!event) {
+ const threads = this.getThreads();
+ for (let i = 0; i < threads.length; i++) {
+ const thread = threads[i];
+ event = thread.findEventById(eventId);
+ if (event) {
+ return event;
+ }
+ }
+ }
+
+ return event;
+ }
+
+ /**
+ * Get one of the notification counts for this room
+ * @param type - The type of notification count to get. default: 'total'
+ * @returns The notification count, or undefined if there is no count
+ * for this type.
+ */
+ public getUnreadNotificationCount(type = NotificationCountType.Total): number {
+ let count = this.getRoomUnreadNotificationCount(type);
+ for (const threadNotification of this.threadNotifications.values()) {
+ count += threadNotification[type] ?? 0;
+ }
+ return count;
+ }
+
+ /**
+ * Get the notification for the event context (room or thread timeline)
+ */
+ public getUnreadCountForEventContext(type = NotificationCountType.Total, event: MatrixEvent): number {
+ const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
+
+ return (
+ (isThreadEvent
+ ? this.getThreadUnreadNotificationCount(event.threadRootId, type)
+ : this.getRoomUnreadNotificationCount(type)) ?? 0
+ );
+ }
+
+ /**
+ * Get one of the notification counts for this room
+ * @param type - The type of notification count to get. default: 'total'
+ * @returns The notification count, or undefined if there is no count
+ * for this type.
+ */
+ public getRoomUnreadNotificationCount(type = NotificationCountType.Total): number {
+ return this.notificationCounts[type] ?? 0;
+ }
+
+ /**
+ * Get one of the notification counts for a thread
+ * @param threadId - the root event ID
+ * @param type - The type of notification count to get. default: 'total'
+ * @returns The notification count, or undefined if there is no count
+ * for this type.
+ */
+ public getThreadUnreadNotificationCount(threadId: string, type = NotificationCountType.Total): number {
+ return this.threadNotifications.get(threadId)?.[type] ?? 0;
+ }
+
+ /**
+ * Checks if the current room has unread thread notifications
+ * @returns
+ */
+ public hasThreadUnreadNotification(): boolean {
+ for (const notification of this.threadNotifications.values()) {
+ if ((notification.highlight ?? 0) > 0 || (notification.total ?? 0) > 0) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Swet one of the notification count for a thread
+ * @param threadId - the root event ID
+ * @param type - The type of notification count to get. default: 'total'
+ * @returns
+ */
+ public setThreadUnreadNotificationCount(threadId: string, type: NotificationCountType, count: number): void {
+ const notification: NotificationCount = {
+ highlight: this.threadNotifications.get(threadId)?.highlight,
+ total: this.threadNotifications.get(threadId)?.total,
+ ...{
+ [type]: count,
+ },
+ };
+
+ this.threadNotifications.set(threadId, notification);
+
+ this.emit(RoomEvent.UnreadNotifications, notification, threadId);
+ }
+
+ /**
+ * @returns the notification count type for all the threads in the room
+ */
+ public get threadsAggregateNotificationType(): NotificationCountType | null {
+ let type: NotificationCountType | null = null;
+ for (const threadNotification of this.threadNotifications.values()) {
+ if ((threadNotification.highlight ?? 0) > 0) {
+ return NotificationCountType.Highlight;
+ } else if ((threadNotification.total ?? 0) > 0 && !type) {
+ type = NotificationCountType.Total;
+ }
+ }
+ return type;
+ }
+
+ /**
+ * Resets the thread notifications for this room
+ */
+ public resetThreadUnreadNotificationCount(notificationsToKeep?: string[]): void {
+ if (notificationsToKeep) {
+ for (const [threadId] of this.threadNotifications) {
+ if (!notificationsToKeep.includes(threadId)) {
+ this.threadNotifications.delete(threadId);
+ }
+ }
+ } else {
+ this.threadNotifications.clear();
+ }
+ this.emit(RoomEvent.UnreadNotifications);
+ }
+
+ /**
+ * Set one of the notification counts for this room
+ * @param type - The type of notification count to set.
+ * @param count - The new count
+ */
+ public setUnreadNotificationCount(type: NotificationCountType, count: number): void {
+ this.notificationCounts[type] = count;
+ this.emit(RoomEvent.UnreadNotifications, this.notificationCounts);
+ }
+
+ public setUnread(type: NotificationCountType, count: number): void {
+ return this.setUnreadNotificationCount(type, count);
+ }
+
+ public setSummary(summary: IRoomSummary): void {
+ const heroes = summary["m.heroes"];
+ const joinedCount = summary["m.joined_member_count"];
+ const invitedCount = summary["m.invited_member_count"];
+ if (Number.isInteger(joinedCount)) {
+ this.currentState.setJoinedMemberCount(joinedCount!);
+ }
+ if (Number.isInteger(invitedCount)) {
+ this.currentState.setInvitedMemberCount(invitedCount!);
+ }
+ if (Array.isArray(heroes)) {
+ // be cautious about trusting server values,
+ // and make sure heroes doesn't contain our own id
+ // just to be sure
+ this.summaryHeroes = heroes.filter((userId) => {
+ return userId !== this.myUserId;
+ });
+ }
+ }
+
+ /**
+ * Whether to send encrypted messages to devices within this room.
+ * @param value - true to blacklist unverified devices, null
+ * to use the global value for this room.
+ */
+ public setBlacklistUnverifiedDevices(value: boolean): void {
+ this.blacklistUnverifiedDevices = value;
+ }
+
+ /**
+ * Whether to send encrypted messages to devices within this room.
+ * @returns true if blacklisting unverified devices, null
+ * if the global value should be used for this room.
+ */
+ public getBlacklistUnverifiedDevices(): boolean | null {
+ if (this.blacklistUnverifiedDevices === undefined) return null;
+ return this.blacklistUnverifiedDevices;
+ }
+
+ /**
+ * Get the avatar URL for a room if one was set.
+ * @param baseUrl - The homeserver base URL. See
+ * {@link MatrixClient#getHomeserverUrl}.
+ * @param width - The desired width of the thumbnail.
+ * @param height - The desired height of the thumbnail.
+ * @param resizeMethod - The thumbnail resize method to use, either
+ * "crop" or "scale".
+ * @param allowDefault - True to allow an identicon for this room if an
+ * avatar URL wasn't explicitly set. Default: true. (Deprecated)
+ * @returns the avatar URL or null.
+ */
+ public getAvatarUrl(
+ baseUrl: string,
+ width: number,
+ height: number,
+ resizeMethod: ResizeMethod,
+ allowDefault = true,
+ ): string | null {
+ const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, "");
+ if (!roomAvatarEvent && !allowDefault) {
+ return null;
+ }
+
+ const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null;
+ if (mainUrl) {
+ return getHttpUriForMxc(baseUrl, mainUrl, width, height, resizeMethod);
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the mxc avatar url for the room, if one was set.
+ * @returns the mxc avatar url or falsy
+ */
+ public getMxcAvatarUrl(): string | null {
+ return this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url || null;
+ }
+
+ /**
+ * Get this room's canonical alias
+ * The alias returned by this function may not necessarily
+ * still point to this room.
+ * @returns The room's canonical alias, or null if there is none
+ */
+ public getCanonicalAlias(): string | null {
+ const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
+ if (canonicalAlias) {
+ return canonicalAlias.getContent().alias || null;
+ }
+ return null;
+ }
+
+ /**
+ * Get this room's alternative aliases
+ * @returns The room's alternative aliases, or an empty array
+ */
+ public getAltAliases(): string[] {
+ const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
+ if (canonicalAlias) {
+ return canonicalAlias.getContent().alt_aliases || [];
+ }
+ return [];
+ }
+
+ /**
+ * Add events to a timeline
+ *
+ * <p>Will fire "Room.timeline" for each event added.
+ *
+ * @param events - A list of events to add.
+ *
+ * @param toStartOfTimeline - True to add these events to the start
+ * (oldest) instead of the end (newest) of the timeline. If true, the oldest
+ * event will be the <b>last</b> element of 'events'.
+ *
+ * @param timeline - timeline to
+ * add events to.
+ *
+ * @param paginationToken - token for the next batch of events
+ *
+ * @remarks
+ * Fires {@link RoomEvent.Timeline}
+ */
+ public addEventsToTimeline(
+ events: MatrixEvent[],
+ toStartOfTimeline: boolean,
+ timeline: EventTimeline,
+ paginationToken?: string,
+ ): void {
+ timeline.getTimelineSet().addEventsToTimeline(events, toStartOfTimeline, timeline, paginationToken);
+ }
+
+ /**
+ * Get the instance of the thread associated with the current event
+ * @param eventId - the ID of the current event
+ * @returns a thread instance if known
+ */
+ public getThread(eventId: string): Thread | null {
+ return this.threads.get(eventId) ?? null;
+ }
+
+ /**
+ * Get all the known threads in the room
+ */
+ public getThreads(): Thread[] {
+ return Array.from(this.threads.values());
+ }
+
+ /**
+ * Get a member from the current room state.
+ * @param userId - The user ID of the member.
+ * @returns The member or `null`.
+ */
+ public getMember(userId: string): RoomMember | null {
+ return this.currentState.getMember(userId);
+ }
+
+ /**
+ * Get all currently loaded members from the current
+ * room state.
+ * @returns Room members
+ */
+ public getMembers(): RoomMember[] {
+ return this.currentState.getMembers();
+ }
+
+ /**
+ * Get a list of members whose membership state is "join".
+ * @returns A list of currently joined members.
+ */
+ public getJoinedMembers(): RoomMember[] {
+ return this.getMembersWithMembership("join");
+ }
+
+ /**
+ * Returns the number of joined members in this room
+ * This method caches the result.
+ * This is a wrapper around the method of the same name in roomState, returning
+ * its result for the room's current state.
+ * @returns The number of members in this room whose membership is 'join'
+ */
+ public getJoinedMemberCount(): number {
+ return this.currentState.getJoinedMemberCount();
+ }
+
+ /**
+ * Returns the number of invited members in this room
+ * @returns The number of members in this room whose membership is 'invite'
+ */
+ public getInvitedMemberCount(): number {
+ return this.currentState.getInvitedMemberCount();
+ }
+
+ /**
+ * Returns the number of invited + joined members in this room
+ * @returns The number of members in this room whose membership is 'invite' or 'join'
+ */
+ public getInvitedAndJoinedMemberCount(): number {
+ return this.getInvitedMemberCount() + this.getJoinedMemberCount();
+ }
+
+ /**
+ * Get a list of members with given membership state.
+ * @param membership - The membership state.
+ * @returns A list of members with the given membership state.
+ */
+ public getMembersWithMembership(membership: string): RoomMember[] {
+ return this.currentState.getMembers().filter(function (m) {
+ return m.membership === membership;
+ });
+ }
+
+ /**
+ * Get a list of members we should be encrypting for in this room
+ * @returns A list of members who
+ * we should encrypt messages for in this room.
+ */
+ public async getEncryptionTargetMembers(): Promise<RoomMember[]> {
+ await this.loadMembersIfNeeded();
+ let members = this.getMembersWithMembership("join");
+ if (this.shouldEncryptForInvitedMembers()) {
+ members = members.concat(this.getMembersWithMembership("invite"));
+ }
+ return members;
+ }
+
+ /**
+ * Determine whether we should encrypt messages for invited users in this room
+ * @returns if we should encrypt messages for invited users
+ */
+ public shouldEncryptForInvitedMembers(): boolean {
+ const ev = this.currentState.getStateEvents(EventType.RoomHistoryVisibility, "");
+ return ev?.getContent()?.history_visibility !== "joined";
+ }
+
+ /**
+ * Get the default room name (i.e. what a given user would see if the
+ * room had no m.room.name)
+ * @param userId - The userId from whose perspective we want
+ * to calculate the default name
+ * @returns The default room name
+ */
+ public getDefaultRoomName(userId: string): string {
+ return this.calculateRoomName(userId, true);
+ }
+
+ /**
+ * Check if the given user_id has the given membership state.
+ * @param userId - The user ID to check.
+ * @param membership - The membership e.g. `'join'`
+ * @returns True if this user_id has the given membership state.
+ */
+ public hasMembershipState(userId: string, membership: string): boolean {
+ const member = this.getMember(userId);
+ if (!member) {
+ return false;
+ }
+ return member.membership === membership;
+ }
+
+ /**
+ * Add a timelineSet for this room with the given filter
+ * @param filter - The filter to be applied to this timelineSet
+ * @param opts - Configuration options
+ * @returns The timelineSet
+ */
+ public getOrCreateFilteredTimelineSet(
+ filter: Filter,
+ { prepopulateTimeline = true, useSyncEvents = true, pendingEvents = true }: ICreateFilterOpts = {},
+ ): EventTimelineSet {
+ if (this.filteredTimelineSets[filter.filterId!]) {
+ return this.filteredTimelineSets[filter.filterId!];
+ }
+ const opts = Object.assign({ filter, pendingEvents }, this.opts);
+ const timelineSet = new EventTimelineSet(this, opts);
+ this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
+ if (useSyncEvents) {
+ this.filteredTimelineSets[filter.filterId!] = timelineSet;
+ this.timelineSets.push(timelineSet);
+ }
+
+ const unfilteredLiveTimeline = this.getLiveTimeline();
+ // Not all filter are possible to replicate client-side only
+ // When that's the case we do not want to prepopulate from the live timeline
+ // as we would get incorrect results compared to what the server would send back
+ if (prepopulateTimeline) {
+ // populate up the new timelineSet with filtered events from our live
+ // unfiltered timeline.
+ //
+ // XXX: This is risky as our timeline
+ // may have grown huge and so take a long time to filter.
+ // see https://github.com/vector-im/vector-web/issues/2109
+
+ unfilteredLiveTimeline.getEvents().forEach(function (event) {
+ timelineSet.addLiveEvent(event);
+ });
+
+ // find the earliest unfiltered timeline
+ let timeline = unfilteredLiveTimeline;
+ while (timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) {
+ timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)!;
+ }
+
+ timelineSet
+ .getLiveTimeline()
+ .setPaginationToken(timeline.getPaginationToken(EventTimeline.BACKWARDS), EventTimeline.BACKWARDS);
+ } else if (useSyncEvents) {
+ const livePaginationToken = unfilteredLiveTimeline.getPaginationToken(Direction.Forward);
+ timelineSet.getLiveTimeline().setPaginationToken(livePaginationToken, Direction.Backward);
+ }
+
+ // alternatively, we could try to do something like this to try and re-paginate
+ // in the filtered events from nothing, but Mark says it's an abuse of the API
+ // to do so:
+ //
+ // timelineSet.resetLiveTimeline(
+ // unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS)
+ // );
+
+ return timelineSet;
+ }
+
+ private async getThreadListFilter(filterType = ThreadFilterType.All): Promise<Filter> {
+ const myUserId = this.client.getUserId()!;
+ const filter = new Filter(myUserId);
+
+ const definition: IFilterDefinition = {
+ room: {
+ timeline: {
+ [FILTER_RELATED_BY_REL_TYPES.name]: [THREAD_RELATION_TYPE.name],
+ },
+ },
+ };
+
+ if (filterType === ThreadFilterType.My) {
+ definition!.room!.timeline![FILTER_RELATED_BY_SENDERS.name] = [myUserId];
+ }
+
+ filter.setDefinition(definition);
+ const filterId = await this.client.getOrCreateFilter(`THREAD_PANEL_${this.roomId}_${filterType}`, filter);
+
+ filter.filterId = filterId;
+
+ return filter;
+ }
+
+ private async createThreadTimelineSet(filterType?: ThreadFilterType): Promise<EventTimelineSet> {
+ let timelineSet: EventTimelineSet;
+ if (Thread.hasServerSideListSupport) {
+ timelineSet = new EventTimelineSet(
+ this,
+ {
+ ...this.opts,
+ pendingEvents: false,
+ },
+ undefined,
+ undefined,
+ filterType ?? ThreadFilterType.All,
+ );
+ this.reEmitter.reEmit(timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
+ } else if (Thread.hasServerSideSupport) {
+ const filter = await this.getThreadListFilter(filterType);
+
+ timelineSet = this.getOrCreateFilteredTimelineSet(filter, {
+ prepopulateTimeline: false,
+ useSyncEvents: false,
+ pendingEvents: false,
+ });
+ } else {
+ timelineSet = new EventTimelineSet(this, {
+ pendingEvents: false,
+ });
+
+ Array.from(this.threads).forEach(([, thread]) => {
+ if (thread.length === 0) return;
+ const currentUserParticipated = thread.timeline.some((event) => {
+ return event.getSender() === this.client.getUserId();
+ });
+ if (filterType !== ThreadFilterType.My || currentUserParticipated) {
+ timelineSet.getLiveTimeline().addEvent(thread.rootEvent!, {
+ toStartOfTimeline: false,
+ });
+ }
+ });
+ }
+
+ return timelineSet;
+ }
+
+ private threadsReady = false;
+
+ /**
+ * Takes the given thread root events and creates threads for them.
+ */
+ public processThreadRoots(events: MatrixEvent[], toStartOfTimeline: boolean): void {
+ for (const rootEvent of events) {
+ EventTimeline.setEventMetadata(rootEvent, this.currentState, toStartOfTimeline);
+ if (!this.getThread(rootEvent.getId()!)) {
+ this.createThread(rootEvent.getId()!, rootEvent, [], toStartOfTimeline);
+ }
+ }
+ }
+
+ /**
+ * Fetch the bare minimum of room threads required for the thread list to work reliably.
+ * With server support that means fetching one page.
+ * Without server support that means fetching as much at once as the server allows us to.
+ */
+ public async fetchRoomThreads(): Promise<void> {
+ if (this.threadsReady || !this.client.supportsThreads()) {
+ return;
+ }
+
+ if (Thread.hasServerSideListSupport) {
+ await Promise.all([
+ this.fetchRoomThreadList(ThreadFilterType.All),
+ this.fetchRoomThreadList(ThreadFilterType.My),
+ ]);
+ } else {
+ const allThreadsFilter = await this.getThreadListFilter();
+
+ const { chunk: events } = await this.client.createMessagesRequest(
+ this.roomId,
+ "",
+ Number.MAX_SAFE_INTEGER,
+ Direction.Backward,
+ allThreadsFilter,
+ );
+
+ if (!events.length) return;
+
+ // Sorted by last_reply origin_server_ts
+ const threadRoots = events.map(this.client.getEventMapper()).sort((eventA, eventB) => {
+ /**
+ * `origin_server_ts` in a decentralised world is far from ideal
+ * but for lack of any better, we will have to use this
+ * Long term the sorting should be handled by homeservers and this
+ * is only meant as a short term patch
+ */
+ const threadAMetadata = eventA.getServerAggregatedRelation<IThreadBundledRelationship>(
+ THREAD_RELATION_TYPE.name,
+ )!;
+ const threadBMetadata = eventB.getServerAggregatedRelation<IThreadBundledRelationship>(
+ THREAD_RELATION_TYPE.name,
+ )!;
+ return threadAMetadata.latest_event.origin_server_ts - threadBMetadata.latest_event.origin_server_ts;
+ });
+
+ let latestMyThreadsRootEvent: MatrixEvent | undefined;
+ const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
+ for (const rootEvent of threadRoots) {
+ const opts = {
+ duplicateStrategy: DuplicateStrategy.Ignore,
+ fromCache: false,
+ roomState,
+ };
+ this.threadsTimelineSets[0]?.addLiveEvent(rootEvent, opts);
+
+ const threadRelationship = rootEvent.getServerAggregatedRelation<IThreadBundledRelationship>(
+ THREAD_RELATION_TYPE.name,
+ );
+ if (threadRelationship?.current_user_participated) {
+ this.threadsTimelineSets[1]?.addLiveEvent(rootEvent, opts);
+ latestMyThreadsRootEvent = rootEvent;
+ }
+ }
+
+ this.processThreadRoots(threadRoots, true);
+
+ this.client.decryptEventIfNeeded(threadRoots[threadRoots.length - 1]);
+ if (latestMyThreadsRootEvent) {
+ this.client.decryptEventIfNeeded(latestMyThreadsRootEvent);
+ }
+ }
+
+ this.on(ThreadEvent.NewReply, this.onThreadNewReply);
+ this.on(ThreadEvent.Delete, this.onThreadDelete);
+ this.threadsReady = true;
+ }
+
+ public async processPollEvents(events: MatrixEvent[]): Promise<void> {
+ const processPollStartEvent = (event: MatrixEvent): void => {
+ if (!M_POLL_START.matches(event.getType())) return;
+ try {
+ const poll = new Poll(event, this.client, this);
+ this.polls.set(event.getId()!, poll);
+ this.emit(PollEvent.New, poll);
+ } catch {}
+ // poll creation can fail for malformed poll start events
+ };
+
+ const processPollRelationEvent = (event: MatrixEvent): void => {
+ const relationEventId = event.relationEventId;
+ if (relationEventId && this.polls.has(relationEventId)) {
+ const poll = this.polls.get(relationEventId);
+ poll?.onNewRelation(event);
+ }
+ };
+
+ const processPollEvent = (event: MatrixEvent): void => {
+ processPollStartEvent(event);
+ processPollRelationEvent(event);
+ };
+
+ for (const event of events) {
+ try {
+ await this.client.decryptEventIfNeeded(event);
+ processPollEvent(event);
+ } catch {}
+ }
+ }
+
+ /**
+ * Fetch a single page of threadlist messages for the specific thread filter
+ * @internal
+ */
+ private async fetchRoomThreadList(filter?: ThreadFilterType): Promise<void> {
+ const timelineSet = filter === ThreadFilterType.My ? this.threadsTimelineSets[1] : this.threadsTimelineSets[0];
+
+ const { chunk: events, end } = await this.client.createThreadListMessagesRequest(
+ this.roomId,
+ null,
+ undefined,
+ Direction.Backward,
+ timelineSet.threadListType,
+ timelineSet.getFilter(),
+ );
+
+ timelineSet.getLiveTimeline().setPaginationToken(end ?? null, Direction.Backward);
+
+ if (!events.length) return;
+
+ const matrixEvents = events.map(this.client.getEventMapper());
+ this.processThreadRoots(matrixEvents, true);
+ const roomState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
+ for (const rootEvent of matrixEvents) {
+ timelineSet.addLiveEvent(rootEvent, {
+ duplicateStrategy: DuplicateStrategy.Replace,
+ fromCache: false,
+ roomState,
+ });
+ }
+ }
+
+ private onThreadNewReply(thread: Thread): void {
+ this.updateThreadRootEvents(thread, false, true);
+ }
+
+ private onThreadDelete(thread: Thread): void {
+ this.threads.delete(thread.id);
+
+ const timeline = this.getTimelineForEvent(thread.id);
+ const roomEvent = timeline?.getEvents()?.find((it) => it.getId() === thread.id);
+ if (roomEvent) {
+ thread.clearEventMetadata(roomEvent);
+ } else {
+ logger.debug("onThreadDelete: Could not find root event in room timeline");
+ }
+ for (const timelineSet of this.threadsTimelineSets) {
+ timelineSet.removeEvent(thread.id);
+ }
+ }
+
+ /**
+ * Forget the timelineSet for this room with the given filter
+ *
+ * @param filter - the filter whose timelineSet is to be forgotten
+ */
+ public removeFilteredTimelineSet(filter: Filter): void {
+ const timelineSet = this.filteredTimelineSets[filter.filterId!];
+ delete this.filteredTimelineSets[filter.filterId!];
+ const i = this.timelineSets.indexOf(timelineSet);
+ if (i > -1) {
+ this.timelineSets.splice(i, 1);
+ }
+ }
+
+ public eventShouldLiveIn(
+ event: MatrixEvent,
+ events?: MatrixEvent[],
+ roots?: Set<string>,
+ ): {
+ shouldLiveInRoom: boolean;
+ shouldLiveInThread: boolean;
+ threadId?: string;
+ } {
+ if (!this.client?.supportsThreads()) {
+ return {
+ shouldLiveInRoom: true,
+ shouldLiveInThread: false,
+ };
+ }
+
+ // A thread root is always shown in both timelines
+ if (event.isThreadRoot || roots?.has(event.getId()!)) {
+ return {
+ shouldLiveInRoom: true,
+ shouldLiveInThread: true,
+ threadId: event.getId(),
+ };
+ }
+
+ // A thread relation is always only shown in a thread
+ if (event.isRelation(THREAD_RELATION_TYPE.name)) {
+ return {
+ shouldLiveInRoom: false,
+ shouldLiveInThread: true,
+ threadId: event.threadRootId,
+ };
+ }
+
+ const parentEventId = event.getAssociatedId();
+ let parentEvent: MatrixEvent | undefined;
+ if (parentEventId) {
+ parentEvent = this.findEventById(parentEventId) ?? events?.find((e) => e.getId() === parentEventId);
+ }
+
+ // Treat relations and redactions as extensions of their parents so evaluate parentEvent instead
+ if (parentEvent && (event.isRelation() || event.isRedaction())) {
+ return this.eventShouldLiveIn(parentEvent, events, roots);
+ }
+
+ // Edge case where we know the event is a relation but don't have the parentEvent
+ if (roots?.has(event.relationEventId!)) {
+ return {
+ shouldLiveInRoom: true,
+ shouldLiveInThread: true,
+ threadId: event.relationEventId,
+ };
+ }
+
+ // We've exhausted all scenarios, can safely assume that this event should live in the room timeline only
+ return {
+ shouldLiveInRoom: true,
+ shouldLiveInThread: false,
+ };
+ }
+
+ public findThreadForEvent(event?: MatrixEvent): Thread | null {
+ if (!event) return null;
+
+ const { threadId } = this.eventShouldLiveIn(event);
+ return threadId ? this.getThread(threadId) : null;
+ }
+
+ private addThreadedEvents(threadId: string, events: MatrixEvent[], toStartOfTimeline = false): void {
+ let thread = this.getThread(threadId);
+
+ if (!thread) {
+ const rootEvent = this.findEventById(threadId) ?? events.find((e) => e.getId() === threadId);
+ thread = this.createThread(threadId, rootEvent, events, toStartOfTimeline);
+ }
+
+ thread.addEvents(events, toStartOfTimeline);
+ }
+
+ /**
+ * Adds events to a thread's timeline. Will fire "Thread.update"
+ */
+ public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
+ events.forEach(this.applyRedaction);
+
+ const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
+ for (const event of events) {
+ const { threadId, shouldLiveInThread } = this.eventShouldLiveIn(event);
+ if (shouldLiveInThread && !eventsByThread[threadId!]) {
+ eventsByThread[threadId!] = [];
+ }
+ eventsByThread[threadId!]?.push(event);
+ }
+
+ Object.entries(eventsByThread).map(([threadId, threadEvents]) =>
+ this.addThreadedEvents(threadId, threadEvents, toStartOfTimeline),
+ );
+ }
+
+ private updateThreadRootEvents = (thread: Thread, toStartOfTimeline: boolean, recreateEvent: boolean): void => {
+ if (thread.length) {
+ this.updateThreadRootEvent(this.threadsTimelineSets?.[0], thread, toStartOfTimeline, recreateEvent);
+ if (thread.hasCurrentUserParticipated) {
+ this.updateThreadRootEvent(this.threadsTimelineSets?.[1], thread, toStartOfTimeline, recreateEvent);
+ }
+ }
+ };
+
+ private updateThreadRootEvent = (
+ timelineSet: Optional<EventTimelineSet>,
+ thread: Thread,
+ toStartOfTimeline: boolean,
+ recreateEvent: boolean,
+ ): void => {
+ if (timelineSet && thread.rootEvent) {
+ if (recreateEvent) {
+ timelineSet.removeEvent(thread.id);
+ }
+ if (Thread.hasServerSideSupport) {
+ timelineSet.addLiveEvent(thread.rootEvent, {
+ duplicateStrategy: DuplicateStrategy.Replace,
+ fromCache: false,
+ roomState: this.currentState,
+ });
+ } else {
+ timelineSet.addEventToTimeline(thread.rootEvent, timelineSet.getLiveTimeline(), { toStartOfTimeline });
+ }
+ }
+ };
+
+ public createThread(
+ threadId: string,
+ rootEvent: MatrixEvent | undefined,
+ events: MatrixEvent[] = [],
+ toStartOfTimeline: boolean,
+ ): Thread {
+ if (this.threads.has(threadId)) {
+ return this.threads.get(threadId)!;
+ }
+
+ if (rootEvent) {
+ const relatedEvents = this.relations.getAllChildEventsForEvent(rootEvent.getId()!);
+ if (relatedEvents?.length) {
+ // Include all relations of the root event, given it'll be visible in both timelines,
+ // except `m.replace` as that will already be applied atop the event using `MatrixEvent::makeReplaced`
+ events = events.concat(relatedEvents.filter((e) => !e.isRelation(RelationType.Replace)));
+ }
+ }
+
+ const thread = new Thread(threadId, rootEvent, {
+ room: this,
+ client: this.client,
+ pendingEventOrdering: this.opts.pendingEventOrdering,
+ receipts: this.cachedThreadReadReceipts.get(threadId) ?? [],
+ });
+
+ // All read receipts should now come down from sync, we do not need to keep
+ // a reference to the cached receipts anymore.
+ this.cachedThreadReadReceipts.delete(threadId);
+
+ // If we managed to create a thread and figure out its `id` then we can use it
+ // This has to happen before thread.addEvents, because that adds events to the eventtimeline, and the
+ // eventtimeline sometimes looks up thread information via the room.
+ this.threads.set(thread.id, thread);
+
+ // This is necessary to be able to jump to events in threads:
+ // If we jump to an event in a thread where neither the event, nor the root,
+ // nor any thread event are loaded yet, we'll load the event as well as the thread root, create the thread,
+ // and pass the event through this.
+ thread.addEvents(events, false);
+
+ this.reEmitter.reEmit(thread, [
+ ThreadEvent.Delete,
+ ThreadEvent.Update,
+ ThreadEvent.NewReply,
+ RoomEvent.Timeline,
+ RoomEvent.TimelineReset,
+ ]);
+ const isNewer =
+ this.lastThread?.rootEvent &&
+ rootEvent?.localTimestamp &&
+ this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp;
+
+ if (!this.lastThread || isNewer) {
+ this.lastThread = thread;
+ }
+
+ if (this.threadsReady) {
+ this.updateThreadRootEvents(thread, toStartOfTimeline, false);
+ }
+ this.emit(ThreadEvent.New, thread, toStartOfTimeline);
+
+ return thread;
+ }
+
+ private applyRedaction = (event: MatrixEvent): void => {
+ if (event.isRedaction()) {
+ const redactId = event.event.redacts;
+
+ // if we know about this event, redact its contents now.
+ const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
+ if (redactedEvent) {
+ redactedEvent.makeRedacted(event);
+
+ // If this is in the current state, replace it with the redacted version
+ if (redactedEvent.isState()) {
+ const currentStateEvent = this.currentState.getStateEvents(
+ redactedEvent.getType(),
+ redactedEvent.getStateKey()!,
+ );
+ if (currentStateEvent?.getId() === redactedEvent.getId()) {
+ this.currentState.setStateEvents([redactedEvent]);
+ }
+ }
+
+ this.emit(RoomEvent.Redaction, event, this);
+
+ // TODO: we stash user displaynames (among other things) in
+ // RoomMember objects which are then attached to other events
+ // (in the sender and target fields). We should get those
+ // RoomMember objects to update themselves when the events that
+ // they are based on are changed.
+
+ // Remove any visibility change on this event.
+ this.visibilityEvents.delete(redactId!);
+
+ // If this event is a visibility change event, remove it from the
+ // list of visibility changes and update any event affected by it.
+ if (redactedEvent.isVisibilityEvent()) {
+ this.redactVisibilityChangeEvent(event);
+ }
+ }
+
+ // FIXME: apply redactions to notification list
+
+ // NB: We continue to add the redaction event to the timeline so
+ // clients can say "so and so redacted an event" if they wish to. Also
+ // this may be needed to trigger an update.
+ }
+ };
+
+ private processLiveEvent(event: MatrixEvent): void {
+ this.applyRedaction(event);
+
+ // Implement MSC3531: hiding messages.
+ if (event.isVisibilityEvent()) {
+ // This event changes the visibility of another event, record
+ // the visibility change, inform clients if necessary.
+ this.applyNewVisibilityEvent(event);
+ }
+ // If any pending visibility change is waiting for this (older) event,
+ this.applyPendingVisibilityEvents(event);
+
+ // Sliding Sync modifications:
+ // The proxy cannot guarantee every sent event will have a transaction_id field, so we need
+ // to check the event ID against the list of pending events if there is no transaction ID
+ // field. Only do this for events sent by us though as it's potentially expensive to loop
+ // the pending events map.
+ const txnId = event.getUnsigned().transaction_id;
+ if (!txnId && event.getSender() === this.myUserId) {
+ // check the txn map for a matching event ID
+ for (const [tid, localEvent] of this.txnToEvent) {
+ if (localEvent.getId() === event.getId()) {
+ logger.debug("processLiveEvent: found sent event without txn ID: ", tid, event.getId());
+ // update the unsigned field so we can re-use the same codepaths
+ const unsigned = event.getUnsigned();
+ unsigned.transaction_id = tid;
+ event.setUnsigned(unsigned);
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Add an event to the end of this room's live timelines. Will fire
+ * "Room.timeline".
+ *
+ * @param event - Event to be added
+ * @param addLiveEventOptions - addLiveEvent options
+ * @internal
+ *
+ * @remarks
+ * Fires {@link RoomEvent.Timeline}
+ */
+ private addLiveEvent(event: MatrixEvent, addLiveEventOptions: IAddLiveEventOptions): void {
+ const { duplicateStrategy, timelineWasEmpty, fromCache } = addLiveEventOptions;
+
+ // add to our timeline sets
+ for (const timelineSet of this.timelineSets) {
+ timelineSet.addLiveEvent(event, {
+ duplicateStrategy,
+ fromCache,
+ timelineWasEmpty,
+ });
+ }
+
+ // synthesize and inject implicit read receipts
+ // Done after adding the event because otherwise the app would get a read receipt
+ // pointing to an event that wasn't yet in the timeline
+ // Don't synthesize RR for m.room.redaction as this causes the RR to go missing.
+ if (event.sender && event.getType() !== EventType.RoomRedaction) {
+ this.addReceipt(synthesizeReceipt(event.sender.userId, event, ReceiptType.Read), true);
+
+ // Any live events from a user could be taken as implicit
+ // presence information: evidence that they are currently active.
+ // ...except in a world where we use 'user.currentlyActive' to reduce
+ // presence spam, this isn't very useful - we'll get a transition when
+ // they are no longer currently active anyway. So don't bother to
+ // reset the lastActiveAgo and lastPresenceTs from the RoomState's user.
+ }
+ }
+
+ /**
+ * Add a pending outgoing event to this room.
+ *
+ * <p>The event is added to either the pendingEventList, or the live timeline,
+ * depending on the setting of opts.pendingEventOrdering.
+ *
+ * <p>This is an internal method, intended for use by MatrixClient.
+ *
+ * @param event - The event to add.
+ *
+ * @param txnId - Transaction id for this outgoing event
+ *
+ * @throws if the event doesn't have status SENDING, or we aren't given a
+ * unique transaction id.
+ *
+ * @remarks
+ * Fires {@link RoomEvent.LocalEchoUpdated}
+ */
+ public addPendingEvent(event: MatrixEvent, txnId: string): void {
+ if (event.status !== EventStatus.SENDING && event.status !== EventStatus.NOT_SENT) {
+ throw new Error("addPendingEvent called on an event with status " + event.status);
+ }
+
+ if (this.txnToEvent.get(txnId)) {
+ throw new Error("addPendingEvent called on an event with known txnId " + txnId);
+ }
+
+ // call setEventMetadata to set up event.sender etc
+ // as event is shared over all timelineSets, we set up its metadata based
+ // on the unfiltered timelineSet.
+ EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS)!, false);
+
+ this.txnToEvent.set(txnId, event);
+ if (this.pendingEventList) {
+ if (this.pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) {
+ logger.warn("Setting event as NOT_SENT due to messages in the same state");
+ event.setStatus(EventStatus.NOT_SENT);
+ }
+ this.pendingEventList.push(event);
+ this.savePendingEvents();
+ if (event.isRelation()) {
+ // For pending events, add them to the relations collection immediately.
+ // (The alternate case below already covers this as part of adding to
+ // the timeline set.)
+ this.aggregateNonLiveRelation(event);
+ }
+
+ if (event.isRedaction()) {
+ const redactId = event.event.redacts;
+ let redactedEvent = this.pendingEventList.find((e) => e.getId() === redactId);
+ if (!redactedEvent && redactId) {
+ redactedEvent = this.findEventById(redactId);
+ }
+ if (redactedEvent) {
+ redactedEvent.markLocallyRedacted(event);
+ this.emit(RoomEvent.Redaction, event, this);
+ }
+ }
+ } else {
+ for (const timelineSet of this.timelineSets) {
+ if (timelineSet.getFilter()) {
+ if (timelineSet.getFilter()!.filterRoomTimeline([event]).length) {
+ timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), {
+ toStartOfTimeline: false,
+ });
+ }
+ } else {
+ timelineSet.addEventToTimeline(event, timelineSet.getLiveTimeline(), {
+ toStartOfTimeline: false,
+ });
+ }
+ }
+ }
+
+ this.emit(RoomEvent.LocalEchoUpdated, event, this);
+ }
+
+ /**
+ * Persists all pending events to local storage
+ *
+ * If the current room is encrypted only encrypted events will be persisted
+ * all messages that are not yet encrypted will be discarded
+ *
+ * This is because the flow of EVENT_STATUS transition is
+ * `queued => sending => encrypting => sending => sent`
+ *
+ * Steps 3 and 4 are skipped for unencrypted room.
+ * It is better to discard an unencrypted message rather than persisting
+ * it locally for everyone to read
+ */
+ private savePendingEvents(): void {
+ if (this.pendingEventList) {
+ const pendingEvents = this.pendingEventList
+ .map((event) => {
+ return {
+ ...event.event,
+ txn_id: event.getTxnId(),
+ };
+ })
+ .filter((event) => {
+ // Filter out the unencrypted messages if the room is encrypted
+ const isEventEncrypted = event.type === EventType.RoomMessageEncrypted;
+ const isRoomEncrypted = this.client.isRoomEncrypted(this.roomId);
+ return isEventEncrypted || !isRoomEncrypted;
+ });
+
+ this.client.store.setPendingEvents(this.roomId, pendingEvents);
+ }
+ }
+
+ /**
+ * Used to aggregate the local echo for a relation, and also
+ * for re-applying a relation after it's redaction has been cancelled,
+ * as the local echo for the redaction of the relation would have
+ * un-aggregated the relation. Note that this is different from regular messages,
+ * which are just kept detached for their local echo.
+ *
+ * Also note that live events are aggregated in the live EventTimelineSet.
+ * @param event - the relation event that needs to be aggregated.
+ */
+ private aggregateNonLiveRelation(event: MatrixEvent): void {
+ this.relations.aggregateChildEvent(event);
+ }
+
+ public getEventForTxnId(txnId: string): MatrixEvent | undefined {
+ return this.txnToEvent.get(txnId);
+ }
+
+ /**
+ * Deal with the echo of a message we sent.
+ *
+ * <p>We move the event to the live timeline if it isn't there already, and
+ * update it.
+ *
+ * @param remoteEvent - The event received from
+ * /sync
+ * @param localEvent - The local echo, which
+ * should be either in the pendingEventList or the timeline.
+ *
+ * @internal
+ *
+ * @remarks
+ * Fires {@link RoomEvent.LocalEchoUpdated}
+ */
+ public handleRemoteEcho(remoteEvent: MatrixEvent, localEvent: MatrixEvent): void {
+ const oldEventId = localEvent.getId()!;
+ const newEventId = remoteEvent.getId()!;
+ const oldStatus = localEvent.status;
+
+ logger.debug(`Got remote echo for event ${oldEventId} -> ${newEventId} old status ${oldStatus}`);
+
+ // no longer pending
+ this.txnToEvent.delete(remoteEvent.getUnsigned().transaction_id!);
+
+ // if it's in the pending list, remove it
+ if (this.pendingEventList) {
+ this.removePendingEvent(oldEventId);
+ }
+
+ // replace the event source (this will preserve the plaintext payload if
+ // any, which is good, because we don't want to try decoding it again).
+ localEvent.handleRemoteEcho(remoteEvent.event);
+
+ const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(remoteEvent);
+ const thread = threadId ? this.getThread(threadId) : null;
+ thread?.setEventMetadata(localEvent);
+ thread?.timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
+
+ if (shouldLiveInRoom) {
+ for (const timelineSet of this.timelineSets) {
+ // if it's already in the timeline, update the timeline map. If it's not, add it.
+ timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId);
+ }
+ }
+
+ this.emit(RoomEvent.LocalEchoUpdated, localEvent, this, oldEventId, oldStatus);
+ }
+
+ /**
+ * Update the status / event id on a pending event, to reflect its transmission
+ * progress.
+ *
+ * <p>This is an internal method.
+ *
+ * @param event - local echo event
+ * @param newStatus - status to assign
+ * @param newEventId - new event id to assign. Ignored unless newStatus == EventStatus.SENT.
+ *
+ * @remarks
+ * Fires {@link RoomEvent.LocalEchoUpdated}
+ */
+ public updatePendingEvent(event: MatrixEvent, newStatus: EventStatus, newEventId?: string): void {
+ logger.log(
+ `setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` +
+ `event ID ${event.getId()} -> ${newEventId}`,
+ );
+
+ // if the message was sent, we expect an event id
+ if (newStatus == EventStatus.SENT && !newEventId) {
+ throw new Error("updatePendingEvent called with status=SENT, but no new event id");
+ }
+
+ // SENT races against /sync, so we have to special-case it.
+ if (newStatus == EventStatus.SENT) {
+ const timeline = this.getTimelineForEvent(newEventId!);
+ if (timeline) {
+ // we've already received the event via the event stream.
+ // nothing more to do here, assuming the transaction ID was correctly matched.
+ // Let's check that.
+ const remoteEvent = this.findEventById(newEventId!);
+ const remoteTxnId = remoteEvent?.getUnsigned().transaction_id;
+ if (!remoteTxnId && remoteEvent) {
+ // This code path is mostly relevant for the Sliding Sync proxy.
+ // The remote event did not contain a transaction ID, so we did not handle
+ // the remote echo yet. Handle it now.
+ const unsigned = remoteEvent.getUnsigned();
+ unsigned.transaction_id = event.getTxnId();
+ remoteEvent.setUnsigned(unsigned);
+ // the remote event is _already_ in the timeline, so we need to remove it so
+ // we can convert the local event into the final event.
+ this.removeEvent(remoteEvent.getId()!);
+ this.handleRemoteEcho(remoteEvent, event);
+ }
+ return;
+ }
+ }
+
+ const oldStatus = event.status;
+ const oldEventId = event.getId()!;
+
+ if (!oldStatus) {
+ throw new Error("updatePendingEventStatus called on an event which is not a local echo.");
+ }
+
+ const allowed = ALLOWED_TRANSITIONS[oldStatus];
+ if (!allowed?.includes(newStatus)) {
+ throw new Error(`Invalid EventStatus transition ${oldStatus}->${newStatus}`);
+ }
+
+ event.setStatus(newStatus);
+
+ if (newStatus == EventStatus.SENT) {
+ // update the event id
+ event.replaceLocalEventId(newEventId!);
+
+ const { shouldLiveInRoom, threadId } = this.eventShouldLiveIn(event);
+ const thread = threadId ? this.getThread(threadId) : undefined;
+ thread?.setEventMetadata(event);
+ thread?.timelineSet.replaceEventId(oldEventId, newEventId!);
+
+ if (shouldLiveInRoom) {
+ // if the event was already in the timeline (which will be the case if
+ // opts.pendingEventOrdering==chronological), we need to update the
+ // timeline map.
+ for (const timelineSet of this.timelineSets) {
+ timelineSet.replaceEventId(oldEventId, newEventId!);
+ }
+ }
+ } else if (newStatus == EventStatus.CANCELLED) {
+ // remove it from the pending event list, or the timeline.
+ if (this.pendingEventList) {
+ const removedEvent = this.getPendingEvent(oldEventId);
+ this.removePendingEvent(oldEventId);
+ if (removedEvent?.isRedaction()) {
+ this.revertRedactionLocalEcho(removedEvent);
+ }
+ }
+ this.removeEvent(oldEventId);
+ }
+ this.savePendingEvents();
+
+ this.emit(RoomEvent.LocalEchoUpdated, event, this, oldEventId, oldStatus);
+ }
+
+ private revertRedactionLocalEcho(redactionEvent: MatrixEvent): void {
+ const redactId = redactionEvent.event.redacts;
+ if (!redactId) {
+ return;
+ }
+ const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId);
+ if (redactedEvent) {
+ redactedEvent.unmarkLocallyRedacted();
+ // re-render after undoing redaction
+ this.emit(RoomEvent.RedactionCancelled, redactionEvent, this);
+ // reapply relation now redaction failed
+ if (redactedEvent.isRelation()) {
+ this.aggregateNonLiveRelation(redactedEvent);
+ }
+ }
+ }
+
+ /**
+ * Add some events to this room. This can include state events, message
+ * events and typing notifications. These events are treated as "live" so
+ * they will go to the end of the timeline.
+ *
+ * @param events - A list of events to add.
+ * @param addLiveEventOptions - addLiveEvent options
+ * @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'.
+ */
+ public addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): void;
+ /**
+ * @deprecated In favor of the overload with `IAddLiveEventOptions`
+ */
+ public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: DuplicateStrategy, fromCache?: boolean): void;
+ public addLiveEvents(
+ events: MatrixEvent[],
+ duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
+ fromCache = false,
+ ): void {
+ let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy;
+ let timelineWasEmpty: boolean | undefined = false;
+ if (typeof duplicateStrategyOrOpts === "object") {
+ ({
+ duplicateStrategy,
+ fromCache = false,
+ /* roomState, (not used here) */
+ timelineWasEmpty,
+ } = duplicateStrategyOrOpts);
+ } else if (duplicateStrategyOrOpts !== undefined) {
+ // Deprecation warning
+ // FIXME: Remove after 2023-06-01 (technical debt)
+ logger.warn(
+ "Overload deprecated: " +
+ "`Room.addLiveEvents(events, duplicateStrategy?, fromCache?)` " +
+ "is deprecated in favor of the overload with `Room.addLiveEvents(events, IAddLiveEventOptions)`",
+ );
+ }
+
+ if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
+ throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
+ }
+
+ // sanity check that the live timeline is still live
+ for (let i = 0; i < this.timelineSets.length; i++) {
+ const liveTimeline = this.timelineSets[i].getLiveTimeline();
+ if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) {
+ throw new Error(
+ "live timeline " +
+ i +
+ " is no longer live - it has a pagination token " +
+ "(" +
+ liveTimeline.getPaginationToken(EventTimeline.FORWARDS) +
+ ")",
+ );
+ }
+ if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) {
+ throw new Error(`live timeline ${i} is no longer live - it has a neighbouring timeline`);
+ }
+ }
+
+ const threadRoots = this.findThreadRoots(events);
+ const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
+
+ const options: IAddLiveEventOptions = {
+ duplicateStrategy,
+ fromCache,
+ timelineWasEmpty,
+ };
+
+ for (const event of events) {
+ // TODO: We should have a filter to say "only add state event types X Y Z to the timeline".
+ this.processLiveEvent(event);
+
+ if (event.getUnsigned().transaction_id) {
+ const existingEvent = this.txnToEvent.get(event.getUnsigned().transaction_id!);
+ if (existingEvent) {
+ // remote echo of an event we sent earlier
+ this.handleRemoteEcho(event, existingEvent);
+ continue; // we can skip adding the event to the timeline sets, it is already there
+ }
+ }
+
+ const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
+ event,
+ events,
+ threadRoots,
+ );
+
+ if (shouldLiveInThread && !eventsByThread[threadId ?? ""]) {
+ eventsByThread[threadId ?? ""] = [];
+ }
+ eventsByThread[threadId ?? ""]?.push(event);
+
+ if (shouldLiveInRoom) {
+ this.addLiveEvent(event, options);
+ }
+ }
+
+ Object.entries(eventsByThread).forEach(([threadId, threadEvents]) => {
+ this.addThreadedEvents(threadId, threadEvents, false);
+ });
+ }
+
+ public partitionThreadedEvents(
+ events: MatrixEvent[],
+ ): [timelineEvents: MatrixEvent[], threadedEvents: MatrixEvent[]] {
+ // Indices to the events array, for readability
+ const ROOM = 0;
+ const THREAD = 1;
+ if (this.client.supportsThreads()) {
+ const threadRoots = this.findThreadRoots(events);
+ return events.reduce(
+ (memo, event: MatrixEvent) => {
+ const { shouldLiveInRoom, shouldLiveInThread, threadId } = this.eventShouldLiveIn(
+ event,
+ events,
+ threadRoots,
+ );
+
+ if (shouldLiveInRoom) {
+ memo[ROOM].push(event);
+ }
+
+ if (shouldLiveInThread) {
+ event.setThreadId(threadId ?? "");
+ memo[THREAD].push(event);
+ }
+
+ return memo;
+ },
+ [[] as MatrixEvent[], [] as MatrixEvent[]],
+ );
+ } else {
+ // When `experimentalThreadSupport` is disabled treat all events as timelineEvents
+ return [events as MatrixEvent[], [] as MatrixEvent[]];
+ }
+ }
+
+ /**
+ * Given some events, find the IDs of all the thread roots that are referred to by them.
+ */
+ private findThreadRoots(events: MatrixEvent[]): Set<string> {
+ const threadRoots = new Set<string>();
+ for (const event of events) {
+ if (event.isRelation(THREAD_RELATION_TYPE.name)) {
+ threadRoots.add(event.relationEventId ?? "");
+ }
+ }
+ return threadRoots;
+ }
+
+ /**
+ * Add a receipt event to the room.
+ * @param event - The m.receipt event.
+ * @param synthetic - True if this event is implicit.
+ */
+ public addReceipt(event: MatrixEvent, synthetic = false): void {
+ const content = event.getContent<ReceiptContent>();
+ Object.keys(content).forEach((eventId: string) => {
+ Object.keys(content[eventId]).forEach((receiptType: ReceiptType | string) => {
+ Object.keys(content[eventId][receiptType]).forEach((userId: string) => {
+ const receipt = content[eventId][receiptType][userId] as Receipt;
+ const receiptForMainTimeline = !receipt.thread_id || receipt.thread_id === MAIN_ROOM_TIMELINE;
+ const receiptDestination: Thread | this | undefined = receiptForMainTimeline
+ ? this
+ : this.threads.get(receipt.thread_id ?? "");
+
+ if (receiptDestination) {
+ receiptDestination.addReceiptToStructure(
+ eventId,
+ receiptType as ReceiptType,
+ userId,
+ receipt,
+ synthetic,
+ );
+
+ // If the read receipt sent for the logged in user matches
+ // the last event of the live timeline, then we know for a fact
+ // that the user has read that message.
+ // We can mark the room as read and not wait for the local echo
+ // from synapse
+ // This needs to be done after the initial sync as we do not want this
+ // logic to run whilst the room is being initialised
+ if (this.client.isInitialSyncComplete() && userId === this.client.getUserId()) {
+ const lastEvent = receiptDestination.timeline[receiptDestination.timeline.length - 1];
+ if (lastEvent && eventId === lastEvent.getId() && userId === lastEvent.getSender()) {
+ receiptDestination.setUnread(NotificationCountType.Total, 0);
+ receiptDestination.setUnread(NotificationCountType.Highlight, 0);
+ }
+ }
+ } else {
+ // The thread does not exist locally, keep the read receipt
+ // in a cache locally, and re-apply the `addReceipt` logic
+ // when the thread is created
+ this.cachedThreadReadReceipts.set(receipt.thread_id!, [
+ ...(this.cachedThreadReadReceipts.get(receipt.thread_id!) ?? []),
+ { eventId, receiptType, userId, receipt, synthetic },
+ ]);
+ }
+
+ const me = this.client.getUserId();
+ // Track the time of the current user's oldest threaded receipt in the room.
+ if (userId === me && !receiptForMainTimeline && receipt.ts < this.oldestThreadedReceiptTs) {
+ this.oldestThreadedReceiptTs = receipt.ts;
+ }
+
+ // Track each user's unthreaded read receipt.
+ if (!receipt.thread_id && receipt.ts > (this.unthreadedReceipts.get(userId)?.ts ?? 0)) {
+ this.unthreadedReceipts.set(userId, receipt);
+ }
+ });
+ });
+ });
+
+ // send events after we've regenerated the structure & cache, otherwise things that
+ // listened for the event would read stale data.
+ this.emit(RoomEvent.Receipt, event, this);
+ }
+
+ /**
+ * Adds/handles ephemeral events such as typing notifications and read receipts.
+ * @param events - A list of events to process
+ */
+ public addEphemeralEvents(events: MatrixEvent[]): void {
+ for (const event of events) {
+ if (event.getType() === EventType.Typing) {
+ this.currentState.setTypingEvent(event);
+ } else if (event.getType() === EventType.Receipt) {
+ this.addReceipt(event);
+ } // else ignore - life is too short for us to care about these events
+ }
+ }
+
+ /**
+ * Removes events from this room.
+ * @param eventIds - A list of eventIds to remove.
+ */
+ public removeEvents(eventIds: string[]): void {
+ for (const eventId of eventIds) {
+ this.removeEvent(eventId);
+ }
+ }
+
+ /**
+ * Removes a single event from this room.
+ *
+ * @param eventId - The id of the event to remove
+ *
+ * @returns true if the event was removed from any of the room's timeline sets
+ */
+ public removeEvent(eventId: string): boolean {
+ let removedAny = false;
+ for (const timelineSet of this.timelineSets) {
+ const removed = timelineSet.removeEvent(eventId);
+ if (removed) {
+ if (removed.isRedaction()) {
+ this.revertRedactionLocalEcho(removed);
+ }
+ removedAny = true;
+ }
+ }
+ return removedAny;
+ }
+
+ /**
+ * Recalculate various aspects of the room, including the room name and
+ * room summary. Call this any time the room's current state is modified.
+ * May fire "Room.name" if the room name is updated.
+ *
+ * @remarks
+ * Fires {@link RoomEvent.Name}
+ */
+ public recalculate(): void {
+ // set fake stripped state events if this is an invite room so logic remains
+ // consistent elsewhere.
+ const membershipEvent = this.currentState.getStateEvents(EventType.RoomMember, this.myUserId);
+ if (membershipEvent) {
+ const membership = membershipEvent.getContent().membership;
+ this.updateMyMembership(membership!);
+
+ if (membership === "invite") {
+ const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || [];
+ strippedStateEvents.forEach((strippedEvent) => {
+ const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key);
+ if (!existingEvent) {
+ // set the fake stripped event instead
+ this.currentState.setStateEvents([
+ new MatrixEvent({
+ type: strippedEvent.type,
+ state_key: strippedEvent.state_key,
+ content: strippedEvent.content,
+ event_id: "$fake" + Date.now(),
+ room_id: this.roomId,
+ user_id: this.myUserId, // technically a lie
+ }),
+ ]);
+ }
+ });
+ }
+ }
+
+ const oldName = this.name;
+ this.name = this.calculateRoomName(this.myUserId);
+ this.normalizedName = normalize(this.name);
+ this.summary = new RoomSummary(this.roomId, {
+ title: this.name,
+ });
+
+ if (oldName !== this.name) {
+ this.emit(RoomEvent.Name, this);
+ }
+ }
+
+ /**
+ * Update the room-tag event for the room. The previous one is overwritten.
+ * @param event - the m.tag event
+ */
+ public addTags(event: MatrixEvent): void {
+ // event content looks like:
+ // content: {
+ // tags: {
+ // $tagName: { $metadata: $value },
+ // $tagName: { $metadata: $value },
+ // }
+ // }
+
+ // XXX: do we need to deep copy here?
+ this.tags = event.getContent().tags || {};
+
+ // XXX: we could do a deep-comparison to see if the tags have really
+ // changed - but do we want to bother?
+ this.emit(RoomEvent.Tags, event, this);
+ }
+
+ /**
+ * Update the account_data events for this room, overwriting events of the same type.
+ * @param events - an array of account_data events to add
+ */
+ public addAccountData(events: MatrixEvent[]): void {
+ for (const event of events) {
+ if (event.getType() === "m.tag") {
+ this.addTags(event);
+ }
+ const eventType = event.getType();
+ const lastEvent = this.accountData.get(eventType);
+ this.accountData.set(eventType, event);
+ this.emit(RoomEvent.AccountData, event, this, lastEvent);
+ }
+ }
+
+ /**
+ * Access account_data event of given event type for this room
+ * @param type - the type of account_data event to be accessed
+ * @returns the account_data event in question
+ */
+ public getAccountData(type: EventType | string): MatrixEvent | undefined {
+ return this.accountData.get(type);
+ }
+
+ /**
+ * Returns whether the syncing user has permission to send a message in the room
+ * @returns true if the user should be permitted to send
+ * message events into the room.
+ */
+ public maySendMessage(): boolean {
+ return (
+ this.getMyMembership() === "join" &&
+ (this.client.isRoomEncrypted(this.roomId)
+ ? this.currentState.maySendEvent(EventType.RoomMessageEncrypted, this.myUserId)
+ : this.currentState.maySendEvent(EventType.RoomMessage, this.myUserId))
+ );
+ }
+
+ /**
+ * Returns whether the given user has permissions to issue an invite for this room.
+ * @param userId - the ID of the Matrix user to check permissions for
+ * @returns true if the user should be permitted to issue invites for this room.
+ */
+ public canInvite(userId: string): boolean {
+ let canInvite = this.getMyMembership() === "join";
+ const powerLevelsEvent = this.currentState.getStateEvents(EventType.RoomPowerLevels, "");
+ const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent();
+ const me = this.getMember(userId);
+ if (powerLevels && me && powerLevels.invite > me.powerLevel) {
+ canInvite = false;
+ }
+ return canInvite;
+ }
+
+ /**
+ * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`.
+ * @returns the join_rule applied to this room
+ */
+ public getJoinRule(): JoinRule {
+ return this.currentState.getJoinRule();
+ }
+
+ /**
+ * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`.
+ * @returns the history_visibility applied to this room
+ */
+ public getHistoryVisibility(): HistoryVisibility {
+ return this.currentState.getHistoryVisibility();
+ }
+
+ /**
+ * Returns the history visibility based on the m.room.history_visibility state event, defaulting to `shared`.
+ * @returns the history_visibility applied to this room
+ */
+ public getGuestAccess(): GuestAccess {
+ return this.currentState.getGuestAccess();
+ }
+
+ /**
+ * Returns the type of the room from the `m.room.create` event content or undefined if none is set
+ * @returns the type of the room.
+ */
+ public getType(): RoomType | string | undefined {
+ const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, "");
+ if (!createEvent) {
+ if (!this.getTypeWarning) {
+ logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event");
+ this.getTypeWarning = true;
+ }
+ return undefined;
+ }
+ return createEvent.getContent()[RoomCreateTypeField];
+ }
+
+ /**
+ * Returns whether the room is a space-room as defined by MSC1772.
+ * @returns true if the room's type is RoomType.Space
+ */
+ public isSpaceRoom(): boolean {
+ return this.getType() === RoomType.Space;
+ }
+
+ /**
+ * Returns whether the room is a call-room as defined by MSC3417.
+ * @returns true if the room's type is RoomType.UnstableCall
+ */
+ public isCallRoom(): boolean {
+ return this.getType() === RoomType.UnstableCall;
+ }
+
+ /**
+ * Returns whether the room is a video room.
+ * @returns true if the room's type is RoomType.ElementVideo
+ */
+ public isElementVideoRoom(): boolean {
+ return this.getType() === RoomType.ElementVideo;
+ }
+
+ /**
+ * Find the predecessor of this room.
+ *
+ * @param msc3946ProcessDynamicPredecessor - if true, look for an
+ * m.room.predecessor state event and use it if found (MSC3946).
+ * @returns null if this room has no predecessor. Otherwise, returns
+ * the roomId, last eventId and viaServers of the predecessor room.
+ *
+ * If msc3946ProcessDynamicPredecessor is true, use m.predecessor events
+ * as well as m.room.create events to find predecessors.
+ *
+ * Note: if an m.predecessor event is used, eventId may be undefined
+ * since last_known_event_id is optional.
+ *
+ * Note: viaServers may be undefined, and will definitely be undefined if
+ * this predecessor comes from a RoomCreate event (rather than a
+ * RoomPredecessor, which has the optional via_servers property).
+ */
+ public findPredecessor(
+ msc3946ProcessDynamicPredecessor = false,
+ ): { roomId: string; eventId?: string; viaServers?: string[] } | null {
+ const currentState = this.getLiveTimeline().getState(EventTimeline.FORWARDS);
+ if (!currentState) {
+ return null;
+ }
+ return currentState.findPredecessor(msc3946ProcessDynamicPredecessor);
+ }
+
+ private roomNameGenerator(state: RoomNameState): string {
+ if (this.client.roomNameGenerator) {
+ const name = this.client.roomNameGenerator(this.roomId, state);
+ if (name !== null) {
+ return name;
+ }
+ }
+
+ switch (state.type) {
+ case RoomNameType.Actual:
+ return state.name;
+ case RoomNameType.Generated:
+ switch (state.subtype) {
+ case "Inviting":
+ return `Inviting ${memberNamesToRoomName(state.names, state.count)}`;
+ default:
+ return memberNamesToRoomName(state.names, state.count);
+ }
+ case RoomNameType.EmptyRoom:
+ if (state.oldName) {
+ return `Empty room (was ${state.oldName})`;
+ } else {
+ return "Empty room";
+ }
+ }
+ }
+
+ /**
+ * This is an internal method. Calculates the name of the room from the current
+ * room state.
+ * @param userId - The client's user ID. Used to filter room members
+ * correctly.
+ * @param ignoreRoomNameEvent - Return the implicit room name that we'd see if there
+ * was no m.room.name event.
+ * @returns The calculated room name.
+ */
+ private calculateRoomName(userId: string, ignoreRoomNameEvent = false): string {
+ if (!ignoreRoomNameEvent) {
+ // check for an alias, if any. for now, assume first alias is the
+ // official one.
+ const mRoomName = this.currentState.getStateEvents(EventType.RoomName, "");
+ if (mRoomName?.getContent().name) {
+ return this.roomNameGenerator({
+ type: RoomNameType.Actual,
+ name: mRoomName.getContent().name,
+ });
+ }
+ }
+
+ const alias = this.getCanonicalAlias();
+ if (alias) {
+ return this.roomNameGenerator({
+ type: RoomNameType.Actual,
+ name: alias,
+ });
+ }
+
+ const joinedMemberCount = this.currentState.getJoinedMemberCount();
+ const invitedMemberCount = this.currentState.getInvitedMemberCount();
+ // -1 because these numbers include the syncing user
+ let inviteJoinCount = joinedMemberCount + invitedMemberCount - 1;
+
+ // get service members (e.g. helper bots) for exclusion
+ let excludedUserIds: string[] = [];
+ const mFunctionalMembers = this.currentState.getStateEvents(UNSTABLE_ELEMENT_FUNCTIONAL_USERS.name, "");
+ if (Array.isArray(mFunctionalMembers?.getContent().service_members)) {
+ excludedUserIds = mFunctionalMembers!.getContent().service_members;
+ }
+
+ // get members that are NOT ourselves and are actually in the room.
+ let otherNames: string[] = [];
+ if (this.summaryHeroes) {
+ // if we have a summary, the member state events should be in the room state
+ this.summaryHeroes.forEach((userId) => {
+ // filter service members
+ if (excludedUserIds.includes(userId)) {
+ inviteJoinCount--;
+ return;
+ }
+ const member = this.getMember(userId);
+ otherNames.push(member ? member.name : userId);
+ });
+ } else {
+ let otherMembers = this.currentState.getMembers().filter((m) => {
+ return m.userId !== userId && (m.membership === "invite" || m.membership === "join");
+ });
+ otherMembers = otherMembers.filter(({ userId }) => {
+ // filter service members
+ if (excludedUserIds.includes(userId)) {
+ inviteJoinCount--;
+ return false;
+ }
+ return true;
+ });
+ // make sure members have stable order
+ otherMembers.sort((a, b) => utils.compare(a.userId, b.userId));
+ // only 5 first members, immitate summaryHeroes
+ otherMembers = otherMembers.slice(0, 5);
+ otherNames = otherMembers.map((m) => m.name);
+ }
+
+ if (inviteJoinCount) {
+ return this.roomNameGenerator({
+ type: RoomNameType.Generated,
+ names: otherNames,
+ count: inviteJoinCount,
+ });
+ }
+
+ const myMembership = this.getMyMembership();
+ // if I have created a room and invited people through
+ // 3rd party invites
+ if (myMembership == "join") {
+ const thirdPartyInvites = this.currentState.getStateEvents(EventType.RoomThirdPartyInvite);
+
+ if (thirdPartyInvites?.length) {
+ const thirdPartyNames = thirdPartyInvites.map((i) => {
+ return i.getContent().display_name;
+ });
+
+ return this.roomNameGenerator({
+ type: RoomNameType.Generated,
+ subtype: "Inviting",
+ names: thirdPartyNames,
+ count: thirdPartyNames.length + 1,
+ });
+ }
+ }
+
+ // let's try to figure out who was here before
+ let leftNames = otherNames;
+ // if we didn't have heroes, try finding them in the room state
+ if (!leftNames.length) {
+ leftNames = this.currentState
+ .getMembers()
+ .filter((m) => {
+ return m.userId !== userId && m.membership !== "invite" && m.membership !== "join";
+ })
+ .map((m) => m.name);
+ }
+
+ let oldName: string | undefined;
+ if (leftNames.length) {
+ oldName = this.roomNameGenerator({
+ type: RoomNameType.Generated,
+ names: leftNames,
+ count: leftNames.length + 1,
+ });
+ }
+
+ return this.roomNameGenerator({
+ type: RoomNameType.EmptyRoom,
+ oldName,
+ });
+ }
+
+ /**
+ * When we receive a new visibility change event:
+ *
+ * - store this visibility change alongside the timeline, in case we
+ * later need to apply it to an event that we haven't received yet;
+ * - if we have already received the event whose visibility has changed,
+ * patch it to reflect the visibility change and inform listeners.
+ */
+ private applyNewVisibilityEvent(event: MatrixEvent): void {
+ const visibilityChange = event.asVisibilityChange();
+ if (!visibilityChange) {
+ // The event is ill-formed.
+ return;
+ }
+
+ // Ignore visibility change events that are not emitted by moderators.
+ const userId = event.getSender();
+ if (!userId) {
+ return;
+ }
+ const isPowerSufficient =
+ (EVENT_VISIBILITY_CHANGE_TYPE.name &&
+ this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.name, userId)) ||
+ (EVENT_VISIBILITY_CHANGE_TYPE.altName &&
+ this.currentState.maySendStateEvent(EVENT_VISIBILITY_CHANGE_TYPE.altName, userId));
+ if (!isPowerSufficient) {
+ // Powerlevel is insufficient.
+ return;
+ }
+
+ // Record this change in visibility.
+ // If the event is not in our timeline and we only receive it later,
+ // we may need to apply the visibility change at a later date.
+
+ const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(visibilityChange.eventId);
+ if (visibilityEventsOnOriginalEvent) {
+ // It would be tempting to simply erase the latest visibility change
+ // but we need to record all of the changes in case the latest change
+ // is ever redacted.
+ //
+ // In practice, linear scans through `visibilityEvents` should be fast.
+ // However, to protect against a potential DoS attack, we limit the
+ // number of iterations in this loop.
+ let index = visibilityEventsOnOriginalEvent.length - 1;
+ const min = Math.max(
+ 0,
+ visibilityEventsOnOriginalEvent.length - MAX_NUMBER_OF_VISIBILITY_EVENTS_TO_SCAN_THROUGH,
+ );
+ for (; index >= min; --index) {
+ const target = visibilityEventsOnOriginalEvent[index];
+ if (target.getTs() < event.getTs()) {
+ break;
+ }
+ }
+ if (index === -1) {
+ visibilityEventsOnOriginalEvent.unshift(event);
+ } else {
+ visibilityEventsOnOriginalEvent.splice(index + 1, 0, event);
+ }
+ } else {
+ this.visibilityEvents.set(visibilityChange.eventId, [event]);
+ }
+
+ // Finally, let's check if the event is already in our timeline.
+ // If so, we need to patch it and inform listeners.
+
+ const originalEvent = this.findEventById(visibilityChange.eventId);
+ if (!originalEvent) {
+ return;
+ }
+ originalEvent.applyVisibilityEvent(visibilityChange);
+ }
+
+ private redactVisibilityChangeEvent(event: MatrixEvent): void {
+ // Sanity checks.
+ if (!event.isVisibilityEvent) {
+ throw new Error("expected a visibility change event");
+ }
+ const relation = event.getRelation();
+ const originalEventId = relation?.event_id;
+ const visibilityEventsOnOriginalEvent = this.visibilityEvents.get(originalEventId!);
+ if (!visibilityEventsOnOriginalEvent) {
+ // No visibility changes on the original event.
+ // In particular, this change event was not recorded,
+ // most likely because it was ill-formed.
+ return;
+ }
+ const index = visibilityEventsOnOriginalEvent.findIndex((change) => change.getId() === event.getId());
+ if (index === -1) {
+ // This change event was not recorded, most likely because
+ // it was ill-formed.
+ return;
+ }
+ // Remove visibility change.
+ visibilityEventsOnOriginalEvent.splice(index, 1);
+
+ // If we removed the latest visibility change event, propagate changes.
+ if (index === visibilityEventsOnOriginalEvent.length) {
+ const originalEvent = this.findEventById(originalEventId!);
+ if (!originalEvent) {
+ return;
+ }
+ if (index === 0) {
+ // We have just removed the only visibility change event.
+ this.visibilityEvents.delete(originalEventId!);
+ originalEvent.applyVisibilityEvent();
+ } else {
+ const newEvent = visibilityEventsOnOriginalEvent[visibilityEventsOnOriginalEvent.length - 1];
+ const newVisibility = newEvent.asVisibilityChange();
+ if (!newVisibility) {
+ // Event is ill-formed.
+ // This breaks our invariant.
+ throw new Error("at this stage, visibility changes should be well-formed");
+ }
+ originalEvent.applyVisibilityEvent(newVisibility);
+ }
+ }
+ }
+
+ /**
+ * When we receive an event whose visibility has been altered by
+ * a (more recent) visibility change event, patch the event in
+ * place so that clients now not to display it.
+ *
+ * @param event - Any matrix event. If this event has at least one a
+ * pending visibility change event, apply the latest visibility
+ * change event.
+ */
+ private applyPendingVisibilityEvents(event: MatrixEvent): void {
+ const visibilityEvents = this.visibilityEvents.get(event.getId()!);
+ if (!visibilityEvents || visibilityEvents.length == 0) {
+ // No pending visibility change in store.
+ return;
+ }
+ const visibilityEvent = visibilityEvents[visibilityEvents.length - 1];
+ const visibilityChange = visibilityEvent.asVisibilityChange();
+ if (!visibilityChange) {
+ return;
+ }
+ if (visibilityChange.visible) {
+ // Events are visible by default, no need to apply a visibility change.
+ // Note that we need to keep the visibility changes in `visibilityEvents`,
+ // in case we later fetch an older visibility change event that is superseded
+ // by `visibilityChange`.
+ }
+ if (visibilityEvent.getTs() < event.getTs()) {
+ // Something is wrong, the visibility change cannot happen before the
+ // event. Presumably an ill-formed event.
+ return;
+ }
+ event.applyVisibilityEvent(visibilityChange);
+ }
+
+ /**
+ * Find when a client has gained thread capabilities by inspecting the oldest
+ * threaded receipt
+ * @returns the timestamp of the oldest threaded receipt
+ */
+ public getOldestThreadedReceiptTs(): number {
+ return this.oldestThreadedReceiptTs;
+ }
+
+ /**
+ * Returns the most recent unthreaded receipt for a given user
+ * @param userId - the MxID of the User
+ * @returns an unthreaded Receipt. Can be undefined if receipts have been disabled
+ * or a user chooses to use private read receipts (or we have simply not received
+ * a receipt from this user yet).
+ */
+ public getLastUnthreadedReceiptFor(userId: string): Receipt | undefined {
+ return this.unthreadedReceipts.get(userId);
+ }
+
+ /**
+ * This issue should also be addressed on synapse's side and is tracked as part
+ * of https://github.com/matrix-org/synapse/issues/14837
+ *
+ *
+ * We consider a room fully read if the current user has sent
+ * the last event in the live timeline of that context and if the read receipt
+ * we have on record matches.
+ * This also detects all unread threads and applies the same logic to those
+ * contexts
+ */
+ public fixupNotifications(userId: string): void {
+ super.fixupNotifications(userId);
+
+ const unreadThreads = this.getThreads().filter(
+ (thread) => this.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0,
+ );
+
+ for (const thread of unreadThreads) {
+ thread.fixupNotifications(userId);
+ }
+ }
+}
+
+// a map from current event status to a list of allowed next statuses
+const ALLOWED_TRANSITIONS: Record<EventStatus, EventStatus[]> = {
+ [EventStatus.ENCRYPTING]: [EventStatus.SENDING, EventStatus.NOT_SENT, EventStatus.CANCELLED],
+ [EventStatus.SENDING]: [EventStatus.ENCRYPTING, EventStatus.QUEUED, EventStatus.NOT_SENT, EventStatus.SENT],
+ [EventStatus.QUEUED]: [EventStatus.SENDING, EventStatus.NOT_SENT, EventStatus.CANCELLED],
+ [EventStatus.SENT]: [],
+ [EventStatus.NOT_SENT]: [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED],
+ [EventStatus.CANCELLED]: [],
+};
+
+export enum RoomNameType {
+ EmptyRoom,
+ Generated,
+ Actual,
+}
+
+export interface EmptyRoomNameState {
+ type: RoomNameType.EmptyRoom;
+ oldName?: string;
+}
+
+export interface GeneratedRoomNameState {
+ type: RoomNameType.Generated;
+ subtype?: "Inviting";
+ names: string[];
+ count: number;
+}
+
+export interface ActualRoomNameState {
+ type: RoomNameType.Actual;
+ name: string;
+}
+
+export type RoomNameState = EmptyRoomNameState | GeneratedRoomNameState | ActualRoomNameState;
+
+// Can be overriden by IMatrixClientCreateOpts::memberNamesToRoomNameFn
+function memberNamesToRoomName(names: string[], count: number): string {
+ const countWithoutMe = count - 1;
+ if (!names.length) {
+ return "Empty room";
+ } else if (names.length === 1 && countWithoutMe <= 1) {
+ return names[0];
+ } else if (names.length === 2 && countWithoutMe <= 2) {
+ return `${names[0]} and ${names[1]}`;
+ } else {
+ const plural = countWithoutMe > 1;
+ if (plural) {
+ return `${names[0]} and ${countWithoutMe} others`;
+ } else {
+ return `${names[0]} and 1 other`;
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/search-result.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/search-result.ts
new file mode 100644
index 0000000..21192a6
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/search-result.ts
@@ -0,0 +1,54 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventContext } from "./event-context";
+import { EventMapper } from "../event-mapper";
+import { IResultContext, ISearchResult } from "../@types/search";
+
+export class SearchResult {
+ /**
+ * Create a SearchResponse from the response to /search
+ */
+
+ public static fromJson(jsonObj: ISearchResult, eventMapper: EventMapper): SearchResult {
+ const jsonContext = jsonObj.context || ({} as IResultContext);
+ let eventsBefore = (jsonContext.events_before || []).map(eventMapper);
+ let eventsAfter = (jsonContext.events_after || []).map(eventMapper);
+
+ const context = new EventContext(eventMapper(jsonObj.result));
+
+ // Filter out any contextual events which do not correspond to the same timeline (thread or room)
+ const threadRootId = context.ourEvent.threadRootId;
+ eventsBefore = eventsBefore.filter((e) => e.threadRootId === threadRootId);
+ eventsAfter = eventsAfter.filter((e) => e.threadRootId === threadRootId);
+
+ context.setPaginateToken(jsonContext.start, true);
+ context.addEvents(eventsBefore, true);
+ context.addEvents(eventsAfter, false);
+ context.setPaginateToken(jsonContext.end, false);
+
+ return new SearchResult(jsonObj.rank, context);
+ }
+
+ /**
+ * Construct a new SearchResult
+ *
+ * @param rank - where this SearchResult ranks in the results
+ * @param context - the matching event and its
+ * context
+ */
+ public constructor(public readonly rank: number, public readonly context: EventContext) {}
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/thread.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/thread.ts
new file mode 100644
index 0000000..9a4ead3
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/thread.ts
@@ -0,0 +1,669 @@
+/*
+Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Optional } from "matrix-events-sdk";
+
+import { MatrixClient, PendingEventOrdering } from "../client";
+import { TypedReEmitter } from "../ReEmitter";
+import { RelationType } from "../@types/event";
+import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "./event";
+import { Direction, EventTimeline } from "./event-timeline";
+import { EventTimelineSet, EventTimelineSetHandlerMap } from "./event-timeline-set";
+import { NotificationCountType, Room, RoomEvent } from "./room";
+import { RoomState } from "./room-state";
+import { ServerControlledNamespacedValue } from "../NamespacedValue";
+import { logger } from "../logger";
+import { ReadReceipt } from "./read-receipt";
+import { CachedReceiptStructure, ReceiptType } from "../@types/read_receipts";
+
+export enum ThreadEvent {
+ New = "Thread.new",
+ Update = "Thread.update",
+ NewReply = "Thread.newReply",
+ ViewThread = "Thread.viewThread",
+ Delete = "Thread.delete",
+}
+
+type EmittedEvents = Exclude<ThreadEvent, ThreadEvent.New> | RoomEvent.Timeline | RoomEvent.TimelineReset;
+
+export type EventHandlerMap = {
+ [ThreadEvent.Update]: (thread: Thread) => void;
+ [ThreadEvent.NewReply]: (thread: Thread, event: MatrixEvent) => void;
+ [ThreadEvent.ViewThread]: () => void;
+ [ThreadEvent.Delete]: (thread: Thread) => void;
+} & EventTimelineSetHandlerMap;
+
+interface IThreadOpts {
+ room: Room;
+ client: MatrixClient;
+ pendingEventOrdering?: PendingEventOrdering;
+ receipts?: CachedReceiptStructure[];
+}
+
+export enum FeatureSupport {
+ None = 0,
+ Experimental = 1,
+ Stable = 2,
+}
+
+export function determineFeatureSupport(stable: boolean, unstable: boolean): FeatureSupport {
+ if (stable) {
+ return FeatureSupport.Stable;
+ } else if (unstable) {
+ return FeatureSupport.Experimental;
+ } else {
+ return FeatureSupport.None;
+ }
+}
+
+export class Thread extends ReadReceipt<EmittedEvents, EventHandlerMap> {
+ public static hasServerSideSupport = FeatureSupport.None;
+ public static hasServerSideListSupport = FeatureSupport.None;
+ public static hasServerSideFwdPaginationSupport = FeatureSupport.None;
+
+ /**
+ * A reference to all the events ID at the bottom of the threads
+ */
+ public readonly timelineSet: EventTimelineSet;
+ public timeline: MatrixEvent[] = [];
+
+ private _currentUserParticipated = false;
+
+ private reEmitter: TypedReEmitter<EmittedEvents, EventHandlerMap>;
+
+ private lastEvent: MatrixEvent | undefined;
+ private replyCount = 0;
+ private lastPendingEvent: MatrixEvent | undefined;
+ private pendingReplyCount = 0;
+
+ public readonly room: Room;
+ public readonly client: MatrixClient;
+ private readonly pendingEventOrdering: PendingEventOrdering;
+
+ public initialEventsFetched = !Thread.hasServerSideSupport;
+ /**
+ * An array of events to add to the timeline once the thread has been initialised
+ * with server suppport.
+ */
+ public replayEvents: MatrixEvent[] | null = [];
+
+ public constructor(public readonly id: string, public rootEvent: MatrixEvent | undefined, opts: IThreadOpts) {
+ super();
+
+ if (!opts?.room) {
+ // Logging/debugging for https://github.com/vector-im/element-web/issues/22141
+ // Hope is that we end up with a more obvious stack trace.
+ throw new Error("element-web#22141: A thread requires a room in order to function");
+ }
+
+ this.room = opts.room;
+ this.client = opts.client;
+ this.pendingEventOrdering = opts.pendingEventOrdering ?? PendingEventOrdering.Chronological;
+ this.timelineSet = new EventTimelineSet(
+ this.room,
+ {
+ timelineSupport: true,
+ pendingEvents: true,
+ },
+ this.client,
+ this,
+ );
+ this.reEmitter = new TypedReEmitter(this);
+
+ this.reEmitter.reEmit(this.timelineSet, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
+
+ this.room.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
+ this.room.on(RoomEvent.Redaction, this.onRedaction);
+ this.room.on(RoomEvent.LocalEchoUpdated, this.onLocalEcho);
+ this.timelineSet.on(RoomEvent.Timeline, this.onTimelineEvent);
+
+ this.processReceipts(opts.receipts);
+
+ // even if this thread is thought to be originating from this client, we initialise it as we may be in a
+ // gappy sync and a thread around this event may already exist.
+ this.updateThreadMetadata();
+ this.setEventMetadata(this.rootEvent);
+ }
+
+ private async fetchRootEvent(): Promise<void> {
+ this.rootEvent = this.room.findEventById(this.id);
+ // If the rootEvent does not exist in the local stores, then fetch it from the server.
+ try {
+ const eventData = await this.client.fetchRoomEvent(this.roomId, this.id);
+ const mapper = this.client.getEventMapper();
+ this.rootEvent = mapper(eventData); // will merge with existing event object if such is known
+ } catch (e) {
+ logger.error("Failed to fetch thread root to construct thread with", e);
+ }
+ await this.processEvent(this.rootEvent);
+ }
+
+ public static setServerSideSupport(status: FeatureSupport): void {
+ Thread.hasServerSideSupport = status;
+ if (status !== FeatureSupport.Stable) {
+ FILTER_RELATED_BY_SENDERS.setPreferUnstable(true);
+ FILTER_RELATED_BY_REL_TYPES.setPreferUnstable(true);
+ THREAD_RELATION_TYPE.setPreferUnstable(true);
+ }
+ }
+
+ public static setServerSideListSupport(status: FeatureSupport): void {
+ Thread.hasServerSideListSupport = status;
+ }
+
+ public static setServerSideFwdPaginationSupport(status: FeatureSupport): void {
+ Thread.hasServerSideFwdPaginationSupport = status;
+ }
+
+ private onBeforeRedaction = (event: MatrixEvent, redaction: MatrixEvent): void => {
+ if (
+ event?.isRelation(THREAD_RELATION_TYPE.name) &&
+ this.room.eventShouldLiveIn(event).threadId === this.id &&
+ event.getId() !== this.id && // the root event isn't counted in the length so ignore this redaction
+ !redaction.status // only respect it when it succeeds
+ ) {
+ this.replyCount--;
+ this.updatePendingReplyCount();
+ this.emit(ThreadEvent.Update, this);
+ }
+ };
+
+ private onRedaction = async (event: MatrixEvent): Promise<void> => {
+ if (event.threadRootId !== this.id) return; // ignore redactions for other timelines
+ if (this.replyCount <= 0) {
+ for (const threadEvent of this.timeline) {
+ this.clearEventMetadata(threadEvent);
+ }
+ this.lastEvent = this.rootEvent;
+ this._currentUserParticipated = false;
+ this.emit(ThreadEvent.Delete, this);
+ } else {
+ await this.updateThreadMetadata();
+ }
+ };
+
+ private onTimelineEvent = (
+ event: MatrixEvent,
+ room: Room | undefined,
+ toStartOfTimeline: boolean | undefined,
+ ): void => {
+ // Add a synthesized receipt when paginating forward in the timeline
+ if (!toStartOfTimeline) {
+ room!.addLocalEchoReceipt(event.getSender()!, event, ReceiptType.Read);
+ }
+ this.onEcho(event, toStartOfTimeline ?? false);
+ };
+
+ private onLocalEcho = (event: MatrixEvent): void => {
+ this.onEcho(event, false);
+ };
+
+ private onEcho = async (event: MatrixEvent, toStartOfTimeline: boolean): Promise<void> => {
+ if (event.threadRootId !== this.id) return; // ignore echoes for other timelines
+ if (this.lastEvent === event) return; // ignore duplicate events
+ await this.updateThreadMetadata();
+ if (!event.isRelation(THREAD_RELATION_TYPE.name)) return; // don't send a new reply event for reactions or edits
+ if (toStartOfTimeline) return; // ignore messages added to the start of the timeline
+ this.emit(ThreadEvent.NewReply, this, event);
+ };
+
+ public get roomState(): RoomState {
+ return this.room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
+ }
+
+ private addEventToTimeline(event: MatrixEvent, toStartOfTimeline: boolean): void {
+ if (!this.findEventById(event.getId()!)) {
+ this.timelineSet.addEventToTimeline(event, this.liveTimeline, {
+ toStartOfTimeline,
+ fromCache: false,
+ roomState: this.roomState,
+ });
+ this.timeline = this.events;
+ }
+ }
+
+ public addEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
+ events.forEach((ev) => this.addEvent(ev, toStartOfTimeline, false));
+ this.updateThreadMetadata();
+ }
+
+ /**
+ * Add an event to the thread and updates
+ * the tail/root references if needed
+ * Will fire "Thread.update"
+ * @param event - The event to add
+ * @param toStartOfTimeline - whether the event is being added
+ * to the start (and not the end) of the timeline.
+ * @param emit - whether to emit the Update event if the thread was updated or not.
+ */
+ public async addEvent(event: MatrixEvent, toStartOfTimeline: boolean, emit = true): Promise<void> {
+ this.setEventMetadata(event);
+
+ const lastReply = this.lastReply();
+ const isNewestReply = !lastReply || event.localTimestamp >= lastReply!.localTimestamp;
+
+ // Add all incoming events to the thread's timeline set when there's no server support
+ if (!Thread.hasServerSideSupport) {
+ // all the relevant membership info to hydrate events with a sender
+ // is held in the main room timeline
+ // We want to fetch the room state from there and pass it down to this thread
+ // timeline set to let it reconcile an event with its relevant RoomMember
+ this.addEventToTimeline(event, toStartOfTimeline);
+
+ this.client.decryptEventIfNeeded(event, {});
+ } else if (!toStartOfTimeline && this.initialEventsFetched && isNewestReply) {
+ this.addEventToTimeline(event, false);
+ this.fetchEditsWhereNeeded(event);
+ } else if (event.isRelation(RelationType.Annotation) || event.isRelation(RelationType.Replace)) {
+ if (!this.initialEventsFetched) {
+ /**
+ * A thread can be fully discovered via a single sync response
+ * And when that's the case we still ask the server to do an initialisation
+ * as it's the safest to ensure we have everything.
+ * However when we are in that scenario we might loose annotation or edits
+ *
+ * This fix keeps a reference to those events and replay them once the thread
+ * has been initialised properly.
+ */
+ this.replayEvents?.push(event);
+ } else {
+ this.addEventToTimeline(event, toStartOfTimeline);
+ }
+ // Apply annotations and replace relations to the relations of the timeline only
+ this.timelineSet.relations?.aggregateParentEvent(event);
+ this.timelineSet.relations?.aggregateChildEvent(event, this.timelineSet);
+ return;
+ }
+
+ // If no thread support exists we want to count all thread relation
+ // added as a reply. We can't rely on the bundled relationships count
+ if ((!Thread.hasServerSideSupport || !this.rootEvent) && event.isRelation(THREAD_RELATION_TYPE.name)) {
+ this.replyCount++;
+ }
+
+ if (emit) {
+ this.emit(ThreadEvent.NewReply, this, event);
+ this.updateThreadMetadata();
+ }
+ }
+
+ public async processEvent(event: Optional<MatrixEvent>): Promise<void> {
+ if (event) {
+ this.setEventMetadata(event);
+ await this.fetchEditsWhereNeeded(event);
+ }
+ this.timeline = this.events;
+ }
+
+ /**
+ * Processes the receipts that were caught during initial sync
+ * When clients become aware of a thread, they try to retrieve those read receipts
+ * and apply them to the current thread
+ * @param receipts - A collection of the receipts cached from initial sync
+ */
+ private processReceipts(receipts: CachedReceiptStructure[] = []): void {
+ for (const { eventId, receiptType, userId, receipt, synthetic } of receipts) {
+ this.addReceiptToStructure(eventId, receiptType as ReceiptType, userId, receipt, synthetic);
+ }
+ }
+
+ private getRootEventBundledRelationship(rootEvent = this.rootEvent): IThreadBundledRelationship | undefined {
+ return rootEvent?.getServerAggregatedRelation<IThreadBundledRelationship>(THREAD_RELATION_TYPE.name);
+ }
+
+ private async processRootEvent(): Promise<void> {
+ const bundledRelationship = this.getRootEventBundledRelationship();
+ if (Thread.hasServerSideSupport && bundledRelationship) {
+ this.replyCount = bundledRelationship.count;
+ this._currentUserParticipated = !!bundledRelationship.current_user_participated;
+
+ const mapper = this.client.getEventMapper();
+ // re-insert roomId
+ this.lastEvent = mapper({
+ ...bundledRelationship.latest_event,
+ room_id: this.roomId,
+ });
+ this.updatePendingReplyCount();
+ await this.processEvent(this.lastEvent);
+ }
+ }
+
+ private updatePendingReplyCount(): void {
+ const unfilteredPendingEvents =
+ this.pendingEventOrdering === PendingEventOrdering.Detached ? this.room.getPendingEvents() : this.events;
+ const pendingEvents = unfilteredPendingEvents.filter(
+ (ev) =>
+ ev.threadRootId === this.id &&
+ ev.isRelation(THREAD_RELATION_TYPE.name) &&
+ ev.status !== null &&
+ ev.getId() !== this.lastEvent?.getId(),
+ );
+ this.lastPendingEvent = pendingEvents.length ? pendingEvents[pendingEvents.length - 1] : undefined;
+ this.pendingReplyCount = pendingEvents.length;
+ }
+
+ /**
+ * Reset the live timeline of all timelineSets, and start new ones.
+ *
+ * <p>This is used when /sync returns a 'limited' timeline. 'Limited' means that there's a gap between the messages
+ * /sync returned, and the last known message in our timeline. In such a case, our live timeline isn't live anymore
+ * and has to be replaced by a new one. To make sure we can continue paginating our timelines correctly, we have to
+ * set new pagination tokens on the old and the new timeline.
+ *
+ * @param backPaginationToken - token for back-paginating the new timeline
+ * @param forwardPaginationToken - token for forward-paginating the old live timeline,
+ * if absent or null, all timelines are reset, removing old ones (including the previous live
+ * timeline which would otherwise be unable to paginate forwards without this token).
+ * Removing just the old live timeline whilst preserving previous ones is not supported.
+ */
+ public async resetLiveTimeline(
+ backPaginationToken?: string | null,
+ forwardPaginationToken?: string | null,
+ ): Promise<void> {
+ const oldLive = this.liveTimeline;
+ this.timelineSet.resetLiveTimeline(backPaginationToken ?? undefined, forwardPaginationToken ?? undefined);
+ const newLive = this.liveTimeline;
+
+ // FIXME: Remove the following as soon as https://github.com/matrix-org/synapse/issues/14830 is resolved.
+ //
+ // The pagination API for thread timelines currently can't handle the type of pagination tokens returned by sync
+ //
+ // To make this work anyway, we'll have to transform them into one of the types that the API can handle.
+ // One option is passing the tokens to /messages, which can handle sync tokens, and returns the right format.
+ // /messages does not return new tokens on requests with a limit of 0.
+ // This means our timelines might overlap a slight bit, but that's not an issue, as we deduplicate messages
+ // anyway.
+
+ let newBackward: string | undefined;
+ let oldForward: string | undefined;
+ if (backPaginationToken) {
+ const res = await this.client.createMessagesRequest(this.roomId, backPaginationToken, 1, Direction.Forward);
+ newBackward = res.end;
+ }
+ if (forwardPaginationToken) {
+ const res = await this.client.createMessagesRequest(
+ this.roomId,
+ forwardPaginationToken,
+ 1,
+ Direction.Backward,
+ );
+ oldForward = res.start;
+ }
+ // Only replace the token if we don't have paginated away from this position already. This situation doesn't
+ // occur today, but if the above issue is resolved, we'd have to go down this path.
+ if (forwardPaginationToken && oldLive.getPaginationToken(Direction.Forward) === forwardPaginationToken) {
+ oldLive.setPaginationToken(oldForward ?? null, Direction.Forward);
+ }
+ if (backPaginationToken && newLive.getPaginationToken(Direction.Backward) === backPaginationToken) {
+ newLive.setPaginationToken(newBackward ?? null, Direction.Backward);
+ }
+ }
+
+ private async updateThreadMetadata(): Promise<void> {
+ this.updatePendingReplyCount();
+
+ if (Thread.hasServerSideSupport) {
+ // Ensure we show *something* as soon as possible, we'll update it as soon as we get better data, but we
+ // don't want the thread preview to be empty if we can avoid it
+ if (!this.initialEventsFetched) {
+ await this.processRootEvent();
+ }
+ await this.fetchRootEvent();
+ }
+ await this.processRootEvent();
+
+ if (!this.initialEventsFetched) {
+ this.initialEventsFetched = true;
+ // fetch initial event to allow proper pagination
+ try {
+ // if the thread has regular events, this will just load the last reply.
+ // if the thread is newly created, this will load the root event.
+ if (this.replyCount === 0 && this.rootEvent) {
+ this.timelineSet.addEventsToTimeline([this.rootEvent], true, this.liveTimeline, null);
+ this.liveTimeline.setPaginationToken(null, Direction.Backward);
+ } else {
+ await this.client.paginateEventTimeline(this.liveTimeline, {
+ backwards: true,
+ limit: Math.max(1, this.length),
+ });
+ }
+ for (const event of this.replayEvents!) {
+ this.addEvent(event, false);
+ }
+ this.replayEvents = null;
+ // just to make sure that, if we've created a timeline window for this thread before the thread itself
+ // existed (e.g. when creating a new thread), we'll make sure the panel is force refreshed correctly.
+ this.emit(RoomEvent.TimelineReset, this.room, this.timelineSet, true);
+ } catch (e) {
+ logger.error("Failed to load start of newly created thread: ", e);
+ this.initialEventsFetched = false;
+ }
+ }
+
+ this.emit(ThreadEvent.Update, this);
+ }
+
+ // XXX: Workaround for https://github.com/matrix-org/matrix-spec-proposals/pull/2676/files#r827240084
+ private async fetchEditsWhereNeeded(...events: MatrixEvent[]): Promise<unknown> {
+ return Promise.all(
+ events
+ .filter((e) => e.isEncrypted())
+ .map((event: MatrixEvent) => {
+ if (event.isRelation()) return; // skip - relations don't get edits
+ return this.client
+ .relations(this.roomId, event.getId()!, RelationType.Replace, event.getType(), {
+ limit: 1,
+ })
+ .then((relations) => {
+ if (relations.events.length) {
+ event.makeReplaced(relations.events[0]);
+ }
+ })
+ .catch((e) => {
+ logger.error("Failed to load edits for encrypted thread event", e);
+ });
+ }),
+ );
+ }
+
+ public setEventMetadata(event: Optional<MatrixEvent>): void {
+ if (event) {
+ EventTimeline.setEventMetadata(event, this.roomState, false);
+ event.setThread(this);
+ }
+ }
+
+ public clearEventMetadata(event: Optional<MatrixEvent>): void {
+ if (event) {
+ event.setThread(undefined);
+ delete event.event?.unsigned?.["m.relations"]?.[THREAD_RELATION_TYPE.name];
+ }
+ }
+
+ /**
+ * Finds an event by ID in the current thread
+ */
+ public findEventById(eventId: string): MatrixEvent | undefined {
+ return this.timelineSet.findEventById(eventId);
+ }
+
+ /**
+ * Return last reply to the thread, if known.
+ */
+ public lastReply(matches: (ev: MatrixEvent) => boolean = (): boolean => true): MatrixEvent | null {
+ for (let i = this.timeline.length - 1; i >= 0; i--) {
+ const event = this.timeline[i];
+ if (matches(event)) {
+ return event;
+ }
+ }
+ return null;
+ }
+
+ public get roomId(): string {
+ return this.room.roomId;
+ }
+
+ /**
+ * The number of messages in the thread
+ * Only count rel_type=m.thread as we want to
+ * exclude annotations from that number
+ */
+ public get length(): number {
+ return this.replyCount + this.pendingReplyCount;
+ }
+
+ /**
+ * A getter for the last event of the thread.
+ * This might be a synthesized event, if so, it will not emit any events to listeners.
+ */
+ public get replyToEvent(): Optional<MatrixEvent> {
+ return this.lastPendingEvent ?? this.lastEvent ?? this.lastReply();
+ }
+
+ public get events(): MatrixEvent[] {
+ return this.liveTimeline.getEvents();
+ }
+
+ public has(eventId: string): boolean {
+ return this.timelineSet.findEventById(eventId) instanceof MatrixEvent;
+ }
+
+ public get hasCurrentUserParticipated(): boolean {
+ return this._currentUserParticipated;
+ }
+
+ public get liveTimeline(): EventTimeline {
+ return this.timelineSet.getLiveTimeline();
+ }
+
+ public getUnfilteredTimelineSet(): EventTimelineSet {
+ return this.timelineSet;
+ }
+
+ public addReceipt(event: MatrixEvent, synthetic: boolean): void {
+ throw new Error("Unsupported function on the thread model");
+ }
+
+ /**
+ * Get the ID of the event that a given user has read up to within this thread,
+ * or null if we have received no read receipt (at all) from them.
+ * @param userId - The user ID to get read receipt event ID for
+ * @param ignoreSynthesized - If true, return only receipts that have been
+ * sent by the server, not implicit ones generated
+ * by the JS SDK.
+ * @returns ID of the latest event that the given user has read, or null.
+ */
+ public getEventReadUpTo(userId: string, ignoreSynthesized?: boolean): string | null {
+ const isCurrentUser = userId === this.client.getUserId();
+ const lastReply = this.timeline[this.timeline.length - 1];
+ if (isCurrentUser && lastReply) {
+ // If the last activity in a thread is prior to the first threaded read receipt
+ // sent in the room (suggesting that it was sent before the user started
+ // using a client that supported threaded read receipts), we want to
+ // consider this thread as read.
+ const beforeFirstThreadedReceipt = lastReply.getTs() < this.room.getOldestThreadedReceiptTs();
+ const lastReplyId = lastReply.getId();
+ // Some unsent events do not have an ID, we do not want to consider them read
+ if (beforeFirstThreadedReceipt && lastReplyId) {
+ return lastReplyId;
+ }
+ }
+
+ const readUpToId = super.getEventReadUpTo(userId, ignoreSynthesized);
+
+ // Check whether the unthreaded read receipt for that user is more recent
+ // than the read receipt inside that thread.
+ if (lastReply) {
+ const unthreadedReceipt = this.room.getLastUnthreadedReceiptFor(userId);
+ if (!unthreadedReceipt) {
+ return readUpToId;
+ }
+
+ for (let i = this.timeline?.length - 1; i >= 0; --i) {
+ const ev = this.timeline[i];
+ // If we encounter the `readUpToId` we do not need to look further
+ // there is no "more recent" unthreaded read receipt
+ if (ev.getId() === readUpToId) return readUpToId;
+
+ // Inspecting events from most recent to oldest, we're checking
+ // whether an unthreaded read receipt is more recent that the current event.
+ // We usually prefer relying on the order of the DAG but in this scenario
+ // it is not possible and we have to rely on timestamp
+ if (ev.getTs() < unthreadedReceipt.ts) return ev.getId() ?? readUpToId;
+ }
+ }
+
+ return readUpToId;
+ }
+
+ /**
+ * Determine if the given user has read a particular event.
+ *
+ * It is invalid to call this method with an event that is not part of this thread.
+ *
+ * This is not a definitive check as it only checks the events that have been
+ * loaded client-side at the time of execution.
+ * @param userId - The user ID to check the read state of.
+ * @param eventId - The event ID to check if the user read.
+ * @returns True if the user has read the event, false otherwise.
+ */
+ public hasUserReadEvent(userId: string, eventId: string): boolean {
+ if (userId === this.client.getUserId()) {
+ // Consider an event read if it's part of a thread that is before the
+ // first threaded receipt sent in that room. It is likely that it is
+ // part of a thread that was created before MSC3771 was implemented.
+ // Or before the last unthreaded receipt for the logged in user
+ const beforeFirstThreadedReceipt =
+ (this.lastReply()?.getTs() ?? 0) < this.room.getOldestThreadedReceiptTs();
+ const unthreadedReceiptTs = this.room.getLastUnthreadedReceiptFor(userId)?.ts ?? 0;
+ const beforeLastUnthreadedReceipt = (this?.lastReply()?.getTs() ?? 0) < unthreadedReceiptTs;
+ if (beforeFirstThreadedReceipt || beforeLastUnthreadedReceipt) {
+ return true;
+ }
+ }
+
+ return super.hasUserReadEvent(userId, eventId);
+ }
+
+ public setUnread(type: NotificationCountType, count: number): void {
+ return this.room.setThreadUnreadNotificationCount(this.id, type, count);
+ }
+}
+
+export const FILTER_RELATED_BY_SENDERS = new ServerControlledNamespacedValue(
+ "related_by_senders",
+ "io.element.relation_senders",
+);
+export const FILTER_RELATED_BY_REL_TYPES = new ServerControlledNamespacedValue(
+ "related_by_rel_types",
+ "io.element.relation_types",
+);
+export const THREAD_RELATION_TYPE = new ServerControlledNamespacedValue("m.thread", "io.element.thread");
+
+export enum ThreadFilterType {
+ "My",
+ "All",
+}
+
+export function threadFilterTypeToFilter(type: ThreadFilterType | null): "all" | "participated" {
+ switch (type) {
+ case ThreadFilterType.My:
+ return "participated";
+ default:
+ return "all";
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/typed-event-emitter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/typed-event-emitter.ts
new file mode 100644
index 0000000..3cfe602
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/typed-event-emitter.ts
@@ -0,0 +1,114 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// eslint-disable-next-line no-restricted-imports
+import { EventEmitter } from "events";
+
+export enum EventEmitterEvents {
+ NewListener = "newListener",
+ RemoveListener = "removeListener",
+ Error = "error",
+}
+
+type AnyListener = (...args: any) => any;
+export type ListenerMap<E extends string> = { [eventName in E]: AnyListener };
+type EventEmitterEventListener = (eventName: string, listener: AnyListener) => void;
+type EventEmitterErrorListener = (error: Error) => void;
+
+export type Listener<E extends string, A extends ListenerMap<E>, T extends E | EventEmitterEvents> = T extends E
+ ? A[T]
+ : T extends EventEmitterEvents
+ ? EventEmitterErrorListener
+ : EventEmitterEventListener;
+
+/**
+ * Typed Event Emitter class which can act as a Base Model for all our model
+ * and communication events.
+ * This makes it much easier for us to distinguish between events, as we now need
+ * to properly type this, so that our events are not stringly-based and prone
+ * to silly typos.
+ */
+export class TypedEventEmitter<
+ Events extends string,
+ Arguments extends ListenerMap<Events>,
+ SuperclassArguments extends ListenerMap<any> = Arguments,
+> extends EventEmitter {
+ public addListener<T extends Events | EventEmitterEvents>(
+ event: T,
+ listener: Listener<Events, Arguments, T>,
+ ): this {
+ return super.addListener(event, listener);
+ }
+
+ public emit<T extends Events>(event: T, ...args: Parameters<SuperclassArguments[T]>): boolean;
+ public emit<T extends Events>(event: T, ...args: Parameters<Arguments[T]>): boolean;
+ public emit<T extends Events>(event: T, ...args: any[]): boolean {
+ return super.emit(event, ...args);
+ }
+
+ public eventNames(): (Events | EventEmitterEvents)[] {
+ return super.eventNames() as Array<Events | EventEmitterEvents>;
+ }
+
+ public listenerCount(event: Events | EventEmitterEvents): number {
+ return super.listenerCount(event);
+ }
+
+ public listeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["listeners"]> {
+ return super.listeners(event);
+ }
+
+ public off<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
+ return super.off(event, listener);
+ }
+
+ public on<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
+ return super.on(event, listener);
+ }
+
+ public once<T extends Events | EventEmitterEvents>(event: T, listener: Listener<Events, Arguments, T>): this {
+ return super.once(event, listener);
+ }
+
+ public prependListener<T extends Events | EventEmitterEvents>(
+ event: T,
+ listener: Listener<Events, Arguments, T>,
+ ): this {
+ return super.prependListener(event, listener);
+ }
+
+ public prependOnceListener<T extends Events | EventEmitterEvents>(
+ event: T,
+ listener: Listener<Events, Arguments, T>,
+ ): this {
+ return super.prependOnceListener(event, listener);
+ }
+
+ public removeAllListeners(event?: Events | EventEmitterEvents): this {
+ return super.removeAllListeners(event);
+ }
+
+ public removeListener<T extends Events | EventEmitterEvents>(
+ event: T,
+ listener: Listener<Events, Arguments, T>,
+ ): this {
+ return super.removeListener(event, listener);
+ }
+
+ public rawListeners(event: Events | EventEmitterEvents): ReturnType<EventEmitter["rawListeners"]> {
+ return super.rawListeners(event);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/models/user.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/user.ts
new file mode 100644
index 0000000..054a174
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/models/user.ts
@@ -0,0 +1,281 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixEvent } from "./event";
+import { TypedEventEmitter } from "./typed-event-emitter";
+
+export enum UserEvent {
+ DisplayName = "User.displayName",
+ AvatarUrl = "User.avatarUrl",
+ Presence = "User.presence",
+ CurrentlyActive = "User.currentlyActive",
+ LastPresenceTs = "User.lastPresenceTs",
+}
+
+export type UserEventHandlerMap = {
+ /**
+ * Fires whenever any user's display name changes.
+ * @param event - The matrix event which caused this event to fire.
+ * @param user - The user whose User.displayName changed.
+ * @example
+ * ```
+ * matrixClient.on("User.displayName", function(event, user){
+ * var newName = user.displayName;
+ * });
+ * ```
+ */
+ [UserEvent.DisplayName]: (event: MatrixEvent | undefined, user: User) => void;
+ /**
+ * Fires whenever any user's avatar URL changes.
+ * @param event - The matrix event which caused this event to fire.
+ * @param user - The user whose User.avatarUrl changed.
+ * @example
+ * ```
+ * matrixClient.on("User.avatarUrl", function(event, user){
+ * var newUrl = user.avatarUrl;
+ * });
+ * ```
+ */
+ [UserEvent.AvatarUrl]: (event: MatrixEvent | undefined, user: User) => void;
+ /**
+ * Fires whenever any user's presence changes.
+ * @param event - The matrix event which caused this event to fire.
+ * @param user - The user whose User.presence changed.
+ * @example
+ * ```
+ * matrixClient.on("User.presence", function(event, user){
+ * var newPresence = user.presence;
+ * });
+ * ```
+ */
+ [UserEvent.Presence]: (event: MatrixEvent | undefined, user: User) => void;
+ /**
+ * Fires whenever any user's currentlyActive changes.
+ * @param event - The matrix event which caused this event to fire.
+ * @param user - The user whose User.currentlyActive changed.
+ * @example
+ * ```
+ * matrixClient.on("User.currentlyActive", function(event, user){
+ * var newCurrentlyActive = user.currentlyActive;
+ * });
+ * ```
+ */
+ [UserEvent.CurrentlyActive]: (event: MatrixEvent | undefined, user: User) => void;
+ /**
+ * Fires whenever any user's lastPresenceTs changes,
+ * ie. whenever any presence event is received for a user.
+ * @param event - The matrix event which caused this event to fire.
+ * @param user - The user whose User.lastPresenceTs changed.
+ * @example
+ * ```
+ * matrixClient.on("User.lastPresenceTs", function(event, user){
+ * var newlastPresenceTs = user.lastPresenceTs;
+ * });
+ * ```
+ */
+ [UserEvent.LastPresenceTs]: (event: MatrixEvent | undefined, user: User) => void;
+};
+
+export class User extends TypedEventEmitter<UserEvent, UserEventHandlerMap> {
+ private modified = -1;
+
+ /**
+ * The 'displayname' of the user if known.
+ * @privateRemarks
+ * Should be read-only
+ */
+ public displayName?: string;
+ public rawDisplayName?: string;
+ /**
+ * The 'avatar_url' of the user if known.
+ * @privateRemarks
+ * Should be read-only
+ */
+ public avatarUrl?: string;
+ /**
+ * The presence status message if known.
+ * @privateRemarks
+ * Should be read-only
+ */
+ public presenceStatusMsg?: string;
+ /**
+ * The presence enum if known.
+ * @privateRemarks
+ * Should be read-only
+ */
+ public presence = "offline";
+ /**
+ * Timestamp (ms since the epoch) for when we last received presence data for this user.
+ * We can subtract lastActiveAgo from this to approximate an absolute value for when a user was last active.
+ * @privateRemarks
+ * Should be read-only
+ */
+ public lastActiveAgo = 0;
+ /**
+ * The time elapsed in ms since the user interacted proactively with the server,
+ * or we saw a message from the user
+ * @privateRemarks
+ * Should be read-only
+ */
+ public lastPresenceTs = 0;
+ /**
+ * Whether we should consider lastActiveAgo to be an approximation
+ * and that the user should be seen as active 'now'
+ * @privateRemarks
+ * Should be read-only
+ */
+ public currentlyActive = false;
+ /**
+ * The events describing this user.
+ * @privateRemarks
+ * Should be read-only
+ */
+ public events: {
+ /** The m.presence event for this user. */
+ presence?: MatrixEvent;
+ profile?: MatrixEvent;
+ } = {};
+
+ /**
+ * Construct a new User. A User must have an ID and can optionally have extra information associated with it.
+ * @param userId - Required. The ID of this user.
+ */
+ public constructor(public readonly userId: string) {
+ super();
+ this.displayName = userId;
+ this.rawDisplayName = userId;
+ this.updateModifiedTime();
+ }
+
+ /**
+ * Update this User with the given presence event. May fire "User.presence",
+ * "User.avatarUrl" and/or "User.displayName" if this event updates this user's
+ * properties.
+ * @param event - The `m.presence` event.
+ *
+ * @remarks
+ * Fires {@link UserEvent.Presence}
+ * Fires {@link UserEvent.DisplayName}
+ * Fires {@link UserEvent.AvatarUrl}
+ */
+ public setPresenceEvent(event: MatrixEvent): void {
+ if (event.getType() !== "m.presence") {
+ return;
+ }
+ const firstFire = this.events.presence === null;
+ this.events.presence = event;
+
+ const eventsToFire: UserEvent[] = [];
+ if (event.getContent().presence !== this.presence || firstFire) {
+ eventsToFire.push(UserEvent.Presence);
+ }
+ if (event.getContent().avatar_url && event.getContent().avatar_url !== this.avatarUrl) {
+ eventsToFire.push(UserEvent.AvatarUrl);
+ }
+ if (event.getContent().displayname && event.getContent().displayname !== this.displayName) {
+ eventsToFire.push(UserEvent.DisplayName);
+ }
+ if (
+ event.getContent().currently_active !== undefined &&
+ event.getContent().currently_active !== this.currentlyActive
+ ) {
+ eventsToFire.push(UserEvent.CurrentlyActive);
+ }
+
+ this.presence = event.getContent().presence;
+ eventsToFire.push(UserEvent.LastPresenceTs);
+
+ if (event.getContent().status_msg) {
+ this.presenceStatusMsg = event.getContent().status_msg;
+ }
+ if (event.getContent().displayname) {
+ this.displayName = event.getContent().displayname;
+ }
+ if (event.getContent().avatar_url) {
+ this.avatarUrl = event.getContent().avatar_url;
+ }
+ this.lastActiveAgo = event.getContent().last_active_ago;
+ this.lastPresenceTs = Date.now();
+ this.currentlyActive = event.getContent().currently_active;
+
+ this.updateModifiedTime();
+
+ for (const eventToFire of eventsToFire) {
+ this.emit(eventToFire, event, this);
+ }
+ }
+
+ /**
+ * Manually set this user's display name. No event is emitted in response to this
+ * as there is no underlying MatrixEvent to emit with.
+ * @param name - The new display name.
+ */
+ public setDisplayName(name: string): void {
+ const oldName = this.displayName;
+ this.displayName = name;
+ if (name !== oldName) {
+ this.updateModifiedTime();
+ }
+ }
+
+ /**
+ * Manually set this user's non-disambiguated display name. No event is emitted
+ * in response to this as there is no underlying MatrixEvent to emit with.
+ * @param name - The new display name.
+ */
+ public setRawDisplayName(name?: string): void {
+ this.rawDisplayName = name;
+ }
+
+ /**
+ * Manually set this user's avatar URL. No event is emitted in response to this
+ * as there is no underlying MatrixEvent to emit with.
+ * @param url - The new avatar URL.
+ */
+ public setAvatarUrl(url?: string): void {
+ const oldUrl = this.avatarUrl;
+ this.avatarUrl = url;
+ if (url !== oldUrl) {
+ this.updateModifiedTime();
+ }
+ }
+
+ /**
+ * Update the last modified time to the current time.
+ */
+ private updateModifiedTime(): void {
+ this.modified = Date.now();
+ }
+
+ /**
+ * Get the timestamp when this User was last updated. This timestamp is
+ * updated when this User receives a new Presence event which has updated a
+ * property on this object. It is updated <i>before</i> firing events.
+ * @returns The timestamp
+ */
+ public getLastModifiedTime(): number {
+ return this.modified;
+ }
+
+ /**
+ * Get the absolute timestamp when this User was last known active on the server.
+ * It is *NOT* accurate if this.currentlyActive is true.
+ * @returns The timestamp
+ */
+ public getLastActiveTs(): number {
+ return this.lastPresenceTs - this.lastActiveAgo;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/pushprocessor.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/pushprocessor.ts
new file mode 100644
index 0000000..78d26fe
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/pushprocessor.ts
@@ -0,0 +1,770 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { deepCompare, escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils";
+import { logger } from "./logger";
+import { MatrixClient } from "./client";
+import { MatrixEvent } from "./models/event";
+import {
+ ConditionKind,
+ IAnnotatedPushRule,
+ ICallStartedCondition,
+ ICallStartedPrefixCondition,
+ IContainsDisplayNameCondition,
+ IEventMatchCondition,
+ IEventPropertyIsCondition,
+ IEventPropertyContainsCondition,
+ IPushRule,
+ IPushRules,
+ IRoomMemberCountCondition,
+ ISenderNotificationPermissionCondition,
+ PushRuleAction,
+ PushRuleActionName,
+ PushRuleCondition,
+ PushRuleKind,
+ PushRuleSet,
+ RuleId,
+ TweakName,
+} from "./@types/PushRules";
+import { EventType } from "./@types/event";
+
+const RULEKINDS_IN_ORDER = [
+ PushRuleKind.Override,
+ PushRuleKind.ContentSpecific,
+ PushRuleKind.RoomSpecific,
+ PushRuleKind.SenderSpecific,
+ PushRuleKind.Underride,
+];
+
+// The default override rules to apply to the push rules that arrive from the server.
+// We do this for two reasons:
+// 1. Synapse is unlikely to send us the push rule in an incremental sync - see
+// https://github.com/matrix-org/synapse/pull/4867#issuecomment-481446072 for
+// more details.
+// 2. We often want to start using push rules ahead of the server supporting them,
+// and so we can put them here.
+const DEFAULT_OVERRIDE_RULES: IPushRule[] = [
+ {
+ // For homeservers which don't support MSC2153 yet
+ rule_id: ".m.rule.reaction",
+ default: true,
+ enabled: true,
+ conditions: [
+ {
+ kind: ConditionKind.EventMatch,
+ key: "type",
+ pattern: "m.reaction",
+ },
+ ],
+ actions: [PushRuleActionName.DontNotify],
+ },
+ {
+ rule_id: RuleId.IsUserMention,
+ default: true,
+ enabled: true,
+ conditions: [
+ {
+ kind: ConditionKind.EventPropertyContains,
+ key: "content.org\\.matrix\\.msc3952\\.mentions.user_ids",
+ value: "", // The user ID is dynamically added in rewriteDefaultRules.
+ },
+ ],
+ actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight }],
+ },
+ {
+ rule_id: RuleId.IsRoomMention,
+ default: true,
+ enabled: true,
+ conditions: [
+ {
+ kind: ConditionKind.EventPropertyIs,
+ key: "content.org\\.matrix\\.msc3952\\.mentions.room",
+ value: true,
+ },
+ {
+ kind: ConditionKind.SenderNotificationPermission,
+ key: "room",
+ },
+ ],
+ actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Highlight }],
+ },
+ {
+ // For homeservers which don't support MSC3786 yet
+ rule_id: ".org.matrix.msc3786.rule.room.server_acl",
+ default: true,
+ enabled: true,
+ conditions: [
+ {
+ kind: ConditionKind.EventMatch,
+ key: "type",
+ pattern: EventType.RoomServerAcl,
+ },
+ {
+ kind: ConditionKind.EventMatch,
+ key: "state_key",
+ pattern: "",
+ },
+ ],
+ actions: [],
+ },
+];
+
+const DEFAULT_UNDERRIDE_RULES: IPushRule[] = [
+ {
+ // For homeservers which don't support MSC3914 yet
+ rule_id: ".org.matrix.msc3914.rule.room.call",
+ default: true,
+ enabled: true,
+ conditions: [
+ {
+ kind: ConditionKind.EventMatch,
+ key: "type",
+ pattern: "org.matrix.msc3401.call",
+ },
+ {
+ kind: ConditionKind.CallStarted,
+ },
+ ],
+ actions: [PushRuleActionName.Notify, { set_tweak: TweakName.Sound, value: "default" }],
+ },
+];
+
+export interface IActionsObject {
+ /** Whether this event should notify the user or not. */
+ notify: boolean;
+ /** How this event should be notified. */
+ tweaks: Partial<Record<TweakName, any>>;
+}
+
+export class PushProcessor {
+ /**
+ * Construct a Push Processor.
+ * @param client - The Matrix client object to use
+ */
+ public constructor(private readonly client: MatrixClient) {}
+
+ /**
+ * Maps the original key from the push rules to a list of property names
+ * after unescaping.
+ */
+ private readonly parsedKeys = new Map<string, string[]>();
+
+ /**
+ * Convert a list of actions into a object with the actions as keys and their values
+ * @example
+ * eg. `[ 'notify', { set_tweak: 'sound', value: 'default' } ]`
+ * becomes `{ notify: true, tweaks: { sound: 'default' } }`
+ * @param actionList - The actions list
+ *
+ * @returns A object with key 'notify' (true or false) and an object of actions
+ */
+ public static actionListToActionsObject(actionList: PushRuleAction[]): IActionsObject {
+ const actionObj: IActionsObject = { notify: false, tweaks: {} };
+ for (const action of actionList) {
+ if (action === PushRuleActionName.Notify) {
+ actionObj.notify = true;
+ } else if (typeof action === "object") {
+ if (action.value === undefined) {
+ action.value = true;
+ }
+ actionObj.tweaks[action.set_tweak] = action.value;
+ }
+ }
+ return actionObj;
+ }
+
+ /**
+ * Rewrites conditions on a client's push rules to match the defaults
+ * where applicable. Useful for upgrading push rules to more strict
+ * conditions when the server is falling behind on defaults.
+ * @param incomingRules - The client's existing push rules
+ * @param userId - The Matrix ID of the client.
+ * @returns The rewritten rules
+ */
+ public static rewriteDefaultRules(incomingRules: IPushRules, userId: string | undefined = undefined): IPushRules {
+ let newRules: IPushRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone
+
+ // These lines are mostly to make the tests happy. We shouldn't run into these
+ // properties missing in practice.
+ if (!newRules) newRules = {} as IPushRules;
+ if (!newRules.global) newRules.global = {} as PushRuleSet;
+ if (!newRules.global.override) newRules.global.override = [];
+ if (!newRules.global.underride) newRules.global.underride = [];
+
+ // Merge the client-level defaults with the ones from the server
+ const globalOverrides = newRules.global.override;
+ for (const originalOverride of DEFAULT_OVERRIDE_RULES) {
+ const existingRule = globalOverrides.find((r) => r.rule_id === originalOverride.rule_id);
+
+ // Dynamically add the user ID as the value for the is_user_mention rule.
+ let override: IPushRule;
+ if (originalOverride.rule_id === RuleId.IsUserMention) {
+ // If the user ID wasn't provided, skip the rule.
+ if (!userId) {
+ continue;
+ }
+
+ override = JSON.parse(JSON.stringify(originalOverride)); // deep clone
+ override.conditions![0].value = userId;
+ } else {
+ override = originalOverride;
+ }
+
+ if (existingRule) {
+ // Copy over the actions, default, and conditions. Don't touch the user's preference.
+ existingRule.default = override.default;
+ existingRule.conditions = override.conditions;
+ existingRule.actions = override.actions;
+ } else {
+ // Add the rule
+ const ruleId = override.rule_id;
+ logger.warn(`Adding default global override for ${ruleId}`);
+ globalOverrides.push(override);
+ }
+ }
+
+ const globalUnderrides = newRules.global.underride ?? [];
+ for (const underride of DEFAULT_UNDERRIDE_RULES) {
+ const existingRule = globalUnderrides.find((r) => r.rule_id === underride.rule_id);
+
+ if (existingRule) {
+ // Copy over the actions, default, and conditions. Don't touch the user's preference.
+ existingRule.default = underride.default;
+ existingRule.conditions = underride.conditions;
+ existingRule.actions = underride.actions;
+ } else {
+ // Add the rule
+ const ruleId = underride.rule_id;
+ logger.warn(`Adding default global underride for ${ruleId}`);
+ globalUnderrides.push(underride);
+ }
+ }
+
+ return newRules;
+ }
+
+ /**
+ * Pre-caches the parsed keys for push rules and cleans out any obsolete cache
+ * entries. Should be called after push rules are updated.
+ * @param newRules - The new push rules.
+ */
+ public updateCachedPushRuleKeys(newRules: IPushRules): void {
+ // These lines are mostly to make the tests happy. We shouldn't run into these
+ // properties missing in practice.
+ if (!newRules) newRules = {} as IPushRules;
+ if (!newRules.global) newRules.global = {} as PushRuleSet;
+ if (!newRules.global.override) newRules.global.override = [];
+ if (!newRules.global.room) newRules.global.room = [];
+ if (!newRules.global.sender) newRules.global.sender = [];
+ if (!newRules.global.underride) newRules.global.underride = [];
+
+ // Process the 'key' property on event_match conditions pre-cache the
+ // values and clean-out any unused values.
+ const toRemoveKeys = new Set(this.parsedKeys.keys());
+ for (const ruleset of [
+ newRules.global.override,
+ newRules.global.room,
+ newRules.global.sender,
+ newRules.global.underride,
+ ]) {
+ for (const rule of ruleset) {
+ if (!rule.conditions) {
+ continue;
+ }
+
+ for (const condition of rule.conditions) {
+ if (condition.kind !== ConditionKind.EventMatch) {
+ continue;
+ }
+
+ // Ensure we keep this key.
+ toRemoveKeys.delete(condition.key);
+
+ // Pre-process the key.
+ this.parsedKeys.set(condition.key, PushProcessor.partsForDottedKey(condition.key));
+ }
+ }
+ }
+ // Any keys that were previously cached, but are no longer needed should
+ // be removed.
+ toRemoveKeys.forEach((k) => this.parsedKeys.delete(k));
+ }
+
+ private static cachedGlobToRegex: Record<string, RegExp> = {}; // $glob: RegExp
+
+ private matchingRuleFromKindSet(ev: MatrixEvent, kindset: PushRuleSet): IAnnotatedPushRule | null {
+ for (const kind of RULEKINDS_IN_ORDER) {
+ const ruleset = kindset[kind];
+ if (!ruleset) {
+ continue;
+ }
+
+ for (const rule of ruleset) {
+ if (!rule.enabled) {
+ continue;
+ }
+
+ const rawrule = this.templateRuleToRaw(kind, rule);
+ if (!rawrule) {
+ continue;
+ }
+
+ if (this.ruleMatchesEvent(rawrule, ev)) {
+ return {
+ ...rule,
+ kind,
+ };
+ }
+ }
+ }
+ return null;
+ }
+
+ private templateRuleToRaw(
+ kind: PushRuleKind,
+ tprule: IPushRule,
+ ): Pick<IPushRule, "rule_id" | "actions" | "conditions"> | null {
+ const rawrule: Pick<IPushRule, "rule_id" | "actions" | "conditions"> = {
+ rule_id: tprule.rule_id,
+ actions: tprule.actions,
+ conditions: [],
+ };
+ switch (kind) {
+ case PushRuleKind.Underride:
+ case PushRuleKind.Override:
+ rawrule.conditions = tprule.conditions;
+ break;
+ case PushRuleKind.RoomSpecific:
+ if (!tprule.rule_id) {
+ return null;
+ }
+ rawrule.conditions!.push({
+ kind: ConditionKind.EventMatch,
+ key: "room_id",
+ value: tprule.rule_id,
+ });
+ break;
+ case PushRuleKind.SenderSpecific:
+ if (!tprule.rule_id) {
+ return null;
+ }
+ rawrule.conditions!.push({
+ kind: ConditionKind.EventMatch,
+ key: "user_id",
+ value: tprule.rule_id,
+ });
+ break;
+ case PushRuleKind.ContentSpecific:
+ if (!tprule.pattern) {
+ return null;
+ }
+ rawrule.conditions!.push({
+ kind: ConditionKind.EventMatch,
+ key: "content.body",
+ pattern: tprule.pattern,
+ });
+ break;
+ }
+ return rawrule;
+ }
+
+ private eventFulfillsCondition(cond: PushRuleCondition, ev: MatrixEvent): boolean {
+ switch (cond.kind) {
+ case ConditionKind.EventMatch:
+ return this.eventFulfillsEventMatchCondition(cond, ev);
+ case ConditionKind.EventPropertyIs:
+ return this.eventFulfillsEventPropertyIsCondition(cond, ev);
+ case ConditionKind.EventPropertyContains:
+ return this.eventFulfillsEventPropertyContains(cond, ev);
+ case ConditionKind.ContainsDisplayName:
+ return this.eventFulfillsDisplayNameCondition(cond, ev);
+ case ConditionKind.RoomMemberCount:
+ return this.eventFulfillsRoomMemberCountCondition(cond, ev);
+ case ConditionKind.SenderNotificationPermission:
+ return this.eventFulfillsSenderNotifPermCondition(cond, ev);
+ case ConditionKind.CallStarted:
+ case ConditionKind.CallStartedPrefix:
+ return this.eventFulfillsCallStartedCondition(cond, ev);
+ }
+
+ // unknown conditions: we previously matched all unknown conditions,
+ // but given that rules can be added to the base rules on a server,
+ // it's probably better to not match unknown conditions.
+ return false;
+ }
+
+ private eventFulfillsSenderNotifPermCondition(
+ cond: ISenderNotificationPermissionCondition,
+ ev: MatrixEvent,
+ ): boolean {
+ const notifLevelKey = cond["key"];
+ if (!notifLevelKey) {
+ return false;
+ }
+
+ const room = this.client.getRoom(ev.getRoomId());
+ if (!room?.currentState) {
+ return false;
+ }
+
+ // Note that this should not be the current state of the room but the state at
+ // the point the event is in the DAG. Unfortunately the js-sdk does not store
+ // this.
+ return room.currentState.mayTriggerNotifOfType(notifLevelKey, ev.getSender()!);
+ }
+
+ private eventFulfillsRoomMemberCountCondition(cond: IRoomMemberCountCondition, ev: MatrixEvent): boolean {
+ if (!cond.is) {
+ return false;
+ }
+
+ const room = this.client.getRoom(ev.getRoomId());
+ if (!room || !room.currentState || !room.currentState.members) {
+ return false;
+ }
+
+ const memberCount = room.currentState.getJoinedMemberCount();
+
+ const m = cond.is.match(/^([=<>]*)(\d*)$/);
+ if (!m) {
+ return false;
+ }
+ const ineq = m[1];
+ const rhs = parseInt(m[2]);
+ if (isNaN(rhs)) {
+ return false;
+ }
+ switch (ineq) {
+ case "":
+ case "==":
+ return memberCount == rhs;
+ case "<":
+ return memberCount < rhs;
+ case ">":
+ return memberCount > rhs;
+ case "<=":
+ return memberCount <= rhs;
+ case ">=":
+ return memberCount >= rhs;
+ default:
+ return false;
+ }
+ }
+
+ private eventFulfillsDisplayNameCondition(cond: IContainsDisplayNameCondition, ev: MatrixEvent): boolean {
+ let content = ev.getContent();
+ if (ev.isEncrypted() && ev.getClearContent()) {
+ content = ev.getClearContent()!;
+ }
+ if (!content || !content.body || typeof content.body != "string") {
+ return false;
+ }
+
+ const room = this.client.getRoom(ev.getRoomId());
+ const member = room?.currentState?.getMember(this.client.credentials.userId!);
+ if (!member) {
+ return false;
+ }
+
+ const displayName = member.name;
+
+ // N.B. we can't use \b as it chokes on unicode. however \W seems to be okay
+ // as shorthand for [^0-9A-Za-z_].
+ const pat = new RegExp("(^|\\W)" + escapeRegExp(displayName) + "(\\W|$)", "i");
+ return content.body.search(pat) > -1;
+ }
+
+ /**
+ * Check whether the given event matches the push rule condition by fetching
+ * the property from the event and comparing against the condition's glob-based
+ * pattern.
+ * @param cond - The push rule condition to check for a match.
+ * @param ev - The event to check for a match.
+ */
+ private eventFulfillsEventMatchCondition(cond: IEventMatchCondition, ev: MatrixEvent): boolean {
+ if (!cond.key) {
+ return false;
+ }
+
+ const val = this.valueForDottedKey(cond.key, ev);
+ if (typeof val !== "string") {
+ return false;
+ }
+
+ // XXX This does not match in a case-insensitive manner.
+ //
+ // See https://spec.matrix.org/v1.5/client-server-api/#conditions-1
+ if (cond.value) {
+ return cond.value === val;
+ }
+
+ if (typeof cond.pattern !== "string") {
+ return false;
+ }
+
+ const regex =
+ cond.key === "content.body"
+ ? this.createCachedRegex("(^|\\W)", cond.pattern, "(\\W|$)")
+ : this.createCachedRegex("^", cond.pattern, "$");
+
+ return !!val.match(regex);
+ }
+
+ /**
+ * Check whether the given event matches the push rule condition by fetching
+ * the property from the event and comparing exactly against the condition's
+ * value.
+ * @param cond - The push rule condition to check for a match.
+ * @param ev - The event to check for a match.
+ */
+ private eventFulfillsEventPropertyIsCondition(cond: IEventPropertyIsCondition, ev: MatrixEvent): boolean {
+ if (!cond.key || cond.value === undefined) {
+ return false;
+ }
+ return cond.value === this.valueForDottedKey(cond.key, ev);
+ }
+
+ /**
+ * Check whether the given event matches the push rule condition by fetching
+ * the property from the event and comparing exactly against the condition's
+ * value.
+ * @param cond - The push rule condition to check for a match.
+ * @param ev - The event to check for a match.
+ */
+ private eventFulfillsEventPropertyContains(cond: IEventPropertyContainsCondition, ev: MatrixEvent): boolean {
+ if (!cond.key || cond.value === undefined) {
+ return false;
+ }
+ const val = this.valueForDottedKey(cond.key, ev);
+ if (!Array.isArray(val)) {
+ return false;
+ }
+ return val.includes(cond.value);
+ }
+
+ private eventFulfillsCallStartedCondition(
+ _cond: ICallStartedCondition | ICallStartedPrefixCondition,
+ ev: MatrixEvent,
+ ): boolean {
+ // Since servers don't support properly sending push notification
+ // about MSC3401 call events, we do the handling ourselves
+ return (
+ ["m.ring", "m.prompt"].includes(ev.getContent()["m.intent"]) &&
+ !("m.terminated" in ev.getContent()) &&
+ (ev.getPrevContent()["m.terminated"] !== ev.getContent()["m.terminated"] ||
+ deepCompare(ev.getPrevContent(), {}))
+ );
+ }
+
+ private createCachedRegex(prefix: string, glob: string, suffix: string): RegExp {
+ if (PushProcessor.cachedGlobToRegex[glob]) {
+ return PushProcessor.cachedGlobToRegex[glob];
+ }
+ PushProcessor.cachedGlobToRegex[glob] = new RegExp(
+ prefix + globToRegexp(glob) + suffix,
+ "i", // Case insensitive
+ );
+ return PushProcessor.cachedGlobToRegex[glob];
+ }
+
+ /**
+ * Parse the key into the separate fields to search by splitting on
+ * unescaped ".", and then removing any escape characters.
+ *
+ * @param str - The key of the push rule condition: a dotted field.
+ * @returns The unescaped parts to fetch.
+ * @internal
+ */
+ public static partsForDottedKey(str: string): string[] {
+ const result = [];
+
+ // The current field and whether the previous character was the escape
+ // character (a backslash).
+ let part = "";
+ let escaped = false;
+
+ // Iterate over each character, and decide whether to append to the current
+ // part (following the escape rules) or to start a new part (based on the
+ // field separator).
+ for (const c of str) {
+ // If the previous character was the escape character (a backslash)
+ // then decide what to append to the current part.
+ if (escaped) {
+ if (c === "\\" || c === ".") {
+ // An escaped backslash or dot just gets added.
+ part += c;
+ } else {
+ // A character that shouldn't be escaped gets the backslash prepended.
+ part += "\\" + c;
+ }
+ // This always resets being escaped.
+ escaped = false;
+ continue;
+ }
+
+ if (c == ".") {
+ // The field separator creates a new part.
+ result.push(part);
+ part = "";
+ } else if (c == "\\") {
+ // A backslash adds no characters, but starts an escape sequence.
+ escaped = true;
+ } else {
+ // Otherwise, just add the current character.
+ part += c;
+ }
+ }
+
+ // Ensure the final part is included. If there's an open escape sequence
+ // it should be included.
+ if (escaped) {
+ part += "\\";
+ }
+ result.push(part);
+
+ return result;
+ }
+
+ /**
+ * For a dotted field and event, fetch the value at that position, if one
+ * exists.
+ *
+ * @param key - The key of the push rule condition: a dotted field to fetch.
+ * @param ev - The matrix event to fetch the field from.
+ * @returns The value at the dotted path given by key.
+ */
+ private valueForDottedKey(key: string, ev: MatrixEvent): any {
+ // The key should already have been parsed via updateCachedPushRuleKeys,
+ // but if it hasn't (maybe via an old consumer of the SDK which hasn't
+ // been updated?) then lazily calculate it here.
+ let parts = this.parsedKeys.get(key);
+ if (parts === undefined) {
+ parts = PushProcessor.partsForDottedKey(key);
+ this.parsedKeys.set(key, parts);
+ }
+ let val: any;
+
+ // special-case the first component to deal with encrypted messages
+ const firstPart = parts[0];
+ let currentIndex = 0;
+ if (firstPart === "content") {
+ val = ev.getContent();
+ ++currentIndex;
+ } else if (firstPart === "type") {
+ val = ev.getType();
+ ++currentIndex;
+ } else {
+ // use the raw event for any other fields
+ val = ev.event;
+ }
+
+ for (; currentIndex < parts.length; ++currentIndex) {
+ // The previous iteration resulted in null or undefined, bail (and
+ // avoid the type error of attempting to retrieve a property).
+ if (isNullOrUndefined(val)) {
+ return undefined;
+ }
+
+ const thisPart = parts[currentIndex];
+ val = val[thisPart];
+ }
+ return val;
+ }
+
+ private matchingRuleForEventWithRulesets(ev: MatrixEvent, rulesets?: IPushRules): IAnnotatedPushRule | null {
+ if (!rulesets) {
+ return null;
+ }
+ if (ev.getSender() === this.client.credentials.userId) {
+ return null;
+ }
+
+ return this.matchingRuleFromKindSet(ev, rulesets.global);
+ }
+
+ private pushActionsForEventAndRulesets(ev: MatrixEvent, rulesets?: IPushRules): IActionsObject {
+ const rule = this.matchingRuleForEventWithRulesets(ev, rulesets);
+ if (!rule) {
+ return {} as IActionsObject;
+ }
+
+ const actionObj = PushProcessor.actionListToActionsObject(rule.actions);
+
+ // Some actions are implicit in some situations: we add those here
+ if (actionObj.tweaks.highlight === undefined) {
+ // if it isn't specified, highlight if it's a content
+ // rule but otherwise not
+ actionObj.tweaks.highlight = rule.kind == PushRuleKind.ContentSpecific;
+ }
+
+ return actionObj;
+ }
+
+ public ruleMatchesEvent(rule: Partial<IPushRule> & Pick<IPushRule, "conditions">, ev: MatrixEvent): boolean {
+ // Disable the deprecated mentions push rules if the new mentions property exists.
+ if (
+ this.client.supportsIntentionalMentions() &&
+ ev.getContent()["org.matrix.msc3952.mentions"] !== undefined &&
+ (rule.rule_id === RuleId.ContainsUserName ||
+ rule.rule_id === RuleId.ContainsDisplayName ||
+ rule.rule_id === RuleId.AtRoomNotification)
+ ) {
+ return false;
+ }
+
+ return !rule.conditions?.some((cond) => !this.eventFulfillsCondition(cond, ev));
+ }
+
+ /**
+ * Get the user's push actions for the given event
+ */
+ public actionsForEvent(ev: MatrixEvent): IActionsObject {
+ return this.pushActionsForEventAndRulesets(ev, this.client.pushRules);
+ }
+
+ /**
+ * Get one of the users push rules by its ID
+ *
+ * @param ruleId - The ID of the rule to search for
+ * @returns The push rule, or null if no such rule was found
+ */
+ public getPushRuleById(ruleId: string): IPushRule | null {
+ const result = this.getPushRuleAndKindById(ruleId);
+ return result?.rule ?? null;
+ }
+
+ /**
+ * Get one of the users push rules by its ID
+ *
+ * @param ruleId - The ID of the rule to search for
+ * @returns rule The push rule, or null if no such rule was found
+ * @returns kind - The PushRuleKind of the rule to search for
+ */
+ public getPushRuleAndKindById(ruleId: string): { rule: IPushRule; kind: PushRuleKind } | null {
+ for (const scope of ["global"] as const) {
+ if (this.client.pushRules?.[scope] === undefined) continue;
+
+ for (const kind of RULEKINDS_IN_ORDER) {
+ if (this.client.pushRules[scope][kind] === undefined) continue;
+
+ for (const rule of this.client.pushRules[scope][kind]!) {
+ if (rule.rule_id === ruleId) return { rule, kind };
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/randomstring.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/randomstring.ts
new file mode 100644
index 0000000..0ed46fb
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/randomstring.ts
@@ -0,0 +1,42 @@
+/*
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+const LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
+const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+const DIGITS = "0123456789";
+
+export function randomString(len: number): string {
+ return randomStringFrom(len, UPPERCASE + LOWERCASE + DIGITS);
+}
+
+export function randomLowercaseString(len: number): string {
+ return randomStringFrom(len, LOWERCASE);
+}
+
+export function randomUppercaseString(len: number): string {
+ return randomStringFrom(len, UPPERCASE);
+}
+
+function randomStringFrom(len: number, chars: string): string {
+ let ret = "";
+
+ for (let i = 0; i < len; ++i) {
+ ret += chars.charAt(Math.floor(Math.random() * chars.length));
+ }
+
+ return ret;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/realtime-callbacks.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/realtime-callbacks.ts
new file mode 100644
index 0000000..1b03a57
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/realtime-callbacks.ts
@@ -0,0 +1,191 @@
+/*
+Copyright 2016 OpenMarket Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* A re-implementation of the javascript callback functions (setTimeout,
+ * clearTimeout; setInterval and clearInterval are not yet implemented) which
+ * try to improve handling of large clock jumps (as seen when
+ * suspending/resuming the system).
+ *
+ * In particular, if a timeout would have fired while the system was suspended,
+ * it will instead fire as soon as possible after resume.
+ */
+
+import { logger } from "./logger";
+
+// we schedule a callback at least this often, to check if we've missed out on
+// some wall-clock time due to being suspended.
+const TIMER_CHECK_PERIOD_MS = 1000;
+
+// counter, for making up ids to return from setTimeout
+let count = 0;
+
+// the key for our callback with the real global.setTimeout
+let realCallbackKey: NodeJS.Timeout | number;
+
+type Callback = {
+ runAt: number;
+ func: (...params: any[]) => void;
+ params: any[];
+ key: number;
+};
+
+// a sorted list of the callbacks to be run.
+// each is an object with keys [runAt, func, params, key].
+const callbackList: Callback[] = [];
+
+// var debuglog = logger.log.bind(logger);
+/* istanbul ignore next */
+const debuglog = function (...params: any[]): void {};
+
+/**
+ * reimplementation of window.setTimeout, which will call the callback if
+ * the wallclock time goes past the deadline.
+ *
+ * @param func - callback to be called after a delay
+ * @param delayMs - number of milliseconds to delay by
+ *
+ * @returns an identifier for this callback, which may be passed into
+ * clearTimeout later.
+ */
+export function setTimeout(func: (...params: any[]) => void, delayMs: number, ...params: any[]): number {
+ delayMs = delayMs || 0;
+ if (delayMs < 0) {
+ delayMs = 0;
+ }
+
+ const runAt = Date.now() + delayMs;
+ const key = count++;
+ debuglog("setTimeout: scheduling cb", key, "at", runAt, "(delay", delayMs, ")");
+ const data = {
+ runAt: runAt,
+ func: func,
+ params: params,
+ key: key,
+ };
+
+ // figure out where it goes in the list
+ const idx = binarySearch(callbackList, function (el) {
+ return el.runAt - runAt;
+ });
+
+ callbackList.splice(idx, 0, data);
+ scheduleRealCallback();
+
+ return key;
+}
+
+/**
+ * reimplementation of window.clearTimeout, which mirrors setTimeout
+ *
+ * @param key - result from an earlier setTimeout call
+ */
+export function clearTimeout(key: number): void {
+ if (callbackList.length === 0) {
+ return;
+ }
+
+ // remove the element from the list
+ let i: number;
+ for (i = 0; i < callbackList.length; i++) {
+ const cb = callbackList[i];
+ if (cb.key == key) {
+ callbackList.splice(i, 1);
+ break;
+ }
+ }
+
+ // iff it was the first one in the list, reschedule our callback.
+ if (i === 0) {
+ scheduleRealCallback();
+ }
+}
+
+// use the real global.setTimeout to schedule a callback to runCallbacks.
+function scheduleRealCallback(): void {
+ if (realCallbackKey) {
+ global.clearTimeout(realCallbackKey as NodeJS.Timeout);
+ }
+
+ const first = callbackList[0];
+
+ if (!first) {
+ debuglog("scheduleRealCallback: no more callbacks, not rescheduling");
+ return;
+ }
+
+ const timestamp = Date.now();
+ const delayMs = Math.min(first.runAt - timestamp, TIMER_CHECK_PERIOD_MS);
+
+ debuglog("scheduleRealCallback: now:", timestamp, "delay:", delayMs);
+ realCallbackKey = global.setTimeout(runCallbacks, delayMs);
+}
+
+function runCallbacks(): void {
+ const timestamp = Date.now();
+ debuglog("runCallbacks: now:", timestamp);
+
+ // get the list of things to call
+ const callbacksToRun: Callback[] = [];
+ // eslint-disable-next-line
+ while (true) {
+ const first = callbackList[0];
+ if (!first || first.runAt > timestamp) {
+ break;
+ }
+ const cb = callbackList.shift()!;
+ debuglog("runCallbacks: popping", cb.key);
+ callbacksToRun.push(cb);
+ }
+
+ // reschedule the real callback before running our functions, to
+ // keep the codepaths the same whether or not our functions
+ // register their own setTimeouts.
+ scheduleRealCallback();
+
+ for (const cb of callbacksToRun) {
+ try {
+ cb.func.apply(global, cb.params);
+ } catch (e) {
+ logger.error("Uncaught exception in callback function", e);
+ }
+ }
+}
+
+/* search in a sorted array.
+ *
+ * returns the index of the last element for which func returns
+ * greater than zero, or array.length if no such element exists.
+ */
+function binarySearch<T>(array: T[], func: (v: T) => number): number {
+ // min is inclusive, max exclusive.
+ let min = 0;
+ let max = array.length;
+
+ while (min < max) {
+ const mid = (min + max) >> 1;
+ const res = func(array[mid]);
+ if (res > 0) {
+ // the element at 'mid' is too big; set it as the new max.
+ max = mid;
+ } else {
+ // the element at 'mid' is too small. 'min' is inclusive, so +1.
+ min = mid + 1;
+ }
+ }
+ // presumably, min==max now.
+ return min;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/MSC3906Rendezvous.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/MSC3906Rendezvous.ts
new file mode 100644
index 0000000..f431c83
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/MSC3906Rendezvous.ts
@@ -0,0 +1,264 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { UnstableValue } from "matrix-events-sdk";
+
+import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
+import { MatrixClient } from "../client";
+import { CrossSigningInfo } from "../crypto/CrossSigning";
+import { DeviceInfo } from "../crypto/deviceinfo";
+import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
+import { logger } from "../logger";
+import { sleep } from "../utils";
+
+enum PayloadType {
+ Start = "m.login.start",
+ Finish = "m.login.finish",
+ Progress = "m.login.progress",
+}
+
+enum Outcome {
+ Success = "success",
+ Failure = "failure",
+ Verified = "verified",
+ Declined = "declined",
+ Unsupported = "unsupported",
+}
+
+export interface MSC3906RendezvousPayload {
+ type: PayloadType;
+ intent?: RendezvousIntent;
+ outcome?: Outcome;
+ device_id?: string;
+ device_key?: string;
+ verifying_device_id?: string;
+ verifying_device_key?: string;
+ master_key?: string;
+ protocols?: string[];
+ protocol?: string;
+ login_token?: string;
+ homeserver?: string;
+}
+
+const LOGIN_TOKEN_PROTOCOL = new UnstableValue("login_token", "org.matrix.msc3906.login_token");
+
+/**
+ * Implements MSC3906 to allow a user to sign in on a new device using QR code.
+ * This implementation only supports generating a QR code on a device that is already signed in.
+ * Note that this is UNSTABLE and may have breaking changes without notice.
+ */
+export class MSC3906Rendezvous {
+ private newDeviceId?: string;
+ private newDeviceKey?: string;
+ private ourIntent: RendezvousIntent = RendezvousIntent.RECIPROCATE_LOGIN_ON_EXISTING_DEVICE;
+ private _code?: string;
+
+ /**
+ * @param channel - The secure channel used for communication
+ * @param client - The Matrix client in used on the device already logged in
+ * @param onFailure - Callback for when the rendezvous fails
+ */
+ public constructor(
+ private channel: RendezvousChannel<MSC3906RendezvousPayload>,
+ private client: MatrixClient,
+ public onFailure?: RendezvousFailureListener,
+ ) {}
+
+ /**
+ * Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet.
+ */
+ public get code(): string | undefined {
+ return this._code;
+ }
+
+ /**
+ * Generate the code including doing partial set up of the channel where required.
+ */
+ public async generateCode(): Promise<void> {
+ if (this._code) {
+ return;
+ }
+
+ this._code = JSON.stringify(await this.channel.generateCode(this.ourIntent));
+ }
+
+ public async startAfterShowingCode(): Promise<string | undefined> {
+ const checksum = await this.channel.connect();
+
+ logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`);
+
+ const features = await buildFeatureSupportMap(await this.client.getVersions());
+ // determine available protocols
+ if (features.get(Feature.LoginTokenRequest) === ServerSupport.Unsupported) {
+ logger.info("Server doesn't support MSC3882");
+ await this.send({ type: PayloadType.Finish, outcome: Outcome.Unsupported });
+ await this.cancel(RendezvousFailureReason.HomeserverLacksSupport);
+ return undefined;
+ }
+
+ await this.send({ type: PayloadType.Progress, protocols: [LOGIN_TOKEN_PROTOCOL.name] });
+
+ logger.info("Waiting for other device to chose protocol");
+ const { type, protocol, outcome } = await this.receive();
+
+ if (type === PayloadType.Finish) {
+ // new device decided not to complete
+ switch (outcome ?? "") {
+ case "unsupported":
+ await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm);
+ break;
+ default:
+ await this.cancel(RendezvousFailureReason.Unknown);
+ }
+ return undefined;
+ }
+
+ if (type !== PayloadType.Progress) {
+ await this.cancel(RendezvousFailureReason.Unknown);
+ return undefined;
+ }
+
+ if (!protocol || !LOGIN_TOKEN_PROTOCOL.matches(protocol)) {
+ await this.cancel(RendezvousFailureReason.UnsupportedAlgorithm);
+ return undefined;
+ }
+
+ return checksum;
+ }
+
+ private async receive(): Promise<MSC3906RendezvousPayload> {
+ return (await this.channel.receive()) as MSC3906RendezvousPayload;
+ }
+
+ private async send(payload: MSC3906RendezvousPayload): Promise<void> {
+ await this.channel.send(payload);
+ }
+
+ public async declineLoginOnExistingDevice(): Promise<void> {
+ logger.info("User declined sign in");
+ await this.send({ type: PayloadType.Finish, outcome: Outcome.Declined });
+ }
+
+ public async approveLoginOnExistingDevice(loginToken: string): Promise<string | undefined> {
+ // eslint-disable-next-line camelcase
+ await this.send({ type: PayloadType.Progress, login_token: loginToken, homeserver: this.client.baseUrl });
+
+ logger.info("Waiting for outcome");
+ const res = await this.receive();
+ if (!res) {
+ return undefined;
+ }
+ const { outcome, device_id: deviceId, device_key: deviceKey } = res;
+
+ if (outcome !== "success") {
+ throw new Error("Linking failed");
+ }
+
+ this.newDeviceId = deviceId;
+ this.newDeviceKey = deviceKey;
+
+ return deviceId;
+ }
+
+ private async verifyAndCrossSignDevice(deviceInfo: DeviceInfo): Promise<CrossSigningInfo | DeviceInfo> {
+ if (!this.client.crypto) {
+ throw new Error("Crypto not available on client");
+ }
+
+ if (!this.newDeviceId) {
+ throw new Error("No new device ID set");
+ }
+
+ // check that keys received from the server for the new device match those received from the device itself
+ if (deviceInfo.getFingerprint() !== this.newDeviceKey) {
+ throw new Error(
+ `New device has different keys than expected: ${this.newDeviceKey} vs ${deviceInfo.getFingerprint()}`,
+ );
+ }
+
+ const userId = this.client.getUserId();
+
+ if (!userId) {
+ throw new Error("No user ID set");
+ }
+ // mark the device as verified locally + cross sign
+ logger.info(`Marking device ${this.newDeviceId} as verified`);
+ const info = await this.client.crypto.setDeviceVerification(userId, this.newDeviceId, true, false, true);
+
+ const masterPublicKey = this.client.crypto.crossSigningInfo.getId("master")!;
+
+ await this.send({
+ type: PayloadType.Finish,
+ outcome: Outcome.Verified,
+ verifying_device_id: this.client.getDeviceId()!,
+ verifying_device_key: this.client.getDeviceEd25519Key()!,
+ master_key: masterPublicKey,
+ });
+
+ return info;
+ }
+
+ /**
+ * Verify the device and cross-sign it.
+ * @param timeout - time in milliseconds to wait for device to come online
+ * @returns the new device info if the device was verified
+ */
+ public async verifyNewDeviceOnExistingDevice(
+ timeout = 10 * 1000,
+ ): Promise<DeviceInfo | CrossSigningInfo | undefined> {
+ if (!this.newDeviceId) {
+ throw new Error("No new device to sign");
+ }
+
+ if (!this.newDeviceKey) {
+ logger.info("No new device key to sign");
+ return undefined;
+ }
+
+ if (!this.client.crypto) {
+ throw new Error("Crypto not available on client");
+ }
+
+ const userId = this.client.getUserId();
+
+ if (!userId) {
+ throw new Error("No user ID set");
+ }
+
+ let deviceInfo = this.client.crypto.getStoredDevice(userId, this.newDeviceId);
+
+ if (!deviceInfo) {
+ logger.info("Going to wait for new device to be online");
+ await sleep(timeout);
+ deviceInfo = this.client.crypto.getStoredDevice(userId, this.newDeviceId);
+ }
+
+ if (deviceInfo) {
+ return await this.verifyAndCrossSignDevice(deviceInfo);
+ }
+
+ throw new Error("Device not online within timeout");
+ }
+
+ public async cancel(reason: RendezvousFailureReason): Promise<void> {
+ this.onFailure?.(reason);
+ await this.channel.cancel(reason);
+ }
+
+ public async close(): Promise<void> {
+ await this.channel.close();
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousChannel.ts
new file mode 100644
index 0000000..549ebc8
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousChannel.ts
@@ -0,0 +1,48 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RendezvousCode, RendezvousIntent, RendezvousFailureReason } from ".";
+
+export interface RendezvousChannel<T> {
+ /**
+ * @returns the checksum/confirmation digits to be shown to the user
+ */
+ connect(): Promise<string>;
+
+ /**
+ * Send a payload via the channel.
+ * @param data - payload to send
+ */
+ send(data: T): Promise<void>;
+
+ /**
+ * Receive a payload from the channel.
+ * @returns the received payload
+ */
+ receive(): Promise<Partial<T> | undefined>;
+
+ /**
+ * Close the channel and clear up any resources.
+ */
+ close(): Promise<void>;
+
+ /**
+ * @returns a representation of the channel that can be encoded in a QR or similar
+ */
+ generateCode(intent: RendezvousIntent): Promise<RendezvousCode>;
+
+ cancel(reason: RendezvousFailureReason): Promise<void>;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousCode.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousCode.ts
new file mode 100644
index 0000000..86608aa
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousCode.ts
@@ -0,0 +1,25 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RendezvousTransportDetails, RendezvousIntent } from ".";
+
+export interface RendezvousCode {
+ intent: RendezvousIntent;
+ rendezvous?: {
+ transport: RendezvousTransportDetails;
+ algorithm: string;
+ };
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousError.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousError.ts
new file mode 100644
index 0000000..8b76fc1
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousError.ts
@@ -0,0 +1,23 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RendezvousFailureReason } from ".";
+
+export class RendezvousError extends Error {
+ public constructor(message: string, public readonly code: RendezvousFailureReason) {
+ super(message);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousFailureReason.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousFailureReason.ts
new file mode 100644
index 0000000..b19a91c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousFailureReason.ts
@@ -0,0 +1,31 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export type RendezvousFailureListener = (reason: RendezvousFailureReason) => void;
+
+export enum RendezvousFailureReason {
+ UserDeclined = "user_declined",
+ OtherDeviceNotSignedIn = "other_device_not_signed_in",
+ OtherDeviceAlreadySignedIn = "other_device_already_signed_in",
+ Unknown = "unknown",
+ Expired = "expired",
+ UserCancelled = "user_cancelled",
+ InvalidCode = "invalid_code",
+ UnsupportedAlgorithm = "unsupported_algorithm",
+ DataMismatch = "data_mismatch",
+ UnsupportedTransport = "unsupported_transport",
+ HomeserverLacksSupport = "homeserver_lacks_support",
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousIntent.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousIntent.ts
new file mode 100644
index 0000000..db53ef9
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousIntent.ts
@@ -0,0 +1,20 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export enum RendezvousIntent {
+ LOGIN_ON_NEW_DEVICE = "login.start",
+ RECIPROCATE_LOGIN_ON_EXISTING_DEVICE = "login.reciprocate",
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousTransport.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousTransport.ts
new file mode 100644
index 0000000..08905be
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/RendezvousTransport.ts
@@ -0,0 +1,58 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RendezvousFailureListener, RendezvousFailureReason } from ".";
+
+export interface RendezvousTransportDetails {
+ type: string;
+}
+
+/**
+ * Interface representing a generic rendezvous transport.
+ */
+export interface RendezvousTransport<T> {
+ /**
+ * Ready state of the transport. This is set to true when the transport is ready to be used.
+ */
+ readonly ready: boolean;
+
+ /**
+ * Listener for cancellation events. This is called when the rendezvous is cancelled or fails.
+ */
+ onFailure?: RendezvousFailureListener;
+
+ /**
+ * @returns the transport details that can be encoded in a QR or similar
+ */
+ details(): Promise<RendezvousTransportDetails>;
+
+ /**
+ * Send data via the transport.
+ * @param data - the data itself
+ */
+ send(data: T): Promise<void>;
+
+ /**
+ * Receive data from the transport.
+ */
+ receive(): Promise<Partial<T> | undefined>;
+
+ /**
+ * Cancel the rendezvous. This will call `onCancelled()` if it is set.
+ * @param reason - the reason for the cancellation/failure
+ */
+ cancel(reason: RendezvousFailureReason): Promise<void>;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts
new file mode 100644
index 0000000..be60ee5
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/MSC3903ECDHv2RendezvousChannel.ts
@@ -0,0 +1,259 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { SAS } from "@matrix-org/olm";
+
+import {
+ RendezvousError,
+ RendezvousCode,
+ RendezvousIntent,
+ RendezvousChannel,
+ RendezvousTransportDetails,
+ RendezvousTransport,
+ RendezvousFailureReason,
+} from "..";
+import { encodeUnpaddedBase64, decodeBase64 } from "../../crypto/olmlib";
+import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto";
+import { generateDecimalSas } from "../../crypto/verification/SASDecimal";
+import { UnstableValue } from "../../NamespacedValue";
+
+const ECDH_V2 = new UnstableValue(
+ "m.rendezvous.v2.curve25519-aes-sha256",
+ "org.matrix.msc3903.rendezvous.v2.curve25519-aes-sha256",
+);
+
+export interface ECDHv2RendezvousCode extends RendezvousCode {
+ rendezvous: {
+ transport: RendezvousTransportDetails;
+ algorithm: typeof ECDH_V2.name | typeof ECDH_V2.altName;
+ key: string;
+ };
+}
+
+export type MSC3903ECDHPayload = PlainTextPayload | EncryptedPayload;
+
+export interface PlainTextPayload {
+ algorithm: typeof ECDH_V2.name | typeof ECDH_V2.altName;
+ key?: string;
+}
+
+export interface EncryptedPayload {
+ iv: string;
+ ciphertext: string;
+}
+
+async function importKey(key: Uint8Array): Promise<CryptoKey> {
+ if (!subtleCrypto) {
+ throw new Error("Web Crypto is not available");
+ }
+
+ const imported = subtleCrypto.importKey("raw", key, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
+
+ return imported;
+}
+
+/**
+ * Implementation of the unstable [MSC3903](https://github.com/matrix-org/matrix-spec-proposals/pull/3903)
+ * X25519/ECDH key agreement based secure rendezvous channel.
+ * Note that this is UNSTABLE and may have breaking changes without notice.
+ */
+export class MSC3903ECDHv2RendezvousChannel<T> implements RendezvousChannel<T> {
+ private olmSAS?: SAS;
+ private ourPublicKey: Uint8Array;
+ private aesKey?: CryptoKey;
+ private connected = false;
+
+ public constructor(
+ private transport: RendezvousTransport<MSC3903ECDHPayload>,
+ private theirPublicKey?: Uint8Array,
+ public onFailure?: (reason: RendezvousFailureReason) => void,
+ ) {
+ this.olmSAS = new global.Olm.SAS();
+ this.ourPublicKey = decodeBase64(this.olmSAS.get_pubkey());
+ }
+
+ public async generateCode(intent: RendezvousIntent): Promise<ECDHv2RendezvousCode> {
+ if (this.transport.ready) {
+ throw new Error("Code already generated");
+ }
+
+ await this.transport.send({ algorithm: ECDH_V2.name });
+
+ const rendezvous: ECDHv2RendezvousCode = {
+ rendezvous: {
+ algorithm: ECDH_V2.name,
+ key: encodeUnpaddedBase64(this.ourPublicKey),
+ transport: await this.transport.details(),
+ },
+ intent,
+ };
+
+ return rendezvous;
+ }
+
+ public async connect(): Promise<string> {
+ if (this.connected) {
+ throw new Error("Channel already connected");
+ }
+
+ if (!this.olmSAS) {
+ throw new Error("Channel closed");
+ }
+
+ const isInitiator = !this.theirPublicKey;
+
+ if (isInitiator) {
+ // wait for the other side to send us their public key
+ const rawRes = await this.transport.receive();
+ if (!rawRes) {
+ throw new Error("No response from other device");
+ }
+ const res = rawRes as Partial<PlainTextPayload>;
+ const { key, algorithm } = res;
+ if (!algorithm || !ECDH_V2.matches(algorithm) || !key) {
+ throw new RendezvousError(
+ "Unsupported algorithm: " + algorithm,
+ RendezvousFailureReason.UnsupportedAlgorithm,
+ );
+ }
+
+ this.theirPublicKey = decodeBase64(key);
+ } else {
+ // send our public key unencrypted
+ await this.transport.send({
+ algorithm: ECDH_V2.name,
+ key: encodeUnpaddedBase64(this.ourPublicKey),
+ });
+ }
+
+ this.connected = true;
+
+ this.olmSAS.set_their_key(encodeUnpaddedBase64(this.theirPublicKey!));
+
+ const initiatorKey = isInitiator ? this.ourPublicKey : this.theirPublicKey!;
+ const recipientKey = isInitiator ? this.theirPublicKey! : this.ourPublicKey;
+ let aesInfo = ECDH_V2.name;
+ aesInfo += `|${encodeUnpaddedBase64(initiatorKey)}`;
+ aesInfo += `|${encodeUnpaddedBase64(recipientKey)}`;
+
+ const aesKeyBytes = this.olmSAS.generate_bytes(aesInfo, 32);
+
+ this.aesKey = await importKey(aesKeyBytes);
+
+ // blank the bytes out to make sure not kept in memory
+ aesKeyBytes.fill(0);
+
+ const rawChecksum = this.olmSAS.generate_bytes(aesInfo, 5);
+ return generateDecimalSas(Array.from(rawChecksum)).join("-");
+ }
+
+ private async encrypt(data: T): Promise<MSC3903ECDHPayload> {
+ if (!subtleCrypto) {
+ throw new Error("Web Crypto is not available");
+ }
+
+ const iv = new Uint8Array(32);
+ crypto.getRandomValues(iv);
+
+ const encodedData = new TextEncoder().encode(JSON.stringify(data));
+
+ const ciphertext = await subtleCrypto.encrypt(
+ {
+ name: "AES-GCM",
+ iv,
+ tagLength: 128,
+ },
+ this.aesKey as CryptoKey,
+ encodedData,
+ );
+
+ return {
+ iv: encodeUnpaddedBase64(iv),
+ ciphertext: encodeUnpaddedBase64(ciphertext),
+ };
+ }
+
+ public async send(payload: T): Promise<void> {
+ if (!this.olmSAS) {
+ throw new Error("Channel closed");
+ }
+
+ if (!this.aesKey) {
+ throw new Error("Shared secret not set up");
+ }
+
+ return this.transport.send(await this.encrypt(payload));
+ }
+
+ private async decrypt({ iv, ciphertext }: EncryptedPayload): Promise<Partial<T>> {
+ if (!ciphertext || !iv) {
+ throw new Error("Missing ciphertext and/or iv");
+ }
+
+ const ciphertextBytes = decodeBase64(ciphertext);
+
+ if (!subtleCrypto) {
+ throw new Error("Web Crypto is not available");
+ }
+
+ const plaintext = await subtleCrypto.decrypt(
+ {
+ name: "AES-GCM",
+ iv: decodeBase64(iv),
+ tagLength: 128,
+ },
+ this.aesKey as CryptoKey,
+ ciphertextBytes,
+ );
+
+ return JSON.parse(new TextDecoder().decode(new Uint8Array(plaintext)));
+ }
+
+ public async receive(): Promise<Partial<T> | undefined> {
+ if (!this.olmSAS) {
+ throw new Error("Channel closed");
+ }
+ if (!this.aesKey) {
+ throw new Error("Shared secret not set up");
+ }
+
+ const rawData = await this.transport.receive();
+ if (!rawData) {
+ return undefined;
+ }
+ const data = rawData as Partial<EncryptedPayload>;
+ if (data.ciphertext && data.iv) {
+ return this.decrypt(data as EncryptedPayload);
+ }
+
+ throw new Error("Data received but no ciphertext");
+ }
+
+ public async close(): Promise<void> {
+ if (this.olmSAS) {
+ this.olmSAS.free();
+ this.olmSAS = undefined;
+ }
+ }
+
+ public async cancel(reason: RendezvousFailureReason): Promise<void> {
+ try {
+ await this.transport.cancel(reason);
+ } finally {
+ await this.close();
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/index.ts
new file mode 100644
index 0000000..f157bbe
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/channels/index.ts
@@ -0,0 +1,17 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export * from "./MSC3903ECDHv2RendezvousChannel";
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/index.ts
new file mode 100644
index 0000000..379b133
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/index.ts
@@ -0,0 +1,23 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export * from "./MSC3906Rendezvous";
+export * from "./RendezvousChannel";
+export * from "./RendezvousCode";
+export * from "./RendezvousError";
+export * from "./RendezvousFailureReason";
+export * from "./RendezvousIntent";
+export * from "./RendezvousTransport";
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts
new file mode 100644
index 0000000..430ee92
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/MSC3886SimpleHttpRendezvousTransport.ts
@@ -0,0 +1,193 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { UnstableValue } from "matrix-events-sdk";
+
+import { logger } from "../../logger";
+import { sleep } from "../../utils";
+import {
+ RendezvousFailureListener,
+ RendezvousFailureReason,
+ RendezvousTransport,
+ RendezvousTransportDetails,
+} from "..";
+import { MatrixClient } from "../../matrix";
+import { ClientPrefix } from "../../http-api";
+
+const TYPE = new UnstableValue("http.v1", "org.matrix.msc3886.http.v1");
+
+export interface MSC3886SimpleHttpRendezvousTransportDetails extends RendezvousTransportDetails {
+ uri: string;
+}
+
+/**
+ * Implementation of the unstable [MSC3886](https://github.com/matrix-org/matrix-spec-proposals/pull/3886)
+ * simple HTTP rendezvous protocol.
+ * Note that this is UNSTABLE and may have breaking changes without notice.
+ */
+export class MSC3886SimpleHttpRendezvousTransport<T extends {}> implements RendezvousTransport<T> {
+ private uri?: string;
+ private etag?: string;
+ private expiresAt?: Date;
+ private client: MatrixClient;
+ private fallbackRzServer?: string;
+ private fetchFn?: typeof global.fetch;
+ private cancelled = false;
+ private _ready = false;
+ public onFailure?: RendezvousFailureListener;
+
+ public constructor({
+ onFailure,
+ client,
+ fallbackRzServer,
+ fetchFn,
+ }: {
+ fetchFn?: typeof global.fetch;
+ onFailure?: RendezvousFailureListener;
+ client: MatrixClient;
+ fallbackRzServer?: string;
+ }) {
+ this.fetchFn = fetchFn;
+ this.onFailure = onFailure;
+ this.client = client;
+ this.fallbackRzServer = fallbackRzServer;
+ }
+
+ public get ready(): boolean {
+ return this._ready;
+ }
+
+ public async details(): Promise<MSC3886SimpleHttpRendezvousTransportDetails> {
+ if (!this.uri) {
+ throw new Error("Rendezvous not set up");
+ }
+
+ return {
+ type: TYPE.name,
+ uri: this.uri,
+ };
+ }
+
+ private fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> {
+ if (this.fetchFn) {
+ return this.fetchFn(resource, options);
+ }
+ return global.fetch(resource, options);
+ }
+
+ private async getPostEndpoint(): Promise<string | undefined> {
+ try {
+ if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc3886")) {
+ return `${this.client.baseUrl}${ClientPrefix.Unstable}/org.matrix.msc3886/rendezvous`;
+ }
+ } catch (err) {
+ logger.warn("Failed to get unstable features", err);
+ }
+
+ return this.fallbackRzServer;
+ }
+
+ public async send(data: T): Promise<void> {
+ if (this.cancelled) {
+ return;
+ }
+ const method = this.uri ? "PUT" : "POST";
+ const uri = this.uri ?? (await this.getPostEndpoint());
+
+ if (!uri) {
+ throw new Error("Invalid rendezvous URI");
+ }
+
+ const headers: Record<string, string> = { "content-type": "application/json" };
+ if (this.etag) {
+ headers["if-match"] = this.etag;
+ }
+
+ const res = await this.fetch(uri, { method, headers, body: JSON.stringify(data) });
+ if (res.status === 404) {
+ return this.cancel(RendezvousFailureReason.Unknown);
+ }
+ this.etag = res.headers.get("etag") ?? undefined;
+
+ if (method === "POST") {
+ const location = res.headers.get("location");
+ if (!location) {
+ throw new Error("No rendezvous URI given");
+ }
+ const expires = res.headers.get("expires");
+ if (expires) {
+ this.expiresAt = new Date(expires);
+ }
+ // we would usually expect the final `url` to be set by a proper fetch implementation.
+ // however, if a polyfill based on XHR is used it won't be set, we we use existing URI as fallback
+ const baseUrl = res.url ?? uri;
+ // resolve location header which could be relative or absolute
+ this.uri = new URL(location, `${baseUrl}${baseUrl.endsWith("/") ? "" : "/"}`).href;
+ this._ready = true;
+ }
+ }
+
+ public async receive(): Promise<Partial<T> | undefined> {
+ if (!this.uri) {
+ throw new Error("Rendezvous not set up");
+ }
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ if (this.cancelled) {
+ return undefined;
+ }
+
+ const headers: Record<string, string> = {};
+ if (this.etag) {
+ headers["if-none-match"] = this.etag;
+ }
+ const poll = await this.fetch(this.uri, { method: "GET", headers });
+
+ if (poll.status === 404) {
+ this.cancel(RendezvousFailureReason.Unknown);
+ return undefined;
+ }
+
+ // rely on server expiring the channel rather than checking ourselves
+
+ if (poll.headers.get("content-type") !== "application/json") {
+ this.etag = poll.headers.get("etag") ?? undefined;
+ } else if (poll.status === 200) {
+ this.etag = poll.headers.get("etag") ?? undefined;
+ return poll.json();
+ }
+ await sleep(1000);
+ }
+ }
+
+ public async cancel(reason: RendezvousFailureReason): Promise<void> {
+ if (reason === RendezvousFailureReason.Unknown && this.expiresAt && this.expiresAt.getTime() < Date.now()) {
+ reason = RendezvousFailureReason.Expired;
+ }
+
+ this.cancelled = true;
+ this._ready = false;
+ this.onFailure?.(reason);
+
+ if (this.uri && reason === RendezvousFailureReason.UserDeclined) {
+ try {
+ await this.fetch(this.uri, { method: "DELETE" });
+ } catch (e) {
+ logger.warn(e);
+ }
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/index.ts
new file mode 100644
index 0000000..6d8d642
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rendezvous/transports/index.ts
@@ -0,0 +1,17 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export * from "./MSC3886SimpleHttpRendezvousTransport";
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/room-hierarchy.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/room-hierarchy.ts
new file mode 100644
index 0000000..5c0b61d
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/room-hierarchy.ts
@@ -0,0 +1,152 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Room } from "./models/room";
+import { IHierarchyRoom, IHierarchyRelation } from "./@types/spaces";
+import { MatrixClient } from "./client";
+import { EventType } from "./@types/event";
+import { MatrixError } from "./http-api";
+
+export class RoomHierarchy {
+ // Map from room id to list of servers which are listed as a via somewhere in the loaded hierarchy
+ public readonly viaMap = new Map<string, Set<string>>();
+ // Map from room id to list of rooms which claim this room as their child
+ public readonly backRefs = new Map<string, string[]>();
+ // Map from room id to object
+ public readonly roomMap = new Map<string, IHierarchyRoom>();
+ private loadRequest?: ReturnType<MatrixClient["getRoomHierarchy"]>;
+ private nextBatch?: string;
+ private _rooms?: IHierarchyRoom[];
+ private serverSupportError?: Error;
+
+ /**
+ * Construct a new RoomHierarchy
+ *
+ * A RoomHierarchy instance allows you to easily make use of the /hierarchy API and paginate it.
+ *
+ * @param root - the root of this hierarchy
+ * @param pageSize - the maximum number of rooms to return per page, can be overridden per load request.
+ * @param maxDepth - the maximum depth to traverse the hierarchy to
+ * @param suggestedOnly - whether to only return rooms with suggested=true.
+ */
+ public constructor(
+ public readonly root: Room,
+ private readonly pageSize?: number,
+ private readonly maxDepth?: number,
+ private readonly suggestedOnly = false,
+ ) {}
+
+ public get noSupport(): boolean {
+ return !!this.serverSupportError;
+ }
+
+ public get canLoadMore(): boolean {
+ return !!this.serverSupportError || !!this.nextBatch || !this._rooms;
+ }
+
+ public get loading(): boolean {
+ return !!this.loadRequest;
+ }
+
+ public get rooms(): IHierarchyRoom[] | undefined {
+ return this._rooms;
+ }
+
+ public async load(pageSize = this.pageSize): Promise<IHierarchyRoom[]> {
+ if (this.loadRequest) return this.loadRequest.then((r) => r.rooms);
+
+ this.loadRequest = this.root.client.getRoomHierarchy(
+ this.root.roomId,
+ pageSize,
+ this.maxDepth,
+ this.suggestedOnly,
+ this.nextBatch,
+ );
+
+ let rooms: IHierarchyRoom[];
+ try {
+ ({ rooms, next_batch: this.nextBatch } = await this.loadRequest);
+ } catch (e) {
+ if ((<MatrixError>e).errcode === "M_UNRECOGNIZED") {
+ this.serverSupportError = <MatrixError>e;
+ } else {
+ throw e;
+ }
+
+ return [];
+ } finally {
+ this.loadRequest = undefined;
+ }
+
+ if (this._rooms) {
+ this._rooms = this._rooms.concat(rooms);
+ } else {
+ this._rooms = rooms;
+ }
+
+ rooms.forEach((room) => {
+ this.roomMap.set(room.room_id, room);
+
+ room.children_state.forEach((ev) => {
+ if (ev.type !== EventType.SpaceChild) return;
+ const childRoomId = ev.state_key;
+
+ // track backrefs for quicker hierarchy navigation
+ if (!this.backRefs.has(childRoomId)) {
+ this.backRefs.set(childRoomId, []);
+ }
+ this.backRefs.get(childRoomId)!.push(room.room_id);
+
+ // fill viaMap
+ if (Array.isArray(ev.content.via)) {
+ if (!this.viaMap.has(childRoomId)) {
+ this.viaMap.set(childRoomId, new Set());
+ }
+ const vias = this.viaMap.get(childRoomId)!;
+ ev.content.via.forEach((via) => vias.add(via));
+ }
+ });
+ });
+
+ return rooms;
+ }
+
+ public getRelation(parentId: string, childId: string): IHierarchyRelation | undefined {
+ return this.roomMap.get(parentId)?.children_state.find((e) => e.state_key === childId);
+ }
+
+ public isSuggested(parentId: string, childId: string): boolean | undefined {
+ return this.getRelation(parentId, childId)?.content.suggested;
+ }
+
+ // locally remove a relation as a form of local echo
+ public removeRelation(parentId: string, childId: string): void {
+ const backRefs = this.backRefs.get(childId);
+ if (backRefs?.length === 1) {
+ this.backRefs.delete(childId);
+ } else if (backRefs?.length) {
+ this.backRefs.set(
+ childId,
+ backRefs.filter((ref) => ref !== parentId),
+ );
+ }
+
+ const room = this.roomMap.get(parentId);
+ if (room) {
+ room.children_state = room.children_state.filter((ev) => ev.state_key !== childId);
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/KeyClaimManager.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/KeyClaimManager.ts
new file mode 100644
index 0000000..9df8f89
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/KeyClaimManager.ts
@@ -0,0 +1,77 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { OlmMachine, UserId } from "@matrix-org/matrix-sdk-crypto-js";
+
+import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
+
+/**
+ * KeyClaimManager: linearises calls to OlmMachine.getMissingSessions to avoid races
+ *
+ * We have one of these per `RustCrypto` (and hence per `MatrixClient`).
+ */
+export class KeyClaimManager {
+ private currentClaimPromise: Promise<void>;
+ private stopped = false;
+
+ public constructor(
+ private readonly olmMachine: OlmMachine,
+ private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
+ ) {
+ this.currentClaimPromise = Promise.resolve();
+ }
+
+ /**
+ * Tell the KeyClaimManager to immediately stop processing requests.
+ *
+ * Any further calls, and any still in the queue, will fail with an error.
+ */
+ public stop(): void {
+ this.stopped = true;
+ }
+
+ /**
+ * Given a list of users, attempt to ensure that we have Olm Sessions active with each of their devices
+ *
+ * If we don't have an active olm session, we will claim a one-time key and start one.
+ *
+ * @param userList - list of userIDs to claim
+ */
+ public ensureSessionsForUsers(userList: Array<UserId>): Promise<void> {
+ // The Rust-SDK requires that we only have one getMissingSessions process in flight at once. This little dance
+ // ensures that, by only having one call to ensureSessionsForUsersInner active at once (and making them
+ // queue up in order).
+ const prom = this.currentClaimPromise
+ .catch(() => {
+ // any errors in the previous claim will have been reported already, so there is nothing to do here.
+ // we just throw away the error and start anew.
+ })
+ .then(() => this.ensureSessionsForUsersInner(userList));
+ this.currentClaimPromise = prom;
+ return prom;
+ }
+
+ private async ensureSessionsForUsersInner(userList: Array<UserId>): Promise<void> {
+ // bail out quickly if we've been stopped.
+ if (this.stopped) {
+ throw new Error(`Cannot ensure Olm sessions: shutting down`);
+ }
+ const claimRequest = await this.olmMachine.getMissingSessions(userList);
+ if (claimRequest) {
+ await this.outgoingRequestProcessor.makeOutgoingRequest(claimRequest);
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/OutgoingRequestProcessor.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/OutgoingRequestProcessor.ts
new file mode 100644
index 0000000..7ac9a21
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/OutgoingRequestProcessor.ts
@@ -0,0 +1,116 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {
+ OlmMachine,
+ KeysBackupRequest,
+ KeysClaimRequest,
+ KeysQueryRequest,
+ KeysUploadRequest,
+ RoomMessageRequest,
+ SignatureUploadRequest,
+ ToDeviceRequest,
+} from "@matrix-org/matrix-sdk-crypto-js";
+
+import { logger } from "../logger";
+import { IHttpOpts, MatrixHttpApi, Method } from "../http-api";
+import { QueryDict } from "../utils";
+
+/**
+ * Common interface for all the request types returned by `OlmMachine.outgoingRequests`.
+ */
+export interface OutgoingRequest {
+ readonly id: string | undefined;
+ readonly type: number;
+}
+
+/**
+ * OutgoingRequestManager: turns `OutgoingRequest`s from the rust sdk into HTTP requests
+ *
+ * We have one of these per `RustCrypto` (and hence per `MatrixClient`), not that it does anything terribly complicated.
+ * It's responsible for:
+ *
+ * * holding the reference to the `MatrixHttpApi`
+ * * turning `OutgoingRequest`s from the rust backend into HTTP requests, and sending them
+ * * sending the results of such requests back to the rust backend.
+ */
+export class OutgoingRequestProcessor {
+ public constructor(
+ private readonly olmMachine: OlmMachine,
+ private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
+ ) {}
+
+ public async makeOutgoingRequest(msg: OutgoingRequest): Promise<void> {
+ let resp: string;
+
+ /* refer https://docs.rs/matrix-sdk-crypto/0.6.0/matrix_sdk_crypto/requests/enum.OutgoingRequests.html
+ * for the complete list of request types
+ */
+ if (msg instanceof KeysUploadRequest) {
+ resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/upload", {}, msg.body);
+ } else if (msg instanceof KeysQueryRequest) {
+ resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/query", {}, msg.body);
+ } else if (msg instanceof KeysClaimRequest) {
+ resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/claim", {}, msg.body);
+ } else if (msg instanceof SignatureUploadRequest) {
+ resp = await this.rawJsonRequest(Method.Post, "/_matrix/client/v3/keys/signatures/upload", {}, msg.body);
+ } else if (msg instanceof KeysBackupRequest) {
+ resp = await this.rawJsonRequest(Method.Put, "/_matrix/client/v3/room_keys/keys", {}, msg.body);
+ } else if (msg instanceof ToDeviceRequest) {
+ const path =
+ `/_matrix/client/v3/sendToDevice/${encodeURIComponent(msg.event_type)}/` +
+ encodeURIComponent(msg.txn_id);
+ resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
+ } else if (msg instanceof RoomMessageRequest) {
+ const path =
+ `/_matrix/client/v3/room/${encodeURIComponent(msg.room_id)}/send/` +
+ `${encodeURIComponent(msg.event_type)}/${encodeURIComponent(msg.txn_id)}`;
+ resp = await this.rawJsonRequest(Method.Put, path, {}, msg.body);
+ } else {
+ logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
+ resp = "";
+ }
+
+ if (msg.id) {
+ await this.olmMachine.markRequestAsSent(msg.id, msg.type, resp);
+ }
+ }
+
+ private async rawJsonRequest(method: Method, path: string, queryParams: QueryDict, body: string): Promise<string> {
+ const opts = {
+ // inhibit the JSON stringification and parsing within HttpApi.
+ json: false,
+
+ // nevertheless, we are sending, and accept, JSON.
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ },
+
+ // we use the full prefix
+ prefix: "",
+ };
+
+ try {
+ const response = await this.http.authedRequest<string>(method, path, queryParams, body, opts);
+ logger.info(`rust-crypto: successfully made HTTP request: ${method} ${path}`);
+ return response;
+ } catch (e) {
+ logger.warn(`rust-crypto: error making HTTP request: ${method} ${path}: ${e}`);
+ throw e;
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts
new file mode 100644
index 0000000..1649a69
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/RoomEncryptor.ts
@@ -0,0 +1,153 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EncryptionSettings, OlmMachine, RoomId, UserId } from "@matrix-org/matrix-sdk-crypto-js";
+
+import { EventType } from "../@types/event";
+import { IContent, MatrixEvent } from "../models/event";
+import { Room } from "../models/room";
+import { logger, PrefixedLogger } from "../logger";
+import { KeyClaimManager } from "./KeyClaimManager";
+import { RoomMember } from "../models/room-member";
+import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
+
+/**
+ * RoomEncryptor: responsible for encrypting messages to a given room
+ */
+export class RoomEncryptor {
+ private readonly prefixedLogger: PrefixedLogger;
+
+ /**
+ * @param olmMachine - The rust-sdk's OlmMachine
+ * @param keyClaimManager - Our KeyClaimManager, which manages the queue of one-time-key claim requests
+ * @param room - The room we want to encrypt for
+ * @param encryptionSettings - body of the m.room.encryption event currently in force in this room
+ */
+ public constructor(
+ private readonly olmMachine: OlmMachine,
+ private readonly keyClaimManager: KeyClaimManager,
+ private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
+ private readonly room: Room,
+ private encryptionSettings: IContent,
+ ) {
+ this.prefixedLogger = logger.withPrefix(`[${room.roomId} encryption]`);
+ }
+
+ /**
+ * Handle a new `m.room.encryption` event in this room
+ *
+ * @param config - The content of the encryption event
+ */
+ public onCryptoEvent(config: IContent): void {
+ if (JSON.stringify(this.encryptionSettings) != JSON.stringify(config)) {
+ this.prefixedLogger.error(`Ignoring m.room.encryption event which requests a change of config`);
+ }
+ }
+
+ /**
+ * Handle a new `m.room.member` event in this room
+ *
+ * @param member - new membership state
+ */
+ public onRoomMembership(member: RoomMember): void {
+ this.prefixedLogger.debug(`${member.membership} event for ${member.userId}`);
+
+ if (
+ member.membership == "join" ||
+ (member.membership == "invite" && this.room.shouldEncryptForInvitedMembers())
+ ) {
+ // make sure we are tracking the deviceList for this user
+ this.prefixedLogger.debug(`starting to track devices for: ${member.userId}`);
+ this.olmMachine.updateTrackedUsers([new UserId(member.userId)]);
+ }
+
+ // TODO: handle leaves (including our own)
+ }
+
+ /**
+ * Prepare to encrypt events in this room.
+ *
+ * This ensures that we have a megolm session ready to use and that we have shared its key with all the devices
+ * in the room.
+ */
+ public async ensureEncryptionSession(): Promise<void> {
+ if (this.encryptionSettings.algorithm !== "m.megolm.v1.aes-sha2") {
+ throw new Error(
+ `Cannot encrypt in ${this.room.roomId} for unsupported algorithm '${this.encryptionSettings.algorithm}'`,
+ );
+ }
+
+ const members = await this.room.getEncryptionTargetMembers();
+ this.prefixedLogger.debug(
+ `Encrypting for users (shouldEncryptForInvitedMembers: ${this.room.shouldEncryptForInvitedMembers()}):`,
+ members.map((u) => `${u.userId} (${u.membership})`),
+ );
+
+ const userList = members.map((u) => new UserId(u.userId));
+ await this.keyClaimManager.ensureSessionsForUsers(userList);
+
+ this.prefixedLogger.debug("Sessions for users are ready; now sharing room key");
+
+ const rustEncryptionSettings = new EncryptionSettings();
+ /* FIXME historyVisibility, rotation, etc */
+
+ const shareMessages = await this.olmMachine.shareRoomKey(
+ new RoomId(this.room.roomId),
+ userList,
+ rustEncryptionSettings,
+ );
+ if (shareMessages) {
+ for (const m of shareMessages) {
+ await this.outgoingRequestProcessor.makeOutgoingRequest(m);
+ }
+ }
+ }
+
+ /**
+ * Discard any existing group session for this room
+ */
+ public async forceDiscardSession(): Promise<void> {
+ const r = await this.olmMachine.invalidateGroupSession(new RoomId(this.room.roomId));
+ if (r) {
+ this.prefixedLogger.info("Discarded existing group session");
+ }
+ }
+
+ /**
+ * Encrypt an event for this room
+ *
+ * This will ensure that we have a megolm session for this room, share it with the devices in the room, and
+ * then encrypt the event using the session.
+ *
+ * @param event - Event to be encrypted.
+ */
+ public async encryptEvent(event: MatrixEvent): Promise<void> {
+ await this.ensureEncryptionSession();
+
+ const encryptedContent = await this.olmMachine.encryptRoomEvent(
+ new RoomId(this.room.roomId),
+ event.getType(),
+ JSON.stringify(event.getContent()),
+ );
+
+ event.makeEncrypted(
+ EventType.RoomMessageEncrypted,
+ JSON.parse(encryptedContent),
+ this.olmMachine.identityKeys.curve25519.toBase64(),
+ this.olmMachine.identityKeys.ed25519.toBase64(),
+ );
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/browserify-index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/browserify-index.ts
new file mode 100644
index 0000000..7f91e90
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/browserify-index.ts
@@ -0,0 +1,31 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* This file replaces rust-crypto/index.ts when the js-sdk is being built for browserify.
+ *
+ * It is a stub, so that we do not import the whole of the base64'ed wasm artifact into the browserify bundle.
+ * It deliberately does nothing except raise an exception.
+ */
+
+import { IHttpOpts, MatrixHttpApi } from "../http-api";
+
+export async function initRustCrypto(
+ _http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
+ _userId: string,
+ _deviceId: string,
+): Promise<Crypto> {
+ throw new Error("Rust crypto is not supported under browserify.");
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/constants.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/constants.ts
new file mode 100644
index 0000000..9d72060
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/constants.ts
@@ -0,0 +1,18 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/** The prefix used on indexeddbs created by rust-crypto */
+export const RUST_SDK_STORE_PREFIX = "matrix-js-sdk";
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/index.ts
new file mode 100644
index 0000000..e2c541f
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/index.ts
@@ -0,0 +1,45 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
+
+import { RustCrypto } from "./rust-crypto";
+import { logger } from "../logger";
+import { RUST_SDK_STORE_PREFIX } from "./constants";
+import { IHttpOpts, MatrixHttpApi } from "../http-api";
+
+export async function initRustCrypto(
+ http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
+ userId: string,
+ deviceId: string,
+): Promise<RustCrypto> {
+ // initialise the rust matrix-sdk-crypto-js, if it hasn't already been done
+ await RustSdkCryptoJs.initAsync();
+
+ // enable tracing in the rust-sdk
+ new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Trace).turnOn();
+
+ const u = new RustSdkCryptoJs.UserId(userId);
+ const d = new RustSdkCryptoJs.DeviceId(deviceId);
+ logger.info("Init OlmMachine");
+
+ // TODO: use the pickle key for the passphrase
+ const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(u, d, RUST_SDK_STORE_PREFIX, "test pass");
+ const rustCrypto = new RustCrypto(olmMachine, http, userId, deviceId);
+
+ logger.info("Completed rust crypto-sdk setup");
+ return rustCrypto;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/rust-crypto.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/rust-crypto.ts
new file mode 100644
index 0000000..4a0b1f8
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/rust-crypto/rust-crypto.ts
@@ -0,0 +1,334 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-js";
+
+import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto";
+import type { IToDeviceEvent } from "../sync-accumulator";
+import type { IEncryptedEventInfo } from "../crypto/api";
+import { MatrixEvent } from "../models/event";
+import { Room } from "../models/room";
+import { RoomMember } from "../models/room-member";
+import { CryptoBackend, OnSyncCompletedData } from "../common-crypto/CryptoBackend";
+import { logger } from "../logger";
+import { IHttpOpts, MatrixHttpApi } from "../http-api";
+import { DeviceTrustLevel, UserTrustLevel } from "../crypto/CrossSigning";
+import { RoomEncryptor } from "./RoomEncryptor";
+import { OutgoingRequest, OutgoingRequestProcessor } from "./OutgoingRequestProcessor";
+import { KeyClaimManager } from "./KeyClaimManager";
+
+/**
+ * An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
+ */
+export class RustCrypto implements CryptoBackend {
+ public globalErrorOnUnknownDevices = false;
+
+ /** whether {@link stop} has been called */
+ private stopped = false;
+
+ /** whether {@link outgoingRequestLoop} is currently running */
+ private outgoingRequestLoopRunning = false;
+
+ /** mapping of roomId → encryptor class */
+ private roomEncryptors: Record<string, RoomEncryptor> = {};
+
+ private keyClaimManager: KeyClaimManager;
+ private outgoingRequestProcessor: OutgoingRequestProcessor;
+
+ public constructor(
+ private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
+ http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
+ _userId: string,
+ _deviceId: string,
+ ) {
+ this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
+ this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ //
+ // CryptoBackend implementation
+ //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ public stop(): void {
+ // stop() may be called multiple times, but attempting to close() the OlmMachine twice
+ // will cause an error.
+ if (this.stopped) {
+ return;
+ }
+ this.stopped = true;
+
+ this.keyClaimManager.stop();
+
+ // make sure we close() the OlmMachine; doing so means that all the Rust objects will be
+ // cleaned up; in particular, the indexeddb connections will be closed, which means they
+ // can then be deleted.
+ this.olmMachine.close();
+ }
+
+ public async encryptEvent(event: MatrixEvent, _room: Room): Promise<void> {
+ const roomId = event.getRoomId()!;
+ const encryptor = this.roomEncryptors[roomId];
+
+ if (!encryptor) {
+ throw new Error(`Cannot encrypt event in unconfigured room ${roomId}`);
+ }
+
+ await encryptor.encryptEvent(event);
+ }
+
+ public async decryptEvent(event: MatrixEvent): Promise<IEventDecryptionResult> {
+ const roomId = event.getRoomId();
+ if (!roomId) {
+ // presumably, a to-device message. These are normally decrypted in preprocessToDeviceMessages
+ // so the fact it has come back here suggests that decryption failed.
+ //
+ // once we drop support for the libolm crypto implementation, we can stop passing to-device messages
+ // through decryptEvent and hence get rid of this case.
+ throw new Error("to-device event was not decrypted in preprocessToDeviceMessages");
+ }
+ const res = (await this.olmMachine.decryptRoomEvent(
+ JSON.stringify({
+ event_id: event.getId(),
+ type: event.getWireType(),
+ sender: event.getSender(),
+ state_key: event.getStateKey(),
+ content: event.getWireContent(),
+ origin_server_ts: event.getTs(),
+ }),
+ new RustSdkCryptoJs.RoomId(event.getRoomId()!),
+ )) as RustSdkCryptoJs.DecryptedRoomEvent;
+ return {
+ clearEvent: JSON.parse(res.event),
+ claimedEd25519Key: res.senderClaimedEd25519Key,
+ senderCurve25519Key: res.senderCurve25519Key,
+ forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain,
+ };
+ }
+
+ public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
+ // TODO: make this work properly. Or better, replace it.
+
+ const ret: Partial<IEncryptedEventInfo> = {};
+
+ ret.senderKey = event.getSenderKey() ?? undefined;
+ ret.algorithm = event.getWireContent().algorithm;
+
+ if (!ret.senderKey || !ret.algorithm) {
+ ret.encrypted = false;
+ return ret as IEncryptedEventInfo;
+ }
+ ret.encrypted = true;
+ ret.authenticated = true;
+ ret.mismatchedSender = true;
+ return ret as IEncryptedEventInfo;
+ }
+
+ public checkUserTrust(userId: string): UserTrustLevel {
+ // TODO
+ return new UserTrustLevel(false, false, false);
+ }
+
+ public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel {
+ // TODO
+ return new DeviceTrustLevel(false, false, false, false);
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ //
+ // CryptoApi implementation
+ //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ public globalBlacklistUnverifiedDevices = false;
+
+ public async userHasCrossSigningKeys(): Promise<boolean> {
+ // TODO
+ return false;
+ }
+
+ public prepareToEncrypt(room: Room): void {
+ const encryptor = this.roomEncryptors[room.roomId];
+
+ if (encryptor) {
+ encryptor.ensureEncryptionSession();
+ }
+ }
+
+ public forceDiscardSession(roomId: string): Promise<void> {
+ return this.roomEncryptors[roomId]?.forceDiscardSession();
+ }
+
+ public async exportRoomKeys(): Promise<IMegolmSessionData[]> {
+ // TODO
+ return [];
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ //
+ // SyncCryptoCallbacks implementation
+ //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ /**
+ * Apply sync changes to the olm machine
+ * @param events - the received to-device messages
+ * @param oneTimeKeysCounts - the received one time key counts
+ * @param unusedFallbackKeys - the received unused fallback keys
+ * @returns A list of preprocessed to-device messages.
+ */
+ private async receiveSyncChanges({
+ events,
+ oneTimeKeysCounts = new Map<string, number>(),
+ unusedFallbackKeys = new Set<string>(),
+ }: {
+ events?: IToDeviceEvent[];
+ oneTimeKeysCounts?: Map<string, number>;
+ unusedFallbackKeys?: Set<string>;
+ }): Promise<IToDeviceEvent[]> {
+ const result = await this.olmMachine.receiveSyncChanges(
+ events ? JSON.stringify(events) : "[]",
+ new RustSdkCryptoJs.DeviceLists(),
+ oneTimeKeysCounts,
+ unusedFallbackKeys,
+ );
+
+ // receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages.
+ return JSON.parse(result);
+ }
+
+ /** called by the sync loop to preprocess incoming to-device messages
+ *
+ * @param events - the received to-device messages
+ * @returns A list of preprocessed to-device messages.
+ */
+ public preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
+ // send the received to-device messages into receiveSyncChanges. We have no info on device-list changes,
+ // one-time-keys, or fallback keys, so just pass empty data.
+ return this.receiveSyncChanges({ events });
+ }
+
+ /** called by the sync loop to preprocess one time key counts
+ *
+ * @param oneTimeKeysCounts - the received one time key counts
+ * @returns A list of preprocessed to-device messages.
+ */
+ public async preprocessOneTimeKeyCounts(oneTimeKeysCounts: Map<string, number>): Promise<void> {
+ await this.receiveSyncChanges({ oneTimeKeysCounts });
+ }
+
+ /** called by the sync loop to preprocess unused fallback keys
+ *
+ * @param unusedFallbackKeys - the received unused fallback keys
+ * @returns A list of preprocessed to-device messages.
+ */
+ public async preprocessUnusedFallbackKeys(unusedFallbackKeys: Set<string>): Promise<void> {
+ await this.receiveSyncChanges({ unusedFallbackKeys });
+ }
+
+ /** called by the sync loop on m.room.encrypted events
+ *
+ * @param room - in which the event was received
+ * @param event - encryption event to be processed
+ */
+ public async onCryptoEvent(room: Room, event: MatrixEvent): Promise<void> {
+ const config = event.getContent();
+
+ const existingEncryptor = this.roomEncryptors[room.roomId];
+ if (existingEncryptor) {
+ existingEncryptor.onCryptoEvent(config);
+ } else {
+ this.roomEncryptors[room.roomId] = new RoomEncryptor(
+ this.olmMachine,
+ this.keyClaimManager,
+ this.outgoingRequestProcessor,
+ room,
+ config,
+ );
+ }
+
+ // start tracking devices for any users already known to be in this room.
+ const members = await room.getEncryptionTargetMembers();
+ logger.debug(
+ `[${room.roomId} encryption] starting to track devices for: `,
+ members.map((u) => `${u.userId} (${u.membership})`),
+ );
+ await this.olmMachine.updateTrackedUsers(members.map((u) => new RustSdkCryptoJs.UserId(u.userId)));
+ }
+
+ /** called by the sync loop after processing each sync.
+ *
+ * TODO: figure out something equivalent for sliding sync.
+ *
+ * @param syncState - information on the completed sync.
+ */
+ public onSyncCompleted(syncState: OnSyncCompletedData): void {
+ // Processing the /sync may have produced new outgoing requests which need sending, so kick off the outgoing
+ // request loop, if it's not already running.
+ this.outgoingRequestLoop();
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ //
+ // Other public functions
+ //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ /** called by the MatrixClient on a room membership event
+ *
+ * @param event - The matrix event which caused this event to fire.
+ * @param member - The member whose RoomMember.membership changed.
+ * @param oldMembership - The previous membership state. Null if it's a new member.
+ */
+ public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {
+ const enc = this.roomEncryptors[event.getRoomId()!];
+ if (!enc) {
+ // not encrypting in this room
+ return;
+ }
+ enc.onRoomMembership(member);
+ }
+
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+ //
+ // Outgoing requests
+ //
+ ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private async outgoingRequestLoop(): Promise<void> {
+ if (this.outgoingRequestLoopRunning) {
+ return;
+ }
+ this.outgoingRequestLoopRunning = true;
+ try {
+ while (!this.stopped) {
+ const outgoingRequests: Object[] = await this.olmMachine.outgoingRequests();
+ if (outgoingRequests.length == 0 || this.stopped) {
+ // no more messages to send (or we have been told to stop): exit the loop
+ return;
+ }
+ for (const msg of outgoingRequests) {
+ await this.outgoingRequestProcessor.makeOutgoingRequest(msg as OutgoingRequest);
+ }
+ }
+ } catch (e) {
+ logger.error("Error processing outgoing-message requests from rust crypto-sdk", e);
+ } finally {
+ this.outgoingRequestLoopRunning = false;
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/scheduler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/scheduler.ts
new file mode 100644
index 0000000..6b6bae1
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/scheduler.ts
@@ -0,0 +1,335 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module which manages queuing, scheduling and retrying
+ * of requests.
+ */
+import * as utils from "./utils";
+import { logger } from "./logger";
+import { MatrixEvent } from "./models/event";
+import { EventType } from "./@types/event";
+import { IDeferred } from "./utils";
+import { ConnectionError, MatrixError } from "./http-api";
+import { ISendEventResponse } from "./@types/requests";
+
+const DEBUG = false; // set true to enable console logging.
+
+interface IQueueEntry<T> {
+ event: MatrixEvent;
+ defer: IDeferred<T>;
+ attempts: number;
+}
+
+/**
+ * The function to invoke to process (send) events in the queue.
+ * @param event - The event to send.
+ * @returns Resolved/rejected depending on the outcome of the request.
+ */
+type ProcessFunction<T> = (event: MatrixEvent) => Promise<T>;
+
+// eslint-disable-next-line camelcase
+export class MatrixScheduler<T = ISendEventResponse> {
+ /**
+ * Retries events up to 4 times using exponential backoff. This produces wait
+ * times of 2, 4, 8, and 16 seconds (30s total) after which we give up. If the
+ * failure was due to a rate limited request, the time specified in the error is
+ * waited before being retried.
+ * @param attempts - Number of attempts that have been made, including the one that just failed (ie. starting at 1)
+ * @see retryAlgorithm
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ public static RETRY_BACKOFF_RATELIMIT(event: MatrixEvent | null, attempts: number, err: MatrixError): number {
+ if (err.httpStatus === 400 || err.httpStatus === 403 || err.httpStatus === 401) {
+ // client error; no amount of retrying with save you now.
+ return -1;
+ }
+ if (err instanceof ConnectionError) {
+ return -1;
+ }
+
+ // if event that we are trying to send is too large in any way then retrying won't help
+ if (err.name === "M_TOO_LARGE") {
+ return -1;
+ }
+
+ if (err.name === "M_LIMIT_EXCEEDED") {
+ const waitTime = err.data.retry_after_ms;
+ if (waitTime > 0) {
+ return waitTime;
+ }
+ }
+ if (attempts > 4) {
+ return -1; // give up
+ }
+ return 1000 * Math.pow(2, attempts);
+ }
+
+ /**
+ * Queues `m.room.message` events and lets other events continue
+ * concurrently.
+ * @see queueAlgorithm
+ */
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ public static QUEUE_MESSAGES(event: MatrixEvent): string | null {
+ // enqueue messages or events that associate with another event (redactions and relations)
+ if (event.getType() === EventType.RoomMessage || event.hasAssociation()) {
+ // put these events in the 'message' queue.
+ return "message";
+ }
+ // allow all other events continue concurrently.
+ return null;
+ }
+
+ // queueName: [{
+ // event: MatrixEvent, // event to send
+ // defer: Deferred, // defer to resolve/reject at the END of the retries
+ // attempts: Number // number of times we've called processFn
+ // }, ...]
+ private readonly queues: Record<string, IQueueEntry<T>[]> = {};
+ private activeQueues: string[] = [];
+ private procFn: ProcessFunction<T> | null = null;
+
+ /**
+ * Construct a scheduler for Matrix. Requires
+ * {@link MatrixScheduler#setProcessFunction} to be provided
+ * with a way of processing events.
+ * @param retryAlgorithm - Optional. The retry
+ * algorithm to apply when determining when to try to send an event again.
+ * Defaults to {@link MatrixScheduler.RETRY_BACKOFF_RATELIMIT}.
+ * @param queueAlgorithm - Optional. The queuing
+ * algorithm to apply when determining which events should be sent before the
+ * given event. Defaults to {@link MatrixScheduler.QUEUE_MESSAGES}.
+ */
+ public constructor(
+ /**
+ * The retry algorithm to apply when retrying events. To stop retrying, return
+ * `-1`. If this event was part of a queue, it will be removed from
+ * the queue.
+ * @param event - The event being retried.
+ * @param attempts - The number of failed attempts. This will always be \>= 1.
+ * @param err - The most recent error message received when trying
+ * to send this event.
+ * @returns The number of milliseconds to wait before trying again. If
+ * this is 0, the request will be immediately retried. If this is
+ * `-1`, the event will be marked as
+ * {@link EventStatus.NOT_SENT} and will not be retried.
+ */
+ public readonly retryAlgorithm = MatrixScheduler.RETRY_BACKOFF_RATELIMIT,
+ /**
+ * The queuing algorithm to apply to events. This function must be idempotent as
+ * it may be called multiple times with the same event. All queues created are
+ * serviced in a FIFO manner. To send the event ASAP, return `null`
+ * which will not put this event in a queue. Events that fail to send that form
+ * part of a queue will be removed from the queue and the next event in the
+ * queue will be sent.
+ * @param event - The event to be sent.
+ * @returns The name of the queue to put the event into. If a queue with
+ * this name does not exist, it will be created. If this is `null`,
+ * the event is not put into a queue and will be sent concurrently.
+ */
+ public readonly queueAlgorithm = MatrixScheduler.QUEUE_MESSAGES,
+ ) {}
+
+ /**
+ * Retrieve a queue based on an event. The event provided does not need to be in
+ * the queue.
+ * @param event - An event to get the queue for.
+ * @returns A shallow copy of events in the queue or null.
+ * Modifying this array will not modify the list itself. Modifying events in
+ * this array <i>will</i> modify the underlying event in the queue.
+ * @see MatrixScheduler.removeEventFromQueue To remove an event from the queue.
+ */
+ public getQueueForEvent(event: MatrixEvent): MatrixEvent[] | null {
+ const name = this.queueAlgorithm(event);
+ if (!name || !this.queues[name]) {
+ return null;
+ }
+ return this.queues[name].map(function (obj) {
+ return obj.event;
+ });
+ }
+
+ /**
+ * Remove this event from the queue. The event is equal to another event if they
+ * have the same ID returned from event.getId().
+ * @param event - The event to remove.
+ * @returns True if this event was removed.
+ */
+ public removeEventFromQueue(event: MatrixEvent): boolean {
+ const name = this.queueAlgorithm(event);
+ if (!name || !this.queues[name]) {
+ return false;
+ }
+ let removed = false;
+ utils.removeElement(this.queues[name], (element) => {
+ if (element.event.getId() === event.getId()) {
+ // XXX we should probably reject the promise?
+ // https://github.com/matrix-org/matrix-js-sdk/issues/496
+ removed = true;
+ return true;
+ }
+ return false;
+ });
+ return removed;
+ }
+
+ /**
+ * Set the process function. Required for events in the queue to be processed.
+ * If set after events have been added to the queue, this will immediately start
+ * processing them.
+ * @param fn - The function that can process events
+ * in the queue.
+ */
+ public setProcessFunction(fn: ProcessFunction<T>): void {
+ this.procFn = fn;
+ this.startProcessingQueues();
+ }
+
+ /**
+ * Queue an event if it is required and start processing queues.
+ * @param event - The event that may be queued.
+ * @returns A promise if the event was queued, which will be
+ * resolved or rejected in due time, else null.
+ */
+ public queueEvent(event: MatrixEvent): Promise<T> | null {
+ const queueName = this.queueAlgorithm(event);
+ if (!queueName) {
+ return null;
+ }
+ // add the event to the queue and make a deferred for it.
+ if (!this.queues[queueName]) {
+ this.queues[queueName] = [];
+ }
+ const defer = utils.defer<T>();
+ this.queues[queueName].push({
+ event: event,
+ defer: defer,
+ attempts: 0,
+ });
+ debuglog("Queue algorithm dumped event %s into queue '%s'", event.getId(), queueName);
+ this.startProcessingQueues();
+ return defer.promise;
+ }
+
+ private startProcessingQueues(): void {
+ if (!this.procFn) return;
+ // for each inactive queue with events in them
+ Object.keys(this.queues)
+ .filter((queueName) => {
+ return this.activeQueues.indexOf(queueName) === -1 && this.queues[queueName].length > 0;
+ })
+ .forEach((queueName) => {
+ // mark the queue as active
+ this.activeQueues.push(queueName);
+ // begin processing the head of the queue
+ debuglog("Spinning up queue: '%s'", queueName);
+ this.processQueue(queueName);
+ });
+ }
+
+ private processQueue = (queueName: string): void => {
+ // get head of queue
+ const obj = this.peekNextEvent(queueName);
+ if (!obj) {
+ this.disableQueue(queueName);
+ return;
+ }
+ debuglog("Queue '%s' has %s pending events", queueName, this.queues[queueName].length);
+ // fire the process function and if it resolves, resolve the deferred. Else
+ // invoke the retry algorithm.
+
+ // First wait for a resolved promise, so the resolve handlers for
+ // the deferred of the previously sent event can run.
+ // This way enqueued relations/redactions to enqueued events can receive
+ // the remove id of their target before being sent.
+ Promise.resolve()
+ .then(() => {
+ return this.procFn!(obj.event);
+ })
+ .then(
+ (res) => {
+ // remove this from the queue
+ this.removeNextEvent(queueName);
+ debuglog("Queue '%s' sent event %s", queueName, obj.event.getId());
+ obj.defer.resolve(res);
+ // keep processing
+ this.processQueue(queueName);
+ },
+ (err) => {
+ obj.attempts += 1;
+ // ask the retry algorithm when/if we should try again
+ const waitTimeMs = this.retryAlgorithm(obj.event, obj.attempts, err);
+ debuglog(
+ "retry(%s) err=%s event_id=%s waitTime=%s",
+ obj.attempts,
+ err,
+ obj.event.getId(),
+ waitTimeMs,
+ );
+ if (waitTimeMs === -1) {
+ // give up (you quitter!)
+ debuglog("Queue '%s' giving up on event %s", queueName, obj.event.getId());
+ // remove this from the queue
+ this.clearQueue(queueName, err);
+ } else {
+ setTimeout(this.processQueue, waitTimeMs, queueName);
+ }
+ },
+ );
+ };
+
+ private disableQueue(queueName: string): void {
+ // queue is empty. Mark as inactive and stop recursing.
+ const index = this.activeQueues.indexOf(queueName);
+ if (index >= 0) {
+ this.activeQueues.splice(index, 1);
+ }
+ debuglog("Stopping queue '%s' as it is now empty", queueName);
+ }
+
+ private clearQueue(queueName: string, err: unknown): void {
+ debuglog("clearing queue '%s'", queueName);
+ let obj: IQueueEntry<T> | undefined;
+ while ((obj = this.removeNextEvent(queueName))) {
+ obj.defer.reject(err);
+ }
+ this.disableQueue(queueName);
+ }
+
+ private peekNextEvent(queueName: string): IQueueEntry<T> | undefined {
+ const queue = this.queues[queueName];
+ if (!Array.isArray(queue)) {
+ return undefined;
+ }
+ return queue[0];
+ }
+
+ private removeNextEvent(queueName: string): IQueueEntry<T> | undefined {
+ const queue = this.queues[queueName];
+ if (!Array.isArray(queue)) {
+ return undefined;
+ }
+ return queue.shift();
+ }
+}
+
+/* istanbul ignore next */
+function debuglog(...args: any[]): void {
+ if (DEBUG) {
+ logger.log(...args);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/secret-storage.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/secret-storage.ts
new file mode 100644
index 0000000..f0c19c4
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/secret-storage.ts
@@ -0,0 +1,88 @@
+/*
+Copyright 2021-2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * Implementation of server-side secret storage
+ *
+ * @see https://spec.matrix.org/v1.6/client-server-api/#storage
+ */
+
+/**
+ * Common base interface for Secret Storage Keys.
+ *
+ * The common properties for all encryption keys used in server-side secret storage.
+ *
+ * @see https://spec.matrix.org/v1.6/client-server-api/#key-storage
+ */
+export interface SecretStorageKeyDescriptionCommon {
+ /** A human-readable name for this key. */
+ // XXX: according to the spec, this is optional
+ name: string;
+
+ /** The encryption algorithm used with this key. */
+ algorithm: string;
+
+ /** Information for deriving this key from a passphrase. */
+ // XXX: according to the spec, this is optional
+ passphrase: PassphraseInfo;
+}
+
+/**
+ * Properties for a SSSS key using the `m.secret_storage.v1.aes-hmac-sha2` algorithm.
+ *
+ * Corresponds to `AesHmacSha2KeyDescription` in the specification.
+ *
+ * @see https://spec.matrix.org/v1.6/client-server-api/#msecret_storagev1aes-hmac-sha2
+ */
+export interface SecretStorageKeyDescriptionAesV1 extends SecretStorageKeyDescriptionCommon {
+ // XXX: strictly speaking, we should be able to enforce the algorithm here. But
+ // this interface ends up being incorrectly used where other algorithms are in use (notably
+ // in device-dehydration support), and unpicking that is too much like hard work
+ // at the moment.
+ // algorithm: "m.secret_storage.v1.aes-hmac-sha2";
+
+ /** The 16-byte AES initialization vector, encoded as base64. */
+ iv: string;
+
+ /** The MAC of the result of encrypting 32 bytes of 0, encoded as base64. */
+ mac: string;
+}
+
+/**
+ * Union type for secret storage keys.
+ *
+ * For now, this is only {@link SecretStorageKeyDescriptionAesV1}, but other interfaces may be added in future.
+ */
+export type SecretStorageKeyDescription = SecretStorageKeyDescriptionAesV1;
+
+/**
+ * Information on how to generate the key from a passphrase.
+ *
+ * @see https://spec.matrix.org/v1.6/client-server-api/#deriving-keys-from-passphrases
+ */
+export interface PassphraseInfo {
+ /** The algorithm to be used to derive the key. */
+ algorithm: "m.pbkdf2";
+
+ /** The number of PBKDF2 iterations to use. */
+ iterations: number;
+
+ /** The salt to be used for PBKDF2. */
+ salt: string;
+
+ /** The number of bits to generate. Defaults to 256. */
+ bits?: number;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/service-types.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/service-types.ts
new file mode 100644
index 0000000..3ed08bb
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/service-types.ts
@@ -0,0 +1,20 @@
+/*
+Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export enum SERVICE_TYPES {
+ IS = "SERVICE_TYPE_IS", // An identity server
+ IM = "SERVICE_TYPE_IM", // An integration manager
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts
new file mode 100644
index 0000000..93e29e0
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync-sdk.ts
@@ -0,0 +1,1027 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
+import { NotificationCountType, Room, RoomEvent } from "./models/room";
+import { logger } from "./logger";
+import * as utils from "./utils";
+import { EventTimeline } from "./models/event-timeline";
+import { ClientEvent, IStoredClientOpts, MatrixClient } from "./client";
+import {
+ ISyncStateData,
+ SyncState,
+ _createAndReEmitRoom,
+ SyncApiOptions,
+ defaultClientOpts,
+ defaultSyncApiOpts,
+} from "./sync";
+import { MatrixEvent } from "./models/event";
+import { Crypto } from "./crypto";
+import { IMinimalEvent, IRoomEvent, IStateEvent, IStrippedState, ISyncResponse } from "./sync-accumulator";
+import { MatrixError } from "./http-api";
+import {
+ Extension,
+ ExtensionState,
+ MSC3575RoomData,
+ MSC3575SlidingSyncResponse,
+ SlidingSync,
+ SlidingSyncEvent,
+ SlidingSyncState,
+} from "./sliding-sync";
+import { EventType } from "./@types/event";
+import { IPushRules } from "./@types/PushRules";
+import { RoomStateEvent } from "./models/room-state";
+import { RoomMemberEvent } from "./models/room-member";
+
+// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed
+// to RECONNECTING. This is needed to inform the client of server issues when the
+// keepAlive is successful but the server /sync fails.
+const FAILED_SYNC_ERROR_THRESHOLD = 3;
+
+type ExtensionE2EERequest = {
+ enabled: boolean;
+};
+
+type ExtensionE2EEResponse = Pick<
+ ISyncResponse,
+ | "device_lists"
+ | "device_one_time_keys_count"
+ | "device_unused_fallback_key_types"
+ | "org.matrix.msc2732.device_unused_fallback_key_types"
+>;
+
+class ExtensionE2EE implements Extension<ExtensionE2EERequest, ExtensionE2EEResponse> {
+ public constructor(private readonly crypto: Crypto) {}
+
+ public name(): string {
+ return "e2ee";
+ }
+
+ public when(): ExtensionState {
+ return ExtensionState.PreProcess;
+ }
+
+ public onRequest(isInitial: boolean): ExtensionE2EERequest | undefined {
+ if (!isInitial) {
+ return undefined;
+ }
+ return {
+ enabled: true, // this is sticky so only send it on the initial request
+ };
+ }
+
+ public async onResponse(data: ExtensionE2EEResponse): Promise<void> {
+ // Handle device list updates
+ if (data["device_lists"]) {
+ await this.crypto.handleDeviceListChanges(
+ {
+ oldSyncToken: "yep", // XXX need to do this so the device list changes get processed :(
+ },
+ data["device_lists"],
+ );
+ }
+
+ // Handle one_time_keys_count
+ if (data["device_one_time_keys_count"]) {
+ const currentCount = data["device_one_time_keys_count"].signed_curve25519 || 0;
+ this.crypto.updateOneTimeKeyCount(currentCount);
+ }
+ if (data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"]) {
+ // The presence of device_unused_fallback_key_types indicates that the
+ // server supports fallback keys. If there's no unused
+ // signed_curve25519 fallback key we need a new one.
+ const unusedFallbackKeys =
+ data["device_unused_fallback_key_types"] || data["org.matrix.msc2732.device_unused_fallback_key_types"];
+ this.crypto.setNeedsNewFallback(
+ Array.isArray(unusedFallbackKeys) && !unusedFallbackKeys.includes("signed_curve25519"),
+ );
+ }
+ this.crypto.onSyncCompleted({});
+ }
+}
+
+type ExtensionToDeviceRequest = {
+ since?: string;
+ limit?: number;
+ enabled?: boolean;
+};
+
+type ExtensionToDeviceResponse = {
+ events: Required<ISyncResponse>["to_device"]["events"];
+ next_batch: string | null;
+};
+
+class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, ExtensionToDeviceResponse> {
+ private nextBatch: string | null = null;
+
+ public constructor(private readonly client: MatrixClient, private readonly cryptoCallbacks?: SyncCryptoCallbacks) {}
+
+ public name(): string {
+ return "to_device";
+ }
+
+ public when(): ExtensionState {
+ return ExtensionState.PreProcess;
+ }
+
+ public onRequest(isInitial: boolean): ExtensionToDeviceRequest {
+ const extReq: ExtensionToDeviceRequest = {
+ since: this.nextBatch !== null ? this.nextBatch : undefined,
+ };
+ if (isInitial) {
+ extReq["limit"] = 100;
+ extReq["enabled"] = true;
+ }
+ return extReq;
+ }
+
+ public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
+ const cancelledKeyVerificationTxns: string[] = [];
+ let events = data["events"] || [];
+ if (events.length > 0 && this.cryptoCallbacks) {
+ events = await this.cryptoCallbacks.preprocessToDeviceMessages(events);
+ }
+ events
+ .map(this.client.getEventMapper())
+ .map((toDeviceEvent) => {
+ // map is a cheap inline forEach
+ // We want to flag m.key.verification.start events as cancelled
+ // if there's an accompanying m.key.verification.cancel event, so
+ // we pull out the transaction IDs from the cancellation events
+ // so we can flag the verification events as cancelled in the loop
+ // below.
+ if (toDeviceEvent.getType() === "m.key.verification.cancel") {
+ const txnId: string | undefined = toDeviceEvent.getContent()["transaction_id"];
+ if (txnId) {
+ cancelledKeyVerificationTxns.push(txnId);
+ }
+ }
+
+ // as mentioned above, .map is a cheap inline forEach, so return
+ // the unmodified event.
+ return toDeviceEvent;
+ })
+ .forEach((toDeviceEvent) => {
+ const content = toDeviceEvent.getContent();
+ if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") {
+ // the mapper already logged a warning.
+ logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender());
+ return;
+ }
+
+ if (
+ toDeviceEvent.getType() === "m.key.verification.start" ||
+ toDeviceEvent.getType() === "m.key.verification.request"
+ ) {
+ const txnId = content["transaction_id"];
+ if (cancelledKeyVerificationTxns.includes(txnId)) {
+ toDeviceEvent.flagCancelled();
+ }
+ }
+
+ this.client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent);
+ });
+
+ this.nextBatch = data.next_batch;
+ }
+}
+
+type ExtensionAccountDataRequest = {
+ enabled: boolean;
+};
+
+type ExtensionAccountDataResponse = {
+ global: IMinimalEvent[];
+ rooms: Record<string, IMinimalEvent[]>;
+};
+
+class ExtensionAccountData implements Extension<ExtensionAccountDataRequest, ExtensionAccountDataResponse> {
+ public constructor(private readonly client: MatrixClient) {}
+
+ public name(): string {
+ return "account_data";
+ }
+
+ public when(): ExtensionState {
+ return ExtensionState.PostProcess;
+ }
+
+ public onRequest(isInitial: boolean): ExtensionAccountDataRequest | undefined {
+ if (!isInitial) {
+ return undefined;
+ }
+ return {
+ enabled: true,
+ };
+ }
+
+ public onResponse(data: ExtensionAccountDataResponse): void {
+ if (data.global && data.global.length > 0) {
+ this.processGlobalAccountData(data.global);
+ }
+
+ for (const roomId in data.rooms) {
+ const accountDataEvents = mapEvents(this.client, roomId, data.rooms[roomId]);
+ const room = this.client.getRoom(roomId);
+ if (!room) {
+ logger.warn("got account data for room but room doesn't exist on client:", roomId);
+ continue;
+ }
+ room.addAccountData(accountDataEvents);
+ accountDataEvents.forEach((e) => {
+ this.client.emit(ClientEvent.Event, e);
+ });
+ }
+ }
+
+ private processGlobalAccountData(globalAccountData: IMinimalEvent[]): void {
+ const events = mapEvents(this.client, undefined, globalAccountData);
+ const prevEventsMap = events.reduce<Record<string, MatrixEvent | undefined>>((m, c) => {
+ m[c.getType()] = this.client.store.getAccountData(c.getType());
+ return m;
+ }, {});
+ this.client.store.storeAccountDataEvents(events);
+ events.forEach((accountDataEvent) => {
+ // Honour push rules that come down the sync stream but also
+ // honour push rules that were previously cached. Base rules
+ // will be updated when we receive push rules via getPushRules
+ // (see sync) before syncing over the network.
+ if (accountDataEvent.getType() === EventType.PushRules) {
+ const rules = accountDataEvent.getContent<IPushRules>();
+ this.client.setPushRules(rules);
+ }
+ const prevEvent = prevEventsMap[accountDataEvent.getType()];
+ this.client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent);
+ return accountDataEvent;
+ });
+ }
+}
+
+type ExtensionTypingRequest = {
+ enabled: boolean;
+};
+
+type ExtensionTypingResponse = {
+ rooms: Record<string, IMinimalEvent>;
+};
+
+class ExtensionTyping implements Extension<ExtensionTypingRequest, ExtensionTypingResponse> {
+ public constructor(private readonly client: MatrixClient) {}
+
+ public name(): string {
+ return "typing";
+ }
+
+ public when(): ExtensionState {
+ return ExtensionState.PostProcess;
+ }
+
+ public onRequest(isInitial: boolean): ExtensionTypingRequest | undefined {
+ if (!isInitial) {
+ return undefined; // don't send a JSON object for subsequent requests, we don't need to.
+ }
+ return {
+ enabled: true,
+ };
+ }
+
+ public onResponse(data: ExtensionTypingResponse): void {
+ if (!data?.rooms) {
+ return;
+ }
+
+ for (const roomId in data.rooms) {
+ processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]);
+ }
+ }
+}
+
+type ExtensionReceiptsRequest = {
+ enabled: boolean;
+};
+
+type ExtensionReceiptsResponse = {
+ rooms: Record<string, IMinimalEvent>;
+};
+
+class ExtensionReceipts implements Extension<ExtensionReceiptsRequest, ExtensionReceiptsResponse> {
+ public constructor(private readonly client: MatrixClient) {}
+
+ public name(): string {
+ return "receipts";
+ }
+
+ public when(): ExtensionState {
+ return ExtensionState.PostProcess;
+ }
+
+ public onRequest(isInitial: boolean): ExtensionReceiptsRequest | undefined {
+ if (isInitial) {
+ return {
+ enabled: true,
+ };
+ }
+ return undefined; // don't send a JSON object for subsequent requests, we don't need to.
+ }
+
+ public onResponse(data: ExtensionReceiptsResponse): void {
+ if (!data?.rooms) {
+ return;
+ }
+
+ for (const roomId in data.rooms) {
+ processEphemeralEvents(this.client, roomId, [data.rooms[roomId]]);
+ }
+ }
+}
+
+/**
+ * A copy of SyncApi such that it can be used as a drop-in replacement for sync v2. For the actual
+ * sliding sync API, see sliding-sync.ts or the class SlidingSync.
+ */
+export class SlidingSyncSdk {
+ private readonly opts: IStoredClientOpts;
+ private readonly syncOpts: SyncApiOptions;
+ private syncState: SyncState | null = null;
+ private syncStateData?: ISyncStateData;
+ private lastPos: string | null = null;
+ private failCount = 0;
+ private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response
+
+ public constructor(
+ private readonly slidingSync: SlidingSync,
+ private readonly client: MatrixClient,
+ opts?: IStoredClientOpts,
+ syncOpts?: SyncApiOptions,
+ ) {
+ this.opts = defaultClientOpts(opts);
+ this.syncOpts = defaultSyncApiOpts(syncOpts);
+
+ if (client.getNotifTimelineSet()) {
+ client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
+ }
+
+ this.slidingSync.on(SlidingSyncEvent.Lifecycle, this.onLifecycle.bind(this));
+ this.slidingSync.on(SlidingSyncEvent.RoomData, this.onRoomData.bind(this));
+ const extensions: Extension<any, any>[] = [
+ new ExtensionToDevice(this.client, this.syncOpts.cryptoCallbacks),
+ new ExtensionAccountData(this.client),
+ new ExtensionTyping(this.client),
+ new ExtensionReceipts(this.client),
+ ];
+ if (this.syncOpts.crypto) {
+ extensions.push(new ExtensionE2EE(this.syncOpts.crypto));
+ }
+ extensions.forEach((ext) => {
+ this.slidingSync.registerExtension(ext);
+ });
+ }
+
+ private onRoomData(roomId: string, roomData: MSC3575RoomData): void {
+ let room = this.client.store.getRoom(roomId);
+ if (!room) {
+ if (!roomData.initial) {
+ logger.debug("initial flag not set but no stored room exists for room ", roomId, roomData);
+ return;
+ }
+ room = _createAndReEmitRoom(this.client, roomId, this.opts);
+ }
+ this.processRoomData(this.client, room, roomData);
+ }
+
+ private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err?: Error): void {
+ if (err) {
+ logger.debug("onLifecycle", state, err);
+ }
+ switch (state) {
+ case SlidingSyncState.Complete:
+ this.purgeNotifications();
+ if (!resp) {
+ break;
+ }
+ // Element won't stop showing the initial loading spinner unless we fire SyncState.Prepared
+ if (!this.lastPos) {
+ this.updateSyncState(SyncState.Prepared, {
+ oldSyncToken: undefined,
+ nextSyncToken: resp.pos,
+ catchingUp: false,
+ fromCache: false,
+ });
+ }
+ // Conversely, Element won't show the room list unless there is at least 1x SyncState.Syncing
+ // so hence for the very first sync we will fire prepared then immediately syncing.
+ this.updateSyncState(SyncState.Syncing, {
+ oldSyncToken: this.lastPos!,
+ nextSyncToken: resp.pos,
+ catchingUp: false,
+ fromCache: false,
+ });
+ this.lastPos = resp.pos;
+ break;
+ case SlidingSyncState.RequestFinished:
+ if (err) {
+ this.failCount += 1;
+ this.updateSyncState(
+ this.failCount > FAILED_SYNC_ERROR_THRESHOLD ? SyncState.Error : SyncState.Reconnecting,
+ {
+ error: new MatrixError(err),
+ },
+ );
+ if (this.shouldAbortSync(new MatrixError(err))) {
+ return; // shouldAbortSync actually stops syncing too so we don't need to do anything.
+ }
+ } else {
+ this.failCount = 0;
+ }
+ break;
+ }
+ }
+
+ /**
+ * Sync rooms the user has left.
+ * @returns Resolved when they've been added to the store.
+ */
+ public async syncLeftRooms(): Promise<Room[]> {
+ return []; // TODO
+ }
+
+ /**
+ * Peek into a room. This will result in the room in question being synced so it
+ * is accessible via getRooms(). Live updates for the room will be provided.
+ * @param roomId - The room ID to peek into.
+ * @returns A promise which resolves once the room has been added to the
+ * store.
+ */
+ public async peek(_roomId: string): Promise<Room> {
+ return null!; // TODO
+ }
+
+ /**
+ * Stop polling for updates in the peeked room. NOPs if there is no room being
+ * peeked.
+ */
+ public stopPeeking(): void {
+ // TODO
+ }
+
+ /**
+ * Returns the current state of this sync object
+ * @see MatrixClient#event:"sync"
+ */
+ public getSyncState(): SyncState | null {
+ return this.syncState;
+ }
+
+ /**
+ * Returns the additional data object associated with
+ * the current sync state, or null if there is no
+ * such data.
+ * Sync errors, if available, are put in the 'error' key of
+ * this object.
+ */
+ public getSyncStateData(): ISyncStateData | null {
+ return this.syncStateData ?? null;
+ }
+
+ // Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts
+
+ public createRoom(roomId: string): Room {
+ // XXX cargoculted from sync.ts
+ const { timelineSupport } = this.client;
+ const room = new Room(roomId, this.client, this.client.getUserId()!, {
+ lazyLoadMembers: this.opts.lazyLoadMembers,
+ pendingEventOrdering: this.opts.pendingEventOrdering,
+ timelineSupport,
+ });
+ this.client.reEmitter.reEmit(room, [
+ RoomEvent.Name,
+ RoomEvent.Redaction,
+ RoomEvent.RedactionCancelled,
+ RoomEvent.Receipt,
+ RoomEvent.Tags,
+ RoomEvent.LocalEchoUpdated,
+ RoomEvent.AccountData,
+ RoomEvent.MyMembership,
+ RoomEvent.Timeline,
+ RoomEvent.TimelineReset,
+ ]);
+ this.registerStateListeners(room);
+ return room;
+ }
+
+ private registerStateListeners(room: Room): void {
+ // XXX cargoculted from sync.ts
+ // we need to also re-emit room state and room member events, so hook it up
+ // to the client now. We need to add a listener for RoomState.members in
+ // order to hook them correctly.
+ this.client.reEmitter.reEmit(room.currentState, [
+ RoomStateEvent.Events,
+ RoomStateEvent.Members,
+ RoomStateEvent.NewMember,
+ RoomStateEvent.Update,
+ ]);
+ room.currentState.on(RoomStateEvent.NewMember, (event, state, member) => {
+ member.user = this.client.getUser(member.userId) ?? undefined;
+ this.client.reEmitter.reEmit(member, [
+ RoomMemberEvent.Name,
+ RoomMemberEvent.Typing,
+ RoomMemberEvent.PowerLevel,
+ RoomMemberEvent.Membership,
+ ]);
+ });
+ }
+
+ /*
+ private deregisterStateListeners(room: Room): void { // XXX cargoculted from sync.ts
+ // could do with a better way of achieving this.
+ room.currentState.removeAllListeners(RoomStateEvent.Events);
+ room.currentState.removeAllListeners(RoomStateEvent.Members);
+ room.currentState.removeAllListeners(RoomStateEvent.NewMember);
+ } */
+
+ private shouldAbortSync(error: MatrixError): boolean {
+ if (error.errcode === "M_UNKNOWN_TOKEN") {
+ // The logout already happened, we just need to stop.
+ logger.warn("Token no longer valid - assuming logout");
+ this.stop();
+ this.updateSyncState(SyncState.Error, { error });
+ return true;
+ }
+ return false;
+ }
+
+ private async processRoomData(client: MatrixClient, room: Room, roomData: MSC3575RoomData): Promise<void> {
+ roomData = ensureNameEvent(client, room.roomId, roomData);
+ const stateEvents = mapEvents(this.client, room.roomId, roomData.required_state);
+ // Prevent events from being decrypted ahead of time
+ // this helps large account to speed up faster
+ // room::decryptCriticalEvent is in charge of decrypting all the events
+ // required for a client to function properly
+ let timelineEvents = mapEvents(this.client, room.roomId, roomData.timeline, false);
+ const ephemeralEvents: MatrixEvent[] = []; // TODO this.mapSyncEventsFormat(joinObj.ephemeral);
+
+ // TODO: handle threaded / beacon events
+
+ if (roomData.initial) {
+ // we should not know about any of these timeline entries if this is a genuinely new room.
+ // If we do, then we've effectively done scrollback (e.g requesting timeline_limit: 1 for
+ // this room, then timeline_limit: 50).
+ const knownEvents = new Set<string>();
+ room.getLiveTimeline()
+ .getEvents()
+ .forEach((e) => {
+ knownEvents.add(e.getId()!);
+ });
+ // all unknown events BEFORE a known event must be scrollback e.g:
+ // D E <-- what we know
+ // A B C D E F <-- what we just received
+ // means:
+ // A B C <-- scrollback
+ // D E <-- dupes
+ // F <-- new event
+ // We bucket events based on if we have seen a known event yet.
+ const oldEvents: MatrixEvent[] = [];
+ const newEvents: MatrixEvent[] = [];
+ let seenKnownEvent = false;
+ for (let i = timelineEvents.length - 1; i >= 0; i--) {
+ const recvEvent = timelineEvents[i];
+ if (knownEvents.has(recvEvent.getId()!)) {
+ seenKnownEvent = true;
+ continue; // don't include this event, it's a dupe
+ }
+ if (seenKnownEvent) {
+ // old -> new
+ oldEvents.push(recvEvent);
+ } else {
+ // old -> new
+ newEvents.unshift(recvEvent);
+ }
+ }
+ timelineEvents = newEvents;
+ if (oldEvents.length > 0) {
+ // old events are scrollback, insert them now
+ room.addEventsToTimeline(oldEvents, true, room.getLiveTimeline(), roomData.prev_batch);
+ }
+ }
+
+ const encrypted = this.client.isRoomEncrypted(room.roomId);
+ // we do this first so it's correct when any of the events fire
+ if (roomData.notification_count != null) {
+ room.setUnreadNotificationCount(NotificationCountType.Total, roomData.notification_count);
+ }
+
+ if (roomData.highlight_count != null) {
+ // We track unread notifications ourselves in encrypted rooms, so don't
+ // bother setting it here. We trust our calculations better than the
+ // server's for this case, and therefore will assume that our non-zero
+ // count is accurate.
+ if (!encrypted || (encrypted && room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0)) {
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, roomData.highlight_count);
+ }
+ }
+
+ if (Number.isInteger(roomData.invited_count)) {
+ room.currentState.setInvitedMemberCount(roomData.invited_count!);
+ }
+ if (Number.isInteger(roomData.joined_count)) {
+ room.currentState.setJoinedMemberCount(roomData.joined_count!);
+ }
+
+ if (roomData.invite_state) {
+ const inviteStateEvents = mapEvents(this.client, room.roomId, roomData.invite_state);
+ this.injectRoomEvents(room, inviteStateEvents);
+ if (roomData.initial) {
+ room.recalculate();
+ this.client.store.storeRoom(room);
+ this.client.emit(ClientEvent.Room, room);
+ }
+ inviteStateEvents.forEach((e) => {
+ this.client.emit(ClientEvent.Event, e);
+ });
+ room.updateMyMembership("invite");
+ return;
+ }
+
+ if (roomData.initial) {
+ // set the back-pagination token. Do this *before* adding any
+ // events so that clients can start back-paginating.
+ room.getLiveTimeline().setPaginationToken(roomData.prev_batch ?? null, EventTimeline.BACKWARDS);
+ }
+
+ /* TODO
+ else if (roomData.limited) {
+
+ let limited = true;
+
+ // we've got a limited sync, so we *probably* have a gap in the
+ // timeline, so should reset. But we might have been peeking or
+ // paginating and already have some of the events, in which
+ // case we just want to append any subsequent events to the end
+ // of the existing timeline.
+ //
+ // This is particularly important in the case that we already have
+ // *all* of the events in the timeline - in that case, if we reset
+ // the timeline, we'll end up with an entirely empty timeline,
+ // which we'll try to paginate but not get any new events (which
+ // will stop us linking the empty timeline into the chain).
+ //
+ for (let i = timelineEvents.length - 1; i >= 0; i--) {
+ const eventId = timelineEvents[i].getId();
+ if (room.getTimelineForEvent(eventId)) {
+ logger.debug("Already have event " + eventId + " in limited " +
+ "sync - not resetting");
+ limited = false;
+
+ // we might still be missing some of the events before i;
+ // we don't want to be adding them to the end of the
+ // timeline because that would put them out of order.
+ timelineEvents.splice(0, i);
+
+ // XXX: there's a problem here if the skipped part of the
+ // timeline modifies the state set in stateEvents, because
+ // we'll end up using the state from stateEvents rather
+ // than the later state from timelineEvents. We probably
+ // need to wind stateEvents forward over the events we're
+ // skipping.
+ break;
+ }
+ }
+
+ if (limited) {
+ room.resetLiveTimeline(
+ roomData.prev_batch,
+ null, // TODO this.syncOpts.canResetEntireTimeline(room.roomId) ? null : syncEventData.oldSyncToken,
+ );
+
+ // We have to assume any gap in any timeline is
+ // reason to stop incrementally tracking notifications and
+ // reset the timeline.
+ this.client.resetNotifTimelineSet();
+ this.registerStateListeners(room);
+ }
+ } */
+
+ this.injectRoomEvents(room, stateEvents, timelineEvents, roomData.num_live);
+
+ // we deliberately don't add ephemeral events to the timeline
+ room.addEphemeralEvents(ephemeralEvents);
+
+ // local fields must be set before any async calls because call site assumes
+ // synchronous execution prior to emitting SlidingSyncState.Complete
+ room.updateMyMembership("join");
+
+ room.recalculate();
+ if (roomData.initial) {
+ client.store.storeRoom(room);
+ client.emit(ClientEvent.Room, room);
+ }
+
+ // check if any timeline events should bing and add them to the notifEvents array:
+ // we'll purge this once we've fully processed the sync response
+ this.addNotifications(timelineEvents);
+
+ const processRoomEvent = async (e: MatrixEvent): Promise<void> => {
+ client.emit(ClientEvent.Event, e);
+ if (e.isState() && e.getType() == EventType.RoomEncryption && this.syncOpts.cryptoCallbacks) {
+ await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
+ }
+ };
+
+ await utils.promiseMapSeries(stateEvents, processRoomEvent);
+ await utils.promiseMapSeries(timelineEvents, processRoomEvent);
+ ephemeralEvents.forEach(function (e) {
+ client.emit(ClientEvent.Event, e);
+ });
+
+ // 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
+ // notification count
+ room.decryptCriticalEvents();
+ }
+
+ /**
+ * Injects events into a room's model.
+ * @param stateEventList - A list of state events. This is the state
+ * at the *START* of the timeline list if it is supplied.
+ * @param timelineEventList - A list of timeline events. Lower index
+ * is earlier in time. Higher index is later.
+ * @param numLive - the number of events in timelineEventList which just happened,
+ * supplied from the server.
+ */
+ public injectRoomEvents(
+ room: Room,
+ stateEventList: MatrixEvent[],
+ timelineEventList?: MatrixEvent[],
+ numLive?: number,
+ ): void {
+ timelineEventList = timelineEventList || [];
+ stateEventList = stateEventList || [];
+ numLive = numLive || 0;
+
+ // If there are no events in the timeline yet, initialise it with
+ // the given state events
+ const liveTimeline = room.getLiveTimeline();
+ const timelineWasEmpty = liveTimeline.getEvents().length == 0;
+ if (timelineWasEmpty) {
+ // Passing these events into initialiseState will freeze them, so we need
+ // to compute and cache the push actions for them now, otherwise sync dies
+ // with an attempt to assign to read only property.
+ // XXX: This is pretty horrible and is assuming all sorts of behaviour from
+ // these functions that it shouldn't be. We should probably either store the
+ // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise
+ // find some solution where MatrixEvents are immutable but allow for a cache
+ // field.
+ for (const ev of stateEventList) {
+ this.client.getPushActionsForEvent(ev);
+ }
+ liveTimeline.initialiseState(stateEventList);
+ }
+
+ // If the timeline wasn't empty, we process the state events here: they're
+ // defined as updates to the state before the start of the timeline, so this
+ // starts to roll the state forward.
+ // XXX: That's what we *should* do, but this can happen if we were previously
+ // peeking in a room, in which case we obviously do *not* want to add the
+ // state events here onto the end of the timeline. Historically, the js-sdk
+ // has just set these new state events on the old and new state. This seems
+ // very wrong because there could be events in the timeline that diverge the
+ // state, in which case this is going to leave things out of sync. However,
+ // for now I think it;s best to behave the same as the code has done previously.
+ if (!timelineWasEmpty) {
+ // XXX: As above, don't do this...
+ //room.addLiveEvents(stateEventList || []);
+ // Do this instead...
+ room.oldState.setStateEvents(stateEventList);
+ room.currentState.setStateEvents(stateEventList);
+ }
+
+ // the timeline is broken into 'live' events which just happened and normal timeline events
+ // which are still to be appended to the end of the live timeline but happened a while ago.
+ // The live events are marked as fromCache=false to ensure that downstream components know
+ // this is a live event, not historical (from a remote server cache).
+
+ let liveTimelineEvents: MatrixEvent[] = [];
+ if (numLive > 0) {
+ // last numLive events are live
+ liveTimelineEvents = timelineEventList.slice(-1 * numLive);
+ // everything else is not live
+ timelineEventList = timelineEventList.slice(0, -1 * liveTimelineEvents.length);
+ }
+
+ // execute the timeline events. This will continue to diverge the current state
+ // if the timeline has any state events in it.
+ // This also needs to be done before running push rules on the events as they need
+ // to be decorated with sender etc.
+ room.addLiveEvents(timelineEventList, {
+ fromCache: true,
+ });
+ if (liveTimelineEvents.length > 0) {
+ room.addLiveEvents(liveTimelineEvents, {
+ fromCache: false,
+ });
+ }
+
+ room.recalculate();
+
+ // resolve invites now we have set the latest state
+ this.resolveInvites(room);
+ }
+
+ private resolveInvites(room: Room): void {
+ if (!room || !this.opts.resolveInvitesToProfiles) {
+ return;
+ }
+ const client = this.client;
+ // For each invited room member we want to give them a displayname/avatar url
+ // if they have one (the m.room.member invites don't contain this).
+ room.getMembersWithMembership("invite").forEach(function (member) {
+ if (member.requestedProfileInfo) return;
+ member.requestedProfileInfo = true;
+ // try to get a cached copy first.
+ const user = client.getUser(member.userId);
+ let promise: ReturnType<MatrixClient["getProfileInfo"]>;
+ if (user) {
+ promise = Promise.resolve({
+ avatar_url: user.avatarUrl,
+ displayname: user.displayName,
+ });
+ } else {
+ promise = client.getProfileInfo(member.userId);
+ }
+ promise.then(
+ function (info) {
+ // slightly naughty by doctoring the invite event but this means all
+ // the code paths remain the same between invite/join display name stuff
+ // which is a worthy trade-off for some minor pollution.
+ const inviteEvent = member.events.member!;
+ if (inviteEvent.getContent().membership !== "invite") {
+ // between resolving and now they have since joined, so don't clobber
+ return;
+ }
+ inviteEvent.getContent().avatar_url = info.avatar_url;
+ inviteEvent.getContent().displayname = info.displayname;
+ // fire listeners
+ member.setMembershipEvent(inviteEvent, room.currentState);
+ },
+ function (_err) {
+ // OH WELL.
+ },
+ );
+ });
+ }
+
+ public retryImmediately(): boolean {
+ return true;
+ }
+
+ /**
+ * Main entry point. Blocks until stop() is called.
+ */
+ public async sync(): Promise<void> {
+ logger.debug("Sliding sync init loop");
+
+ // 1) We need to get push rules so we can check if events should bing as we get
+ // them from /sync.
+ while (!this.client.isGuest()) {
+ try {
+ logger.debug("Getting push rules...");
+ const result = await this.client.getPushRules();
+ logger.debug("Got push rules");
+ this.client.pushRules = result;
+ break;
+ } catch (err) {
+ logger.error("Getting push rules failed", err);
+ if (this.shouldAbortSync(<MatrixError>err)) {
+ return;
+ }
+ }
+ }
+
+ // start syncing
+ await this.slidingSync.start();
+ }
+
+ /**
+ * Stops the sync object from syncing.
+ */
+ public stop(): void {
+ logger.debug("SyncApi.stop");
+ this.slidingSync.stop();
+ }
+
+ /**
+ * Sets the sync state and emits an event to say so
+ * @param newState - The new state string
+ * @param data - Object of additional data to emit in the event
+ */
+ private updateSyncState(newState: SyncState, data?: ISyncStateData): void {
+ const old = this.syncState;
+ this.syncState = newState;
+ this.syncStateData = data;
+ this.client.emit(ClientEvent.Sync, this.syncState, old, data);
+ }
+
+ /**
+ * Takes a list of timelineEvents and adds and adds to notifEvents
+ * as appropriate.
+ * This must be called after the room the events belong to has been stored.
+ *
+ * @param timelineEventList - A list of timeline events. Lower index
+ * is earlier in time. Higher index is later.
+ */
+ private addNotifications(timelineEventList: MatrixEvent[]): void {
+ // gather our notifications into this.notifEvents
+ if (!this.client.getNotifTimelineSet()) {
+ return;
+ }
+ for (const timelineEvent of timelineEventList) {
+ const pushActions = this.client.getPushActionsForEvent(timelineEvent);
+ if (pushActions && pushActions.notify && pushActions.tweaks && pushActions.tweaks.highlight) {
+ this.notifEvents.push(timelineEvent);
+ }
+ }
+ }
+
+ /**
+ * Purge any events in the notifEvents array. Used after a /sync has been complete.
+ * This should not be called at a per-room scope (e.g in onRoomData) because otherwise the ordering
+ * will be messed up e.g room A gets a bing, room B gets a newer bing, but both in the same /sync
+ * response. If we purge at a per-room scope then we could process room B before room A leading to
+ * room B appearing earlier in the notifications timeline, even though it has the higher origin_server_ts.
+ */
+ private purgeNotifications(): void {
+ this.notifEvents.sort(function (a, b) {
+ return a.getTs() - b.getTs();
+ });
+ this.notifEvents.forEach((event) => {
+ this.client.getNotifTimelineSet()?.addLiveEvent(event);
+ });
+ this.notifEvents = [];
+ }
+}
+
+function ensureNameEvent(client: MatrixClient, roomId: string, roomData: MSC3575RoomData): MSC3575RoomData {
+ // make sure m.room.name is in required_state if there is a name, replacing anything previously
+ // there if need be. This ensures clients transparently 'calculate' the right room name. Native
+ // sliding sync clients should just read the "name" field.
+ if (!roomData.name) {
+ return roomData;
+ }
+ for (const stateEvent of roomData.required_state) {
+ if (stateEvent.type === EventType.RoomName && stateEvent.state_key === "") {
+ stateEvent.content = {
+ name: roomData.name,
+ };
+ return roomData;
+ }
+ }
+ roomData.required_state.push({
+ event_id: "$fake-sliding-sync-name-event-" + roomId,
+ state_key: "",
+ type: EventType.RoomName,
+ content: {
+ name: roomData.name,
+ },
+ sender: client.getUserId()!,
+ origin_server_ts: new Date().getTime(),
+ });
+ return roomData;
+}
+
+type TaggedEvent = (IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent) & { room_id?: string };
+
+// Helper functions which set up JS SDK structs are below and are identical to the sync v2 counterparts,
+// just outside the class.
+function mapEvents(client: MatrixClient, roomId: string | undefined, events: object[], decrypt = true): MatrixEvent[] {
+ const mapper = client.getEventMapper({ decrypt });
+ return (events as TaggedEvent[]).map(function (e) {
+ e.room_id = roomId;
+ return mapper(e);
+ });
+}
+
+function processEphemeralEvents(client: MatrixClient, roomId: string, ephEvents: IMinimalEvent[]): void {
+ const ephemeralEvents = mapEvents(client, roomId, ephEvents);
+ const room = client.getRoom(roomId);
+ if (!room) {
+ logger.warn("got ephemeral events for room but room doesn't exist on client:", roomId);
+ return;
+ }
+ room.addEphemeralEvents(ephemeralEvents);
+ ephemeralEvents.forEach((e) => {
+ client.emit(ClientEvent.Event, e);
+ });
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync.ts
new file mode 100644
index 0000000..dde5f1b
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/sliding-sync.ts
@@ -0,0 +1,961 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "./logger";
+import { MatrixClient } from "./client";
+import { IRoomEvent, IStateEvent } from "./sync-accumulator";
+import { TypedEventEmitter } from "./models/typed-event-emitter";
+import { sleep, IDeferred, defer } from "./utils";
+import { HTTPError } from "./http-api";
+
+// /sync requests allow you to set a timeout= but the request may continue
+// beyond that and wedge forever, so we need to track how long we are willing
+// to keep open the connection. This constant is *ADDED* to the timeout= value
+// to determine the max time we're willing to wait.
+const BUFFER_PERIOD_MS = 10 * 1000;
+
+export const MSC3575_WILDCARD = "*";
+export const MSC3575_STATE_KEY_ME = "$ME";
+export const MSC3575_STATE_KEY_LAZY = "$LAZY";
+
+/**
+ * Represents a subscription to a room or set of rooms. Controls which events are returned.
+ */
+export interface MSC3575RoomSubscription {
+ required_state?: string[][];
+ timeline_limit?: number;
+ include_old_rooms?: MSC3575RoomSubscription;
+}
+
+/**
+ * Controls which rooms are returned in a given list.
+ */
+export interface MSC3575Filter {
+ is_dm?: boolean;
+ is_encrypted?: boolean;
+ is_invite?: boolean;
+ room_name_like?: string;
+ room_types?: string[];
+ not_room_types?: string[];
+ spaces?: string[];
+ tags?: string[];
+ not_tags?: string[];
+}
+
+/**
+ * Represents a list subscription.
+ */
+export interface MSC3575List extends MSC3575RoomSubscription {
+ ranges: number[][];
+ sort?: string[];
+ filters?: MSC3575Filter;
+ slow_get_all_rooms?: boolean;
+}
+
+/**
+ * A complete Sliding Sync request.
+ */
+export interface MSC3575SlidingSyncRequest {
+ // json body params
+ lists?: Record<string, MSC3575List>;
+ unsubscribe_rooms?: string[];
+ room_subscriptions?: Record<string, MSC3575RoomSubscription>;
+ extensions?: object;
+ txn_id?: string;
+
+ // query params
+ pos?: string;
+ timeout?: number;
+ clientTimeout?: number;
+}
+
+export interface MSC3575RoomData {
+ name: string;
+ required_state: IStateEvent[];
+ timeline: (IRoomEvent | IStateEvent)[];
+ notification_count?: number;
+ highlight_count?: number;
+ joined_count?: number;
+ invited_count?: number;
+ invite_state?: IStateEvent[];
+ initial?: boolean;
+ limited?: boolean;
+ is_dm?: boolean;
+ prev_batch?: string;
+ num_live?: number;
+}
+
+interface ListResponse {
+ count: number;
+ ops: Operation[];
+}
+
+interface BaseOperation {
+ op: string;
+}
+
+interface DeleteOperation extends BaseOperation {
+ op: "DELETE";
+ index: number;
+}
+
+interface InsertOperation extends BaseOperation {
+ op: "INSERT";
+ index: number;
+ room_id: string;
+}
+
+interface InvalidateOperation extends BaseOperation {
+ op: "INVALIDATE";
+ range: [number, number];
+}
+
+interface SyncOperation extends BaseOperation {
+ op: "SYNC";
+ range: [number, number];
+ room_ids: string[];
+}
+
+type Operation = DeleteOperation | InsertOperation | InvalidateOperation | SyncOperation;
+
+/**
+ * A complete Sliding Sync response
+ */
+export interface MSC3575SlidingSyncResponse {
+ pos: string;
+ txn_id?: string;
+ lists: Record<string, ListResponse>;
+ rooms: Record<string, MSC3575RoomData>;
+ extensions: Record<string, object>;
+}
+
+export enum SlidingSyncState {
+ /**
+ * Fired by SlidingSyncEvent.Lifecycle event immediately before processing the response.
+ */
+ RequestFinished = "FINISHED",
+ /**
+ * Fired by SlidingSyncEvent.Lifecycle event immediately after all room data listeners have been
+ * invoked, but before list listeners.
+ */
+ Complete = "COMPLETE",
+}
+
+/**
+ * Internal Class. SlidingList represents a single list in sliding sync. The list can have filters,
+ * multiple sliding windows, and maintains the index-\>room_id mapping.
+ */
+class SlidingList {
+ private list!: MSC3575List;
+ private isModified?: boolean;
+
+ // returned data
+ public roomIndexToRoomId: Record<number, string> = {};
+ public joinedCount = 0;
+
+ /**
+ * Construct a new sliding list.
+ * @param list - The range, sort and filter values to use for this list.
+ */
+ public constructor(list: MSC3575List) {
+ this.replaceList(list);
+ }
+
+ /**
+ * Mark this list as modified or not. Modified lists will return sticky params with calls to getList.
+ * This is useful for the first time the list is sent, or if the list has changed in some way.
+ * @param modified - True to mark this list as modified so all sticky parameters will be re-sent.
+ */
+ public setModified(modified: boolean): void {
+ this.isModified = modified;
+ }
+
+ /**
+ * Update the list range for this list. Does not affect modified status as list ranges are non-sticky.
+ * @param newRanges - The new ranges for the list
+ */
+ public updateListRange(newRanges: number[][]): void {
+ this.list.ranges = JSON.parse(JSON.stringify(newRanges));
+ }
+
+ /**
+ * Replace list parameters. All fields will be replaced with the new list parameters.
+ * @param list - The new list parameters
+ */
+ public replaceList(list: MSC3575List): void {
+ list.filters = list.filters || {};
+ list.ranges = list.ranges || [];
+ this.list = JSON.parse(JSON.stringify(list));
+ this.isModified = true;
+
+ // reset values as the join count may be very different (if filters changed) including the rooms
+ // (e.g. sort orders or sliding window ranges changed)
+
+ // the constantly changing sliding window ranges. Not an array for performance reasons
+ // E.g. tracking ranges 0-99, 500-599, we don't want to have a 600 element array
+ this.roomIndexToRoomId = {};
+ // the total number of joined rooms according to the server, always >= len(roomIndexToRoomId)
+ this.joinedCount = 0;
+ }
+
+ /**
+ * Return a copy of the list suitable for a request body.
+ * @param forceIncludeAllParams - True to forcibly include all params even if the list
+ * hasn't been modified. Callers may want to do this if they are modifying the list prior to calling
+ * updateList.
+ */
+ public getList(forceIncludeAllParams: boolean): MSC3575List {
+ let list = {
+ ranges: JSON.parse(JSON.stringify(this.list.ranges)),
+ };
+ if (this.isModified || forceIncludeAllParams) {
+ list = JSON.parse(JSON.stringify(this.list));
+ }
+ return list;
+ }
+
+ /**
+ * Check if a given index is within the list range. This is required even though the /sync API
+ * provides explicit updates with index positions because of the following situation:
+ * 0 1 2 3 4 5 6 7 8 indexes
+ * a b c d e f COMMANDS: SYNC 0 2 a b c; SYNC 6 8 d e f;
+ * a b c d _ f COMMAND: DELETE 7;
+ * e a b c d f COMMAND: INSERT 0 e;
+ * c=3 is wrong as we are not tracking it, ergo we need to see if `i` is in range else drop it
+ * @param i - The index to check
+ * @returns True if the index is within a sliding window
+ */
+ public isIndexInRange(i: number): boolean {
+ for (const r of this.list.ranges) {
+ if (r[0] <= i && i <= r[1]) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
+
+/**
+ * When onResponse extensions should be invoked: before or after processing the main response.
+ */
+export enum ExtensionState {
+ // Call onResponse before processing the response body. This is useful when your extension is
+ // preparing the ground for the response body e.g. processing to-device messages before the
+ // encrypted event arrives.
+ PreProcess = "ExtState.PreProcess",
+ // Call onResponse after processing the response body. This is useful when your extension is
+ // decorating data from the client, and you rely on MatrixClient.getRoom returning the Room object
+ // e.g. room account data.
+ PostProcess = "ExtState.PostProcess",
+}
+
+/**
+ * An interface that must be satisfied to register extensions
+ */
+export interface Extension<Req extends {}, Res extends {}> {
+ /**
+ * The extension name to go under 'extensions' in the request body.
+ * @returns The JSON key.
+ */
+ name(): string;
+ /**
+ * A function which is called when the request JSON is being formed.
+ * Returns the data to insert under this key.
+ * @param isInitial - True when this is part of the initial request (send sticky params)
+ * @returns The request JSON to send.
+ */
+ onRequest(isInitial: boolean): Req | undefined;
+ /**
+ * A function which is called when there is response JSON under this extension.
+ * @param data - The response JSON under the extension name.
+ */
+ onResponse(data: Res): void;
+ /**
+ * Controls when onResponse should be called.
+ * @returns The state when it should be called.
+ */
+ when(): ExtensionState;
+}
+
+/**
+ * Events which can be fired by the SlidingSync class. These are designed to provide different levels
+ * of information when processing sync responses.
+ * - RoomData: concerns rooms, useful for SlidingSyncSdk to update its knowledge of rooms.
+ * - Lifecycle: concerns callbacks at various well-defined points in the sync process.
+ * - List: concerns lists, useful for UI layers to re-render room lists.
+ * Specifically, the order of event invocation is:
+ * - Lifecycle (state=RequestFinished)
+ * - RoomData (N times)
+ * - Lifecycle (state=Complete)
+ * - List (at most once per list)
+ */
+export enum SlidingSyncEvent {
+ /**
+ * This event fires when there are updates for a room. Fired as and when rooms are encountered
+ * in the response.
+ */
+ RoomData = "SlidingSync.RoomData",
+ /**
+ * This event fires at various points in the /sync loop lifecycle.
+ * - SlidingSyncState.RequestFinished: Fires after we receive a valid response but before the
+ * response has been processed. Perform any pre-process steps here. If there was a problem syncing,
+ * `err` will be set (e.g network errors).
+ * - SlidingSyncState.Complete: Fires after all SlidingSyncEvent.RoomData have been fired but before
+ * SlidingSyncEvent.List.
+ */
+ Lifecycle = "SlidingSync.Lifecycle",
+ /**
+ * This event fires whenever there has been a change to this list index. It fires exactly once
+ * per list, even if there were multiple operations for the list.
+ * It fires AFTER Lifecycle and RoomData events.
+ */
+ List = "SlidingSync.List",
+}
+
+export type SlidingSyncEventHandlerMap = {
+ [SlidingSyncEvent.RoomData]: (roomId: string, roomData: MSC3575RoomData) => void;
+ [SlidingSyncEvent.Lifecycle]: (
+ state: SlidingSyncState,
+ resp: MSC3575SlidingSyncResponse | null,
+ err?: Error,
+ ) => void;
+ [SlidingSyncEvent.List]: (listKey: string, joinedCount: number, roomIndexToRoomId: Record<number, string>) => void;
+};
+
+/**
+ * SlidingSync is a high-level data structure which controls the majority of sliding sync.
+ * It has no hooks into JS SDK except for needing a MatrixClient to perform the HTTP request.
+ * This means this class (and everything it uses) can be used in isolation from JS SDK if needed.
+ * To hook this up with the JS SDK, you need to use SlidingSyncSdk.
+ */
+export class SlidingSync extends TypedEventEmitter<SlidingSyncEvent, SlidingSyncEventHandlerMap> {
+ private lists: Map<string, SlidingList>;
+ private listModifiedCount = 0;
+ private terminated = false;
+ // flag set when resend() is called because we cannot rely on detecting AbortError in JS SDK :(
+ private needsResend = false;
+ // the txn_id to send with the next request.
+ private txnId: string | null = null;
+ // a list (in chronological order of when they were sent) of objects containing the txn ID and
+ // a defer to resolve/reject depending on whether they were successfully sent or not.
+ private txnIdDefers: (IDeferred<string> & { txnId: string })[] = [];
+ // map of extension name to req/resp handler
+ private extensions: Record<string, Extension<any, any>> = {};
+
+ private desiredRoomSubscriptions = new Set<string>(); // the *desired* room subscriptions
+ private confirmedRoomSubscriptions = new Set<string>();
+
+ // map of custom subscription name to the subscription
+ private customSubscriptions: Map<string, MSC3575RoomSubscription> = new Map();
+ // map of room ID to custom subscription name
+ private roomIdToCustomSubscription: Map<string, string> = new Map();
+
+ private pendingReq?: Promise<MSC3575SlidingSyncResponse>;
+ private abortController?: AbortController;
+
+ /**
+ * Create a new sliding sync instance
+ * @param proxyBaseUrl - The base URL of the sliding sync proxy
+ * @param lists - The lists to use for sliding sync.
+ * @param roomSubscriptionInfo - The params to use for room subscriptions.
+ * @param client - The client to use for /sync calls.
+ * @param timeoutMS - The number of milliseconds to wait for a response.
+ */
+ public constructor(
+ private readonly proxyBaseUrl: string,
+ lists: Map<string, MSC3575List>,
+ private roomSubscriptionInfo: MSC3575RoomSubscription,
+ private readonly client: MatrixClient,
+ private readonly timeoutMS: number,
+ ) {
+ super();
+ this.lists = new Map<string, SlidingList>();
+ lists.forEach((list, key) => {
+ this.lists.set(key, new SlidingList(list));
+ });
+ }
+
+ /**
+ * Add a custom room subscription, referred to by an arbitrary name. If a subscription with this
+ * name already exists, it is replaced. No requests are sent by calling this method.
+ * @param name - The name of the subscription. Only used to reference this subscription in
+ * useCustomSubscription.
+ * @param sub - The subscription information.
+ */
+ public addCustomSubscription(name: string, sub: MSC3575RoomSubscription): void {
+ if (this.customSubscriptions.has(name)) {
+ logger.warn(`addCustomSubscription: ${name} already exists as a custom subscription, ignoring.`);
+ return;
+ }
+ this.customSubscriptions.set(name, sub);
+ }
+
+ /**
+ * Use a custom subscription previously added via addCustomSubscription. No requests are sent
+ * by calling this method. Use modifyRoomSubscriptions to resend subscription information.
+ * @param roomId - The room to use the subscription in.
+ * @param name - The name of the subscription. If this name is unknown, the default subscription
+ * will be used.
+ */
+ public useCustomSubscription(roomId: string, name: string): void {
+ // We already know about this custom subscription, as it is immutable,
+ // we don't need to unconfirm the subscription.
+ if (this.roomIdToCustomSubscription.get(roomId) === name) {
+ return;
+ }
+ this.roomIdToCustomSubscription.set(roomId, name);
+ // unconfirm this subscription so a resend() will send it up afresh.
+ this.confirmedRoomSubscriptions.delete(roomId);
+ }
+
+ /**
+ * Get the room index data for a list.
+ * @param key - The list key
+ * @returns The list data which contains the rooms in this list
+ */
+ public getListData(key: string): { joinedCount: number; roomIndexToRoomId: Record<number, string> } | null {
+ const data = this.lists.get(key);
+ if (!data) {
+ return null;
+ }
+ return {
+ joinedCount: data.joinedCount,
+ roomIndexToRoomId: Object.assign({}, data.roomIndexToRoomId),
+ };
+ }
+
+ /**
+ * Get the full request list parameters for a list index. This function is provided for callers to use
+ * in conjunction with setList to update fields on an existing list.
+ * @param key - The list key to get the params for.
+ * @returns A copy of the list params or undefined.
+ */
+ public getListParams(key: string): MSC3575List | null {
+ const params = this.lists.get(key);
+ if (!params) {
+ return null;
+ }
+ return params.getList(true);
+ }
+
+ /**
+ * Set new ranges for an existing list. Calling this function when _only_ the ranges have changed
+ * is more efficient than calling setList(index,list) as this function won't resend sticky params,
+ * whereas setList always will.
+ * @param key - The list key to modify
+ * @param ranges - The new ranges to apply.
+ * @returns A promise which resolves to the transaction ID when it has been received down sync
+ * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
+ * immediately after sending, in which case the action will be applied in the subsequent request)
+ */
+ public setListRanges(key: string, ranges: number[][]): Promise<string> {
+ const list = this.lists.get(key);
+ if (!list) {
+ return Promise.reject(new Error("no list with key " + key));
+ }
+ list.updateListRange(ranges);
+ return this.resend();
+ }
+
+ /**
+ * Add or replace a list. Calling this function will interrupt the /sync request to resend new
+ * lists.
+ * @param key - The key to modify
+ * @param list - The new list parameters.
+ * @returns A promise which resolves to the transaction ID when it has been received down sync
+ * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
+ * immediately after sending, in which case the action will be applied in the subsequent request)
+ */
+ public setList(key: string, list: MSC3575List): Promise<string> {
+ const existingList = this.lists.get(key);
+ if (existingList) {
+ existingList.replaceList(list);
+ this.lists.set(key, existingList);
+ } else {
+ this.lists.set(key, new SlidingList(list));
+ }
+ this.listModifiedCount += 1;
+ return this.resend();
+ }
+
+ /**
+ * Get the room subscriptions for the sync API.
+ * @returns A copy of the desired room subscriptions.
+ */
+ public getRoomSubscriptions(): Set<string> {
+ return new Set(Array.from(this.desiredRoomSubscriptions));
+ }
+
+ /**
+ * Modify the room subscriptions for the sync API. Calling this function will interrupt the
+ * /sync request to resend new subscriptions. If the /sync stream has not started, this will
+ * prepare the room subscriptions for when start() is called.
+ * @param s - The new desired room subscriptions.
+ * @returns A promise which resolves to the transaction ID when it has been received down sync
+ * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
+ * immediately after sending, in which case the action will be applied in the subsequent request)
+ */
+ public modifyRoomSubscriptions(s: Set<string>): Promise<string> {
+ this.desiredRoomSubscriptions = s;
+ return this.resend();
+ }
+
+ /**
+ * Modify which events to retrieve for room subscriptions. Invalidates all room subscriptions
+ * such that they will be sent up afresh.
+ * @param rs - The new room subscription fields to fetch.
+ * @returns A promise which resolves to the transaction ID when it has been received down sync
+ * (or rejects with the transaction ID if the action was not applied e.g the request was cancelled
+ * immediately after sending, in which case the action will be applied in the subsequent request)
+ */
+ public modifyRoomSubscriptionInfo(rs: MSC3575RoomSubscription): Promise<string> {
+ this.roomSubscriptionInfo = rs;
+ this.confirmedRoomSubscriptions = new Set<string>();
+ return this.resend();
+ }
+
+ /**
+ * Register an extension to send with the /sync request.
+ * @param ext - The extension to register.
+ */
+ public registerExtension(ext: Extension<any, any>): void {
+ if (this.extensions[ext.name()]) {
+ throw new Error(`registerExtension: ${ext.name()} already exists as an extension`);
+ }
+ this.extensions[ext.name()] = ext;
+ }
+
+ private getExtensionRequest(isInitial: boolean): Record<string, object | undefined> {
+ const ext: Record<string, object | undefined> = {};
+ Object.keys(this.extensions).forEach((extName) => {
+ ext[extName] = this.extensions[extName].onRequest(isInitial);
+ });
+ return ext;
+ }
+
+ private onPreExtensionsResponse(ext: Record<string, object>): void {
+ Object.keys(ext).forEach((extName) => {
+ if (this.extensions[extName].when() == ExtensionState.PreProcess) {
+ this.extensions[extName].onResponse(ext[extName]);
+ }
+ });
+ }
+
+ private onPostExtensionsResponse(ext: Record<string, object>): void {
+ Object.keys(ext).forEach((extName) => {
+ if (this.extensions[extName].when() == ExtensionState.PostProcess) {
+ this.extensions[extName].onResponse(ext[extName]);
+ }
+ });
+ }
+
+ /**
+ * Invoke all attached room data listeners.
+ * @param roomId - The room which received some data.
+ * @param roomData - The raw sliding sync response JSON.
+ */
+ private invokeRoomDataListeners(roomId: string, roomData: MSC3575RoomData): void {
+ if (!roomData.required_state) {
+ roomData.required_state = [];
+ }
+ if (!roomData.timeline) {
+ roomData.timeline = [];
+ }
+ this.emit(SlidingSyncEvent.RoomData, roomId, roomData);
+ }
+
+ /**
+ * Invoke all attached lifecycle listeners.
+ * @param state - The Lifecycle state
+ * @param resp - The raw sync response JSON
+ * @param err - Any error that occurred when making the request e.g. network errors.
+ */
+ private invokeLifecycleListeners(
+ state: SlidingSyncState,
+ resp: MSC3575SlidingSyncResponse | null,
+ err?: Error,
+ ): void {
+ this.emit(SlidingSyncEvent.Lifecycle, state, resp, err);
+ }
+
+ private shiftRight(listKey: string, hi: number, low: number): void {
+ const list = this.lists.get(listKey);
+ if (!list) {
+ return;
+ }
+ // l h
+ // 0,1,2,3,4 <- before
+ // 0,1,2,2,3 <- after, hi is deleted and low is duplicated
+ for (let i = hi; i > low; i--) {
+ if (list.isIndexInRange(i)) {
+ list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i - 1];
+ }
+ }
+ }
+
+ private shiftLeft(listKey: string, hi: number, low: number): void {
+ const list = this.lists.get(listKey);
+ if (!list) {
+ return;
+ }
+ // l h
+ // 0,1,2,3,4 <- before
+ // 0,1,3,4,4 <- after, low is deleted and hi is duplicated
+ for (let i = low; i < hi; i++) {
+ if (list.isIndexInRange(i)) {
+ list.roomIndexToRoomId[i] = list.roomIndexToRoomId[i + 1];
+ }
+ }
+ }
+
+ private removeEntry(listKey: string, index: number): void {
+ const list = this.lists.get(listKey);
+ if (!list) {
+ return;
+ }
+ // work out the max index
+ let max = -1;
+ for (const n in list.roomIndexToRoomId) {
+ if (Number(n) > max) {
+ max = Number(n);
+ }
+ }
+ if (max < 0 || index > max) {
+ return;
+ }
+ // Everything higher than the gap needs to be shifted left.
+ this.shiftLeft(listKey, max, index);
+ delete list.roomIndexToRoomId[max];
+ }
+
+ private addEntry(listKey: string, index: number): void {
+ const list = this.lists.get(listKey);
+ if (!list) {
+ return;
+ }
+ // work out the max index
+ let max = -1;
+ for (const n in list.roomIndexToRoomId) {
+ if (Number(n) > max) {
+ max = Number(n);
+ }
+ }
+ if (max < 0 || index > max) {
+ return;
+ }
+ // Everything higher than the gap needs to be shifted right, +1 so we don't delete the highest element
+ this.shiftRight(listKey, max + 1, index);
+ }
+
+ private processListOps(list: ListResponse, listKey: string): void {
+ let gapIndex = -1;
+ const listData = this.lists.get(listKey);
+ if (!listData) {
+ return;
+ }
+ list.ops.forEach((op: Operation) => {
+ if (!listData) {
+ return;
+ }
+ switch (op.op) {
+ case "DELETE": {
+ logger.debug("DELETE", listKey, op.index, ";");
+ delete listData.roomIndexToRoomId[op.index];
+ if (gapIndex !== -1) {
+ // we already have a DELETE operation to process, so process it.
+ this.removeEntry(listKey, gapIndex);
+ }
+ gapIndex = op.index;
+ break;
+ }
+ case "INSERT": {
+ logger.debug("INSERT", listKey, op.index, op.room_id, ";");
+ if (listData.roomIndexToRoomId[op.index]) {
+ // something is in this space, shift items out of the way
+ if (gapIndex < 0) {
+ // we haven't been told where to shift from, so make way for a new room entry.
+ this.addEntry(listKey, op.index);
+ } else if (gapIndex > op.index) {
+ // the gap is further down the list, shift every element to the right
+ // starting at the gap so we can just shift each element in turn:
+ // [A,B,C,_] gapIndex=3, op.index=0
+ // [A,B,C,C] i=3
+ // [A,B,B,C] i=2
+ // [A,A,B,C] i=1
+ // Terminate. We'll assign into op.index next.
+ this.shiftRight(listKey, gapIndex, op.index);
+ } else if (gapIndex < op.index) {
+ // the gap is further up the list, shift every element to the left
+ // starting at the gap so we can just shift each element in turn
+ this.shiftLeft(listKey, op.index, gapIndex);
+ }
+ }
+ // forget the gap, we don't need it anymore. This is outside the check for
+ // a room being present in this index position because INSERTs always universally
+ // forget the gap, not conditionally based on the presence of a room in the INSERT
+ // position. Without this, DELETE 0; INSERT 0; would do the wrong thing.
+ gapIndex = -1;
+ listData.roomIndexToRoomId[op.index] = op.room_id;
+ break;
+ }
+ case "INVALIDATE": {
+ const startIndex = op.range[0];
+ for (let i = startIndex; i <= op.range[1]; i++) {
+ delete listData.roomIndexToRoomId[i];
+ }
+ logger.debug("INVALIDATE", listKey, op.range[0], op.range[1], ";");
+ break;
+ }
+ case "SYNC": {
+ const startIndex = op.range[0];
+ for (let i = startIndex; i <= op.range[1]; i++) {
+ const roomId = op.room_ids[i - startIndex];
+ if (!roomId) {
+ break; // we are at the end of list
+ }
+ listData.roomIndexToRoomId[i] = roomId;
+ }
+ logger.debug("SYNC", listKey, op.range[0], op.range[1], (op.room_ids || []).join(" "), ";");
+ break;
+ }
+ }
+ });
+ if (gapIndex !== -1) {
+ // we already have a DELETE operation to process, so process it
+ // Everything higher than the gap needs to be shifted left.
+ this.removeEntry(listKey, gapIndex);
+ }
+ }
+
+ /**
+ * Resend a Sliding Sync request. Used when something has changed in the request. Resolves with
+ * the transaction ID of this request on success. Rejects with the transaction ID of this request
+ * on failure.
+ */
+ public resend(): Promise<string> {
+ if (this.needsResend && this.txnIdDefers.length > 0) {
+ // we already have a resend queued, so just return the same promise
+ return this.txnIdDefers[this.txnIdDefers.length - 1].promise;
+ }
+ this.needsResend = true;
+ this.txnId = this.client.makeTxnId();
+ const d = defer<string>();
+ this.txnIdDefers.push({
+ ...d,
+ txnId: this.txnId,
+ });
+ this.abortController?.abort();
+ this.abortController = new AbortController();
+ return d.promise;
+ }
+
+ private resolveTransactionDefers(txnId?: string): void {
+ if (!txnId) {
+ return;
+ }
+ // find the matching index
+ let txnIndex = -1;
+ for (let i = 0; i < this.txnIdDefers.length; i++) {
+ if (this.txnIdDefers[i].txnId === txnId) {
+ txnIndex = i;
+ break;
+ }
+ }
+ if (txnIndex === -1) {
+ // this shouldn't happen; we shouldn't be seeing txn_ids for things we don't know about,
+ // whine about it.
+ logger.warn(`resolveTransactionDefers: seen ${txnId} but it isn't a pending txn, ignoring.`);
+ return;
+ }
+ // This list is sorted in time, so if the input txnId ACKs in the middle of this array,
+ // then everything before it that hasn't been ACKed yet never will and we should reject them.
+ for (let i = 0; i < txnIndex; i++) {
+ this.txnIdDefers[i].reject(this.txnIdDefers[i].txnId);
+ }
+ this.txnIdDefers[txnIndex].resolve(txnId);
+ // clear out settled promises, including the one we resolved.
+ this.txnIdDefers = this.txnIdDefers.slice(txnIndex + 1);
+ }
+
+ /**
+ * Stop syncing with the server.
+ */
+ public stop(): void {
+ this.terminated = true;
+ this.abortController?.abort();
+ // remove all listeners so things can be GC'd
+ this.removeAllListeners(SlidingSyncEvent.Lifecycle);
+ this.removeAllListeners(SlidingSyncEvent.List);
+ this.removeAllListeners(SlidingSyncEvent.RoomData);
+ }
+
+ /**
+ * Re-setup this connection e.g in the event of an expired session.
+ */
+ private resetup(): void {
+ logger.warn("SlidingSync: resetting connection info");
+ // any pending txn ID defers will be forgotten already by the server, so clear them out
+ this.txnIdDefers.forEach((d) => {
+ d.reject(d.txnId);
+ });
+ this.txnIdDefers = [];
+ // resend sticky params and de-confirm all subscriptions
+ this.lists.forEach((l) => {
+ l.setModified(true);
+ });
+ this.confirmedRoomSubscriptions = new Set<string>(); // leave desired ones alone though!
+ // reset the connection as we might be wedged
+ this.needsResend = true;
+ this.abortController?.abort();
+ this.abortController = new AbortController();
+ }
+
+ /**
+ * Start syncing with the server. Blocks until stopped.
+ */
+ public async start(): Promise<void> {
+ this.abortController = new AbortController();
+
+ let currentPos: string | undefined;
+ while (!this.terminated) {
+ this.needsResend = false;
+ let doNotUpdateList = false;
+ let resp: MSC3575SlidingSyncResponse | undefined;
+ try {
+ const listModifiedCount = this.listModifiedCount;
+ const reqLists: Record<string, MSC3575List> = {};
+ this.lists.forEach((l: SlidingList, key: string) => {
+ reqLists[key] = l.getList(false);
+ });
+ const reqBody: MSC3575SlidingSyncRequest = {
+ lists: reqLists,
+ pos: currentPos,
+ timeout: this.timeoutMS,
+ clientTimeout: this.timeoutMS + BUFFER_PERIOD_MS,
+ extensions: this.getExtensionRequest(currentPos === undefined),
+ };
+ // check if we are (un)subscribing to a room and modify request this one time for it
+ const newSubscriptions = difference(this.desiredRoomSubscriptions, this.confirmedRoomSubscriptions);
+ const unsubscriptions = difference(this.confirmedRoomSubscriptions, this.desiredRoomSubscriptions);
+ if (unsubscriptions.size > 0) {
+ reqBody.unsubscribe_rooms = Array.from(unsubscriptions);
+ }
+ if (newSubscriptions.size > 0) {
+ reqBody.room_subscriptions = {};
+ for (const roomId of newSubscriptions) {
+ const customSubName = this.roomIdToCustomSubscription.get(roomId);
+ let sub = this.roomSubscriptionInfo;
+ if (customSubName && this.customSubscriptions.has(customSubName)) {
+ sub = this.customSubscriptions.get(customSubName)!;
+ }
+ reqBody.room_subscriptions[roomId] = sub;
+ }
+ }
+ if (this.txnId) {
+ reqBody.txn_id = this.txnId;
+ this.txnId = null;
+ }
+ this.pendingReq = this.client.slidingSync(reqBody, this.proxyBaseUrl, this.abortController.signal);
+ resp = await this.pendingReq;
+ currentPos = resp.pos;
+ // update what we think we're subscribed to.
+ for (const roomId of newSubscriptions) {
+ this.confirmedRoomSubscriptions.add(roomId);
+ }
+ for (const roomId of unsubscriptions) {
+ this.confirmedRoomSubscriptions.delete(roomId);
+ }
+ if (listModifiedCount !== this.listModifiedCount) {
+ // the lists have been modified whilst we were waiting for 'await' to return, but the abort()
+ // call did nothing. It is NOT SAFE to modify the list array now. We'll process the response but
+ // not update list pointers.
+ logger.debug("list modified during await call, not updating list");
+ doNotUpdateList = true;
+ }
+ // mark all these lists as having been sent as sticky so we don't keep sending sticky params
+ this.lists.forEach((l) => {
+ l.setModified(false);
+ });
+ // set default empty values so we don't need to null check
+ resp.lists = resp.lists || {};
+ resp.rooms = resp.rooms || {};
+ resp.extensions = resp.extensions || {};
+ Object.keys(resp.lists).forEach((key: string) => {
+ const list = this.lists.get(key);
+ if (!list || !resp) {
+ return;
+ }
+ list.joinedCount = resp.lists[key].count;
+ });
+ this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, resp);
+ } catch (err) {
+ if ((<HTTPError>err).httpStatus) {
+ this.invokeLifecycleListeners(SlidingSyncState.RequestFinished, null, <Error>err);
+ if ((<HTTPError>err).httpStatus === 400) {
+ // session probably expired TODO: assign an errcode
+ // so drop state and re-request
+ this.resetup();
+ currentPos = undefined;
+ await sleep(50); // in case the 400 was for something else; don't tightloop
+ continue;
+ } // else fallthrough to generic error handling
+ } else if (this.needsResend || (<Error>err).name === "AbortError") {
+ continue; // don't sleep as we caused this error by abort()ing the request.
+ }
+ logger.error(err);
+ await sleep(5000);
+ }
+ if (!resp) {
+ continue;
+ }
+ this.onPreExtensionsResponse(resp.extensions);
+
+ Object.keys(resp.rooms).forEach((roomId) => {
+ this.invokeRoomDataListeners(roomId, resp!.rooms[roomId]);
+ });
+
+ const listKeysWithUpdates: Set<string> = new Set();
+ if (!doNotUpdateList) {
+ for (const [key, list] of Object.entries(resp.lists)) {
+ list.ops = list.ops || [];
+ if (list.ops.length > 0) {
+ listKeysWithUpdates.add(key);
+ }
+ this.processListOps(list, key);
+ }
+ }
+ this.invokeLifecycleListeners(SlidingSyncState.Complete, resp);
+ this.onPostExtensionsResponse(resp.extensions);
+ listKeysWithUpdates.forEach((listKey: string) => {
+ const list = this.lists.get(listKey);
+ if (!list) {
+ return;
+ }
+ this.emit(SlidingSyncEvent.List, listKey, list.joinedCount, Object.assign({}, list.roomIndexToRoomId));
+ });
+
+ this.resolveTransactionDefers(resp.txn_id);
+ }
+ }
+}
+
+const difference = (setA: Set<string>, setB: Set<string>): Set<string> => {
+ const diff = new Set(setA);
+ for (const elem of setB) {
+ diff.delete(elem);
+ }
+ return diff;
+};
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/index.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/index.ts
new file mode 100644
index 0000000..650dd9a
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/index.ts
@@ -0,0 +1,248 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventType } from "../@types/event";
+import { Room } from "../models/room";
+import { User } from "../models/user";
+import { IEvent, MatrixEvent } from "../models/event";
+import { Filter } from "../filter";
+import { RoomSummary } from "../models/room-summary";
+import { IMinimalEvent, IRooms, ISyncResponse } from "../sync-accumulator";
+import { IStartClientOpts } from "../client";
+import { IStateEventWithRoomId } from "../@types/search";
+import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
+import { EventEmitterEvents } from "../models/typed-event-emitter";
+
+export interface ISavedSync {
+ nextBatch: string;
+ roomsData: IRooms;
+ accountData: IMinimalEvent[];
+}
+
+/**
+ * A store for most of the data js-sdk needs to store, apart from crypto data
+ */
+export interface IStore {
+ readonly accountData: Map<string, MatrixEvent>; // type : content
+
+ // XXX: The indexeddb store exposes a non-standard emitter for:
+ // "degraded" event for when it falls back to being a memory store due to errors.
+ // "closed" event for when the database closes unexpectedly
+ on?: (event: EventEmitterEvents | "degraded" | "closed", handler: (...args: any[]) => void) => void;
+
+ /** @returns whether or not the database was newly created in this session. */
+ isNewlyCreated(): Promise<boolean>;
+
+ /**
+ * Get the sync token.
+ */
+ getSyncToken(): string | null;
+
+ /**
+ * Set the sync token.
+ */
+ setSyncToken(token: string): void;
+
+ /**
+ * Store the given room.
+ * @param room - The room to be stored. All properties must be stored.
+ */
+ storeRoom(room: Room): void;
+
+ /**
+ * Retrieve a room by its' room ID.
+ * @param roomId - The room ID.
+ * @returns The room or null.
+ */
+ getRoom(roomId: string): Room | null;
+
+ /**
+ * Retrieve all known rooms.
+ * @returns A list of rooms, which may be empty.
+ */
+ getRooms(): Room[];
+
+ /**
+ * Permanently delete a room.
+ */
+ removeRoom(roomId: string): void;
+
+ /**
+ * Retrieve a summary of all the rooms.
+ * @returns A summary of each room.
+ */
+ getRoomSummaries(): RoomSummary[];
+
+ /**
+ * Store a User.
+ * @param user - The user to store.
+ */
+ storeUser(user: User): void;
+
+ /**
+ * Retrieve a User by its' user ID.
+ * @param userId - The user ID.
+ * @returns The user or null.
+ */
+ getUser(userId: string): User | null;
+
+ /**
+ * Retrieve all known users.
+ * @returns A list of users, which may be empty.
+ */
+ getUsers(): User[];
+
+ /**
+ * Retrieve scrollback for this room.
+ * @param room - The matrix room
+ * @param limit - The max number of old events to retrieve.
+ * @returns An array of objects which will be at most 'limit'
+ * length and at least 0. The objects are the raw event JSON.
+ */
+ scrollback(room: Room, limit: number): MatrixEvent[];
+
+ /**
+ * Store events for a room.
+ * @param room - The room to store events for.
+ * @param events - The events to store.
+ * @param token - The token associated with these events.
+ * @param toStart - True if these are paginated results.
+ */
+ storeEvents(room: Room, events: MatrixEvent[], token: string | null, toStart: boolean): void;
+
+ /**
+ * Store a filter.
+ */
+ storeFilter(filter: Filter): void;
+
+ /**
+ * Retrieve a filter.
+ * @returns A filter or null.
+ */
+ getFilter(userId: string, filterId: string): Filter | null;
+
+ /**
+ * Retrieve a filter ID with the given name.
+ * @param filterName - The filter name.
+ * @returns The filter ID or null.
+ */
+ getFilterIdByName(filterName: string): string | null;
+
+ /**
+ * Set a filter name to ID mapping.
+ */
+ setFilterIdByName(filterName: string, filterId?: string): void;
+
+ /**
+ * Store user-scoped account data events
+ * @param events - The events to store.
+ */
+ storeAccountDataEvents(events: MatrixEvent[]): void;
+
+ /**
+ * Get account data event by event type
+ * @param eventType - The event type being queried
+ */
+ getAccountData(eventType: EventType | string): MatrixEvent | undefined;
+
+ /**
+ * setSyncData does nothing as there is no backing data store.
+ *
+ * @param syncData - The sync data
+ * @returns An immediately resolved promise.
+ */
+ setSyncData(syncData: ISyncResponse): Promise<void>;
+
+ /**
+ * We never want to save because we have nothing to save to.
+ *
+ * @returns If the store wants to save
+ */
+ wantsSave(): boolean;
+
+ /**
+ * Save does nothing as there is no backing data store.
+ */
+ save(force?: boolean): void;
+
+ /**
+ * Startup does nothing.
+ * @returns An immediately resolved promise.
+ */
+ startup(): Promise<void>;
+
+ /**
+ * @returns Promise which resolves with a sync response to restore the
+ * client state to where it was at the last save, or null if there
+ * is no saved sync data.
+ */
+ getSavedSync(): Promise<ISavedSync | null>;
+
+ /**
+ * @returns If there is a saved sync, the nextBatch token
+ * for this sync, otherwise null.
+ */
+ getSavedSyncToken(): Promise<string | null>;
+
+ /**
+ * Delete all data from this store. Does nothing since this store
+ * doesn't store anything.
+ * @returns An immediately resolved promise.
+ */
+ deleteAllData(): Promise<void>;
+
+ /**
+ * Returns the out-of-band membership events for this room that
+ * were previously loaded.
+ * @returns the events, potentially an empty array if OOB loading didn't yield any new members
+ * @returns in case the members for this room haven't been stored yet
+ */
+ getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null>;
+
+ /**
+ * Stores the out-of-band membership events for this room. Note that
+ * it still makes sense to store an empty array as the OOB status for the room is
+ * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
+ * @param membershipEvents - the membership events to store
+ * @returns when all members have been stored
+ */
+ setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>;
+
+ clearOutOfBandMembers(roomId: string): Promise<void>;
+
+ getClientOptions(): Promise<IStartClientOpts | undefined>;
+
+ storeClientOptions(options: IStartClientOpts): Promise<void>;
+
+ getPendingEvents(roomId: string): Promise<Partial<IEvent>[]>;
+
+ setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void>;
+
+ /**
+ * Stores batches of outgoing to-device messages
+ */
+ saveToDeviceBatches(batch: ToDeviceBatchWithTxnId[]): Promise<void>;
+
+ /**
+ * Fetches the oldest batch of to-device messages in the queue
+ */
+ getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null>;
+
+ /**
+ * Removes a specific batch of to-device messages from the queue
+ */
+ removeToDeviceBatch(id: number): Promise<void>;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-backend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-backend.ts
new file mode 100644
index 0000000..008867d
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-backend.ts
@@ -0,0 +1,40 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ISavedSync } from "./index";
+import { IEvent, IStateEventWithRoomId, IStoredClientOpts, ISyncResponse } from "../matrix";
+import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
+
+export interface IIndexedDBBackend {
+ connect(onClose?: () => void): Promise<void>;
+ syncToDatabase(userTuples: UserTuple[]): Promise<void>;
+ isNewlyCreated(): Promise<boolean>;
+ setSyncData(syncData: ISyncResponse): Promise<void>;
+ getSavedSync(): Promise<ISavedSync | null>;
+ getNextBatchToken(): Promise<string>;
+ clearDatabase(): Promise<void>;
+ getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null>;
+ setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void>;
+ clearOutOfBandMembers(roomId: string): Promise<void>;
+ getUserPresenceEvents(): Promise<UserTuple[]>;
+ getClientOptions(): Promise<IStoredClientOpts | undefined>;
+ storeClientOptions(options: IStoredClientOpts): Promise<void>;
+ saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void>;
+ getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null>;
+ removeToDeviceBatch(id: number): Promise<void>;
+}
+
+export type UserTuple = [userId: string, presenceEvent: Partial<IEvent>];
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-local-backend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-local-backend.ts
new file mode 100644
index 0000000..80fed44
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-local-backend.ts
@@ -0,0 +1,597 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IMinimalEvent, ISyncData, ISyncResponse, SyncAccumulator } from "../sync-accumulator";
+import * as utils from "../utils";
+import * as IndexedDBHelpers from "../indexeddb-helpers";
+import { logger } from "../logger";
+import { IStateEventWithRoomId, IStoredClientOpts } from "../matrix";
+import { ISavedSync } from "./index";
+import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
+import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
+
+type DbMigration = (db: IDBDatabase) => void;
+const DB_MIGRATIONS: DbMigration[] = [
+ (db): void => {
+ // Make user store, clobber based on user ID. (userId property of User objects)
+ db.createObjectStore("users", { keyPath: ["userId"] });
+
+ // Make account data store, clobber based on event type.
+ // (event.type property of MatrixEvent objects)
+ db.createObjectStore("accountData", { keyPath: ["type"] });
+
+ // Make /sync store (sync tokens, room data, etc), always clobber (const key).
+ db.createObjectStore("sync", { keyPath: ["clobber"] });
+ },
+ (db): void => {
+ const oobMembersStore = db.createObjectStore("oob_membership_events", {
+ keyPath: ["room_id", "state_key"],
+ });
+ oobMembersStore.createIndex("room", "room_id");
+ },
+ (db): void => {
+ db.createObjectStore("client_options", { keyPath: ["clobber"] });
+ },
+ (db): void => {
+ db.createObjectStore("to_device_queue", { autoIncrement: true });
+ },
+ // Expand as needed.
+];
+const VERSION = DB_MIGRATIONS.length;
+
+/**
+ * Helper method to collect results from a Cursor and promiseify it.
+ * @param store - The store to perform openCursor on.
+ * @param keyRange - Optional key range to apply on the cursor.
+ * @param resultMapper - A function which is repeatedly called with a
+ * Cursor.
+ * Return the data you want to keep.
+ * @returns Promise which resolves to an array of whatever you returned from
+ * resultMapper.
+ */
+function selectQuery<T>(
+ store: IDBObjectStore,
+ keyRange: IDBKeyRange | IDBValidKey | undefined,
+ resultMapper: (cursor: IDBCursorWithValue) => T,
+): Promise<T[]> {
+ const query = store.openCursor(keyRange);
+ return new Promise((resolve, reject) => {
+ const results: T[] = [];
+ query.onerror = (): void => {
+ reject(new Error("Query failed: " + query.error));
+ };
+ // collect results
+ query.onsuccess = (): void => {
+ const cursor = query.result;
+ if (!cursor) {
+ resolve(results);
+ return; // end of results
+ }
+ results.push(resultMapper(cursor));
+ cursor.continue();
+ };
+ });
+}
+
+function txnAsPromise(txn: IDBTransaction): Promise<Event> {
+ return new Promise((resolve, reject) => {
+ txn.oncomplete = function (event): void {
+ resolve(event);
+ };
+ txn.onerror = function (): void {
+ reject(txn.error);
+ };
+ });
+}
+
+function reqAsEventPromise(req: IDBRequest): Promise<Event> {
+ return new Promise((resolve, reject) => {
+ req.onsuccess = function (event): void {
+ resolve(event);
+ };
+ req.onerror = function (): void {
+ reject(req.error);
+ };
+ });
+}
+
+function reqAsPromise(req: IDBRequest): Promise<IDBRequest> {
+ return new Promise((resolve, reject) => {
+ req.onsuccess = (): void => resolve(req);
+ req.onerror = (err): void => reject(err);
+ });
+}
+
+function reqAsCursorPromise<T>(req: IDBRequest<T>): Promise<T> {
+ return reqAsEventPromise(req).then((event) => req.result);
+}
+
+export class LocalIndexedDBStoreBackend implements IIndexedDBBackend {
+ public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> {
+ dbName = "matrix-js-sdk:" + (dbName || "default");
+ return IndexedDBHelpers.exists(indexedDB, dbName);
+ }
+
+ private readonly dbName: string;
+ private readonly syncAccumulator: SyncAccumulator;
+ private db?: IDBDatabase;
+ private disconnected = true;
+ private _isNewlyCreated = false;
+ private syncToDatabasePromise?: Promise<void>;
+ private pendingUserPresenceData: UserTuple[] = [];
+
+ /**
+ * Does the actual reading from and writing to the indexeddb
+ *
+ * Construct a new Indexed Database store backend. This requires a call to
+ * `connect()` before this store can be used.
+ * @param indexedDB - The Indexed DB interface e.g
+ * `window.indexedDB`
+ * @param dbName - Optional database name. The same name must be used
+ * to open the same database.
+ */
+ public constructor(private readonly indexedDB: IDBFactory, dbName = "default") {
+ this.dbName = "matrix-js-sdk:" + dbName;
+ this.syncAccumulator = new SyncAccumulator();
+ }
+
+ /**
+ * Attempt to connect to the database. This can fail if the user does not
+ * grant permission.
+ * @returns Promise which resolves if successfully connected.
+ */
+ public connect(onClose?: () => void): Promise<void> {
+ if (!this.disconnected) {
+ logger.log(`LocalIndexedDBStoreBackend.connect: already connected or connecting`);
+ return Promise.resolve();
+ }
+
+ this.disconnected = false;
+
+ logger.log(`LocalIndexedDBStoreBackend.connect: connecting...`);
+ const req = this.indexedDB.open(this.dbName, VERSION);
+ req.onupgradeneeded = (ev): void => {
+ const db = req.result;
+ const oldVersion = ev.oldVersion;
+ logger.log(`LocalIndexedDBStoreBackend.connect: upgrading from ${oldVersion}`);
+ if (oldVersion < 1) {
+ // The database did not previously exist
+ this._isNewlyCreated = true;
+ }
+ DB_MIGRATIONS.forEach((migration, index) => {
+ if (oldVersion <= index) migration(db);
+ });
+ };
+
+ req.onblocked = (): void => {
+ logger.log(`can't yet open LocalIndexedDBStoreBackend because it is open elsewhere`);
+ };
+
+ logger.log(`LocalIndexedDBStoreBackend.connect: awaiting connection...`);
+ return reqAsEventPromise(req).then(async () => {
+ logger.log(`LocalIndexedDBStoreBackend.connect: connected`);
+ this.db = req.result;
+
+ // add a poorly-named listener for when deleteDatabase is called
+ // so we can close our db connections.
+ this.db.onversionchange = (): void => {
+ this.db?.close(); // this does not call onclose
+ this.disconnected = true;
+ this.db = undefined;
+ onClose?.();
+ };
+ this.db.onclose = (): void => {
+ this.disconnected = true;
+ this.db = undefined;
+ onClose?.();
+ };
+
+ await this.init();
+ });
+ }
+
+ /** @returns whether or not the database was newly created in this session. */
+ public isNewlyCreated(): Promise<boolean> {
+ return Promise.resolve(this._isNewlyCreated);
+ }
+
+ /**
+ * Having connected, load initial data from the database and prepare for use
+ * @returns Promise which resolves on success
+ */
+ private init(): Promise<unknown> {
+ return Promise.all([this.loadAccountData(), this.loadSyncData()]).then(([accountData, syncData]) => {
+ logger.log(`LocalIndexedDBStoreBackend: loaded initial data`);
+ this.syncAccumulator.accumulate(
+ {
+ next_batch: syncData.nextBatch,
+ rooms: syncData.roomsData,
+ account_data: {
+ events: accountData,
+ },
+ },
+ true,
+ );
+ });
+ }
+
+ /**
+ * Returns the out-of-band membership events for this room that
+ * were previously loaded.
+ * @returns the events, potentially an empty array if OOB loading didn't yield any new members
+ * @returns in case the members for this room haven't been stored yet
+ */
+ public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> {
+ return new Promise<IStateEventWithRoomId[] | null>((resolve, reject) => {
+ const tx = this.db!.transaction(["oob_membership_events"], "readonly");
+ const store = tx.objectStore("oob_membership_events");
+ const roomIndex = store.index("room");
+ const range = IDBKeyRange.only(roomId);
+ const request = roomIndex.openCursor(range);
+
+ const membershipEvents: IStateEventWithRoomId[] = [];
+ // did we encounter the oob_written marker object
+ // amongst the results? That means OOB member
+ // loading already happened for this room
+ // but there were no members to persist as they
+ // were all known already
+ let oobWritten = false;
+
+ request.onsuccess = (): void => {
+ const cursor = request.result;
+ if (!cursor) {
+ // Unknown room
+ if (!membershipEvents.length && !oobWritten) {
+ return resolve(null);
+ }
+ return resolve(membershipEvents);
+ }
+ const record = cursor.value;
+ if (record.oob_written) {
+ oobWritten = true;
+ } else {
+ membershipEvents.push(record);
+ }
+ cursor.continue();
+ };
+ request.onerror = (err): void => {
+ reject(err);
+ };
+ }).then((events) => {
+ logger.log(`LL: got ${events?.length} membershipEvents from storage for room ${roomId} ...`);
+ return events;
+ });
+ }
+
+ /**
+ * Stores the out-of-band membership events for this room. Note that
+ * it still makes sense to store an empty array as the OOB status for the room is
+ * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
+ * @param membershipEvents - the membership events to store
+ */
+ public async setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
+ logger.log(`LL: backend about to store ${membershipEvents.length}` + ` members for ${roomId}`);
+ const tx = this.db!.transaction(["oob_membership_events"], "readwrite");
+ const store = tx.objectStore("oob_membership_events");
+ membershipEvents.forEach((e) => {
+ store.put(e);
+ });
+ // aside from all the events, we also write a marker object to the store
+ // to mark the fact that OOB members have been written for this room.
+ // It's possible that 0 members need to be written as all where previously know
+ // but we still need to know whether to return null or [] from getOutOfBandMembers
+ // where null means out of band members haven't been stored yet for this room
+ const markerObject = {
+ room_id: roomId,
+ oob_written: true,
+ state_key: 0,
+ };
+ store.put(markerObject);
+ await txnAsPromise(tx);
+ logger.log(`LL: backend done storing for ${roomId}!`);
+ }
+
+ public async clearOutOfBandMembers(roomId: string): Promise<void> {
+ // the approach to delete all members for a room
+ // is to get the min and max state key from the index
+ // for that room, and then delete between those
+ // keys in the store.
+ // this should be way faster than deleting every member
+ // individually for a large room.
+ const readTx = this.db!.transaction(["oob_membership_events"], "readonly");
+ const store = readTx.objectStore("oob_membership_events");
+ const roomIndex = store.index("room");
+ const roomRange = IDBKeyRange.only(roomId);
+
+ const minStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "next")).then(
+ (cursor) => (<IDBValidKey[]>cursor?.primaryKey)[1],
+ );
+ const maxStateKeyProm = reqAsCursorPromise(roomIndex.openKeyCursor(roomRange, "prev")).then(
+ (cursor) => (<IDBValidKey[]>cursor?.primaryKey)[1],
+ );
+ const [minStateKey, maxStateKey] = await Promise.all([minStateKeyProm, maxStateKeyProm]);
+
+ const writeTx = this.db!.transaction(["oob_membership_events"], "readwrite");
+ const writeStore = writeTx.objectStore("oob_membership_events");
+ const membersKeyRange = IDBKeyRange.bound([roomId, minStateKey], [roomId, maxStateKey]);
+
+ logger.log(
+ `LL: Deleting all users + marker in storage for room ${roomId}, with key range:`,
+ [roomId, minStateKey],
+ [roomId, maxStateKey],
+ );
+ await reqAsPromise(writeStore.delete(membersKeyRange));
+ }
+
+ /**
+ * Clear the entire database. This should be used when logging out of a client
+ * to prevent mixing data between accounts.
+ * @returns Resolved when the database is cleared.
+ */
+ public clearDatabase(): Promise<void> {
+ return new Promise((resolve) => {
+ logger.log(`Removing indexeddb instance: ${this.dbName}`);
+ const req = this.indexedDB.deleteDatabase(this.dbName);
+
+ req.onblocked = (): void => {
+ logger.log(`can't yet delete indexeddb ${this.dbName} because it is open elsewhere`);
+ };
+
+ req.onerror = (): void => {
+ // in firefox, with indexedDB disabled, this fails with a
+ // DOMError. We treat this as non-fatal, so that we can still
+ // use the app.
+ logger.warn(`unable to delete js-sdk store indexeddb: ${req.error}`);
+ resolve();
+ };
+
+ req.onsuccess = (): void => {
+ logger.log(`Removed indexeddb instance: ${this.dbName}`);
+ resolve();
+ };
+ });
+ }
+
+ /**
+ * @param copy - If false, the data returned is from internal
+ * buffers and must not be mutated. Otherwise, a copy is made before
+ * returning such that the data can be safely mutated. Default: true.
+ *
+ * @returns Promise which resolves with a sync response to restore the
+ * client state to where it was at the last save, or null if there
+ * is no saved sync data.
+ */
+ public getSavedSync(copy = true): Promise<ISavedSync | null> {
+ const data = this.syncAccumulator.getJSON();
+ if (!data.nextBatch) return Promise.resolve(null);
+ if (copy) {
+ // We must deep copy the stored data so that the /sync processing code doesn't
+ // corrupt the internal state of the sync accumulator (it adds non-clonable keys)
+ return Promise.resolve(utils.deepCopy(data));
+ } else {
+ return Promise.resolve(data);
+ }
+ }
+
+ public getNextBatchToken(): Promise<string> {
+ return Promise.resolve(this.syncAccumulator.getNextBatchToken());
+ }
+
+ public setSyncData(syncData: ISyncResponse): Promise<void> {
+ return Promise.resolve().then(() => {
+ this.syncAccumulator.accumulate(syncData);
+ });
+ }
+
+ /**
+ * Sync users and all accumulated sync data to the database.
+ * If a previous sync is in flight, the new data will be added to the
+ * next sync and the current sync's promise will be returned.
+ * @param userTuples - The user tuples
+ * @returns Promise which resolves if the data was persisted.
+ */
+ public async syncToDatabase(userTuples: UserTuple[]): Promise<void> {
+ if (this.syncToDatabasePromise) {
+ logger.warn("Skipping syncToDatabase() as persist already in flight");
+ this.pendingUserPresenceData.push(...userTuples);
+ return this.syncToDatabasePromise;
+ }
+ userTuples.unshift(...this.pendingUserPresenceData);
+ this.syncToDatabasePromise = this.doSyncToDatabase(userTuples);
+ return this.syncToDatabasePromise;
+ }
+
+ private async doSyncToDatabase(userTuples: UserTuple[]): Promise<void> {
+ try {
+ const syncData = this.syncAccumulator.getJSON(true);
+ await Promise.all([
+ this.persistUserPresenceEvents(userTuples),
+ this.persistAccountData(syncData.accountData),
+ this.persistSyncData(syncData.nextBatch, syncData.roomsData),
+ ]);
+ } finally {
+ this.syncToDatabasePromise = undefined;
+ }
+ }
+
+ /**
+ * Persist rooms /sync data along with the next batch token.
+ * @param nextBatch - The next_batch /sync value.
+ * @param roomsData - The 'rooms' /sync data from a SyncAccumulator
+ * @returns Promise which resolves if the data was persisted.
+ */
+ private persistSyncData(nextBatch: string, roomsData: ISyncResponse["rooms"]): Promise<void> {
+ logger.log("Persisting sync data up to", nextBatch);
+ return utils.promiseTry<void>(() => {
+ const txn = this.db!.transaction(["sync"], "readwrite");
+ const store = txn.objectStore("sync");
+ store.put({
+ clobber: "-", // constant key so will always clobber
+ nextBatch,
+ roomsData,
+ }); // put == UPSERT
+ return txnAsPromise(txn).then(() => {
+ logger.log("Persisted sync data up to", nextBatch);
+ });
+ });
+ }
+
+ /**
+ * Persist a list of account data events. Events with the same 'type' will
+ * be replaced.
+ * @param accountData - An array of raw user-scoped account data events
+ * @returns Promise which resolves if the events were persisted.
+ */
+ private persistAccountData(accountData: IMinimalEvent[]): Promise<void> {
+ return utils.promiseTry<void>(() => {
+ const txn = this.db!.transaction(["accountData"], "readwrite");
+ const store = txn.objectStore("accountData");
+ for (const event of accountData) {
+ store.put(event); // put == UPSERT
+ }
+ return txnAsPromise(txn).then();
+ });
+ }
+
+ /**
+ * Persist a list of [user id, presence event] they are for.
+ * Users with the same 'userId' will be replaced.
+ * Presence events should be the event in its raw form (not the Event
+ * object)
+ * @param tuples - An array of [userid, event] tuples
+ * @returns Promise which resolves if the users were persisted.
+ */
+ private persistUserPresenceEvents(tuples: UserTuple[]): Promise<void> {
+ return utils.promiseTry<void>(() => {
+ const txn = this.db!.transaction(["users"], "readwrite");
+ const store = txn.objectStore("users");
+ for (const tuple of tuples) {
+ store.put({
+ userId: tuple[0],
+ event: tuple[1],
+ }); // put == UPSERT
+ }
+ return txnAsPromise(txn).then();
+ });
+ }
+
+ /**
+ * Load all user presence events from the database. This is not cached.
+ * FIXME: It would probably be more sensible to store the events in the
+ * sync.
+ * @returns A list of presence events in their raw form.
+ */
+ public getUserPresenceEvents(): Promise<UserTuple[]> {
+ return utils.promiseTry<UserTuple[]>(() => {
+ const txn = this.db!.transaction(["users"], "readonly");
+ const store = txn.objectStore("users");
+ return selectQuery(store, undefined, (cursor) => {
+ return [cursor.value.userId, cursor.value.event];
+ });
+ });
+ }
+
+ /**
+ * Load all the account data events from the database. This is not cached.
+ * @returns A list of raw global account events.
+ */
+ private loadAccountData(): Promise<IMinimalEvent[]> {
+ logger.log(`LocalIndexedDBStoreBackend: loading account data...`);
+ return utils.promiseTry<IMinimalEvent[]>(() => {
+ const txn = this.db!.transaction(["accountData"], "readonly");
+ const store = txn.objectStore("accountData");
+ return selectQuery(store, undefined, (cursor) => {
+ return cursor.value;
+ }).then((result: IMinimalEvent[]) => {
+ logger.log(`LocalIndexedDBStoreBackend: loaded account data`);
+ return result;
+ });
+ });
+ }
+
+ /**
+ * Load the sync data from the database.
+ * @returns An object with "roomsData" and "nextBatch" keys.
+ */
+ private loadSyncData(): Promise<ISyncData> {
+ logger.log(`LocalIndexedDBStoreBackend: loading sync data...`);
+ return utils.promiseTry<ISyncData>(() => {
+ const txn = this.db!.transaction(["sync"], "readonly");
+ const store = txn.objectStore("sync");
+ return selectQuery(store, undefined, (cursor) => {
+ return cursor.value;
+ }).then((results: ISyncData[]) => {
+ logger.log(`LocalIndexedDBStoreBackend: loaded sync data`);
+ if (results.length > 1) {
+ logger.warn("loadSyncData: More than 1 sync row found.");
+ }
+ return results.length > 0 ? results[0] : ({} as ISyncData);
+ });
+ });
+ }
+
+ public getClientOptions(): Promise<IStoredClientOpts | undefined> {
+ return Promise.resolve().then(() => {
+ const txn = this.db!.transaction(["client_options"], "readonly");
+ const store = txn.objectStore("client_options");
+ return selectQuery(store, undefined, (cursor) => {
+ return cursor.value?.options;
+ }).then((results) => results[0]);
+ });
+ }
+
+ public async storeClientOptions(options: IStoredClientOpts): Promise<void> {
+ const txn = this.db!.transaction(["client_options"], "readwrite");
+ const store = txn.objectStore("client_options");
+ store.put({
+ clobber: "-", // constant key so will always clobber
+ options: options,
+ }); // put == UPSERT
+ await txnAsPromise(txn);
+ }
+
+ public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
+ const txn = this.db!.transaction(["to_device_queue"], "readwrite");
+ const store = txn.objectStore("to_device_queue");
+ for (const batch of batches) {
+ store.add(batch);
+ }
+ await txnAsPromise(txn);
+ }
+
+ public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
+ const txn = this.db!.transaction(["to_device_queue"], "readonly");
+ const store = txn.objectStore("to_device_queue");
+ const cursor = await reqAsCursorPromise(store.openCursor());
+ if (!cursor) return null;
+
+ const resultBatch = cursor.value as ToDeviceBatchWithTxnId;
+
+ return {
+ id: cursor.key as number,
+ txnId: resultBatch.txnId,
+ eventType: resultBatch.eventType,
+ batch: resultBatch.batch,
+ };
+ }
+
+ public async removeToDeviceBatch(id: number): Promise<void> {
+ const txn = this.db!.transaction(["to_device_queue"], "readwrite");
+ const store = txn.objectStore("to_device_queue");
+ store.delete(id);
+ await txnAsPromise(txn);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-remote-backend.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-remote-backend.ts
new file mode 100644
index 0000000..7e2aa0c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-remote-backend.ts
@@ -0,0 +1,203 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { logger } from "../logger";
+import { defer, IDeferred } from "../utils";
+import { ISavedSync } from "./index";
+import { IStoredClientOpts } from "../client";
+import { IStateEventWithRoomId, ISyncResponse } from "../matrix";
+import { IIndexedDBBackend, UserTuple } from "./indexeddb-backend";
+import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
+
+export class RemoteIndexedDBStoreBackend implements IIndexedDBBackend {
+ private worker?: Worker;
+ private nextSeq = 0;
+ // The currently in-flight requests to the actual backend
+ private inFlight: Record<number, IDeferred<any>> = {}; // seq: promise
+ // Once we start connecting, we keep the promise and re-use it
+ // if we try to connect again
+ private startPromise?: Promise<void>;
+ // Callback for when the IndexedDB gets closed unexpectedly
+ private onClose?(): void;
+
+ /**
+ * An IndexedDB store backend where the actual backend sits in a web
+ * worker.
+ *
+ * Construct a new Indexed Database store backend. This requires a call to
+ * `connect()` before this store can be used.
+ * @param workerFactory - Factory which produces a Worker
+ * @param dbName - Optional database name. The same name must be used
+ * to open the same database.
+ */
+ public constructor(private readonly workerFactory: () => Worker, private readonly dbName?: string) {}
+
+ /**
+ * Attempt to connect to the database. This can fail if the user does not
+ * grant permission.
+ * @returns Promise which resolves if successfully connected.
+ */
+ public connect(onClose?: () => void): Promise<void> {
+ this.onClose = onClose;
+ return this.ensureStarted().then(() => this.doCmd("connect"));
+ }
+
+ /**
+ * Clear the entire database. This should be used when logging out of a client
+ * to prevent mixing data between accounts.
+ * @returns Resolved when the database is cleared.
+ */
+ public clearDatabase(): Promise<void> {
+ return this.ensureStarted().then(() => this.doCmd("clearDatabase"));
+ }
+
+ /** @returns whether or not the database was newly created in this session. */
+ public isNewlyCreated(): Promise<boolean> {
+ return this.doCmd("isNewlyCreated");
+ }
+
+ /**
+ * @returns Promise which resolves with a sync response to restore the
+ * client state to where it was at the last save, or null if there
+ * is no saved sync data.
+ */
+ public getSavedSync(): Promise<ISavedSync> {
+ return this.doCmd("getSavedSync");
+ }
+
+ public getNextBatchToken(): Promise<string> {
+ return this.doCmd("getNextBatchToken");
+ }
+
+ public setSyncData(syncData: ISyncResponse): Promise<void> {
+ return this.doCmd("setSyncData", [syncData]);
+ }
+
+ public syncToDatabase(userTuples: UserTuple[]): Promise<void> {
+ return this.doCmd("syncToDatabase", [userTuples]);
+ }
+
+ /**
+ * Returns the out-of-band membership events for this room that
+ * were previously loaded.
+ * @returns the events, potentially an empty array if OOB loading didn't yield any new members
+ * @returns in case the members for this room haven't been stored yet
+ */
+ public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> {
+ return this.doCmd("getOutOfBandMembers", [roomId]);
+ }
+
+ /**
+ * Stores the out-of-band membership events for this room. Note that
+ * it still makes sense to store an empty array as the OOB status for the room is
+ * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
+ * @param membershipEvents - the membership events to store
+ * @returns when all members have been stored
+ */
+ public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
+ return this.doCmd("setOutOfBandMembers", [roomId, membershipEvents]);
+ }
+
+ public clearOutOfBandMembers(roomId: string): Promise<void> {
+ return this.doCmd("clearOutOfBandMembers", [roomId]);
+ }
+
+ public getClientOptions(): Promise<IStoredClientOpts | undefined> {
+ return this.doCmd("getClientOptions");
+ }
+
+ public storeClientOptions(options: IStoredClientOpts): Promise<void> {
+ return this.doCmd("storeClientOptions", [options]);
+ }
+
+ /**
+ * Load all user presence events from the database. This is not cached.
+ * @returns A list of presence events in their raw form.
+ */
+ public getUserPresenceEvents(): Promise<UserTuple[]> {
+ return this.doCmd("getUserPresenceEvents");
+ }
+
+ public async saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
+ return this.doCmd("saveToDeviceBatches", [batches]);
+ }
+
+ public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
+ return this.doCmd("getOldestToDeviceBatch");
+ }
+
+ public async removeToDeviceBatch(id: number): Promise<void> {
+ return this.doCmd("removeToDeviceBatch", [id]);
+ }
+
+ private ensureStarted(): Promise<void> {
+ if (!this.startPromise) {
+ this.worker = this.workerFactory();
+ this.worker.onmessage = this.onWorkerMessage;
+
+ // tell the worker the db name.
+ this.startPromise = this.doCmd("setupWorker", [this.dbName]).then(() => {
+ logger.log("IndexedDB worker is ready");
+ });
+ }
+ return this.startPromise;
+ }
+
+ private doCmd<T>(command: string, args?: any): Promise<T> {
+ // wrap in a q so if the postMessage throws,
+ // the promise automatically gets rejected
+ return Promise.resolve().then(() => {
+ const seq = this.nextSeq++;
+ const def = defer<T>();
+
+ this.inFlight[seq] = def;
+
+ this.worker?.postMessage({ command, seq, args });
+
+ return def.promise;
+ });
+ }
+
+ private onWorkerMessage = (ev: MessageEvent): void => {
+ const msg = ev.data;
+
+ if (msg.command == "closed") {
+ this.onClose?.();
+ } else if (msg.command == "cmd_success" || msg.command == "cmd_fail") {
+ if (msg.seq === undefined) {
+ logger.error("Got reply from worker with no seq");
+ return;
+ }
+
+ const def = this.inFlight[msg.seq];
+ if (def === undefined) {
+ logger.error("Got reply for unknown seq " + msg.seq);
+ return;
+ }
+ delete this.inFlight[msg.seq];
+
+ if (msg.command == "cmd_success") {
+ def.resolve(msg.result);
+ } else {
+ const error = new Error(msg.error.message);
+ error.name = msg.error.name;
+ def.reject(error);
+ }
+ } else {
+ logger.warn("Unrecognised message from worker: ", msg);
+ }
+ };
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-store-worker.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-store-worker.ts
new file mode 100644
index 0000000..52a7fa6
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb-store-worker.ts
@@ -0,0 +1,157 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend";
+import { logger } from "../logger";
+
+interface ICmd {
+ command: string;
+ seq: number;
+ args: any[];
+}
+
+/**
+ * This class lives in the webworker and drives a LocalIndexedDBStoreBackend
+ * controlled by messages from the main process.
+ *
+ * @example
+ * It should be instantiated by a web worker script provided by the application
+ * in a script, for example:
+ * ```
+ * import {IndexedDBStoreWorker} from 'matrix-js-sdk/lib/indexeddb-worker.js';
+ * const remoteWorker = new IndexedDBStoreWorker(postMessage);
+ * onmessage = remoteWorker.onMessage;
+ * ```
+ *
+ * Note that it is advisable to import this class by referencing the file directly to
+ * avoid a dependency on the whole js-sdk.
+ *
+ */
+export class IndexedDBStoreWorker {
+ private backend?: LocalIndexedDBStoreBackend;
+
+ /**
+ * @param postMessage - The web worker postMessage function that
+ * should be used to communicate back to the main script.
+ */
+ public constructor(private readonly postMessage: InstanceType<typeof Worker>["postMessage"]) {}
+
+ private onClose = (): void => {
+ this.postMessage.call(null, {
+ command: "closed",
+ });
+ };
+
+ /**
+ * Passes a message event from the main script into the class. This method
+ * can be directly assigned to the web worker `onmessage` variable.
+ *
+ * @param ev - The message event
+ */
+ public onMessage = (ev: MessageEvent): void => {
+ const msg: ICmd = ev.data;
+ let prom: Promise<any> | undefined;
+
+ switch (msg.command) {
+ case "setupWorker":
+ // this is the 'indexedDB' global (where global != window
+ // because it's a web worker and there is no window).
+ this.backend = new LocalIndexedDBStoreBackend(indexedDB, msg.args[0]);
+ prom = Promise.resolve();
+ break;
+ case "connect":
+ prom = this.backend?.connect(this.onClose);
+ break;
+ case "isNewlyCreated":
+ prom = this.backend?.isNewlyCreated();
+ break;
+ case "clearDatabase":
+ prom = this.backend?.clearDatabase();
+ break;
+ case "getSavedSync":
+ prom = this.backend?.getSavedSync(false);
+ break;
+ case "setSyncData":
+ prom = this.backend?.setSyncData(msg.args[0]);
+ break;
+ case "syncToDatabase":
+ prom = this.backend?.syncToDatabase(msg.args[0]);
+ break;
+ case "getUserPresenceEvents":
+ prom = this.backend?.getUserPresenceEvents();
+ break;
+ case "getNextBatchToken":
+ prom = this.backend?.getNextBatchToken();
+ break;
+ case "getOutOfBandMembers":
+ prom = this.backend?.getOutOfBandMembers(msg.args[0]);
+ break;
+ case "clearOutOfBandMembers":
+ prom = this.backend?.clearOutOfBandMembers(msg.args[0]);
+ break;
+ case "setOutOfBandMembers":
+ prom = this.backend?.setOutOfBandMembers(msg.args[0], msg.args[1]);
+ break;
+ case "getClientOptions":
+ prom = this.backend?.getClientOptions();
+ break;
+ case "storeClientOptions":
+ prom = this.backend?.storeClientOptions(msg.args[0]);
+ break;
+ case "saveToDeviceBatches":
+ prom = this.backend?.saveToDeviceBatches(msg.args[0]);
+ break;
+ case "getOldestToDeviceBatch":
+ prom = this.backend?.getOldestToDeviceBatch();
+ break;
+ case "removeToDeviceBatch":
+ prom = this.backend?.removeToDeviceBatch(msg.args[0]);
+ break;
+ }
+
+ if (prom === undefined) {
+ this.postMessage({
+ command: "cmd_fail",
+ seq: msg.seq,
+ // Can't be an Error because they're not structured cloneable
+ error: "Unrecognised command",
+ });
+ return;
+ }
+
+ prom.then(
+ (ret) => {
+ this.postMessage.call(null, {
+ command: "cmd_success",
+ seq: msg.seq,
+ result: ret,
+ });
+ },
+ (err) => {
+ logger.error("Error running command: " + msg.command, err);
+ this.postMessage.call(null, {
+ command: "cmd_fail",
+ seq: msg.seq,
+ // Just send a string because Error objects aren't cloneable
+ error: {
+ message: err.message,
+ name: err.name,
+ },
+ });
+ },
+ );
+ };
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb.ts
new file mode 100644
index 0000000..cc77bf9
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/indexeddb.ts
@@ -0,0 +1,383 @@
+/*
+Copyright 2017 - 2021 Vector Creations Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/* eslint-disable @babel/no-invalid-this */
+
+import { MemoryStore, IOpts as IBaseOpts } from "./memory";
+import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend";
+import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend";
+import { User } from "../models/user";
+import { IEvent, MatrixEvent } from "../models/event";
+import { logger } from "../logger";
+import { ISavedSync } from "./index";
+import { IIndexedDBBackend } from "./indexeddb-backend";
+import { ISyncResponse } from "../sync-accumulator";
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+import { IStateEventWithRoomId } from "../@types/search";
+import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
+import { IStoredClientOpts } from "../client";
+
+/**
+ * This is an internal module. See {@link IndexedDBStore} for the public class.
+ */
+
+// If this value is too small we'll be writing very often which will cause
+// noticeable stop-the-world pauses. If this value is too big we'll be writing
+// so infrequently that the /sync size gets bigger on reload. Writing more
+// often does not affect the length of the pause since the entire /sync
+// response is persisted each time.
+const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes
+
+interface IOpts extends IBaseOpts {
+ /** The Indexed DB interface e.g. `window.indexedDB` */
+ indexedDB: IDBFactory;
+ /** Optional database name. The same name must be used to open the same database. */
+ dbName?: string;
+ /** Optional factory to spin up a Worker to execute the IDB transactions within. */
+ workerFactory?: () => Worker;
+}
+
+type EventHandlerMap = {
+ // Fired when an IDB command fails on a degradable path, and the store falls back to MemoryStore
+ // This signals the potential for data volatility.
+ degraded: (e: Error) => void;
+ // Fired when the IndexedDB gets closed unexpectedly, for example, if the underlying storage is removed or
+ // if the user clears the database in the browser's history preferences.
+ closed: () => void;
+};
+
+export class IndexedDBStore extends MemoryStore {
+ public static exists(indexedDB: IDBFactory, dbName: string): Promise<boolean> {
+ return LocalIndexedDBStoreBackend.exists(indexedDB, dbName);
+ }
+
+ /**
+ * The backend instance.
+ * Call through to this API if you need to perform specific indexeddb actions like deleting the database.
+ */
+ public readonly backend: IIndexedDBBackend;
+
+ private startedUp = false;
+ private syncTs = 0;
+ // Records the last-modified-time of each user at the last point we saved
+ // the database, such that we can derive the set if users that have been
+ // modified since we last saved.
+ private userModifiedMap: Record<string, number> = {}; // user_id : timestamp
+ private emitter = new TypedEventEmitter<keyof EventHandlerMap, EventHandlerMap>();
+
+ /**
+ * Construct a new Indexed Database store, which extends MemoryStore.
+ *
+ * This store functions like a MemoryStore except it periodically persists
+ * the contents of the store to an IndexedDB backend.
+ *
+ * All data is still kept in-memory but can be loaded from disk by calling
+ * `startup()`. This can make startup times quicker as a complete
+ * sync from the server is not required. This does not reduce memory usage as all
+ * the data is eagerly fetched when `startup()` is called.
+ * ```
+ * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
+ * let store = new IndexedDBStore(opts);
+ * await store.startup(); // load from indexed db
+ * let client = sdk.createClient({
+ * store: store,
+ * });
+ * client.startClient();
+ * client.on("sync", function(state, prevState, data) {
+ * if (state === "PREPARED") {
+ * console.log("Started up, now with go faster stripes!");
+ * }
+ * });
+ * ```
+ *
+ * @param opts - Options object.
+ */
+ public constructor(opts: IOpts) {
+ super(opts);
+
+ if (!opts.indexedDB) {
+ throw new Error("Missing required option: indexedDB");
+ }
+
+ if (opts.workerFactory) {
+ this.backend = new RemoteIndexedDBStoreBackend(opts.workerFactory, opts.dbName);
+ } else {
+ this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName);
+ }
+ }
+
+ public on = this.emitter.on.bind(this.emitter);
+
+ /**
+ * @returns Resolved when loaded from indexed db.
+ */
+ public startup(): Promise<void> {
+ if (this.startedUp) {
+ logger.log(`IndexedDBStore.startup: already started`);
+ return Promise.resolve();
+ }
+
+ logger.log(`IndexedDBStore.startup: connecting to backend`);
+ return this.backend
+ .connect(this.onClose)
+ .then(() => {
+ logger.log(`IndexedDBStore.startup: loading presence events`);
+ return this.backend.getUserPresenceEvents();
+ })
+ .then((userPresenceEvents) => {
+ logger.log(`IndexedDBStore.startup: processing presence events`);
+ userPresenceEvents.forEach(([userId, rawEvent]) => {
+ const u = new User(userId);
+ if (rawEvent) {
+ u.setPresenceEvent(new MatrixEvent(rawEvent));
+ }
+ this.userModifiedMap[u.userId] = u.getLastModifiedTime();
+ this.storeUser(u);
+ });
+ this.startedUp = true;
+ });
+ }
+
+ private onClose = (): void => {
+ this.emitter.emit("closed");
+ };
+
+ /**
+ * @returns Promise which resolves with a sync response to restore the
+ * client state to where it was at the last save, or null if there
+ * is no saved sync data.
+ */
+ public getSavedSync = this.degradable((): Promise<ISavedSync | null> => {
+ return this.backend.getSavedSync();
+ }, "getSavedSync");
+
+ /** @returns whether or not the database was newly created in this session. */
+ public isNewlyCreated = this.degradable((): Promise<boolean> => {
+ return this.backend.isNewlyCreated();
+ }, "isNewlyCreated");
+
+ /**
+ * @returns If there is a saved sync, the nextBatch token
+ * for this sync, otherwise null.
+ */
+ public getSavedSyncToken = this.degradable((): Promise<string | null> => {
+ return this.backend.getNextBatchToken();
+ }, "getSavedSyncToken");
+
+ /**
+ * Delete all data from this store.
+ * @returns Promise which resolves if the data was deleted from the database.
+ */
+ public deleteAllData = this.degradable((): Promise<void> => {
+ super.deleteAllData();
+ return this.backend.clearDatabase().then(
+ () => {
+ logger.log("Deleted indexeddb data.");
+ },
+ (err) => {
+ logger.error(`Failed to delete indexeddb data: ${err}`);
+ throw err;
+ },
+ );
+ });
+
+ /**
+ * Whether this store would like to save its data
+ * Note that obviously whether the store wants to save or
+ * not could change between calling this function and calling
+ * save().
+ *
+ * @returns True if calling save() will actually save
+ * (at the time this function is called).
+ */
+ public wantsSave(): boolean {
+ const now = Date.now();
+ return now - this.syncTs > WRITE_DELAY_MS;
+ }
+
+ /**
+ * Possibly write data to the database.
+ *
+ * @param force - True to force a save to happen
+ * @returns Promise resolves after the write completes
+ * (or immediately if no write is performed)
+ */
+ public save(force = false): Promise<void> {
+ if (force || this.wantsSave()) {
+ return this.reallySave();
+ }
+ return Promise.resolve();
+ }
+
+ private reallySave = this.degradable((): Promise<void> => {
+ this.syncTs = Date.now(); // set now to guard against multi-writes
+
+ // work out changed users (this doesn't handle deletions but you
+ // can't 'delete' users as they are just presence events).
+ const userTuples: [userId: string, presenceEvent: Partial<IEvent>][] = [];
+ for (const u of this.getUsers()) {
+ if (this.userModifiedMap[u.userId] === u.getLastModifiedTime()) continue;
+ if (!u.events.presence) continue;
+
+ userTuples.push([u.userId, u.events.presence.event]);
+
+ // note that we've saved this version of the user
+ this.userModifiedMap[u.userId] = u.getLastModifiedTime();
+ }
+
+ return this.backend.syncToDatabase(userTuples);
+ });
+
+ public setSyncData = this.degradable((syncData: ISyncResponse): Promise<void> => {
+ return this.backend.setSyncData(syncData);
+ }, "setSyncData");
+
+ /**
+ * Returns the out-of-band membership events for this room that
+ * were previously loaded.
+ * @returns the events, potentially an empty array if OOB loading didn't yield any new members
+ * @returns in case the members for this room haven't been stored yet
+ */
+ public getOutOfBandMembers = this.degradable((roomId: string): Promise<IStateEventWithRoomId[] | null> => {
+ return this.backend.getOutOfBandMembers(roomId);
+ }, "getOutOfBandMembers");
+
+ /**
+ * Stores the out-of-band membership events for this room. Note that
+ * it still makes sense to store an empty array as the OOB status for the room is
+ * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
+ * @param membershipEvents - the membership events to store
+ * @returns when all members have been stored
+ */
+ public setOutOfBandMembers = this.degradable(
+ (roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> => {
+ super.setOutOfBandMembers(roomId, membershipEvents);
+ return this.backend.setOutOfBandMembers(roomId, membershipEvents);
+ },
+ "setOutOfBandMembers",
+ );
+
+ public clearOutOfBandMembers = this.degradable((roomId: string) => {
+ super.clearOutOfBandMembers(roomId);
+ return this.backend.clearOutOfBandMembers(roomId);
+ }, "clearOutOfBandMembers");
+
+ public getClientOptions = this.degradable((): Promise<IStoredClientOpts | undefined> => {
+ return this.backend.getClientOptions();
+ }, "getClientOptions");
+
+ public storeClientOptions = this.degradable((options: IStoredClientOpts): Promise<void> => {
+ super.storeClientOptions(options);
+ return this.backend.storeClientOptions(options);
+ }, "storeClientOptions");
+
+ /**
+ * All member functions of `IndexedDBStore` that access the backend use this wrapper to
+ * watch for failures after initial store startup, including `QuotaExceededError` as
+ * free disk space changes, etc.
+ *
+ * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore`
+ * in place so that the current operation and all future ones are in-memory only.
+ *
+ * @param func - The degradable work to do.
+ * @param fallback - The method name for fallback.
+ * @returns A wrapped member function.
+ */
+ private degradable<A extends Array<any>, R = void>(
+ func: DegradableFn<A, R>,
+ fallback?: keyof MemoryStore,
+ ): DegradableFn<A, R> {
+ const fallbackFn = fallback ? (super[fallback] as Function) : null;
+
+ return async (...args) => {
+ try {
+ return await func.call(this, ...args);
+ } catch (e) {
+ logger.error("IndexedDBStore failure, degrading to MemoryStore", e);
+ this.emitter.emit("degraded", e as Error);
+ try {
+ // We try to delete IndexedDB after degrading since this store is only a
+ // cache (the app will still function correctly without the data).
+ // It's possible that deleting repair IndexedDB for the next app load,
+ // potentially by making a little more space available.
+ logger.log("IndexedDBStore trying to delete degraded data");
+ await this.backend.clearDatabase();
+ logger.log("IndexedDBStore delete after degrading succeeded");
+ } catch (e) {
+ logger.warn("IndexedDBStore delete after degrading failed", e);
+ }
+ // Degrade the store from being an instance of `IndexedDBStore` to instead be
+ // an instance of `MemoryStore` so that future API calls use the memory path
+ // directly and skip IndexedDB entirely. This should be safe as
+ // `IndexedDBStore` already extends from `MemoryStore`, so we are making the
+ // store become its parent type in a way. The mutator methods of
+ // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are
+ // not overridden at all).
+ if (fallbackFn) {
+ return fallbackFn.call(this, ...args);
+ }
+ }
+ };
+ }
+
+ // XXX: ideally these would be stored in indexeddb as part of the room but,
+ // we don't store rooms as such and instead accumulate entire sync responses atm.
+ public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> {
+ if (!this.localStorage) return super.getPendingEvents(roomId);
+
+ const serialized = this.localStorage.getItem(pendingEventsKey(roomId));
+ if (serialized) {
+ try {
+ return JSON.parse(serialized);
+ } catch (e) {
+ logger.error("Could not parse persisted pending events", e);
+ }
+ }
+ return [];
+ }
+
+ public async setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
+ if (!this.localStorage) return super.setPendingEvents(roomId, events);
+
+ if (events.length > 0) {
+ this.localStorage.setItem(pendingEventsKey(roomId), JSON.stringify(events));
+ } else {
+ this.localStorage.removeItem(pendingEventsKey(roomId));
+ }
+ }
+
+ public saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
+ return this.backend.saveToDeviceBatches(batches);
+ }
+
+ public getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
+ return this.backend.getOldestToDeviceBatch();
+ }
+
+ public removeToDeviceBatch(id: number): Promise<void> {
+ return this.backend.removeToDeviceBatch(id);
+ }
+}
+
+/**
+ * @param roomId - ID of the current room
+ * @returns Storage key to retrieve pending events
+ */
+function pendingEventsKey(roomId: string): string {
+ return `mx_pending_events_${roomId}`;
+}
+
+type DegradableFn<A extends Array<any>, T> = (...args: A) => Promise<T>;
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/local-storage-events-emitter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/local-storage-events-emitter.ts
new file mode 100644
index 0000000..adb70cb
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/local-storage-events-emitter.ts
@@ -0,0 +1,46 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+
+export enum LocalStorageErrors {
+ Global = "Global",
+ SetItemError = "setItem",
+ GetItemError = "getItem",
+ RemoveItemError = "removeItem",
+ ClearError = "clear",
+ QuotaExceededError = "QuotaExceededError",
+}
+
+type EventHandlerMap = {
+ [LocalStorageErrors.Global]: (error: Error) => void;
+ [LocalStorageErrors.SetItemError]: (error: Error) => void;
+ [LocalStorageErrors.GetItemError]: (error: Error) => void;
+ [LocalStorageErrors.RemoveItemError]: (error: Error) => void;
+ [LocalStorageErrors.ClearError]: (error: Error) => void;
+ [LocalStorageErrors.QuotaExceededError]: (error: Error) => void;
+};
+
+/**
+ * Used in element-web as a temporary hack to handle all the localStorage errors on the highest level possible
+ * As of 15.11.2021 (DD/MM/YYYY) we're not properly handling local storage exceptions anywhere.
+ * This store, as an event emitter, is used to re-emit local storage exceptions so that we can handle them
+ * and show some kind of a "It's dead Jim" modal to the users, telling them that hey,
+ * maybe you should check out your disk, as it's probably dying and your session may die with it.
+ * See: https://github.com/vector-im/element-web/issues/18423
+ */
+class LocalStorageErrorsEventsEmitter extends TypedEventEmitter<LocalStorageErrors, EventHandlerMap> {}
+export const localStorageErrorsEventsEmitter = new LocalStorageErrorsEventsEmitter();
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/memory.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/memory.ts
new file mode 100644
index 0000000..d859ddd
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/memory.ts
@@ -0,0 +1,436 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module. See {@link MemoryStore} for the public class.
+ */
+
+import { EventType } from "../@types/event";
+import { Room } from "../models/room";
+import { User } from "../models/user";
+import { IEvent, MatrixEvent } from "../models/event";
+import { RoomState, RoomStateEvent } from "../models/room-state";
+import { RoomMember } from "../models/room-member";
+import { Filter } from "../filter";
+import { ISavedSync, IStore } from "./index";
+import { RoomSummary } from "../models/room-summary";
+import { ISyncResponse } from "../sync-accumulator";
+import { IStateEventWithRoomId } from "../@types/search";
+import { IndexedToDeviceBatch, ToDeviceBatchWithTxnId } from "../models/ToDeviceMessage";
+import { IStoredClientOpts } from "../client";
+import { MapWithDefault } from "../utils";
+
+function isValidFilterId(filterId?: string | number | null): boolean {
+ const isValidStr =
+ typeof filterId === "string" &&
+ !!filterId &&
+ filterId !== "undefined" && // exclude these as we've serialized undefined in localStorage before
+ filterId !== "null";
+
+ return isValidStr || typeof filterId === "number";
+}
+
+export interface IOpts {
+ /** The local storage instance to persist some forms of data such as tokens. Rooms will NOT be stored. */
+ localStorage?: Storage;
+}
+
+export class MemoryStore implements IStore {
+ private rooms: Record<string, Room> = {}; // roomId: Room
+ private users: Record<string, User> = {}; // userId: User
+ private syncToken: string | null = null;
+ // userId: {
+ // filterId: Filter
+ // }
+ private filters: MapWithDefault<string, Map<string, Filter>> = new MapWithDefault(() => new Map());
+ public accountData: Map<string, MatrixEvent> = new Map(); // type: content
+ protected readonly localStorage?: Storage;
+ private oobMembers: Map<string, IStateEventWithRoomId[]> = new Map(); // roomId: [member events]
+ private pendingEvents: { [roomId: string]: Partial<IEvent>[] } = {};
+ private clientOptions?: IStoredClientOpts;
+ private pendingToDeviceBatches: IndexedToDeviceBatch[] = [];
+ private nextToDeviceBatchId = 0;
+
+ /**
+ * Construct a new in-memory data store for the Matrix Client.
+ * @param opts - Config options
+ */
+ public constructor(opts: IOpts = {}) {
+ this.localStorage = opts.localStorage;
+ }
+
+ /**
+ * Retrieve the token to stream from.
+ * @returns The token or null.
+ */
+ public getSyncToken(): string | null {
+ return this.syncToken;
+ }
+
+ /** @returns whether or not the database was newly created in this session. */
+ public isNewlyCreated(): Promise<boolean> {
+ return Promise.resolve(true);
+ }
+
+ /**
+ * Set the token to stream from.
+ * @param token - The token to stream from.
+ */
+ public setSyncToken(token: string): void {
+ this.syncToken = token;
+ }
+
+ /**
+ * Store the given room.
+ * @param room - The room to be stored. All properties must be stored.
+ */
+ public storeRoom(room: Room): void {
+ this.rooms[room.roomId] = room;
+ // add listeners for room member changes so we can keep the room member
+ // map up-to-date.
+ room.currentState.on(RoomStateEvent.Members, this.onRoomMember);
+ // add existing members
+ room.currentState.getMembers().forEach((m) => {
+ this.onRoomMember(null, room.currentState, m);
+ });
+ }
+
+ /**
+ * Called when a room member in a room being tracked by this store has been
+ * updated.
+ */
+ private onRoomMember = (event: MatrixEvent | null, state: RoomState, member: RoomMember): void => {
+ if (member.membership === "invite") {
+ // We do NOT add invited members because people love to typo user IDs
+ // which would then show up in these lists (!)
+ return;
+ }
+
+ const user = this.users[member.userId] || new User(member.userId);
+ if (member.name) {
+ user.setDisplayName(member.name);
+ if (member.events.member) {
+ user.setRawDisplayName(member.events.member.getDirectionalContent().displayname);
+ }
+ }
+ if (member.events.member && member.events.member.getContent().avatar_url) {
+ user.setAvatarUrl(member.events.member.getContent().avatar_url);
+ }
+ this.users[user.userId] = user;
+ };
+
+ /**
+ * Retrieve a room by its' room ID.
+ * @param roomId - The room ID.
+ * @returns The room or null.
+ */
+ public getRoom(roomId: string): Room | null {
+ return this.rooms[roomId] || null;
+ }
+
+ /**
+ * Retrieve all known rooms.
+ * @returns A list of rooms, which may be empty.
+ */
+ public getRooms(): Room[] {
+ return Object.values(this.rooms);
+ }
+
+ /**
+ * Permanently delete a room.
+ */
+ public removeRoom(roomId: string): void {
+ if (this.rooms[roomId]) {
+ this.rooms[roomId].currentState.removeListener(RoomStateEvent.Members, this.onRoomMember);
+ }
+ delete this.rooms[roomId];
+ }
+
+ /**
+ * Retrieve a summary of all the rooms.
+ * @returns A summary of each room.
+ */
+ public getRoomSummaries(): RoomSummary[] {
+ return Object.values(this.rooms).map(function (room) {
+ return room.summary!;
+ });
+ }
+
+ /**
+ * Store a User.
+ * @param user - The user to store.
+ */
+ public storeUser(user: User): void {
+ this.users[user.userId] = user;
+ }
+
+ /**
+ * Retrieve a User by its' user ID.
+ * @param userId - The user ID.
+ * @returns The user or null.
+ */
+ public getUser(userId: string): User | null {
+ return this.users[userId] || null;
+ }
+
+ /**
+ * Retrieve all known users.
+ * @returns A list of users, which may be empty.
+ */
+ public getUsers(): User[] {
+ return Object.values(this.users);
+ }
+
+ /**
+ * Retrieve scrollback for this room.
+ * @param room - The matrix room
+ * @param limit - The max number of old events to retrieve.
+ * @returns An array of objects which will be at most 'limit'
+ * length and at least 0. The objects are the raw event JSON.
+ */
+ public scrollback(room: Room, limit: number): MatrixEvent[] {
+ return [];
+ }
+
+ /**
+ * Store events for a room. The events have already been added to the timeline
+ * @param room - The room to store events for.
+ * @param events - The events to store.
+ * @param token - The token associated with these events.
+ * @param toStart - True if these are paginated results.
+ */
+ public storeEvents(room: Room, events: MatrixEvent[], token: string | null, toStart: boolean): void {
+ // no-op because they've already been added to the room instance.
+ }
+
+ /**
+ * Store a filter.
+ */
+ public storeFilter(filter: Filter): void {
+ if (!filter?.userId || !filter?.filterId) return;
+ this.filters.getOrCreate(filter.userId).set(filter.filterId, filter);
+ }
+
+ /**
+ * Retrieve a filter.
+ * @returns A filter or null.
+ */
+ public getFilter(userId: string, filterId: string): Filter | null {
+ return this.filters.get(userId)?.get(filterId) || null;
+ }
+
+ /**
+ * Retrieve a filter ID with the given name.
+ * @param filterName - The filter name.
+ * @returns The filter ID or null.
+ */
+ public getFilterIdByName(filterName: string): string | null {
+ if (!this.localStorage) {
+ return null;
+ }
+ const key = "mxjssdk_memory_filter_" + filterName;
+ // XXX Storage.getItem doesn't throw ...
+ // or are we using something different
+ // than window.localStorage in some cases
+ // that does throw?
+ // that would be very naughty
+ try {
+ const value = this.localStorage.getItem(key);
+ if (isValidFilterId(value)) {
+ return value;
+ }
+ } catch (e) {}
+ return null;
+ }
+
+ /**
+ * Set a filter name to ID mapping.
+ */
+ public setFilterIdByName(filterName: string, filterId?: string): void {
+ if (!this.localStorage) {
+ return;
+ }
+ const key = "mxjssdk_memory_filter_" + filterName;
+ try {
+ if (isValidFilterId(filterId)) {
+ this.localStorage.setItem(key, filterId!);
+ } else {
+ this.localStorage.removeItem(key);
+ }
+ } catch (e) {}
+ }
+
+ /**
+ * Store user-scoped account data events.
+ * N.B. that account data only allows a single event per type, so multiple
+ * events with the same type will replace each other.
+ * @param events - The events to store.
+ */
+ public storeAccountDataEvents(events: MatrixEvent[]): void {
+ events.forEach((event) => {
+ // MSC3391: an event with content of {} should be interpreted as deleted
+ const isDeleted = !Object.keys(event.getContent()).length;
+ if (isDeleted) {
+ this.accountData.delete(event.getType());
+ } else {
+ this.accountData.set(event.getType(), event);
+ }
+ });
+ }
+
+ /**
+ * Get account data event by event type
+ * @param eventType - The event type being queried
+ * @returns the user account_data event of given type, if any
+ */
+ public getAccountData(eventType: EventType | string): MatrixEvent | undefined {
+ return this.accountData.get(eventType);
+ }
+
+ /**
+ * setSyncData does nothing as there is no backing data store.
+ *
+ * @param syncData - The sync data
+ * @returns An immediately resolved promise.
+ */
+ public setSyncData(syncData: ISyncResponse): Promise<void> {
+ return Promise.resolve();
+ }
+
+ /**
+ * We never want to save becase we have nothing to save to.
+ *
+ * @returns If the store wants to save
+ */
+ public wantsSave(): boolean {
+ return false;
+ }
+
+ /**
+ * Save does nothing as there is no backing data store.
+ * @param force - True to force a save (but the memory
+ * store still can't save anything)
+ */
+ public save(force: boolean): void {}
+
+ /**
+ * Startup does nothing as this store doesn't require starting up.
+ * @returns An immediately resolved promise.
+ */
+ public startup(): Promise<void> {
+ return Promise.resolve();
+ }
+
+ /**
+ * @returns Promise which resolves with a sync response to restore the
+ * client state to where it was at the last save, or null if there
+ * is no saved sync data.
+ */
+ public getSavedSync(): Promise<ISavedSync | null> {
+ return Promise.resolve(null);
+ }
+
+ /**
+ * @returns If there is a saved sync, the nextBatch token
+ * for this sync, otherwise null.
+ */
+ public getSavedSyncToken(): Promise<string | null> {
+ return Promise.resolve(null);
+ }
+
+ /**
+ * Delete all data from this store.
+ * @returns An immediately resolved promise.
+ */
+ public deleteAllData(): Promise<void> {
+ this.rooms = {
+ // roomId: Room
+ };
+ this.users = {
+ // userId: User
+ };
+ this.syncToken = null;
+ this.filters = new MapWithDefault(() => new Map());
+ this.accountData = new Map(); // type : content
+ return Promise.resolve();
+ }
+
+ /**
+ * Returns the out-of-band membership events for this room that
+ * were previously loaded.
+ * @returns the events, potentially an empty array if OOB loading didn't yield any new members
+ * @returns in case the members for this room haven't been stored yet
+ */
+ public getOutOfBandMembers(roomId: string): Promise<IStateEventWithRoomId[] | null> {
+ return Promise.resolve(this.oobMembers.get(roomId) || null);
+ }
+
+ /**
+ * Stores the out-of-band membership events for this room. Note that
+ * it still makes sense to store an empty array as the OOB status for the room is
+ * marked as fetched, and getOutOfBandMembers will return an empty array instead of null
+ * @param membershipEvents - the membership events to store
+ * @returns when all members have been stored
+ */
+ public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
+ this.oobMembers.set(roomId, membershipEvents);
+ return Promise.resolve();
+ }
+
+ public clearOutOfBandMembers(roomId: string): Promise<void> {
+ this.oobMembers.delete(roomId);
+ return Promise.resolve();
+ }
+
+ public getClientOptions(): Promise<IStoredClientOpts | undefined> {
+ return Promise.resolve(this.clientOptions);
+ }
+
+ public storeClientOptions(options: IStoredClientOpts): Promise<void> {
+ this.clientOptions = Object.assign({}, options);
+ return Promise.resolve();
+ }
+
+ public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> {
+ return this.pendingEvents[roomId] ?? [];
+ }
+
+ public async setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
+ this.pendingEvents[roomId] = events;
+ }
+
+ public saveToDeviceBatches(batches: ToDeviceBatchWithTxnId[]): Promise<void> {
+ for (const batch of batches) {
+ this.pendingToDeviceBatches.push({
+ id: this.nextToDeviceBatchId++,
+ eventType: batch.eventType,
+ txnId: batch.txnId,
+ batch: batch.batch,
+ });
+ }
+ return Promise.resolve();
+ }
+
+ public async getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
+ if (this.pendingToDeviceBatches.length === 0) return null;
+ return this.pendingToDeviceBatches[0];
+ }
+
+ public removeToDeviceBatch(id: number): Promise<void> {
+ this.pendingToDeviceBatches = this.pendingToDeviceBatches.filter((batch) => batch.id !== id);
+ return Promise.resolve();
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/store/stub.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/stub.ts
new file mode 100644
index 0000000..e4402ed
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/store/stub.ts
@@ -0,0 +1,267 @@
+/*
+Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module.
+ */
+
+import { EventType } from "../@types/event";
+import { Room } from "../models/room";
+import { User } from "../models/user";
+import { IEvent, MatrixEvent } from "../models/event";
+import { Filter } from "../filter";
+import { ISavedSync, IStore } from "./index";
+import { RoomSummary } from "../models/room-summary";
+import { ISyncResponse } from "../sync-accumulator";
+import { IStateEventWithRoomId } from "../@types/search";
+import { IndexedToDeviceBatch, ToDeviceBatch } from "../models/ToDeviceMessage";
+import { IStoredClientOpts } from "../client";
+
+/**
+ * Construct a stub store. This does no-ops on most store methods.
+ */
+export class StubStore implements IStore {
+ public readonly accountData = new Map(); // stub
+ private fromToken: string | null = null;
+
+ /** @returns whether or not the database was newly created in this session. */
+ public isNewlyCreated(): Promise<boolean> {
+ return Promise.resolve(true);
+ }
+
+ /**
+ * Get the sync token.
+ */
+ public getSyncToken(): string | null {
+ return this.fromToken;
+ }
+
+ /**
+ * Set the sync token.
+ */
+ public setSyncToken(token: string): void {
+ this.fromToken = token;
+ }
+
+ /**
+ * No-op.
+ */
+ public storeRoom(room: Room): void {}
+
+ /**
+ * No-op.
+ */
+ public getRoom(roomId: string): Room | null {
+ return null;
+ }
+
+ /**
+ * No-op.
+ * @returns An empty array.
+ */
+ public getRooms(): Room[] {
+ return [];
+ }
+
+ /**
+ * Permanently delete a room.
+ */
+ public removeRoom(roomId: string): void {
+ return;
+ }
+
+ /**
+ * No-op.
+ * @returns An empty array.
+ */
+ public getRoomSummaries(): RoomSummary[] {
+ return [];
+ }
+
+ /**
+ * No-op.
+ */
+ public storeUser(user: User): void {}
+
+ /**
+ * No-op.
+ */
+ public getUser(userId: string): User | null {
+ return null;
+ }
+
+ /**
+ * No-op.
+ */
+ public getUsers(): User[] {
+ return [];
+ }
+
+ /**
+ * No-op.
+ */
+ public scrollback(room: Room, limit: number): MatrixEvent[] {
+ return [];
+ }
+
+ /**
+ * Store events for a room.
+ * @param room - The room to store events for.
+ * @param events - The events to store.
+ * @param token - The token associated with these events.
+ * @param toStart - True if these are paginated results.
+ */
+ public storeEvents(room: Room, events: MatrixEvent[], token: string | null, toStart: boolean): void {}
+
+ /**
+ * Store a filter.
+ */
+ public storeFilter(filter: Filter): void {}
+
+ /**
+ * Retrieve a filter.
+ * @returns A filter or null.
+ */
+ public getFilter(userId: string, filterId: string): Filter | null {
+ return null;
+ }
+
+ /**
+ * Retrieve a filter ID with the given name.
+ * @param filterName - The filter name.
+ * @returns The filter ID or null.
+ */
+ public getFilterIdByName(filterName: string): string | null {
+ return null;
+ }
+
+ /**
+ * Set a filter name to ID mapping.
+ */
+ public setFilterIdByName(filterName: string, filterId?: string): void {}
+
+ /**
+ * Store user-scoped account data events
+ * @param events - The events to store.
+ */
+ public storeAccountDataEvents(events: MatrixEvent[]): void {}
+
+ /**
+ * Get account data event by event type
+ * @param eventType - The event type being queried
+ */
+ public getAccountData(eventType: EventType | string): MatrixEvent | undefined {
+ return undefined;
+ }
+
+ /**
+ * setSyncData does nothing as there is no backing data store.
+ *
+ * @param syncData - The sync data
+ * @returns An immediately resolved promise.
+ */
+ public setSyncData(syncData: ISyncResponse): Promise<void> {
+ return Promise.resolve();
+ }
+
+ /**
+ * We never want to save because we have nothing to save to.
+ *
+ * @returns If the store wants to save
+ */
+ public wantsSave(): boolean {
+ return false;
+ }
+
+ /**
+ * Save does nothing as there is no backing data store.
+ */
+ public save(): void {}
+
+ /**
+ * Startup does nothing.
+ * @returns An immediately resolved promise.
+ */
+ public startup(): Promise<void> {
+ return Promise.resolve();
+ }
+
+ /**
+ * @returns Promise which resolves with a sync response to restore the
+ * client state to where it was at the last save, or null if there
+ * is no saved sync data.
+ */
+ public getSavedSync(): Promise<ISavedSync | null> {
+ return Promise.resolve(null);
+ }
+
+ /**
+ * @returns If there is a saved sync, the nextBatch token
+ * for this sync, otherwise null.
+ */
+ public getSavedSyncToken(): Promise<string | null> {
+ return Promise.resolve(null);
+ }
+
+ /**
+ * Delete all data from this store. Does nothing since this store
+ * doesn't store anything.
+ * @returns An immediately resolved promise.
+ */
+ public deleteAllData(): Promise<void> {
+ return Promise.resolve();
+ }
+
+ public getOutOfBandMembers(): Promise<IStateEventWithRoomId[] | null> {
+ return Promise.resolve(null);
+ }
+
+ public setOutOfBandMembers(roomId: string, membershipEvents: IStateEventWithRoomId[]): Promise<void> {
+ return Promise.resolve();
+ }
+
+ public clearOutOfBandMembers(): Promise<void> {
+ return Promise.resolve();
+ }
+
+ public getClientOptions(): Promise<IStoredClientOpts | undefined> {
+ return Promise.resolve(undefined);
+ }
+
+ public storeClientOptions(options: IStoredClientOpts): Promise<void> {
+ return Promise.resolve();
+ }
+
+ public async getPendingEvents(roomId: string): Promise<Partial<IEvent>[]> {
+ return [];
+ }
+
+ public setPendingEvents(roomId: string, events: Partial<IEvent>[]): Promise<void> {
+ return Promise.resolve();
+ }
+
+ public async saveToDeviceBatches(batch: ToDeviceBatch[]): Promise<void> {
+ return Promise.resolve();
+ }
+
+ public getOldestToDeviceBatch(): Promise<IndexedToDeviceBatch | null> {
+ return Promise.resolve(null);
+ }
+
+ public async removeToDeviceBatch(id: number): Promise<void> {
+ return Promise.resolve();
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/sync-accumulator.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/sync-accumulator.ts
new file mode 100644
index 0000000..fef03d7
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/sync-accumulator.ts
@@ -0,0 +1,715 @@
+/*
+Copyright 2017 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module. See {@link SyncAccumulator} for the public class.
+ */
+
+import { logger } from "./logger";
+import { deepCopy, isSupportedReceiptType, MapWithDefault, recursiveMapToObject } from "./utils";
+import { IContent, IUnsigned } from "./models/event";
+import { IRoomSummary } from "./models/room-summary";
+import { EventType } from "./@types/event";
+import { MAIN_ROOM_TIMELINE, ReceiptContent, ReceiptType } from "./@types/read_receipts";
+import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
+
+interface IOpts {
+ /**
+ * The ideal maximum number of timeline entries to keep in the sync response.
+ * This is best-effort, as clients do not always have a back-pagination token for each event,
+ * so it's possible there may be slightly *less* than this value. There will never be more.
+ * This cannot be 0 or else it makes it impossible to scroll back in a room.
+ * Default: 50.
+ */
+ maxTimelineEntries?: number;
+}
+
+export interface IMinimalEvent {
+ content: IContent;
+ type: EventType | string;
+ unsigned?: IUnsigned;
+}
+
+export interface IEphemeral {
+ events: IMinimalEvent[];
+}
+
+/* eslint-disable camelcase */
+interface UnreadNotificationCounts {
+ highlight_count?: number;
+ notification_count?: number;
+}
+
+export interface IRoomEvent extends IMinimalEvent {
+ event_id: string;
+ sender: string;
+ origin_server_ts: number;
+ /** @deprecated - legacy field */
+ age?: number;
+}
+
+export interface IStateEvent extends IRoomEvent {
+ prev_content?: IContent;
+ state_key: string;
+}
+
+interface IState {
+ events: IStateEvent[];
+}
+
+export interface ITimeline {
+ events: Array<IRoomEvent | IStateEvent>;
+ limited?: boolean;
+ prev_batch: string | null;
+}
+
+export interface IJoinedRoom {
+ "summary": IRoomSummary;
+ "state": IState;
+ "timeline": ITimeline;
+ "ephemeral": IEphemeral;
+ "account_data": IAccountData;
+ "unread_notifications": UnreadNotificationCounts;
+ "unread_thread_notifications"?: Record<string, UnreadNotificationCounts>;
+ "org.matrix.msc3773.unread_thread_notifications"?: Record<string, UnreadNotificationCounts>;
+}
+
+export interface IStrippedState {
+ content: IContent;
+ state_key: string;
+ type: EventType | string;
+ sender: string;
+}
+
+export interface IInviteState {
+ events: IStrippedState[];
+}
+
+export interface IInvitedRoom {
+ invite_state: IInviteState;
+}
+
+export interface ILeftRoom {
+ state: IState;
+ timeline: ITimeline;
+ account_data: IAccountData;
+}
+
+export interface IRooms {
+ [Category.Join]: Record<string, IJoinedRoom>;
+ [Category.Invite]: Record<string, IInvitedRoom>;
+ [Category.Leave]: Record<string, ILeftRoom>;
+}
+
+interface IPresence {
+ events: IMinimalEvent[];
+}
+
+interface IAccountData {
+ events: IMinimalEvent[];
+}
+
+export interface IToDeviceEvent {
+ content: IContent;
+ sender: string;
+ type: string;
+}
+
+interface IToDevice {
+ events: IToDeviceEvent[];
+}
+
+interface IDeviceLists {
+ changed?: string[];
+ left?: string[];
+}
+
+export interface ISyncResponse {
+ "next_batch": string;
+ "rooms": IRooms;
+ "presence"?: IPresence;
+ "account_data": IAccountData;
+ "to_device"?: IToDevice;
+ "device_lists"?: IDeviceLists;
+ "device_one_time_keys_count"?: Record<string, number>;
+
+ "device_unused_fallback_key_types"?: string[];
+ "org.matrix.msc2732.device_unused_fallback_key_types"?: string[];
+}
+/* eslint-enable camelcase */
+
+export enum Category {
+ Invite = "invite",
+ Leave = "leave",
+ Join = "join",
+}
+
+interface IRoom {
+ _currentState: { [eventType: string]: { [stateKey: string]: IStateEvent } };
+ _timeline: {
+ event: IRoomEvent | IStateEvent;
+ token: string | null;
+ }[];
+ _summary: Partial<IRoomSummary>;
+ _accountData: { [eventType: string]: IMinimalEvent };
+ _unreadNotifications: Partial<UnreadNotificationCounts>;
+ _unreadThreadNotifications?: Record<string, Partial<UnreadNotificationCounts>>;
+ _readReceipts: {
+ [userId: string]: {
+ data: IMinimalEvent;
+ type: ReceiptType;
+ eventId: string;
+ };
+ };
+ _threadReadReceipts: {
+ [threadId: string]: {
+ [userId: string]: {
+ data: IMinimalEvent;
+ type: ReceiptType;
+ eventId: string;
+ };
+ };
+ };
+}
+
+export interface ISyncData {
+ nextBatch: string;
+ accountData: IMinimalEvent[];
+ roomsData: IRooms;
+}
+
+type TaggedEvent = IRoomEvent & { _localTs?: number };
+
+function isTaggedEvent(event: IRoomEvent): event is TaggedEvent {
+ return "_localTs" in event && event["_localTs"] !== undefined;
+}
+
+/**
+ * The purpose of this class is to accumulate /sync responses such that a
+ * complete "initial" JSON response can be returned which accurately represents
+ * the sum total of the /sync responses accumulated to date. It only handles
+ * room data: that is, everything under the "rooms" top-level key.
+ *
+ * This class is used when persisting room data so a complete /sync response can
+ * be loaded from disk and incremental syncs can be performed on the server,
+ * rather than asking the server to do an initial sync on startup.
+ */
+export class SyncAccumulator {
+ private accountData: Record<string, IMinimalEvent> = {}; // $event_type: Object
+ private inviteRooms: Record<string, IInvitedRoom> = {}; // $roomId: { ... sync 'invite' json data ... }
+ private joinRooms: { [roomId: string]: IRoom } = {};
+ // the /sync token which corresponds to the last time rooms were
+ // accumulated. We remember this so that any caller can obtain a
+ // coherent /sync response and know at what point they should be
+ // streaming from without losing events.
+ private nextBatch: string | null = null;
+
+ public constructor(private readonly opts: IOpts = {}) {
+ this.opts.maxTimelineEntries = this.opts.maxTimelineEntries || 50;
+ }
+
+ public accumulate(syncResponse: ISyncResponse, fromDatabase = false): void {
+ this.accumulateRooms(syncResponse, fromDatabase);
+ this.accumulateAccountData(syncResponse);
+ this.nextBatch = syncResponse.next_batch;
+ }
+
+ private accumulateAccountData(syncResponse: ISyncResponse): void {
+ if (!syncResponse.account_data || !syncResponse.account_data.events) {
+ return;
+ }
+ // Clobbers based on event type.
+ syncResponse.account_data.events.forEach((e) => {
+ this.accountData[e.type] = e;
+ });
+ }
+
+ /**
+ * Accumulate incremental /sync room data.
+ * @param syncResponse - the complete /sync JSON
+ * @param fromDatabase - True if the sync response is one saved to the database
+ */
+ private accumulateRooms(syncResponse: ISyncResponse, fromDatabase = false): void {
+ if (!syncResponse.rooms) {
+ return;
+ }
+ if (syncResponse.rooms.invite) {
+ Object.keys(syncResponse.rooms.invite).forEach((roomId) => {
+ this.accumulateRoom(roomId, Category.Invite, syncResponse.rooms.invite[roomId], fromDatabase);
+ });
+ }
+ if (syncResponse.rooms.join) {
+ Object.keys(syncResponse.rooms.join).forEach((roomId) => {
+ this.accumulateRoom(roomId, Category.Join, syncResponse.rooms.join[roomId], fromDatabase);
+ });
+ }
+ if (syncResponse.rooms.leave) {
+ Object.keys(syncResponse.rooms.leave).forEach((roomId) => {
+ this.accumulateRoom(roomId, Category.Leave, syncResponse.rooms.leave[roomId], fromDatabase);
+ });
+ }
+ }
+
+ private accumulateRoom(roomId: string, category: Category.Invite, data: IInvitedRoom, fromDatabase: boolean): void;
+ private accumulateRoom(roomId: string, category: Category.Join, data: IJoinedRoom, fromDatabase: boolean): void;
+ private accumulateRoom(roomId: string, category: Category.Leave, data: ILeftRoom, fromDatabase: boolean): void;
+ private accumulateRoom(roomId: string, category: Category, data: any, fromDatabase = false): void {
+ // Valid /sync state transitions
+ // +--------+ <======+ 1: Accept an invite
+ // +== | INVITE | | (5) 2: Leave a room
+ // | +--------+ =====+ | 3: Join a public room previously
+ // |(1) (4) | | left (handle as if new room)
+ // V (2) V | 4: Reject an invite
+ // +------+ ========> +--------+ 5: Invite to a room previously
+ // | JOIN | (3) | LEAVE* | left (handle as if new room)
+ // +------+ <======== +--------+
+ //
+ // * equivalent to "no state"
+ switch (category) {
+ case Category.Invite: // (5)
+ this.accumulateInviteState(roomId, data as IInvitedRoom);
+ break;
+
+ case Category.Join:
+ if (this.inviteRooms[roomId]) {
+ // (1)
+ // was previously invite, now join. We expect /sync to give
+ // the entire state and timeline on 'join', so delete previous
+ // invite state
+ delete this.inviteRooms[roomId];
+ }
+ // (3)
+ this.accumulateJoinState(roomId, data as IJoinedRoom, fromDatabase);
+ break;
+
+ case Category.Leave:
+ if (this.inviteRooms[roomId]) {
+ // (4)
+ delete this.inviteRooms[roomId];
+ } else {
+ // (2)
+ delete this.joinRooms[roomId];
+ }
+ break;
+
+ default:
+ logger.error("Unknown cateogory: ", category);
+ }
+ }
+
+ private accumulateInviteState(roomId: string, data: IInvitedRoom): void {
+ if (!data.invite_state || !data.invite_state.events) {
+ // no new data
+ return;
+ }
+ if (!this.inviteRooms[roomId]) {
+ this.inviteRooms[roomId] = {
+ invite_state: data.invite_state,
+ };
+ return;
+ }
+ // accumulate extra keys for invite->invite transitions
+ // clobber based on event type / state key
+ // We expect invite_state to be small, so just loop over the events
+ const currentData = this.inviteRooms[roomId];
+ data.invite_state.events.forEach((e) => {
+ let hasAdded = false;
+ for (let i = 0; i < currentData.invite_state.events.length; i++) {
+ const current = currentData.invite_state.events[i];
+ if (current.type === e.type && current.state_key == e.state_key) {
+ currentData.invite_state.events[i] = e; // update
+ hasAdded = true;
+ }
+ }
+ if (!hasAdded) {
+ currentData.invite_state.events.push(e);
+ }
+ });
+ }
+
+ // Accumulate timeline and state events in a room.
+ private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void {
+ // 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
+ // to clobber based on type/state_key. Rather than convert arrays to
+ // maps all the time, just keep private maps which contain
+ // the actual current accumulated sync state, and array-ify it when
+ // getJSON() is called.
+
+ // State resolution:
+ // The 'state' key is the delta from the previous sync (or start of time
+ // if no token was supplied), to the START of the timeline. To obtain
+ // the current state, we need to "roll forward" state by reading the
+ // timeline. We want to store the current state so we can drop events
+ // out the end of the timeline based on opts.maxTimelineEntries.
+ //
+ // 'state' 'timeline' current state
+ // |-------x<======================>x
+ // T I M E
+ //
+ // When getJSON() is called, we 'roll back' the current state by the
+ // number of entries in the timeline to work out what 'state' should be.
+
+ // Back-pagination:
+ // On an initial /sync, the server provides a back-pagination token for
+ // the start of the timeline. When /sync deltas come down, they also
+ // include back-pagination tokens for the start of the timeline. This
+ // means not all events in the timeline have back-pagination tokens, as
+ // it is only the ones at the START of the timeline which have them.
+ // In order for us to have a valid timeline (and back-pagination token
+ // to match), we need to make sure that when we remove old timeline
+ // events, that we roll forward to an event which has a back-pagination
+ // token. This means we can't keep a strict sliding-window based on
+ // opts.maxTimelineEntries, and we may have a few less. We should never
+ // have more though, provided that the /sync limit is less than or equal
+ // to opts.maxTimelineEntries.
+
+ if (!this.joinRooms[roomId]) {
+ // Create truly empty objects so event types of 'hasOwnProperty' and co
+ // don't cause this code to break.
+ this.joinRooms[roomId] = {
+ _currentState: Object.create(null),
+ _timeline: [],
+ _accountData: Object.create(null),
+ _unreadNotifications: {},
+ _unreadThreadNotifications: {},
+ _summary: {},
+ _readReceipts: {},
+ _threadReadReceipts: {},
+ };
+ }
+ const currentData = this.joinRooms[roomId];
+
+ if (data.account_data && data.account_data.events) {
+ // clobber based on type
+ data.account_data.events.forEach((e) => {
+ currentData._accountData[e.type] = e;
+ });
+ }
+
+ // these probably clobber, spec is unclear.
+ if (data.unread_notifications) {
+ currentData._unreadNotifications = data.unread_notifications;
+ }
+ currentData._unreadThreadNotifications =
+ data[UNREAD_THREAD_NOTIFICATIONS.stable!] ?? data[UNREAD_THREAD_NOTIFICATIONS.unstable!] ?? undefined;
+
+ if (data.summary) {
+ const HEROES_KEY = "m.heroes";
+ const INVITED_COUNT_KEY = "m.invited_member_count";
+ const JOINED_COUNT_KEY = "m.joined_member_count";
+
+ const acc = currentData._summary;
+ const sum = data.summary;
+ acc[HEROES_KEY] = sum[HEROES_KEY] || acc[HEROES_KEY];
+ acc[JOINED_COUNT_KEY] = sum[JOINED_COUNT_KEY] || acc[JOINED_COUNT_KEY];
+ acc[INVITED_COUNT_KEY] = sum[INVITED_COUNT_KEY] || acc[INVITED_COUNT_KEY];
+ }
+
+ data.ephemeral?.events?.forEach((e) => {
+ // We purposefully do not persist m.typing events.
+ // Technically you could refresh a browser before the timer on a
+ // typing event is up, so it'll look like you aren't typing when
+ // you really still are. However, the alternative is worse. If
+ // we do persist typing events, it will look like people are
+ // typing forever until someone really does start typing (which
+ // will prompt Synapse to send down an actual m.typing event to
+ // clobber the one we persisted).
+ if (e.type !== EventType.Receipt || !e.content) {
+ // This means we'll drop unknown ephemeral events but that
+ // seems okay.
+ return;
+ }
+ // Handle m.receipt events. They clobber based on:
+ // (user_id, receipt_type)
+ // but they are keyed in the event as:
+ // content:{ $event_id: { $receipt_type: { $user_id: {json} }}}
+ // so store them in the former so we can accumulate receipt deltas
+ // quickly and efficiently (we expect a lot of them). Fold the
+ // receipt type into the key name since we only have 1 at the
+ // moment (m.read) and nested JSON objects are slower and more
+ // of a hassle to work with. We'll inflate this back out when
+ // getJSON() is called.
+ Object.keys(e.content).forEach((eventId) => {
+ Object.entries<ReceiptContent>(e.content[eventId]).forEach(([key, value]) => {
+ if (!isSupportedReceiptType(key)) return;
+
+ for (const userId of Object.keys(value)) {
+ const data = e.content[eventId][key][userId];
+
+ const receipt = {
+ data: e.content[eventId][key][userId],
+ type: key as ReceiptType,
+ eventId: eventId,
+ };
+
+ if (!data.thread_id || data.thread_id === MAIN_ROOM_TIMELINE) {
+ currentData._readReceipts[userId] = receipt;
+ } else {
+ currentData._threadReadReceipts = {
+ ...currentData._threadReadReceipts,
+ [data.thread_id]: {
+ ...(currentData._threadReadReceipts[data.thread_id] ?? {}),
+ [userId]: receipt,
+ },
+ };
+ }
+ }
+ });
+ });
+ });
+
+ // if we got a limited sync, we need to remove all timeline entries or else
+ // we will have gaps in the timeline.
+ if (data.timeline && data.timeline.limited) {
+ currentData._timeline = [];
+ }
+
+ // Work out the current state. The deltas need to be applied in the order:
+ // - existing state which didn't come down /sync.
+ // - State events under the 'state' key.
+ // - State events in the 'timeline'.
+ data.state?.events?.forEach((e) => {
+ setState(currentData._currentState, e);
+ });
+ data.timeline?.events?.forEach((e, index) => {
+ // this nops if 'e' isn't a state event
+ setState(currentData._currentState, e);
+ // append the event to the timeline. The back-pagination token
+ // corresponds to the first event in the timeline
+ let transformedEvent: TaggedEvent;
+ if (!fromDatabase) {
+ transformedEvent = Object.assign({}, e);
+ if (transformedEvent.unsigned !== undefined) {
+ transformedEvent.unsigned = Object.assign({}, transformedEvent.unsigned);
+ }
+ const age = e.unsigned ? e.unsigned.age : e.age;
+ if (age !== undefined) transformedEvent._localTs = Date.now() - age;
+ } else {
+ transformedEvent = e;
+ }
+
+ currentData._timeline.push({
+ event: transformedEvent,
+ token: index === 0 ? data.timeline.prev_batch ?? null : null,
+ });
+ });
+
+ // attempt to prune the timeline by jumping between events which have
+ // pagination tokens.
+ if (currentData._timeline.length > this.opts.maxTimelineEntries!) {
+ const startIndex = currentData._timeline.length - this.opts.maxTimelineEntries!;
+ for (let i = startIndex; i < currentData._timeline.length; i++) {
+ if (currentData._timeline[i].token) {
+ // keep all events after this, including this one
+ currentData._timeline = currentData._timeline.slice(i, currentData._timeline.length);
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * Return everything under the 'rooms' key from a /sync response which
+ * represents all room data that should be stored. This should be paired
+ * with the sync token which represents the most recent /sync response
+ * provided to accumulate().
+ * @param forDatabase - True to generate a sync to be saved to storage
+ * @returns An object with a "nextBatch", "roomsData" and "accountData"
+ * keys.
+ * The "nextBatch" key is a string which represents at what point in the
+ * /sync stream the accumulator reached. This token should be used when
+ * restarting a /sync stream at startup. Failure to do so can lead to missing
+ * events. The "roomsData" key is an Object which represents the entire
+ * /sync response from the 'rooms' key onwards. The "accountData" key is
+ * a list of raw events which represent global account data.
+ */
+ public getJSON(forDatabase = false): ISyncData {
+ const data: IRooms = {
+ join: {},
+ invite: {},
+ // always empty. This is set by /sync when a room was previously
+ // in 'invite' or 'join'. On fresh startup, the client won't know
+ // about any previous room being in 'invite' or 'join' so we can
+ // just omit mentioning it at all, even if it has previously come
+ // down /sync.
+ // The notable exception is when a client is kicked or banned:
+ // we may want to hold onto that room so the client can clearly see
+ // why their room has disappeared. We don't persist it though because
+ // it is unclear *when* we can safely remove the room from the DB.
+ // Instead, we assume that if you're loading from the DB, you've
+ // refreshed the page, which means you've seen the kick/ban already.
+ leave: {},
+ };
+ Object.keys(this.inviteRooms).forEach((roomId) => {
+ data.invite[roomId] = this.inviteRooms[roomId];
+ });
+ Object.keys(this.joinRooms).forEach((roomId) => {
+ const roomData = this.joinRooms[roomId];
+ const roomJson: IJoinedRoom = {
+ ephemeral: { events: [] },
+ account_data: { events: [] },
+ state: { events: [] },
+ timeline: {
+ events: [],
+ prev_batch: null,
+ },
+ unread_notifications: roomData._unreadNotifications,
+ unread_thread_notifications: roomData._unreadThreadNotifications,
+ summary: roomData._summary as IRoomSummary,
+ };
+ // Add account data
+ Object.keys(roomData._accountData).forEach((evType) => {
+ roomJson.account_data.events.push(roomData._accountData[evType]);
+ });
+
+ // Add receipt data
+ const receiptEvent = {
+ type: EventType.Receipt,
+ room_id: roomId,
+ content: {
+ // $event_id: { "m.read": { $user_id: $json } }
+ } as IContent,
+ };
+
+ const receiptEventContent: MapWithDefault<
+ string,
+ MapWithDefault<ReceiptType, Map<string, object>>
+ > = new MapWithDefault(() => new MapWithDefault(() => new Map()));
+
+ for (const [userId, receiptData] of Object.entries(roomData._readReceipts)) {
+ receiptEventContent
+ .getOrCreate(receiptData.eventId)
+ .getOrCreate(receiptData.type)
+ .set(userId, receiptData.data);
+ }
+
+ for (const threadReceipts of Object.values(roomData._threadReadReceipts)) {
+ for (const [userId, receiptData] of Object.entries(threadReceipts)) {
+ receiptEventContent
+ .getOrCreate(receiptData.eventId)
+ .getOrCreate(receiptData.type)
+ .set(userId, receiptData.data);
+ }
+ }
+
+ receiptEvent.content = recursiveMapToObject(receiptEventContent);
+
+ // add only if we have some receipt data
+ if (receiptEventContent.size > 0) {
+ roomJson.ephemeral.events.push(receiptEvent as IMinimalEvent);
+ }
+
+ // Add timeline data
+ roomData._timeline.forEach((msgData) => {
+ if (!roomJson.timeline.prev_batch) {
+ // the first event we add to the timeline MUST match up to
+ // the prev_batch token.
+ if (!msgData.token) {
+ return; // this shouldn't happen as we prune constantly.
+ }
+ roomJson.timeline.prev_batch = msgData.token;
+ }
+
+ let transformedEvent: (IRoomEvent | IStateEvent) & { _localTs?: number };
+ if (!forDatabase && isTaggedEvent(msgData.event)) {
+ // This means we have to copy each event, so we can fix it up to
+ // set a correct 'age' parameter whilst keeping the local timestamp
+ // on our stored event. If this turns out to be a bottleneck, it could
+ // be optimised either by doing this in the main process after the data
+ // has been structured-cloned to go between the worker & main process,
+ // or special-casing data from saved syncs to read the local timestamp
+ // directly rather than turning it into age to then immediately be
+ // transformed back again into a local timestamp.
+ transformedEvent = Object.assign({}, msgData.event);
+ if (transformedEvent.unsigned !== undefined) {
+ transformedEvent.unsigned = Object.assign({}, transformedEvent.unsigned);
+ }
+ delete transformedEvent._localTs;
+ transformedEvent.unsigned = transformedEvent.unsigned || {};
+ transformedEvent.unsigned.age = Date.now() - msgData.event._localTs!;
+ } else {
+ transformedEvent = msgData.event;
+ }
+ roomJson.timeline.events.push(transformedEvent);
+ });
+
+ // Add state data: roll back current state to the start of timeline,
+ // by "reverse clobbering" from the end of the timeline to the start.
+ // Convert maps back into arrays.
+ const rollBackState = Object.create(null);
+ for (let i = roomJson.timeline.events.length - 1; i >= 0; i--) {
+ const timelineEvent = roomJson.timeline.events[i];
+ if (
+ (timelineEvent as IStateEvent).state_key === null ||
+ (timelineEvent as IStateEvent).state_key === undefined
+ ) {
+ continue; // not a state event
+ }
+ // since we're going back in time, we need to use the previous
+ // state value else we'll break causality. We don't have the
+ // complete previous state event, so we need to create one.
+ const prevStateEvent = deepCopy(timelineEvent);
+ if (prevStateEvent.unsigned) {
+ if (prevStateEvent.unsigned.prev_content) {
+ prevStateEvent.content = prevStateEvent.unsigned.prev_content;
+ }
+ if (prevStateEvent.unsigned.prev_sender) {
+ prevStateEvent.sender = prevStateEvent.unsigned.prev_sender;
+ }
+ }
+ setState(rollBackState, prevStateEvent);
+ }
+ Object.keys(roomData._currentState).forEach((evType) => {
+ Object.keys(roomData._currentState[evType]).forEach((stateKey) => {
+ let ev = roomData._currentState[evType][stateKey];
+ if (rollBackState[evType] && rollBackState[evType][stateKey]) {
+ // use the reverse clobbered event instead.
+ ev = rollBackState[evType][stateKey];
+ }
+ roomJson.state.events.push(ev);
+ });
+ });
+ data.join[roomId] = roomJson;
+ });
+
+ // Add account data
+ const accData: IMinimalEvent[] = [];
+ Object.keys(this.accountData).forEach((evType) => {
+ accData.push(this.accountData[evType]);
+ });
+
+ return {
+ nextBatch: this.nextBatch!,
+ roomsData: data,
+ accountData: accData,
+ };
+ }
+
+ public getNextBatchToken(): string {
+ return this.nextBatch!;
+ }
+}
+
+function setState(eventMap: Record<string, Record<string, IStateEvent>>, event: IRoomEvent | IStateEvent): void {
+ if ((event as IStateEvent).state_key === null || (event as IStateEvent).state_key === undefined || !event.type) {
+ return;
+ }
+ if (!eventMap[event.type]) {
+ eventMap[event.type] = Object.create(null);
+ }
+ eventMap[event.type][(event as IStateEvent).state_key] = event as IStateEvent;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/sync.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/sync.ts
new file mode 100644
index 0000000..dc5217c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/sync.ts
@@ -0,0 +1,1898 @@
+/*
+Copyright 2015 - 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+ * TODO:
+ * This class mainly serves to take all the syncing logic out of client.js and
+ * into a separate file. It's all very fluid, and this class gut wrenches a lot
+ * of MatrixClient props (e.g. http). Given we want to support WebSockets as
+ * an alternative syncing API, we may want to have a proper syncing interface
+ * for HTTP and WS at some point.
+ */
+
+import { Optional } from "matrix-events-sdk";
+
+import type { SyncCryptoCallbacks } from "./common-crypto/CryptoBackend";
+import { User, UserEvent } from "./models/user";
+import { NotificationCountType, Room, RoomEvent } from "./models/room";
+import * as utils from "./utils";
+import { IDeferred, noUnsafeEventProps, unsafeProp } from "./utils";
+import { Filter } from "./filter";
+import { EventTimeline } from "./models/event-timeline";
+import { logger } from "./logger";
+import { InvalidStoreError, InvalidStoreState } from "./errors";
+import { ClientEvent, IStoredClientOpts, MatrixClient, PendingEventOrdering, ResetTimelineCallback } from "./client";
+import {
+ IEphemeral,
+ IInvitedRoom,
+ IInviteState,
+ IJoinedRoom,
+ ILeftRoom,
+ IMinimalEvent,
+ IRoomEvent,
+ IStateEvent,
+ IStrippedState,
+ ISyncResponse,
+ ITimeline,
+ IToDeviceEvent,
+} from "./sync-accumulator";
+import { MatrixEvent } from "./models/event";
+import { MatrixError, Method } from "./http-api";
+import { ISavedSync } from "./store";
+import { EventType } from "./@types/event";
+import { IPushRules } from "./@types/PushRules";
+import { RoomStateEvent, IMarkerFoundOptions } from "./models/room-state";
+import { RoomMemberEvent } from "./models/room-member";
+import { BeaconEvent } from "./models/beacon";
+import { IEventsResponse } from "./@types/requests";
+import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync";
+import { Feature, ServerSupport } from "./feature";
+import { Crypto } from "./crypto";
+
+const DEBUG = true;
+
+// /sync requests allow you to set a timeout= but the request may continue
+// beyond that and wedge forever, so we need to track how long we are willing
+// to keep open the connection. This constant is *ADDED* to the timeout= value
+// to determine the max time we're willing to wait.
+const BUFFER_PERIOD_MS = 80 * 1000;
+
+// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed
+// to RECONNECTING. This is needed to inform the client of server issues when the
+// keepAlive is successful but the server /sync fails.
+const FAILED_SYNC_ERROR_THRESHOLD = 3;
+
+export enum SyncState {
+ /** Emitted after we try to sync more than `FAILED_SYNC_ERROR_THRESHOLD`
+ * times and are still failing. Or when we enounter a hard error like the
+ * token being invalid. */
+ Error = "ERROR",
+ /** Emitted after the first sync events are ready (this could even be sync
+ * events from the cache) */
+ Prepared = "PREPARED",
+ /** Emitted when the sync loop is no longer running */
+ Stopped = "STOPPED",
+ /** Emitted after each sync request happens */
+ Syncing = "SYNCING",
+ /** Emitted after a connectivity error and we're ready to start syncing again */
+ Catchup = "CATCHUP",
+ /** Emitted for each time we try reconnecting. Will switch to `Error` after
+ * we reach the `FAILED_SYNC_ERROR_THRESHOLD`
+ */
+ Reconnecting = "RECONNECTING",
+}
+
+// Room versions where "insertion", "batch", and "marker" events are controlled
+// by power-levels. MSC2716 is supported in existing room versions but they
+// should only have special meaning when the room creator sends them.
+const MSC2716_ROOM_VERSIONS = ["org.matrix.msc2716v3"];
+
+function getFilterName(userId: string, suffix?: string): string {
+ // scope this on the user ID because people may login on many accounts
+ // and they all need to be stored!
+ return `FILTER_SYNC_${userId}` + (suffix ? "_" + suffix : "");
+}
+
+/* istanbul ignore next */
+function debuglog(...params: any[]): void {
+ if (!DEBUG) return;
+ logger.log(...params);
+}
+
+/**
+ * Options passed into the constructor of SyncApi by MatrixClient
+ */
+export interface SyncApiOptions {
+ /**
+ * Crypto manager
+ *
+ * @deprecated in favour of cryptoCallbacks
+ */
+ crypto?: Crypto;
+
+ /**
+ * If crypto is enabled on our client, callbacks into the crypto module
+ */
+ cryptoCallbacks?: SyncCryptoCallbacks;
+
+ /**
+ * A function which is called
+ * with a room ID and returns a boolean. It should return 'true' if the SDK can
+ * SAFELY remove events from this room. It may not be safe to remove events if
+ * there are other references to the timelines for this room.
+ */
+ canResetEntireTimeline?: ResetTimelineCallback;
+}
+
+interface ISyncOptions {
+ filter?: string;
+ hasSyncedBefore?: boolean;
+}
+
+export interface ISyncStateData {
+ /**
+ * The matrix error if `state=ERROR`.
+ */
+ error?: Error;
+ /**
+ * The 'since' token passed to /sync.
+ * `null` for the first successful sync since this client was
+ * started. Only present if `state=PREPARED` or
+ * `state=SYNCING`.
+ */
+ oldSyncToken?: string;
+ /**
+ * The 'next_batch' result from /sync, which
+ * will become the 'since' token for the next call to /sync. Only present if
+ * `state=PREPARED</code> or <code>state=SYNCING`.
+ */
+ nextSyncToken?: string;
+ /**
+ * True if we are working our way through a
+ * backlog of events after connecting. Only present if `state=SYNCING`.
+ */
+ catchingUp?: boolean;
+ fromCache?: boolean;
+}
+
+enum SetPresence {
+ Offline = "offline",
+ Online = "online",
+ Unavailable = "unavailable",
+}
+
+interface ISyncParams {
+ filter?: string;
+ timeout: number;
+ since?: string;
+ // eslint-disable-next-line camelcase
+ full_state?: boolean;
+ // eslint-disable-next-line camelcase
+ set_presence?: SetPresence;
+ _cacheBuster?: string | number; // not part of the API itself
+}
+
+type WrappedRoom<T> = T & {
+ room: Room;
+ isBrandNewRoom: boolean;
+};
+
+/** add default settings to an IStoredClientOpts */
+export function defaultClientOpts(opts?: IStoredClientOpts): IStoredClientOpts {
+ return {
+ initialSyncLimit: 8,
+ resolveInvitesToProfiles: false,
+ pollTimeout: 30 * 1000,
+ pendingEventOrdering: PendingEventOrdering.Chronological,
+ threadSupport: false,
+ ...opts,
+ };
+}
+
+export function defaultSyncApiOpts(syncOpts?: SyncApiOptions): SyncApiOptions {
+ return {
+ canResetEntireTimeline: (_roomId): boolean => false,
+ ...syncOpts,
+ };
+}
+
+export class SyncApi {
+ private readonly opts: IStoredClientOpts;
+ private readonly syncOpts: SyncApiOptions;
+
+ private _peekRoom: Optional<Room> = null;
+ private currentSyncRequest?: Promise<ISyncResponse>;
+ private abortController?: AbortController;
+ private syncState: SyncState | null = null;
+ private syncStateData?: ISyncStateData; // additional data (eg. error object for failed sync)
+ private catchingUp = false;
+ private running = false;
+ private keepAliveTimer?: ReturnType<typeof setTimeout>;
+ private connectionReturnedDefer?: IDeferred<boolean>;
+ private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response
+ private failedSyncCount = 0; // Number of consecutive failed /sync requests
+ private storeIsInvalid = false; // flag set if the store needs to be cleared before we can start
+
+ /**
+ * Construct an entity which is able to sync with a homeserver.
+ * @param client - The matrix client instance to use.
+ * @param opts - client config options
+ * @param syncOpts - sync-specific options passed by the client
+ * @internal
+ */
+ public constructor(private readonly client: MatrixClient, opts?: IStoredClientOpts, syncOpts?: SyncApiOptions) {
+ this.opts = defaultClientOpts(opts);
+ this.syncOpts = defaultSyncApiOpts(syncOpts);
+
+ if (client.getNotifTimelineSet()) {
+ client.reEmitter.reEmit(client.getNotifTimelineSet()!, [RoomEvent.Timeline, RoomEvent.TimelineReset]);
+ }
+ }
+
+ public createRoom(roomId: string): Room {
+ const room = _createAndReEmitRoom(this.client, roomId, this.opts);
+
+ room.on(RoomStateEvent.Marker, (markerEvent, markerFoundOptions) => {
+ this.onMarkerStateEvent(room, markerEvent, markerFoundOptions);
+ });
+
+ return room;
+ }
+
+ /** When we see the marker state change in the room, we know there is some
+ * new historical messages imported by MSC2716 `/batch_send` somewhere in
+ * the room and we need to throw away the timeline to make sure the
+ * historical messages are shown when we paginate `/messages` again.
+ * @param room - The room where the marker event was sent
+ * @param markerEvent - The new marker event
+ * @param setStateOptions - When `timelineWasEmpty` is set
+ * as `true`, the given marker event will be ignored
+ */
+ private onMarkerStateEvent(
+ room: Room,
+ markerEvent: MatrixEvent,
+ { timelineWasEmpty }: IMarkerFoundOptions = {},
+ ): void {
+ // We don't need to refresh the timeline if it was empty before the
+ // marker arrived. This could be happen in a variety of cases:
+ // 1. From the initial sync
+ // 2. If it's from the first state we're seeing after joining the room
+ // 3. Or whether it's coming from `syncFromCache`
+ if (timelineWasEmpty) {
+ logger.debug(
+ `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` +
+ `because the timeline was empty before the marker arrived which means there is nothing to refresh.`,
+ );
+ return;
+ }
+
+ const isValidMsc2716Event =
+ // Check whether the room version directly supports MSC2716, in
+ // which case, "marker" events are already auth'ed by
+ // power_levels
+ MSC2716_ROOM_VERSIONS.includes(room.getVersion()) ||
+ // MSC2716 is also supported in all existing room versions but
+ // special meaning should only be given to "insertion", "batch",
+ // and "marker" events when they come from the room creator
+ markerEvent.getSender() === room.getCreator();
+
+ // It would be nice if we could also specifically tell whether the
+ // historical messages actually affected the locally cached client
+ // timeline or not. The problem is we can't see the prev_events of
+ // the base insertion event that the marker was pointing to because
+ // prev_events aren't available in the client API's. In most cases,
+ // the history won't be in people's locally cached timelines in the
+ // client, so we don't need to bother everyone about refreshing
+ // their timeline. This works for a v1 though and there are use
+ // cases like initially bootstrapping your bridged room where people
+ // are likely to encounter the historical messages affecting their
+ // current timeline (think someone signing up for Beeper and
+ // importing their Whatsapp history).
+ if (isValidMsc2716Event) {
+ // Saw new marker event, let's let the clients know they should
+ // refresh the timeline.
+ logger.debug(
+ `MarkerState: Timeline needs to be refreshed because ` +
+ `a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`,
+ );
+ room.setTimelineNeedsRefresh(true);
+ room.emit(RoomEvent.HistoryImportedWithinTimeline, markerEvent, room);
+ } else {
+ logger.debug(
+ `MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` +
+ `MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` +
+ `by the room creator.`,
+ );
+ }
+ }
+
+ /**
+ * Sync rooms the user has left.
+ * @returns Resolved when they've been added to the store.
+ */
+ public async syncLeftRooms(): Promise<Room[]> {
+ const client = this.client;
+
+ // grab a filter with limit=1 and include_leave=true
+ const filter = new Filter(this.client.credentials.userId);
+ filter.setTimelineLimit(1);
+ filter.setIncludeLeaveRooms(true);
+
+ const localTimeoutMs = this.opts.pollTimeout! + BUFFER_PERIOD_MS;
+
+ const filterId = await client.getOrCreateFilter(
+ getFilterName(client.credentials.userId!, "LEFT_ROOMS"),
+ filter,
+ );
+
+ const qps: ISyncParams = {
+ timeout: 0, // don't want to block since this is a single isolated req
+ filter: filterId,
+ };
+
+ const data = await client.http.authedRequest<ISyncResponse>(Method.Get, "/sync", qps as any, undefined, {
+ localTimeoutMs,
+ });
+
+ let leaveRooms: WrappedRoom<ILeftRoom>[] = [];
+ if (data.rooms?.leave) {
+ leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave);
+ }
+
+ const rooms = await Promise.all(
+ leaveRooms.map(async (leaveObj) => {
+ const room = leaveObj.room;
+ if (!leaveObj.isBrandNewRoom) {
+ // the intention behind syncLeftRooms is to add in rooms which were
+ // *omitted* from the initial /sync. Rooms the user were joined to
+ // but then left whilst the app is running will appear in this list
+ // and we do not want to bother with them since they will have the
+ // current state already (and may get dupe messages if we add
+ // yet more timeline events!), so skip them.
+ // NB: When we persist rooms to localStorage this will be more
+ // complicated...
+ return;
+ }
+ leaveObj.timeline = leaveObj.timeline || {
+ prev_batch: null,
+ events: [],
+ };
+ const events = this.mapSyncEventsFormat(leaveObj.timeline, room);
+
+ const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room);
+
+ // set the back-pagination token. Do this *before* adding any
+ // events so that clients can start back-paginating.
+ room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, EventTimeline.BACKWARDS);
+
+ await this.injectRoomEvents(room, stateEvents, events);
+
+ room.recalculate();
+ client.store.storeRoom(room);
+ client.emit(ClientEvent.Room, room);
+
+ this.processEventsForNotifs(room, events);
+ return room;
+ }),
+ );
+
+ return rooms.filter(Boolean) as Room[];
+ }
+
+ /**
+ * Peek into a room. This will result in the room in question being synced so it
+ * is accessible via getRooms(). Live updates for the room will be provided.
+ * @param roomId - The room ID to peek into.
+ * @returns A promise which resolves once the room has been added to the
+ * store.
+ */
+ public peek(roomId: string): Promise<Room> {
+ if (this._peekRoom?.roomId === roomId) {
+ return Promise.resolve(this._peekRoom);
+ }
+
+ const client = this.client;
+ this._peekRoom = this.createRoom(roomId);
+ return this.client.roomInitialSync(roomId, 20).then((response) => {
+ // make sure things are init'd
+ response.messages = response.messages || { chunk: [] };
+ response.messages.chunk = response.messages.chunk || [];
+ response.state = response.state || [];
+
+ // FIXME: Mostly duplicated from injectRoomEvents but not entirely
+ // because "state" in this API is at the BEGINNING of the chunk
+ const oldStateEvents = utils.deepCopy(response.state).map(client.getEventMapper());
+ const stateEvents = response.state.map(client.getEventMapper());
+ const messages = response.messages.chunk.map(client.getEventMapper());
+
+ // XXX: copypasted from /sync until we kill off this minging v1 API stuff)
+ // handle presence events (User objects)
+ if (Array.isArray(response.presence)) {
+ response.presence.map(client.getEventMapper()).forEach(function (presenceEvent) {
+ let user = client.store.getUser(presenceEvent.getContent().user_id);
+ if (user) {
+ user.setPresenceEvent(presenceEvent);
+ } else {
+ user = createNewUser(client, presenceEvent.getContent().user_id);
+ user.setPresenceEvent(presenceEvent);
+ client.store.storeUser(user);
+ }
+ client.emit(ClientEvent.Event, presenceEvent);
+ });
+ }
+
+ // set the pagination token before adding the events in case people
+ // fire off pagination requests in response to the Room.timeline
+ // events.
+ if (response.messages.start) {
+ this._peekRoom!.oldState.paginationToken = response.messages.start;
+ }
+
+ // set the state of the room to as it was after the timeline executes
+ this._peekRoom!.oldState.setStateEvents(oldStateEvents);
+ this._peekRoom!.currentState.setStateEvents(stateEvents);
+
+ this.resolveInvites(this._peekRoom!);
+ this._peekRoom!.recalculate();
+
+ // roll backwards to diverge old state. addEventsToTimeline
+ // will overwrite the pagination token, so make sure it overwrites
+ // it with the right thing.
+ this._peekRoom!.addEventsToTimeline(
+ messages.reverse(),
+ true,
+ this._peekRoom!.getLiveTimeline(),
+ response.messages.start,
+ );
+
+ client.store.storeRoom(this._peekRoom!);
+ client.emit(ClientEvent.Room, this._peekRoom!);
+
+ this.peekPoll(this._peekRoom!);
+ return this._peekRoom!;
+ });
+ }
+
+ /**
+ * Stop polling for updates in the peeked room. NOPs if there is no room being
+ * peeked.
+ */
+ public stopPeeking(): void {
+ this._peekRoom = null;
+ }
+
+ /**
+ * Do a peek room poll.
+ * @param token - from= token
+ */
+ private peekPoll(peekRoom: Room, token?: string): void {
+ if (this._peekRoom !== peekRoom) {
+ debuglog("Stopped peeking in room %s", peekRoom.roomId);
+ return;
+ }
+
+ // FIXME: gut wrenching; hard-coded timeout values
+ this.client.http
+ .authedRequest<IEventsResponse>(
+ Method.Get,
+ "/events",
+ {
+ room_id: peekRoom.roomId,
+ timeout: String(30 * 1000),
+ from: token,
+ },
+ undefined,
+ {
+ localTimeoutMs: 50 * 1000,
+ abortSignal: this.abortController?.signal,
+ },
+ )
+ .then(
+ (res) => {
+ if (this._peekRoom !== peekRoom) {
+ debuglog("Stopped peeking in room %s", peekRoom.roomId);
+ return;
+ }
+ // We have a problem that we get presence both from /events and /sync
+ // however, /sync only returns presence for users in rooms
+ // you're actually joined to.
+ // in order to be sure to get presence for all of the users in the
+ // peeked room, we handle presence explicitly here. This may result
+ // in duplicate presence events firing for some users, which is a
+ // performance drain, but such is life.
+ // XXX: copypasted from /sync until we can kill this minging v1 stuff.
+
+ res.chunk
+ .filter(function (e) {
+ return e.type === "m.presence";
+ })
+ .map(this.client.getEventMapper())
+ .forEach((presenceEvent) => {
+ let user = this.client.store.getUser(presenceEvent.getContent().user_id);
+ if (user) {
+ user.setPresenceEvent(presenceEvent);
+ } else {
+ user = createNewUser(this.client, presenceEvent.getContent().user_id);
+ user.setPresenceEvent(presenceEvent);
+ this.client.store.storeUser(user);
+ }
+ this.client.emit(ClientEvent.Event, presenceEvent);
+ });
+
+ // strip out events which aren't for the given room_id (e.g presence)
+ // and also ephemeral events (which we're assuming is anything without
+ // and event ID because the /events API doesn't separate them).
+ const events = res.chunk
+ .filter(function (e) {
+ return e.room_id === peekRoom.roomId && e.event_id;
+ })
+ .map(this.client.getEventMapper());
+
+ peekRoom.addLiveEvents(events);
+ this.peekPoll(peekRoom, res.end);
+ },
+ (err) => {
+ logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err);
+ setTimeout(() => {
+ this.peekPoll(peekRoom, token);
+ }, 30 * 1000);
+ },
+ );
+ }
+
+ /**
+ * Returns the current state of this sync object
+ * @see MatrixClient#event:"sync"
+ */
+ public getSyncState(): SyncState | null {
+ return this.syncState;
+ }
+
+ /**
+ * Returns the additional data object associated with
+ * the current sync state, or null if there is no
+ * such data.
+ * Sync errors, if available, are put in the 'error' key of
+ * this object.
+ */
+ public getSyncStateData(): ISyncStateData | null {
+ return this.syncStateData ?? null;
+ }
+
+ public async recoverFromSyncStartupError(savedSyncPromise: Promise<void> | undefined, error: Error): Promise<void> {
+ // Wait for the saved sync to complete - we send the pushrules and filter requests
+ // before the saved sync has finished so they can run in parallel, but only process
+ // the results after the saved sync is done. Equivalently, we wait for it to finish
+ // before reporting failures from these functions.
+ await savedSyncPromise;
+ const keepaliveProm = this.startKeepAlives();
+ this.updateSyncState(SyncState.Error, { error });
+ await keepaliveProm;
+ }
+
+ /**
+ * Is the lazy loading option different than in previous session?
+ * @param lazyLoadMembers - current options for lazy loading
+ * @returns whether or not the option has changed compared to the previous session */
+ private async wasLazyLoadingToggled(lazyLoadMembers = false): Promise<boolean> {
+ // assume it was turned off before
+ // if we don't know any better
+ let lazyLoadMembersBefore = false;
+ const isStoreNewlyCreated = await this.client.store.isNewlyCreated();
+ if (!isStoreNewlyCreated) {
+ const prevClientOptions = await this.client.store.getClientOptions();
+ if (prevClientOptions) {
+ lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers;
+ }
+ return lazyLoadMembersBefore !== lazyLoadMembers;
+ }
+ return false;
+ }
+
+ private shouldAbortSync(error: MatrixError): boolean {
+ if (error.errcode === "M_UNKNOWN_TOKEN") {
+ // The logout already happened, we just need to stop.
+ logger.warn("Token no longer valid - assuming logout");
+ this.stop();
+ this.updateSyncState(SyncState.Error, { error });
+ return true;
+ }
+ return false;
+ }
+
+ private getPushRules = async (): Promise<void> => {
+ try {
+ debuglog("Getting push rules...");
+ const result = await this.client.getPushRules();
+ debuglog("Got push rules");
+
+ this.client.pushRules = result;
+ } catch (err) {
+ logger.error("Getting push rules failed", err);
+ if (this.shouldAbortSync(<MatrixError>err)) return;
+ // wait for saved sync to complete before doing anything else,
+ // otherwise the sync state will end up being incorrect
+ debuglog("Waiting for saved sync before retrying push rules...");
+ await this.recoverFromSyncStartupError(this.savedSyncPromise, <Error>err);
+ return this.getPushRules(); // try again
+ }
+ };
+
+ private buildDefaultFilter = (): Filter => {
+ const filter = new Filter(this.client.credentials.userId);
+ if (this.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) {
+ filter.setUnreadThreadNotifications(true);
+ }
+ return filter;
+ };
+
+ private checkLazyLoadStatus = async (): Promise<void> => {
+ debuglog("Checking lazy load status...");
+ if (this.opts.lazyLoadMembers && this.client.isGuest()) {
+ this.opts.lazyLoadMembers = false;
+ }
+ if (this.opts.lazyLoadMembers) {
+ debuglog("Checking server lazy load support...");
+ const supported = await this.client.doesServerSupportLazyLoading();
+ if (supported) {
+ debuglog("Enabling lazy load on sync filter...");
+ if (!this.opts.filter) {
+ this.opts.filter = this.buildDefaultFilter();
+ }
+ this.opts.filter.setLazyLoadMembers(true);
+ } else {
+ debuglog("LL: lazy loading requested but not supported " + "by server, so disabling");
+ this.opts.lazyLoadMembers = false;
+ }
+ }
+ // need to vape the store when enabling LL and wasn't enabled before
+ debuglog("Checking whether lazy loading has changed in store...");
+ const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers);
+ if (shouldClear) {
+ this.storeIsInvalid = true;
+ const error = new InvalidStoreError(InvalidStoreState.ToggledLazyLoading, !!this.opts.lazyLoadMembers);
+ this.updateSyncState(SyncState.Error, { error });
+ // bail out of the sync loop now: the app needs to respond to this error.
+ // we leave the state as 'ERROR' which isn't great since this normally means
+ // we're retrying. The client must be stopped before clearing the stores anyway
+ // so the app should stop the client, clear the store and start it again.
+ logger.warn("InvalidStoreError: store is not usable: stopping sync.");
+ return;
+ }
+ if (this.opts.lazyLoadMembers) {
+ this.syncOpts.crypto?.enableLazyLoading();
+ }
+ try {
+ debuglog("Storing client options...");
+ await this.client.storeClientOptions();
+ debuglog("Stored client options");
+ } catch (err) {
+ logger.error("Storing client options failed", err);
+ throw err;
+ }
+ };
+
+ private getFilter = async (): Promise<{
+ filterId?: string;
+ filter?: Filter;
+ }> => {
+ debuglog("Getting filter...");
+ let filter: Filter;
+ if (this.opts.filter) {
+ filter = this.opts.filter;
+ } else {
+ filter = this.buildDefaultFilter();
+ }
+
+ let filterId: string;
+ try {
+ filterId = await this.client.getOrCreateFilter(getFilterName(this.client.credentials.userId!), filter);
+ } catch (err) {
+ logger.error("Getting filter failed", err);
+ if (this.shouldAbortSync(<MatrixError>err)) return {};
+ // wait for saved sync to complete before doing anything else,
+ // otherwise the sync state will end up being incorrect
+ debuglog("Waiting for saved sync before retrying filter...");
+ await this.recoverFromSyncStartupError(this.savedSyncPromise, <Error>err);
+ return this.getFilter(); // try again
+ }
+ return { filter, filterId };
+ };
+
+ private savedSyncPromise?: Promise<void>;
+
+ /**
+ * Main entry point
+ */
+ public async sync(): Promise<void> {
+ this.running = true;
+ this.abortController = new AbortController();
+
+ global.window?.addEventListener?.("online", this.onOnline, false);
+
+ if (this.client.isGuest()) {
+ // no push rules for guests, no access to POST filter for guests.
+ return this.doSync({});
+ }
+
+ // Pull the saved sync token out first, before the worker starts sending
+ // all the sync data which could take a while. This will let us send our
+ // first incremental sync request before we've processed our saved data.
+ debuglog("Getting saved sync token...");
+ const savedSyncTokenPromise = this.client.store.getSavedSyncToken().then((tok) => {
+ debuglog("Got saved sync token");
+ return tok;
+ });
+
+ this.savedSyncPromise = this.client.store
+ .getSavedSync()
+ .then((savedSync) => {
+ debuglog(`Got reply from saved sync, exists? ${!!savedSync}`);
+ if (savedSync) {
+ return this.syncFromCache(savedSync);
+ }
+ })
+ .catch((err) => {
+ logger.error("Getting saved sync failed", err);
+ });
+
+ // We need to do one-off checks before we can begin the /sync loop.
+ // These are:
+ // 1) We need to get push rules so we can check if events should bing as we get
+ // them from /sync.
+ // 2) We need to get/create a filter which we can use for /sync.
+ // 3) We need to check the lazy loading option matches what was used in the
+ // stored sync. If it doesn't, we can't use the stored sync.
+
+ // Now start the first incremental sync request: this can also
+ // take a while so if we set it going now, we can wait for it
+ // to finish while we process our saved sync data.
+ await this.getPushRules();
+ await this.checkLazyLoadStatus();
+ const { filterId, filter } = await this.getFilter();
+ if (!filter) return; // bail, getFilter failed
+
+ // reset the notifications timeline to prepare it to paginate from
+ // the current point in time.
+ // The right solution would be to tie /sync pagination tokens into
+ // /notifications API somehow.
+ this.client.resetNotifTimelineSet();
+
+ if (!this.currentSyncRequest) {
+ let firstSyncFilter = filterId;
+ const savedSyncToken = await savedSyncTokenPromise;
+
+ if (savedSyncToken) {
+ debuglog("Sending first sync request...");
+ } else {
+ debuglog("Sending initial sync request...");
+ const initialFilter = this.buildDefaultFilter();
+ initialFilter.setDefinition(filter.getDefinition());
+ initialFilter.setTimelineLimit(this.opts.initialSyncLimit!);
+ // Use an inline filter, no point uploading it for a single usage
+ firstSyncFilter = JSON.stringify(initialFilter.getDefinition());
+ }
+
+ // Send this first sync request here so we can then wait for the saved
+ // sync data to finish processing before we process the results of this one.
+ this.currentSyncRequest = this.doSyncRequest({ filter: firstSyncFilter }, savedSyncToken);
+ }
+
+ // Now wait for the saved sync to finish...
+ debuglog("Waiting for saved sync before starting sync processing...");
+ await this.savedSyncPromise;
+ // process the first sync request and continue syncing with the normal filterId
+ return this.doSync({ filter: filterId });
+ }
+
+ /**
+ * Stops the sync object from syncing.
+ */
+ public stop(): void {
+ debuglog("SyncApi.stop");
+ // It is necessary to check for the existance of
+ // global.window AND global.window.removeEventListener.
+ // Some platforms (e.g. React Native) register global.window,
+ // but do not have global.window.removeEventListener.
+ global.window?.removeEventListener?.("online", this.onOnline, false);
+ this.running = false;
+ this.abortController?.abort();
+ if (this.keepAliveTimer) {
+ clearTimeout(this.keepAliveTimer);
+ this.keepAliveTimer = undefined;
+ }
+ }
+
+ /**
+ * Retry a backed off syncing request immediately. This should only be used when
+ * the user <b>explicitly</b> attempts to retry their lost connection.
+ * @returns True if this resulted in a request being retried.
+ */
+ public retryImmediately(): boolean {
+ if (!this.connectionReturnedDefer) {
+ return false;
+ }
+ this.startKeepAlives(0);
+ return true;
+ }
+ /**
+ * Process a single set of cached sync data.
+ * @param savedSync - a saved sync that was persisted by a store. This
+ * should have been acquired via client.store.getSavedSync().
+ */
+ private async syncFromCache(savedSync: ISavedSync): Promise<void> {
+ debuglog("sync(): not doing HTTP hit, instead returning stored /sync data");
+
+ const nextSyncToken = savedSync.nextBatch;
+
+ // Set sync token for future incremental syncing
+ this.client.store.setSyncToken(nextSyncToken);
+
+ // No previous sync, set old token to null
+ const syncEventData: ISyncStateData = {
+ nextSyncToken,
+ catchingUp: false,
+ fromCache: true,
+ };
+
+ const data: ISyncResponse = {
+ next_batch: nextSyncToken,
+ rooms: savedSync.roomsData,
+ account_data: {
+ events: savedSync.accountData,
+ },
+ };
+
+ try {
+ await this.processSyncResponse(syncEventData, data);
+ } catch (e) {
+ logger.error("Error processing cached sync", e);
+ }
+
+ // Don't emit a prepared if we've bailed because the store is invalid:
+ // in this case the client will not be usable until stopped & restarted
+ // so this would be useless and misleading.
+ if (!this.storeIsInvalid) {
+ this.updateSyncState(SyncState.Prepared, syncEventData);
+ }
+ }
+
+ /**
+ * Invoke me to do /sync calls
+ */
+ private async doSync(syncOptions: ISyncOptions): Promise<void> {
+ while (this.running) {
+ const syncToken = this.client.store.getSyncToken();
+
+ let data: ISyncResponse;
+ try {
+ if (!this.currentSyncRequest) {
+ this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken);
+ }
+ data = await this.currentSyncRequest;
+ } catch (e) {
+ const abort = await this.onSyncError(<MatrixError>e);
+ if (abort) return;
+ continue;
+ } finally {
+ this.currentSyncRequest = undefined;
+ }
+
+ // set the sync token NOW *before* processing the events. We do this so
+ // if something barfs on an event we can skip it rather than constantly
+ // polling with the same token.
+ this.client.store.setSyncToken(data.next_batch);
+
+ // Reset after a successful sync
+ this.failedSyncCount = 0;
+
+ await this.client.store.setSyncData(data);
+
+ const syncEventData = {
+ oldSyncToken: syncToken ?? undefined,
+ nextSyncToken: data.next_batch,
+ catchingUp: this.catchingUp,
+ };
+
+ if (this.syncOpts.crypto) {
+ // tell the crypto module we're about to process a sync
+ // response
+ await this.syncOpts.crypto.onSyncWillProcess(syncEventData);
+ }
+
+ try {
+ await this.processSyncResponse(syncEventData, data);
+ } catch (e) {
+ // log the exception with stack if we have it, else fall back
+ // to the plain description
+ logger.error("Caught /sync error", e);
+
+ // Emit the exception for client handling
+ this.client.emit(ClientEvent.SyncUnexpectedError, <Error>e);
+ }
+
+ // update this as it may have changed
+ syncEventData.catchingUp = this.catchingUp;
+
+ // emit synced events
+ if (!syncOptions.hasSyncedBefore) {
+ this.updateSyncState(SyncState.Prepared, syncEventData);
+ syncOptions.hasSyncedBefore = true;
+ }
+
+ // tell the crypto module to do its processing. It may block (to do a
+ // /keys/changes request).
+ if (this.syncOpts.cryptoCallbacks) {
+ await this.syncOpts.cryptoCallbacks.onSyncCompleted(syncEventData);
+ }
+
+ // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates
+ this.updateSyncState(SyncState.Syncing, syncEventData);
+
+ if (this.client.store.wantsSave()) {
+ // We always save the device list (if it's dirty) before saving the sync data:
+ // this means we know the saved device list data is at least as fresh as the
+ // stored sync data which means we don't have to worry that we may have missed
+ // device changes. We can also skip the delay since we're not calling this very
+ // frequently (and we don't really want to delay the sync for it).
+ if (this.syncOpts.crypto) {
+ await this.syncOpts.crypto.saveDeviceList(0);
+ }
+
+ // tell databases that everything is now in a consistent state and can be saved.
+ this.client.store.save();
+ }
+ }
+
+ if (!this.running) {
+ debuglog("Sync no longer running: exiting.");
+ if (this.connectionReturnedDefer) {
+ this.connectionReturnedDefer.reject();
+ this.connectionReturnedDefer = undefined;
+ }
+ this.updateSyncState(SyncState.Stopped);
+ }
+ }
+
+ private doSyncRequest(syncOptions: ISyncOptions, syncToken: string | null): Promise<ISyncResponse> {
+ const qps = this.getSyncParams(syncOptions, syncToken);
+ return this.client.http.authedRequest<ISyncResponse>(Method.Get, "/sync", qps as any, undefined, {
+ localTimeoutMs: qps.timeout + BUFFER_PERIOD_MS,
+ abortSignal: this.abortController?.signal,
+ });
+ }
+
+ private getSyncParams(syncOptions: ISyncOptions, syncToken: string | null): ISyncParams {
+ let timeout = this.opts.pollTimeout!;
+
+ if (this.getSyncState() !== SyncState.Syncing || this.catchingUp) {
+ // unless we are happily syncing already, we want the server to return
+ // as quickly as possible, even if there are no events queued. This
+ // serves two purposes:
+ //
+ // * When the connection dies, we want to know asap when it comes back,
+ // so that we can hide the error from the user. (We don't want to
+ // have to wait for an event or a timeout).
+ //
+ // * We want to know if the server has any to_device messages queued up
+ // for us. We do that by calling it with a zero timeout until it
+ // doesn't give us any more to_device messages.
+ this.catchingUp = true;
+ timeout = 0;
+ }
+
+ let filter = syncOptions.filter;
+ if (this.client.isGuest() && !filter) {
+ filter = this.getGuestFilter();
+ }
+
+ const qps: ISyncParams = { filter, timeout };
+
+ if (this.opts.disablePresence) {
+ qps.set_presence = SetPresence.Offline;
+ }
+
+ if (syncToken) {
+ qps.since = syncToken;
+ } else {
+ // use a cachebuster for initialsyncs, to make sure that
+ // we don't get a stale sync
+ // (https://github.com/vector-im/vector-web/issues/1354)
+ qps._cacheBuster = Date.now();
+ }
+
+ if ([SyncState.Reconnecting, SyncState.Error].includes(this.getSyncState()!)) {
+ // we think the connection is dead. If it comes back up, we won't know
+ // about it till /sync returns. If the timeout= is high, this could
+ // be a long time. Set it to 0 when doing retries so we don't have to wait
+ // for an event or a timeout before emiting the SYNCING event.
+ qps.timeout = 0;
+ }
+
+ return qps;
+ }
+
+ private async onSyncError(err: MatrixError): Promise<boolean> {
+ if (!this.running) {
+ debuglog("Sync no longer running: exiting");
+ if (this.connectionReturnedDefer) {
+ this.connectionReturnedDefer.reject();
+ this.connectionReturnedDefer = undefined;
+ }
+ this.updateSyncState(SyncState.Stopped);
+ return true; // abort
+ }
+
+ logger.error("/sync error %s", err);
+
+ if (this.shouldAbortSync(err)) {
+ return true; // abort
+ }
+
+ this.failedSyncCount++;
+ logger.log("Number of consecutive failed sync requests:", this.failedSyncCount);
+
+ debuglog("Starting keep-alive");
+ // Note that we do *not* mark the sync connection as
+ // lost yet: we only do this if a keepalive poke
+ // fails, since long lived HTTP connections will
+ // go away sometimes and we shouldn't treat this as
+ // erroneous. We set the state to 'reconnecting'
+ // instead, so that clients can observe this state
+ // if they wish.
+ const keepAlivePromise = this.startKeepAlives();
+
+ this.currentSyncRequest = undefined;
+ // Transition from RECONNECTING to ERROR after a given number of failed syncs
+ this.updateSyncState(
+ this.failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? SyncState.Error : SyncState.Reconnecting,
+ { error: err },
+ );
+
+ const connDidFail = await keepAlivePromise;
+
+ // Only emit CATCHUP if we detected a connectivity error: if we didn't,
+ // it's quite likely the sync will fail again for the same reason and we
+ // want to stay in ERROR rather than keep flip-flopping between ERROR
+ // and CATCHUP.
+ if (connDidFail && this.getSyncState() === SyncState.Error) {
+ this.updateSyncState(SyncState.Catchup, {
+ catchingUp: true,
+ });
+ }
+ return false;
+ }
+
+ /**
+ * Process data returned from a sync response and propagate it
+ * into the model objects
+ *
+ * @param syncEventData - Object containing sync tokens associated with this sync
+ * @param data - The response from /sync
+ */
+ private async processSyncResponse(syncEventData: ISyncStateData, data: ISyncResponse): Promise<void> {
+ const client = this.client;
+
+ // data looks like:
+ // {
+ // next_batch: $token,
+ // presence: { events: [] },
+ // account_data: { events: [] },
+ // device_lists: { changed: ["@user:server", ... ]},
+ // to_device: { events: [] },
+ // device_one_time_keys_count: { signed_curve25519: 42 },
+ // rooms: {
+ // invite: {
+ // $roomid: {
+ // invite_state: { events: [] }
+ // }
+ // },
+ // join: {
+ // $roomid: {
+ // state: { events: [] },
+ // timeline: { events: [], prev_batch: $token, limited: true },
+ // ephemeral: { events: [] },
+ // summary: {
+ // m.heroes: [ $user_id ],
+ // m.joined_member_count: $count,
+ // m.invited_member_count: $count
+ // },
+ // account_data: { events: [] },
+ // unread_notifications: {
+ // highlight_count: 0,
+ // notification_count: 0,
+ // }
+ // }
+ // },
+ // leave: {
+ // $roomid: {
+ // state: { events: [] },
+ // timeline: { events: [], prev_batch: $token }
+ // }
+ // }
+ // }
+ // }
+
+ // TODO-arch:
+ // - Each event we pass through needs to be emitted via 'event', can we
+ // do this in one place?
+ // - The isBrandNewRoom boilerplate is boilerplatey.
+
+ // handle presence events (User objects)
+ if (Array.isArray(data.presence?.events)) {
+ data.presence!.events.filter(noUnsafeEventProps)
+ .map(client.getEventMapper())
+ .forEach(function (presenceEvent) {
+ let user = client.store.getUser(presenceEvent.getSender()!);
+ if (user) {
+ user.setPresenceEvent(presenceEvent);
+ } else {
+ user = createNewUser(client, presenceEvent.getSender()!);
+ user.setPresenceEvent(presenceEvent);
+ client.store.storeUser(user);
+ }
+ client.emit(ClientEvent.Event, presenceEvent);
+ });
+ }
+
+ // handle non-room account_data
+ if (Array.isArray(data.account_data?.events)) {
+ const events = data.account_data.events.filter(noUnsafeEventProps).map(client.getEventMapper());
+ const prevEventsMap = events.reduce<Record<string, MatrixEvent | undefined>>((m, c) => {
+ m[c.getType()!] = client.store.getAccountData(c.getType());
+ return m;
+ }, {});
+ client.store.storeAccountDataEvents(events);
+ events.forEach(function (accountDataEvent) {
+ // Honour push rules that come down the sync stream but also
+ // honour push rules that were previously cached. Base rules
+ // will be updated when we receive push rules via getPushRules
+ // (see sync) before syncing over the network.
+ if (accountDataEvent.getType() === EventType.PushRules) {
+ const rules = accountDataEvent.getContent<IPushRules>();
+ client.setPushRules(rules);
+ }
+ const prevEvent = prevEventsMap[accountDataEvent.getType()!];
+ client.emit(ClientEvent.AccountData, accountDataEvent, prevEvent);
+ return accountDataEvent;
+ });
+ }
+
+ // handle to-device events
+ if (data.to_device && Array.isArray(data.to_device.events) && data.to_device.events.length > 0) {
+ let toDeviceMessages: IToDeviceEvent[] = data.to_device.events.filter(noUnsafeEventProps);
+
+ if (this.syncOpts.cryptoCallbacks) {
+ toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
+ }
+
+ const cancelledKeyVerificationTxns: string[] = [];
+ toDeviceMessages
+ .map(client.getEventMapper({ toDevice: true }))
+ .map((toDeviceEvent) => {
+ // map is a cheap inline forEach
+ // We want to flag m.key.verification.start events as cancelled
+ // if there's an accompanying m.key.verification.cancel event, so
+ // we pull out the transaction IDs from the cancellation events
+ // so we can flag the verification events as cancelled in the loop
+ // below.
+ if (toDeviceEvent.getType() === "m.key.verification.cancel") {
+ const txnId: string = toDeviceEvent.getContent()["transaction_id"];
+ if (txnId) {
+ cancelledKeyVerificationTxns.push(txnId);
+ }
+ }
+
+ // as mentioned above, .map is a cheap inline forEach, so return
+ // the unmodified event.
+ return toDeviceEvent;
+ })
+ .forEach(function (toDeviceEvent) {
+ const content = toDeviceEvent.getContent();
+ if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") {
+ // the mapper already logged a warning.
+ logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender());
+ return;
+ }
+
+ if (
+ toDeviceEvent.getType() === "m.key.verification.start" ||
+ toDeviceEvent.getType() === "m.key.verification.request"
+ ) {
+ const txnId = content["transaction_id"];
+ if (cancelledKeyVerificationTxns.includes(txnId)) {
+ toDeviceEvent.flagCancelled();
+ }
+ }
+
+ client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent);
+ });
+ } else {
+ // no more to-device events: we can stop polling with a short timeout.
+ this.catchingUp = false;
+ }
+
+ // the returned json structure is a bit crap, so make it into a
+ // nicer form (array) after applying sanity to make sure we don't fail
+ // on missing keys (on the off chance)
+ let inviteRooms: WrappedRoom<IInvitedRoom>[] = [];
+ let joinRooms: WrappedRoom<IJoinedRoom>[] = [];
+ let leaveRooms: WrappedRoom<ILeftRoom>[] = [];
+
+ if (data.rooms) {
+ if (data.rooms.invite) {
+ inviteRooms = this.mapSyncResponseToRoomArray(data.rooms.invite);
+ }
+ if (data.rooms.join) {
+ joinRooms = this.mapSyncResponseToRoomArray(data.rooms.join);
+ }
+ if (data.rooms.leave) {
+ leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave);
+ }
+ }
+
+ this.notifEvents = [];
+
+ // Handle invites
+ await utils.promiseMapSeries(inviteRooms, async (inviteObj) => {
+ const room = inviteObj.room;
+ const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room);
+
+ await this.injectRoomEvents(room, stateEvents);
+
+ const inviter = room.currentState.getStateEvents(EventType.RoomMember, client.getUserId()!)?.getSender();
+
+ const crypto = client.crypto;
+ if (crypto) {
+ const parkedHistory = await crypto.cryptoStore.takeParkedSharedHistory(room.roomId);
+ for (const parked of parkedHistory) {
+ if (parked.senderId === inviter) {
+ await crypto.olmDevice.addInboundGroupSession(
+ room.roomId,
+ parked.senderKey,
+ parked.forwardingCurve25519KeyChain,
+ parked.sessionId,
+ parked.sessionKey,
+ parked.keysClaimed,
+ true,
+ { sharedHistory: true, untrusted: true },
+ );
+ }
+ }
+ }
+
+ if (inviteObj.isBrandNewRoom) {
+ room.recalculate();
+ client.store.storeRoom(room);
+ client.emit(ClientEvent.Room, room);
+ } else {
+ // Update room state for invite->reject->invite cycles
+ room.recalculate();
+ }
+ stateEvents.forEach(function (e) {
+ client.emit(ClientEvent.Event, e);
+ });
+ });
+
+ // Handle joins
+ await utils.promiseMapSeries(joinRooms, async (joinObj) => {
+ const room = joinObj.room;
+ const stateEvents = this.mapSyncEventsFormat(joinObj.state, room);
+ // Prevent events from being decrypted ahead of time
+ // this helps large account to speed up faster
+ // room::decryptCriticalEvent is in charge of decrypting all the events
+ // required for a client to function properly
+ const events = this.mapSyncEventsFormat(joinObj.timeline, room, false);
+ const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral);
+ const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data);
+
+ const encrypted = client.isRoomEncrypted(room.roomId);
+ // We store the server-provided value first so it's correct when any of the events fire.
+ if (joinObj.unread_notifications) {
+ /**
+ * We track unread notifications ourselves in encrypted rooms, so don't
+ * bother setting it here. We trust our calculations better than the
+ * server's for this case, and therefore will assume that our non-zero
+ * count is accurate.
+ *
+ * @see import("./client").fixNotificationCountOnDecryption
+ */
+ if (!encrypted || joinObj.unread_notifications.notification_count === 0) {
+ // In an encrypted room, if the room has notifications enabled then it's typical for
+ // the server to flag all new messages as notifying. However, some push rules calculate
+ // events as ignored based on their event contents (e.g. ignoring msgtype=m.notice messages)
+ // so we want to calculate this figure on the client in all cases.
+ room.setUnreadNotificationCount(
+ NotificationCountType.Total,
+ joinObj.unread_notifications.notification_count ?? 0,
+ );
+ }
+
+ if (!encrypted || room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0) {
+ // If the locally stored highlight count is zero, use the server provided value.
+ room.setUnreadNotificationCount(
+ NotificationCountType.Highlight,
+ joinObj.unread_notifications.highlight_count ?? 0,
+ );
+ }
+ }
+
+ const unreadThreadNotifications =
+ joinObj[UNREAD_THREAD_NOTIFICATIONS.name] ?? joinObj[UNREAD_THREAD_NOTIFICATIONS.altName!];
+ if (unreadThreadNotifications) {
+ // Only partially reset unread notification
+ // We want to keep the client-generated count. Particularly important
+ // for encrypted room that refresh their notification count on event
+ // decryption
+ room.resetThreadUnreadNotificationCount(Object.keys(unreadThreadNotifications));
+ for (const [threadId, unreadNotification] of Object.entries(unreadThreadNotifications)) {
+ if (!encrypted || unreadNotification.notification_count === 0) {
+ room.setThreadUnreadNotificationCount(
+ threadId,
+ NotificationCountType.Total,
+ unreadNotification.notification_count ?? 0,
+ );
+ }
+
+ const hasNoNotifications =
+ room.getThreadUnreadNotificationCount(threadId, NotificationCountType.Highlight) <= 0;
+ if (!encrypted || (encrypted && hasNoNotifications)) {
+ room.setThreadUnreadNotificationCount(
+ threadId,
+ NotificationCountType.Highlight,
+ unreadNotification.highlight_count ?? 0,
+ );
+ }
+ }
+ } else {
+ room.resetThreadUnreadNotificationCount();
+ }
+
+ joinObj.timeline = joinObj.timeline || ({} as ITimeline);
+
+ if (joinObj.isBrandNewRoom) {
+ // set the back-pagination token. Do this *before* adding any
+ // events so that clients can start back-paginating.
+ if (joinObj.timeline.prev_batch !== null) {
+ room.getLiveTimeline().setPaginationToken(joinObj.timeline.prev_batch, EventTimeline.BACKWARDS);
+ }
+ } else if (joinObj.timeline.limited) {
+ let limited = true;
+
+ // we've got a limited sync, so we *probably* have a gap in the
+ // timeline, so should reset. But we might have been peeking or
+ // paginating and already have some of the events, in which
+ // case we just want to append any subsequent events to the end
+ // of the existing timeline.
+ //
+ // This is particularly important in the case that we already have
+ // *all* of the events in the timeline - in that case, if we reset
+ // the timeline, we'll end up with an entirely empty timeline,
+ // which we'll try to paginate but not get any new events (which
+ // will stop us linking the empty timeline into the chain).
+ //
+ for (let i = events.length - 1; i >= 0; i--) {
+ const eventId = events[i].getId()!;
+ if (room.getTimelineForEvent(eventId)) {
+ debuglog(`Already have event ${eventId} in limited sync - not resetting`);
+ limited = false;
+
+ // we might still be missing some of the events before i;
+ // we don't want to be adding them to the end of the
+ // timeline because that would put them out of order.
+ events.splice(0, i);
+
+ // XXX: there's a problem here if the skipped part of the
+ // timeline modifies the state set in stateEvents, because
+ // we'll end up using the state from stateEvents rather
+ // than the later state from timelineEvents. We probably
+ // need to wind stateEvents forward over the events we're
+ // skipping.
+
+ break;
+ }
+ }
+
+ if (limited) {
+ room.resetLiveTimeline(
+ joinObj.timeline.prev_batch,
+ this.syncOpts.canResetEntireTimeline!(room.roomId) ? null : syncEventData.oldSyncToken ?? null,
+ );
+
+ // We have to assume any gap in any timeline is
+ // reason to stop incrementally tracking notifications and
+ // reset the timeline.
+ client.resetNotifTimelineSet();
+ }
+ }
+
+ // process any crypto events *before* emitting the RoomStateEvent events. This
+ // avoids a race condition if the application tries to send a message after the
+ // state event is processed, but before crypto is enabled, which then causes the
+ // crypto layer to complain.
+ if (this.syncOpts.cryptoCallbacks) {
+ for (const e of stateEvents.concat(events)) {
+ if (e.isState() && e.getType() === EventType.RoomEncryption && e.getStateKey() === "") {
+ await this.syncOpts.cryptoCallbacks.onCryptoEvent(room, e);
+ }
+ }
+ }
+
+ try {
+ await this.injectRoomEvents(room, stateEvents, events, syncEventData.fromCache);
+ } catch (e) {
+ logger.error(`Failed to process events on room ${room.roomId}:`, e);
+ }
+
+ // set summary after processing events,
+ // because it will trigger a name calculation
+ // which needs the room state to be up to date
+ if (joinObj.summary) {
+ room.setSummary(joinObj.summary);
+ }
+
+ // we deliberately don't add ephemeral events to the timeline
+ room.addEphemeralEvents(ephemeralEvents);
+
+ // we deliberately don't add accountData to the timeline
+ room.addAccountData(accountDataEvents);
+
+ room.recalculate();
+ if (joinObj.isBrandNewRoom) {
+ client.store.storeRoom(room);
+ client.emit(ClientEvent.Room, room);
+ }
+
+ this.processEventsForNotifs(room, events);
+
+ const emitEvent = (e: MatrixEvent): boolean => client.emit(ClientEvent.Event, e);
+ stateEvents.forEach(emitEvent);
+ events.forEach(emitEvent);
+ ephemeralEvents.forEach(emitEvent);
+ accountDataEvents.forEach(emitEvent);
+
+ // 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
+ // notification count
+ room.decryptCriticalEvents();
+ });
+
+ // Handle leaves (e.g. kicked rooms)
+ await utils.promiseMapSeries(leaveRooms, async (leaveObj) => {
+ const room = leaveObj.room;
+ const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room);
+ const events = this.mapSyncEventsFormat(leaveObj.timeline, room);
+ const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data);
+
+ await this.injectRoomEvents(room, stateEvents, events);
+ room.addAccountData(accountDataEvents);
+
+ room.recalculate();
+ if (leaveObj.isBrandNewRoom) {
+ client.store.storeRoom(room);
+ client.emit(ClientEvent.Room, room);
+ }
+
+ this.processEventsForNotifs(room, events);
+
+ stateEvents.forEach(function (e) {
+ client.emit(ClientEvent.Event, e);
+ });
+ events.forEach(function (e) {
+ client.emit(ClientEvent.Event, e);
+ });
+ accountDataEvents.forEach(function (e) {
+ client.emit(ClientEvent.Event, e);
+ });
+ });
+
+ // update the notification timeline, if appropriate.
+ // we only do this for live events, as otherwise we can't order them sanely
+ // in the timeline relative to ones paginated in by /notifications.
+ // XXX: we could fix this by making EventTimeline support chronological
+ // ordering... but it doesn't, right now.
+ if (syncEventData.oldSyncToken && this.notifEvents.length) {
+ this.notifEvents.sort(function (a, b) {
+ return a.getTs() - b.getTs();
+ });
+ this.notifEvents.forEach(function (event) {
+ client.getNotifTimelineSet()?.addLiveEvent(event);
+ });
+ }
+
+ // Handle device list updates
+ if (data.device_lists) {
+ if (this.syncOpts.crypto) {
+ await this.syncOpts.crypto.handleDeviceListChanges(syncEventData, data.device_lists);
+ } else {
+ // FIXME if we *don't* have a crypto module, we still need to
+ // invalidate the device lists. But that would require a
+ // substantial bit of rework :/.
+ }
+ }
+
+ // Handle one_time_keys_count
+ if (data.device_one_time_keys_count) {
+ const map = new Map<string, number>(Object.entries(data.device_one_time_keys_count));
+ this.syncOpts.cryptoCallbacks?.preprocessOneTimeKeyCounts(map);
+ }
+ if (data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"]) {
+ // The presence of device_unused_fallback_key_types indicates that the
+ // server supports fallback keys. If there's no unused
+ // signed_curve25519 fallback key we need a new one.
+ const unusedFallbackKeys =
+ data.device_unused_fallback_key_types || data["org.matrix.msc2732.device_unused_fallback_key_types"];
+ this.syncOpts.cryptoCallbacks?.preprocessUnusedFallbackKeys(new Set<string>(unusedFallbackKeys || null));
+ }
+ }
+
+ /**
+ * Starts polling the connectivity check endpoint
+ * @param delay - How long to delay until the first poll.
+ * defaults to a short, randomised interval (to prevent
+ * tight-looping if /versions succeeds but /sync etc. fail).
+ * @returns which resolves once the connection returns
+ */
+ private startKeepAlives(delay?: number): Promise<boolean> {
+ if (delay === undefined) {
+ delay = 2000 + Math.floor(Math.random() * 5000);
+ }
+
+ if (this.keepAliveTimer !== null) {
+ clearTimeout(this.keepAliveTimer);
+ }
+ if (delay > 0) {
+ this.keepAliveTimer = setTimeout(this.pokeKeepAlive.bind(this), delay);
+ } else {
+ this.pokeKeepAlive();
+ }
+ if (!this.connectionReturnedDefer) {
+ this.connectionReturnedDefer = utils.defer();
+ }
+ return this.connectionReturnedDefer.promise;
+ }
+
+ /**
+ * Make a dummy call to /_matrix/client/versions, to see if the HS is
+ * reachable.
+ *
+ * On failure, schedules a call back to itself. On success, resolves
+ * this.connectionReturnedDefer.
+ *
+ * @param connDidFail - True if a connectivity failure has been detected. Optional.
+ */
+ private pokeKeepAlive(connDidFail = false): void {
+ const success = (): void => {
+ clearTimeout(this.keepAliveTimer);
+ if (this.connectionReturnedDefer) {
+ this.connectionReturnedDefer.resolve(connDidFail);
+ this.connectionReturnedDefer = undefined;
+ }
+ };
+
+ this.client.http
+ .request(
+ Method.Get,
+ "/_matrix/client/versions",
+ undefined, // queryParams
+ undefined, // data
+ {
+ prefix: "",
+ localTimeoutMs: 15 * 1000,
+ abortSignal: this.abortController?.signal,
+ },
+ )
+ .then(
+ () => {
+ success();
+ },
+ (err) => {
+ if (err.httpStatus == 400 || err.httpStatus == 404) {
+ // treat this as a success because the server probably just doesn't
+ // support /versions: point is, we're getting a response.
+ // We wait a short time though, just in case somehow the server
+ // is in a mode where it 400s /versions responses and sync etc.
+ // responses fail, this will mean we don't hammer in a loop.
+ this.keepAliveTimer = setTimeout(success, 2000);
+ } else {
+ connDidFail = true;
+ this.keepAliveTimer = setTimeout(
+ this.pokeKeepAlive.bind(this, connDidFail),
+ 5000 + Math.floor(Math.random() * 5000),
+ );
+ // A keepalive has failed, so we emit the
+ // error state (whether or not this is the
+ // first failure).
+ // Note we do this after setting the timer:
+ // this lets the unit tests advance the mock
+ // clock when they get the error.
+ this.updateSyncState(SyncState.Error, { error: err });
+ }
+ },
+ );
+ }
+
+ private mapSyncResponseToRoomArray<T extends ILeftRoom | IJoinedRoom | IInvitedRoom>(
+ obj: Record<string, T>,
+ ): Array<WrappedRoom<T>> {
+ // Maps { roomid: {stuff}, roomid: {stuff} }
+ // to
+ // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}]
+ const client = this.client;
+ return Object.keys(obj)
+ .filter((k) => !unsafeProp(k))
+ .map((roomId) => {
+ const arrObj = obj[roomId] as T & { room: Room; isBrandNewRoom: boolean };
+ let room = client.store.getRoom(roomId);
+ let isBrandNewRoom = false;
+ if (!room) {
+ room = this.createRoom(roomId);
+ isBrandNewRoom = true;
+ }
+ arrObj.room = room;
+ arrObj.isBrandNewRoom = isBrandNewRoom;
+ return arrObj;
+ });
+ }
+
+ private mapSyncEventsFormat(
+ obj: IInviteState | ITimeline | IEphemeral,
+ room?: Room,
+ decrypt = true,
+ ): MatrixEvent[] {
+ if (!obj || !Array.isArray(obj.events)) {
+ return [];
+ }
+ const mapper = this.client.getEventMapper({ decrypt });
+ type TaggedEvent = (IStrippedState | IRoomEvent | IStateEvent | IMinimalEvent) & { room_id?: string };
+ return (obj.events as TaggedEvent[]).filter(noUnsafeEventProps).map(function (e) {
+ if (room) {
+ e.room_id = room.roomId;
+ }
+ return mapper(e);
+ });
+ }
+
+ /**
+ */
+ private resolveInvites(room: Room): void {
+ if (!room || !this.opts.resolveInvitesToProfiles) {
+ return;
+ }
+ const client = this.client;
+ // For each invited room member we want to give them a displayname/avatar url
+ // if they have one (the m.room.member invites don't contain this).
+ room.getMembersWithMembership("invite").forEach(function (member) {
+ if (member.requestedProfileInfo) return;
+ member.requestedProfileInfo = true;
+ // try to get a cached copy first.
+ const user = client.getUser(member.userId);
+ let promise;
+ if (user) {
+ promise = Promise.resolve({
+ avatar_url: user.avatarUrl,
+ displayname: user.displayName,
+ });
+ } else {
+ promise = client.getProfileInfo(member.userId);
+ }
+ promise.then(
+ function (info) {
+ // slightly naughty by doctoring the invite event but this means all
+ // the code paths remain the same between invite/join display name stuff
+ // which is a worthy trade-off for some minor pollution.
+ const inviteEvent = member.events.member;
+ if (inviteEvent?.getContent().membership !== "invite") {
+ // between resolving and now they have since joined, so don't clobber
+ return;
+ }
+ inviteEvent.getContent().avatar_url = info.avatar_url;
+ inviteEvent.getContent().displayname = info.displayname;
+ // fire listeners
+ member.setMembershipEvent(inviteEvent, room.currentState);
+ },
+ function (err) {
+ // OH WELL.
+ },
+ );
+ });
+ }
+
+ /**
+ * Injects events into a room's model.
+ * @param stateEventList - A list of state events. This is the state
+ * at the *START* of the timeline list if it is supplied.
+ * @param timelineEventList - A list of timeline events, including threaded. Lower index
+ * is earlier in time. Higher index is later.
+ * @param fromCache - whether the sync response came from cache
+ */
+ public async injectRoomEvents(
+ room: Room,
+ stateEventList: MatrixEvent[],
+ timelineEventList?: MatrixEvent[],
+ fromCache = false,
+ ): Promise<void> {
+ // If there are no events in the timeline yet, initialise it with
+ // the given state events
+ const liveTimeline = room.getLiveTimeline();
+ const timelineWasEmpty = liveTimeline.getEvents().length == 0;
+ if (timelineWasEmpty) {
+ // Passing these events into initialiseState will freeze them, so we need
+ // to compute and cache the push actions for them now, otherwise sync dies
+ // with an attempt to assign to read only property.
+ // XXX: This is pretty horrible and is assuming all sorts of behaviour from
+ // these functions that it shouldn't be. We should probably either store the
+ // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise
+ // find some solution where MatrixEvents are immutable but allow for a cache
+ // field.
+ for (const ev of stateEventList) {
+ this.client.getPushActionsForEvent(ev);
+ }
+ liveTimeline.initialiseState(stateEventList, {
+ timelineWasEmpty,
+ });
+ }
+
+ this.resolveInvites(room);
+
+ // recalculate the room name at this point as adding events to the timeline
+ // may make notifications appear which should have the right name.
+ // XXX: This looks suspect: we'll end up recalculating the room once here
+ // and then again after adding events (processSyncResponse calls it after
+ // calling us) even if no state events were added. It also means that if
+ // one of the room events in timelineEventList is something that needs
+ // a recalculation (like m.room.name) we won't recalculate until we've
+ // finished adding all the events, which will cause the notification to have
+ // the old room name rather than the new one.
+ room.recalculate();
+
+ // If the timeline wasn't empty, we process the state events here: they're
+ // defined as updates to the state before the start of the timeline, so this
+ // starts to roll the state forward.
+ // XXX: That's what we *should* do, but this can happen if we were previously
+ // peeking in a room, in which case we obviously do *not* want to add the
+ // state events here onto the end of the timeline. Historically, the js-sdk
+ // has just set these new state events on the old and new state. This seems
+ // very wrong because there could be events in the timeline that diverge the
+ // state, in which case this is going to leave things out of sync. However,
+ // for now I think it;s best to behave the same as the code has done previously.
+ if (!timelineWasEmpty) {
+ // XXX: As above, don't do this...
+ //room.addLiveEvents(stateEventList || []);
+ // Do this instead...
+ room.oldState.setStateEvents(stateEventList || []);
+ room.currentState.setStateEvents(stateEventList || []);
+ }
+
+ // Execute the timeline events. This will continue to diverge the current state
+ // if the timeline has any state events in it.
+ // This also needs to be done before running push rules on the events as they need
+ // to be decorated with sender etc.
+ room.addLiveEvents(timelineEventList || [], {
+ fromCache,
+ timelineWasEmpty,
+ });
+ this.client.processBeaconEvents(room, timelineEventList);
+ }
+
+ /**
+ * Takes a list of timelineEvents and adds and adds to notifEvents
+ * as appropriate.
+ * This must be called after the room the events belong to has been stored.
+ *
+ * @param timelineEventList - A list of timeline events. Lower index
+ * is earlier in time. Higher index is later.
+ */
+ private processEventsForNotifs(room: Room, timelineEventList: MatrixEvent[]): void {
+ // gather our notifications into this.notifEvents
+ if (this.client.getNotifTimelineSet()) {
+ for (const event of timelineEventList) {
+ const pushActions = this.client.getPushActionsForEvent(event);
+ if (pushActions?.notify && pushActions.tweaks?.highlight) {
+ this.notifEvents.push(event);
+ }
+ }
+ }
+ }
+
+ private getGuestFilter(): string {
+ // Dev note: This used to be conditional to return a filter of 20 events maximum, but
+ // the condition never went to the other branch. This is now hardcoded.
+ return "{}";
+ }
+
+ /**
+ * Sets the sync state and emits an event to say so
+ * @param newState - The new state string
+ * @param data - Object of additional data to emit in the event
+ */
+ private updateSyncState(newState: SyncState, data?: ISyncStateData): void {
+ const old = this.syncState;
+ this.syncState = newState;
+ this.syncStateData = data;
+ this.client.emit(ClientEvent.Sync, this.syncState, old, data);
+ }
+
+ /**
+ * Event handler for the 'online' event
+ * This event is generally unreliable and precise behaviour
+ * varies between browsers, so we poll for connectivity too,
+ * but this might help us reconnect a little faster.
+ */
+ private onOnline = (): void => {
+ debuglog("Browser thinks we are back online");
+ this.startKeepAlives(0);
+ };
+}
+
+function createNewUser(client: MatrixClient, userId: string): User {
+ const user = new User(userId);
+ client.reEmitter.reEmit(user, [
+ UserEvent.AvatarUrl,
+ UserEvent.DisplayName,
+ UserEvent.Presence,
+ UserEvent.CurrentlyActive,
+ UserEvent.LastPresenceTs,
+ ]);
+ return user;
+}
+
+// /!\ This function is not intended for public use! It's only exported from
+// here in order to share some common logic with sliding-sync-sdk.ts.
+export function _createAndReEmitRoom(client: MatrixClient, roomId: string, opts: Partial<IStoredClientOpts>): Room {
+ const { timelineSupport } = client;
+
+ const room = new Room(roomId, client, client.getUserId()!, {
+ lazyLoadMembers: opts.lazyLoadMembers,
+ pendingEventOrdering: opts.pendingEventOrdering,
+ timelineSupport,
+ });
+
+ client.reEmitter.reEmit(room, [
+ RoomEvent.Name,
+ RoomEvent.Redaction,
+ RoomEvent.RedactionCancelled,
+ RoomEvent.Receipt,
+ RoomEvent.Tags,
+ RoomEvent.LocalEchoUpdated,
+ RoomEvent.AccountData,
+ RoomEvent.MyMembership,
+ RoomEvent.Timeline,
+ RoomEvent.TimelineReset,
+ RoomStateEvent.Events,
+ RoomStateEvent.Members,
+ RoomStateEvent.NewMember,
+ RoomStateEvent.Update,
+ BeaconEvent.New,
+ BeaconEvent.Update,
+ BeaconEvent.Destroy,
+ BeaconEvent.LivenessChange,
+ ]);
+
+ // We need to add a listener for RoomState.members in order to hook them
+ // correctly.
+ room.on(RoomStateEvent.NewMember, (event, state, member) => {
+ member.user = client.getUser(member.userId) ?? undefined;
+ client.reEmitter.reEmit(member, [
+ RoomMemberEvent.Name,
+ RoomMemberEvent.Typing,
+ RoomMemberEvent.PowerLevel,
+ RoomMemberEvent.Membership,
+ ]);
+ });
+
+ return room;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/timeline-window.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/timeline-window.ts
new file mode 100644
index 0000000..be64c3b
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/timeline-window.ts
@@ -0,0 +1,507 @@
+/*
+Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Optional } from "matrix-events-sdk";
+
+import { Direction, EventTimeline } from "./models/event-timeline";
+import { logger } from "./logger";
+import { MatrixClient } from "./client";
+import { EventTimelineSet } from "./models/event-timeline-set";
+import { MatrixEvent } from "./models/event";
+
+/**
+ * @internal
+ */
+const DEBUG = false;
+
+/**
+ * @internal
+ */
+/* istanbul ignore next */
+const debuglog = DEBUG ? logger.log.bind(logger) : function (): void {};
+
+/**
+ * the number of times we ask the server for more events before giving up
+ *
+ * @internal
+ */
+const DEFAULT_PAGINATE_LOOP_LIMIT = 5;
+
+interface IOpts {
+ /**
+ * Maximum number of events to keep in the window. If more events are retrieved via pagination requests,
+ * excess events will be dropped from the other end of the window.
+ */
+ windowLimit?: number;
+}
+
+export class TimelineWindow {
+ private readonly windowLimit: number;
+ // these will be TimelineIndex objects; they delineate the 'start' and
+ // 'end' of the window.
+ //
+ // start.index is inclusive; end.index is exclusive.
+ private start?: TimelineIndex;
+ private end?: TimelineIndex;
+ private eventCount = 0;
+
+ /**
+ * Construct a TimelineWindow.
+ *
+ * <p>This abstracts the separate timelines in a Matrix {@link Room} into a single iterable thing.
+ * It keeps track of the start and endpoints of the window, which can be advanced with the help
+ * of pagination requests.
+ *
+ * <p>Before the window is useful, it must be initialised by calling {@link TimelineWindow#load}.
+ *
+ * <p>Note that the window will not automatically extend itself when new events
+ * are received from /sync; you should arrange to call {@link TimelineWindow#paginate}
+ * on {@link RoomEvent.Timeline} events.
+ *
+ * @param client - MatrixClient to be used for context/pagination
+ * requests.
+ *
+ * @param timelineSet - The timelineSet to track
+ *
+ * @param opts - Configuration options for this window
+ */
+ public constructor(
+ private readonly client: MatrixClient,
+ private readonly timelineSet: EventTimelineSet,
+ opts: IOpts = {},
+ ) {
+ this.windowLimit = opts.windowLimit || 1000;
+ }
+
+ /**
+ * Initialise the window to point at a given event, or the live timeline
+ *
+ * @param initialEventId - If given, the window will contain the
+ * given event
+ * @param initialWindowSize - Size of the initial window
+ */
+ public load(initialEventId?: string, initialWindowSize = 20): Promise<void> {
+ // given an EventTimeline, find the event we were looking for, and initialise our
+ // fields so that the event in question is in the middle of the window.
+ const initFields = (timeline: Optional<EventTimeline>): void => {
+ if (!timeline) {
+ throw new Error("No timeline given to initFields");
+ }
+
+ let eventIndex: number;
+
+ const events = timeline.getEvents();
+
+ if (!initialEventId) {
+ // we were looking for the live timeline: initialise to the end
+ eventIndex = events.length;
+ } else {
+ eventIndex = events.findIndex((e) => e.getId() === initialEventId);
+
+ if (eventIndex < 0) {
+ throw new Error("getEventTimeline result didn't include requested event");
+ }
+ }
+
+ const endIndex = Math.min(events.length, eventIndex + Math.ceil(initialWindowSize / 2));
+ const startIndex = Math.max(0, endIndex - initialWindowSize);
+ this.start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex());
+ this.end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex());
+ this.eventCount = endIndex - startIndex;
+ };
+
+ // We avoid delaying the resolution of the promise by a reactor tick if we already have the data we need,
+ // which is important to keep room-switching feeling snappy.
+ if (this.timelineSet.getTimelineForEvent(initialEventId)) {
+ initFields(this.timelineSet.getTimelineForEvent(initialEventId));
+ return Promise.resolve();
+ } else if (initialEventId) {
+ return this.client.getEventTimeline(this.timelineSet, initialEventId).then(initFields);
+ } else {
+ initFields(this.timelineSet.getLiveTimeline());
+ return Promise.resolve();
+ }
+ }
+
+ /**
+ * Get the TimelineIndex of the window in the given direction.
+ *
+ * @param direction - EventTimeline.BACKWARDS to get the TimelineIndex
+ * at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at
+ * the end.
+ *
+ * @returns The requested timeline index if one exists, null
+ * otherwise.
+ */
+ public getTimelineIndex(direction: Direction): TimelineIndex | null {
+ if (direction == EventTimeline.BACKWARDS) {
+ return this.start ?? null;
+ } else if (direction == EventTimeline.FORWARDS) {
+ return this.end ?? null;
+ } else {
+ throw new Error("Invalid direction '" + direction + "'");
+ }
+ }
+
+ /**
+ * Try to extend the window using events that are already in the underlying
+ * TimelineIndex.
+ *
+ * @param direction - EventTimeline.BACKWARDS to try extending it
+ * backwards; EventTimeline.FORWARDS to try extending it forwards.
+ * @param size - number of events to try to extend by.
+ *
+ * @returns true if the window was extended, false otherwise.
+ */
+ public extend(direction: Direction, size: number): boolean {
+ const tl = this.getTimelineIndex(direction);
+
+ if (!tl) {
+ debuglog("TimelineWindow: no timeline yet");
+ return false;
+ }
+
+ const count = direction == EventTimeline.BACKWARDS ? tl.retreat(size) : tl.advance(size);
+
+ if (count) {
+ this.eventCount += count;
+ debuglog("TimelineWindow: increased cap by " + count + " (now " + this.eventCount + ")");
+ // remove some events from the other end, if necessary
+ const excess = this.eventCount - this.windowLimit;
+ if (excess > 0) {
+ this.unpaginate(excess, direction != EventTimeline.BACKWARDS);
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if this window can be extended
+ *
+ * <p>This returns true if we either have more events, or if we have a
+ * pagination token which means we can paginate in that direction. It does not
+ * necessarily mean that there are more events available in that direction at
+ * this time.
+ *
+ * @param direction - EventTimeline.BACKWARDS to check if we can
+ * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards
+ *
+ * @returns true if we can paginate in the given direction
+ */
+ public canPaginate(direction: Direction): boolean {
+ const tl = this.getTimelineIndex(direction);
+
+ if (!tl) {
+ debuglog("TimelineWindow: no timeline yet");
+ return false;
+ }
+
+ if (direction == EventTimeline.BACKWARDS) {
+ if (tl.index > tl.minIndex()) {
+ return true;
+ }
+ } else {
+ if (tl.index < tl.maxIndex()) {
+ return true;
+ }
+ }
+
+ const hasNeighbouringTimeline = tl.timeline.getNeighbouringTimeline(direction);
+ const paginationToken = tl.timeline.getPaginationToken(direction);
+ return Boolean(hasNeighbouringTimeline) || Boolean(paginationToken);
+ }
+
+ /**
+ * Attempt to extend the window
+ *
+ * @param direction - EventTimeline.BACKWARDS to extend the window
+ * backwards (towards older events); EventTimeline.FORWARDS to go forwards.
+ *
+ * @param size - number of events to try to extend by. If fewer than this
+ * number are immediately available, then we return immediately rather than
+ * making an API call.
+ *
+ * @param makeRequest - whether we should make API calls to
+ * fetch further events if we don't have any at all. (This has no effect if
+ * the room already knows about additional events in the relevant direction,
+ * even if there are fewer than 'size' of them, as we will just return those
+ * we already know about.)
+ *
+ * @param requestLimit - limit for the number of API requests we
+ * should make.
+ *
+ * @returns Promise which resolves to a boolean which is true if more events
+ * were successfully retrieved.
+ */
+ public async paginate(
+ direction: Direction,
+ size: number,
+ makeRequest = true,
+ requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT,
+ ): Promise<boolean> {
+ // Either wind back the message cap (if there are enough events in the
+ // timeline to do so), or fire off a pagination request.
+ const tl = this.getTimelineIndex(direction);
+
+ if (!tl) {
+ debuglog("TimelineWindow: no timeline yet");
+ return false;
+ }
+
+ if (tl.pendingPaginate) {
+ return tl.pendingPaginate;
+ }
+
+ // try moving the cap
+ if (this.extend(direction, size)) {
+ return true;
+ }
+
+ if (!makeRequest || requestLimit === 0) {
+ // todo: should we return something different to indicate that there
+ // might be more events out there, but we haven't found them yet?
+ return false;
+ }
+
+ // try making a pagination request
+ const token = tl.timeline.getPaginationToken(direction);
+ if (!token) {
+ debuglog("TimelineWindow: no token");
+ return false;
+ }
+
+ debuglog("TimelineWindow: starting request");
+
+ const prom = this.client
+ .paginateEventTimeline(tl.timeline, {
+ backwards: direction == EventTimeline.BACKWARDS,
+ limit: size,
+ })
+ .finally(function () {
+ tl.pendingPaginate = undefined;
+ })
+ .then((r) => {
+ debuglog("TimelineWindow: request completed with result " + r);
+ if (!r) {
+ return this.paginate(direction, size, false, 0);
+ }
+
+ // recurse to advance the index into the results.
+ //
+ // If we don't get any new events, we want to make sure we keep asking
+ // the server for events for as long as we have a valid pagination
+ // token. In particular, we want to know if we've actually hit the
+ // start of the timeline, or if we just happened to know about all of
+ // the events thanks to https://matrix.org/jira/browse/SYN-645.
+ //
+ // On the other hand, we necessarily want to wait forever for the
+ // server to make its mind up about whether there are other events,
+ // because it gives a bad user experience
+ // (https://github.com/vector-im/vector-web/issues/1204).
+ return this.paginate(direction, size, true, requestLimit - 1);
+ });
+ tl.pendingPaginate = prom;
+ return prom;
+ }
+
+ /**
+ * Remove `delta` events from the start or end of the timeline.
+ *
+ * @param delta - number of events to remove from the timeline
+ * @param startOfTimeline - if events should be removed from the start
+ * of the timeline.
+ */
+ public unpaginate(delta: number, startOfTimeline: boolean): void {
+ const tl = startOfTimeline ? this.start : this.end;
+ if (!tl) {
+ throw new Error(
+ `Attempting to unpaginate startOfTimeline=${startOfTimeline} but don't have this direction`,
+ );
+ }
+
+ // sanity-check the delta
+ if (delta > this.eventCount || delta < 0) {
+ throw new Error(
+ `Attemting to unpaginate ${delta} events, but only have ${this.eventCount} in the timeline`,
+ );
+ }
+
+ while (delta > 0) {
+ const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta);
+ if (count <= 0) {
+ // sadness. This shouldn't be possible.
+ throw new Error("Unable to unpaginate any further, but still have " + this.eventCount + " events");
+ }
+
+ delta -= count;
+ this.eventCount -= count;
+ debuglog("TimelineWindow.unpaginate: dropped " + count + " (now " + this.eventCount + ")");
+ }
+ }
+
+ /**
+ * Get a list of the events currently in the window
+ *
+ * @returns the events in the window
+ */
+ public getEvents(): MatrixEvent[] {
+ if (!this.start) {
+ // not yet loaded
+ return [];
+ }
+
+ const result: MatrixEvent[] = [];
+
+ // iterate through each timeline between this.start and this.end
+ // (inclusive).
+ let timeline = this.start.timeline;
+ // eslint-disable-next-line no-constant-condition
+ while (true) {
+ const events = timeline.getEvents();
+
+ // For the first timeline in the chain, we want to start at
+ // this.start.index. For the last timeline in the chain, we want to
+ // stop before this.end.index. Otherwise, we want to copy all of the
+ // events in the timeline.
+ //
+ // (Note that both this.start.index and this.end.index are relative
+ // to their respective timelines' BaseIndex).
+ //
+ let startIndex = 0;
+ let endIndex = events.length;
+ if (timeline === this.start.timeline) {
+ startIndex = this.start.index + timeline.getBaseIndex();
+ }
+ if (timeline === this.end?.timeline) {
+ endIndex = this.end.index + timeline.getBaseIndex();
+ }
+
+ for (let i = startIndex; i < endIndex; i++) {
+ result.push(events[i]);
+ }
+
+ // if we're not done, iterate to the next timeline.
+ if (timeline === this.end?.timeline) {
+ break;
+ } else {
+ timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS)!;
+ }
+ }
+
+ return result;
+ }
+}
+
+/**
+ * A thing which contains a timeline reference, and an index into it.
+ * @internal
+ */
+export class TimelineIndex {
+ public pendingPaginate?: Promise<boolean>;
+
+ // index: the indexes are relative to BaseIndex, so could well be negative.
+ public constructor(public timeline: EventTimeline, public index: number) {}
+
+ /**
+ * @returns the minimum possible value for the index in the current
+ * timeline
+ */
+ public minIndex(): number {
+ return this.timeline.getBaseIndex() * -1;
+ }
+
+ /**
+ * @returns the maximum possible value for the index in the current
+ * timeline (exclusive - ie, it actually returns one more than the index
+ * of the last element).
+ */
+ public maxIndex(): number {
+ return this.timeline.getEvents().length - this.timeline.getBaseIndex();
+ }
+
+ /**
+ * Try move the index forward, or into the neighbouring timeline
+ *
+ * @param delta - number of events to advance by
+ * @returns number of events successfully advanced by
+ */
+ public advance(delta: number): number {
+ if (!delta) {
+ return 0;
+ }
+
+ // first try moving the index in the current timeline. See if there is room
+ // to do so.
+ let cappedDelta;
+ if (delta < 0) {
+ // we want to wind the index backwards.
+ //
+ // (this.minIndex() - this.index) is a negative number whose magnitude
+ // is the amount of room we have to wind back the index in the current
+ // timeline. We cap delta to this quantity.
+ cappedDelta = Math.max(delta, this.minIndex() - this.index);
+ if (cappedDelta < 0) {
+ this.index += cappedDelta;
+ return cappedDelta;
+ }
+ } else {
+ // we want to wind the index forwards.
+ //
+ // (this.maxIndex() - this.index) is a (positive) number whose magnitude
+ // is the amount of room we have to wind forward the index in the current
+ // timeline. We cap delta to this quantity.
+ cappedDelta = Math.min(delta, this.maxIndex() - this.index);
+ if (cappedDelta > 0) {
+ this.index += cappedDelta;
+ return cappedDelta;
+ }
+ }
+
+ // the index is already at the start/end of the current timeline.
+ //
+ // next see if there is a neighbouring timeline to switch to.
+ const neighbour = this.timeline.getNeighbouringTimeline(
+ delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS,
+ );
+ if (neighbour) {
+ this.timeline = neighbour;
+ if (delta < 0) {
+ this.index = this.maxIndex();
+ } else {
+ this.index = this.minIndex();
+ }
+
+ debuglog("paginate: switched to new neighbour");
+
+ // recurse, using the next timeline
+ return this.advance(delta);
+ }
+
+ return 0;
+ }
+
+ /**
+ * Try move the index backwards, or into the neighbouring timeline
+ *
+ * @param delta - number of events to retreat by
+ * @returns number of events successfully retreated by
+ */
+ public retreat(delta: number): number {
+ return this.advance(delta * -1) * -1;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts
new file mode 100644
index 0000000..0c3aea7
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/utils.ts
@@ -0,0 +1,770 @@
+/*
+Copyright 2015, 2016, 2019, 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module.
+ */
+
+import unhomoglyph from "unhomoglyph";
+import promiseRetry from "p-retry";
+import { Optional } from "matrix-events-sdk";
+
+import { IEvent, MatrixEvent } from "./models/event";
+import { M_TIMESTAMP } from "./@types/location";
+import { ReceiptType } from "./@types/read_receipts";
+
+const interns = new Map<string, string>();
+
+/**
+ * Internalises a string, reusing a known pointer or storing the pointer
+ * if needed for future strings.
+ * @param str - The string to internalise.
+ * @returns The internalised string.
+ */
+export function internaliseString(str: string): string {
+ // Unwrap strings before entering the map, if we somehow got a wrapped
+ // string as our input. This should only happen from tests.
+ if ((str as unknown) instanceof String) {
+ str = str.toString();
+ }
+
+ // Check the map to see if we can store the value
+ if (!interns.has(str)) {
+ interns.set(str, str);
+ }
+
+ // Return any cached string reference
+ return interns.get(str)!;
+}
+
+/**
+ * Encode a dictionary of query parameters.
+ * Omits any undefined/null values.
+ * @param params - A dict of key/values to encode e.g.
+ * `{"foo": "bar", "baz": "taz"}`
+ * @returns The encoded string e.g. foo=bar&baz=taz
+ */
+export function encodeParams(params: QueryDict, urlSearchParams?: URLSearchParams): URLSearchParams {
+ const searchParams = urlSearchParams ?? new URLSearchParams();
+ for (const [key, val] of Object.entries(params)) {
+ if (val !== undefined && val !== null) {
+ if (Array.isArray(val)) {
+ val.forEach((v) => {
+ searchParams.append(key, String(v));
+ });
+ } else {
+ searchParams.append(key, String(val));
+ }
+ }
+ }
+ return searchParams;
+}
+
+export type QueryDict = Record<string, string[] | string | number | boolean | undefined>;
+
+/**
+ * Replace a stable parameter with the unstable naming for params
+ */
+export function replaceParam(stable: string, unstable: string, dict: QueryDict): QueryDict {
+ const result = {
+ ...dict,
+ [unstable]: dict[stable],
+ };
+ delete result[stable];
+ return result;
+}
+
+/**
+ * Decode a query string in `application/x-www-form-urlencoded` format.
+ * @param query - A query string to decode e.g.
+ * foo=bar&via=server1&server2
+ * @returns The decoded object, if any keys occurred multiple times
+ * then the value will be an array of strings, else it will be an array.
+ * This behaviour matches Node's qs.parse but is built on URLSearchParams
+ * for native web compatibility
+ */
+export function decodeParams(query: string): Record<string, string | string[]> {
+ const o: Record<string, string | string[]> = {};
+ const params = new URLSearchParams(query);
+ for (const key of params.keys()) {
+ const val = params.getAll(key);
+ o[key] = val.length === 1 ? val[0] : val;
+ }
+ return o;
+}
+
+/**
+ * Encodes a URI according to a set of template variables. Variables will be
+ * passed through encodeURIComponent.
+ * @param pathTemplate - The path with template variables e.g. '/foo/$bar'.
+ * @param variables - The key/value pairs to replace the template
+ * variables with. E.g. `{ "$bar": "baz" }`.
+ * @returns The result of replacing all template variables e.g. '/foo/baz'.
+ */
+export function encodeUri(pathTemplate: string, variables: Record<string, Optional<string>>): string {
+ for (const key in variables) {
+ if (!variables.hasOwnProperty(key)) {
+ continue;
+ }
+ const value = variables[key];
+ if (value === undefined || value === null) {
+ continue;
+ }
+ pathTemplate = pathTemplate.replace(key, encodeURIComponent(value));
+ }
+ return pathTemplate;
+}
+
+/**
+ * The removeElement() method removes the first element in the array that
+ * satisfies (returns true) the provided testing function.
+ * @param array - The array.
+ * @param fn - Function to execute on each value in the array, with the
+ * function signature `fn(element, index, array)`. Return true to
+ * remove this element and break.
+ * @param reverse - True to search in reverse order.
+ * @returns True if an element was removed.
+ */
+export function removeElement<T>(array: T[], fn: (t: T, i?: number, a?: T[]) => boolean, reverse?: boolean): boolean {
+ let i: number;
+ if (reverse) {
+ for (i = array.length - 1; i >= 0; i--) {
+ if (fn(array[i], i, array)) {
+ array.splice(i, 1);
+ return true;
+ }
+ }
+ } else {
+ for (i = 0; i < array.length; i++) {
+ if (fn(array[i], i, array)) {
+ array.splice(i, 1);
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+/**
+ * Checks if the given thing is a function.
+ * @param value - The thing to check.
+ * @returns True if it is a function.
+ */
+export function isFunction(value: any): boolean {
+ return Object.prototype.toString.call(value) === "[object Function]";
+}
+
+/**
+ * Checks that the given object has the specified keys.
+ * @param obj - The object to check.
+ * @param keys - The list of keys that 'obj' must have.
+ * @throws If the object is missing keys.
+ */
+// note using 'keys' here would shadow the 'keys' function defined above
+export function checkObjectHasKeys(obj: object, keys: string[]): void {
+ for (const key of keys) {
+ if (!obj.hasOwnProperty(key)) {
+ throw new Error("Missing required key: " + key);
+ }
+ }
+}
+
+/**
+ * Deep copy the given object. The object MUST NOT have circular references and
+ * MUST NOT have functions.
+ * @param obj - The object to deep copy.
+ * @returns A copy of the object without any references to the original.
+ */
+export function deepCopy<T>(obj: T): T {
+ return JSON.parse(JSON.stringify(obj));
+}
+
+/**
+ * Compare two objects for equality. The objects MUST NOT have circular references.
+ *
+ * @param x - The first object to compare.
+ * @param y - The second object to compare.
+ *
+ * @returns true if the two objects are equal
+ */
+export function deepCompare(x: any, y: any): boolean {
+ // Inspired by
+ // http://stackoverflow.com/questions/1068834/object-comparison-in-javascript#1144249
+
+ // Compare primitives and functions.
+ // Also check if both arguments link to the same object.
+ if (x === y) {
+ return true;
+ }
+
+ if (typeof x !== typeof y) {
+ return false;
+ }
+
+ // special-case NaN (since NaN !== NaN)
+ if (typeof x === "number" && isNaN(x) && isNaN(y)) {
+ return true;
+ }
+
+ // special-case null (since typeof null == 'object', but null.constructor
+ // throws)
+ if (x === null || y === null) {
+ return x === y;
+ }
+
+ // everything else is either an unequal primitive, or an object
+ if (!(x instanceof Object)) {
+ return false;
+ }
+
+ // check they are the same type of object
+ if (x.constructor !== y.constructor || x.prototype !== y.prototype) {
+ return false;
+ }
+
+ // special-casing for some special types of object
+ if (x instanceof RegExp || x instanceof Date) {
+ return x.toString() === y.toString();
+ }
+
+ // the object algorithm works for Array, but it's sub-optimal.
+ if (Array.isArray(x)) {
+ if (x.length !== y.length) {
+ return false;
+ }
+
+ for (let i = 0; i < x.length; i++) {
+ if (!deepCompare(x[i], y[i])) {
+ return false;
+ }
+ }
+ } else {
+ // check that all of y's direct keys are in x
+ for (const p in y) {
+ if (y.hasOwnProperty(p) !== x.hasOwnProperty(p)) {
+ return false;
+ }
+ }
+
+ // finally, compare each of x's keys with y
+ for (const p in x) {
+ if (y.hasOwnProperty(p) !== x.hasOwnProperty(p) || !deepCompare(x[p], y[p])) {
+ return false;
+ }
+ }
+ }
+ return true;
+}
+
+// Dev note: This returns an array of tuples, but jsdoc doesn't like that. https://github.com/jsdoc/jsdoc/issues/1703
+/**
+ * Creates an array of object properties/values (entries) then
+ * sorts the result by key, recursively. The input object must
+ * ensure it does not have loops. If the input is not an object
+ * then it will be returned as-is.
+ * @param obj - The object to get entries of
+ * @returns The entries, sorted by key.
+ */
+export function deepSortedObjectEntries(obj: any): [string, any][] {
+ if (typeof obj !== "object") return obj;
+
+ // Apparently these are object types...
+ if (obj === null || obj === undefined || Array.isArray(obj)) return obj;
+
+ const pairs: [string, any][] = [];
+ for (const [k, v] of Object.entries(obj)) {
+ pairs.push([k, deepSortedObjectEntries(v)]);
+ }
+
+ // lexicographicCompare is faster than localeCompare, so let's use that.
+ pairs.sort((a, b) => lexicographicCompare(a[0], b[0]));
+
+ return pairs;
+}
+
+/**
+ * Returns whether the given value is a finite number without type-coercion
+ *
+ * @param value - the value to test
+ * @returns whether or not value is a finite number without type-coercion
+ */
+export function isNumber(value: any): value is number {
+ return typeof value === "number" && isFinite(value);
+}
+
+/**
+ * Removes zero width chars, diacritics and whitespace from the string
+ * Also applies an unhomoglyph on the string, to prevent similar looking chars
+ * @param str - the string to remove hidden characters from
+ * @returns a string with the hidden characters removed
+ */
+export function removeHiddenChars(str: string): string {
+ if (typeof str === "string") {
+ return unhomoglyph(str.normalize("NFD").replace(removeHiddenCharsRegex, ""));
+ }
+ return "";
+}
+
+/**
+ * Removes the direction override characters from a string
+ * @returns string with chars removed
+ */
+export function removeDirectionOverrideChars(str: string): string {
+ if (typeof str === "string") {
+ return str.replace(/[\u202d-\u202e]/g, "");
+ }
+ return "";
+}
+
+export function normalize(str: string): string {
+ // Note: we have to match the filter with the removeHiddenChars() because the
+ // function strips spaces and other characters (M becomes RN for example, in lowercase).
+ return (
+ removeHiddenChars(str.toLowerCase())
+ // Strip all punctuation
+ .replace(/[\\'!"#$%&()*+,\-./:;<=>?@[\]^_`{|}~\u2000-\u206f\u2e00-\u2e7f]/g, "")
+ // We also doubly convert to lowercase to work around oddities of the library.
+ .toLowerCase()
+ );
+}
+
+// Regex matching bunch of unicode control characters and otherwise misleading/invisible characters.
+// Includes:
+// various width spaces U+2000 - U+200D
+// LTR and RTL marks U+200E and U+200F
+// LTR/RTL and other directional formatting marks U+202A - U+202F
+// Arabic Letter RTL mark U+061C
+// Combining characters U+0300 - U+036F
+// Zero width no-break space (BOM) U+FEFF
+// Blank/invisible characters (U2800, U2062-U2063)
+// eslint-disable-next-line no-misleading-character-class
+const removeHiddenCharsRegex = /[\u2000-\u200F\u202A-\u202F\u0300-\u036F\uFEFF\u061C\u2800\u2062-\u2063\s]/g;
+
+export function escapeRegExp(string: string): string {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+export function globToRegexp(glob: string, extended = false): string {
+ // From
+ // https://github.com/matrix-org/synapse/blob/abbee6b29be80a77e05730707602f3bbfc3f38cb/synapse/push/__init__.py#L132
+ // Because micromatch is about 130KB with dependencies,
+ // and minimatch is not much better.
+ const replacements: [RegExp, string | ((substring: string, ...args: any[]) => string)][] = [
+ [/\\\*/g, ".*"],
+ [/\?/g, "."],
+ ];
+ if (!extended) {
+ replacements.push([
+ /\\\[(!|)(.*)\\]/g,
+ (_match: string, neg: string, pat: string): string =>
+ ["[", neg ? "^" : "", pat.replace(/\\-/, "-"), "]"].join(""),
+ ]);
+ }
+ return replacements.reduce(
+ // https://github.com/microsoft/TypeScript/issues/30134
+ (pat, args) => (args ? pat.replace(args[0], args[1] as any) : pat),
+ escapeRegExp(glob),
+ );
+}
+
+export function ensureNoTrailingSlash(url: string): string;
+export function ensureNoTrailingSlash(url: undefined): undefined;
+export function ensureNoTrailingSlash(url?: string): string | undefined;
+export function ensureNoTrailingSlash(url?: string): string | undefined {
+ if (url?.endsWith("/")) {
+ return url.slice(0, -1);
+ } else {
+ return url;
+ }
+}
+
+/**
+ * Returns a promise which resolves with a given value after the given number of ms
+ */
+export function sleep<T>(ms: number, value?: T): Promise<T> {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms, value);
+ });
+}
+
+/**
+ * Promise/async version of {@link setImmediate}.
+ */
+export function immediate(): Promise<void> {
+ return new Promise(setImmediate);
+}
+
+export function isNullOrUndefined(val: any): boolean {
+ return val === null || val === undefined;
+}
+
+export interface IDeferred<T> {
+ resolve: (value: T | Promise<T>) => void;
+ reject: (reason?: any) => void;
+ promise: Promise<T>;
+}
+
+// Returns a Deferred
+export function defer<T = void>(): IDeferred<T> {
+ let resolve!: IDeferred<T>["resolve"];
+ let reject!: IDeferred<T>["reject"];
+
+ const promise = new Promise<T>((_resolve, _reject) => {
+ resolve = _resolve;
+ reject = _reject;
+ });
+
+ return { resolve, reject, promise };
+}
+
+export async function promiseMapSeries<T>(
+ promises: Array<T | Promise<T>>,
+ fn: (t: T) => Promise<unknown> | undefined, // if async we don't care about the type as we only await resolution
+): Promise<void> {
+ for (const o of promises) {
+ await fn(await o);
+ }
+}
+
+export function promiseTry<T>(fn: () => T | Promise<T>): Promise<T> {
+ return Promise.resolve(fn());
+}
+
+// Creates and awaits all promises, running no more than `chunkSize` at the same time
+export async function chunkPromises<T>(fns: (() => Promise<T>)[], chunkSize: number): Promise<T[]> {
+ const results: T[] = [];
+ for (let i = 0; i < fns.length; i += chunkSize) {
+ results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map((fn) => fn()))));
+ }
+ return results;
+}
+
+/**
+ * Retries the function until it succeeds or is interrupted. The given function must return
+ * a promise which throws/rejects on error, otherwise the retry will assume the request
+ * succeeded. The promise chain returned will contain the successful promise. The given function
+ * should always return a new promise.
+ * @param promiseFn - The function to call to get a fresh promise instance. Takes an
+ * attempt count as an argument, for logging/debugging purposes.
+ * @returns The promise for the retried operation.
+ */
+export function simpleRetryOperation<T>(promiseFn: (attempt: number) => Promise<T>): Promise<T> {
+ return promiseRetry(
+ (attempt: number) => {
+ return promiseFn(attempt);
+ },
+ {
+ forever: true,
+ factor: 2,
+ minTimeout: 3000, // ms
+ maxTimeout: 15000, // ms
+ },
+ );
+}
+
+// String averaging inspired by https://stackoverflow.com/a/2510816
+// Dev note: We make the alphabet a string because it's easier to write syntactically
+// than arrays. Thankfully, strings implement the useful parts of the Array interface
+// anyhow.
+
+/**
+ * The default alphabet used by string averaging in this SDK. This matches
+ * all usefully printable ASCII characters (0x20-0x7E, inclusive).
+ */
+export const DEFAULT_ALPHABET = ((): string => {
+ let str = "";
+ for (let c = 0x20; c <= 0x7e; c++) {
+ str += String.fromCharCode(c);
+ }
+ return str;
+})();
+
+/**
+ * Pads a string using the given alphabet as a base. The returned string will be
+ * padded at the end with the first character in the alphabet.
+ *
+ * This is intended for use with string averaging.
+ * @param s - The string to pad.
+ * @param n - The length to pad to.
+ * @param alphabet - The alphabet to use as a single string.
+ * @returns The padded string.
+ */
+export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): string {
+ return s.padEnd(n, alphabet[0]);
+}
+
+/**
+ * Converts a baseN number to a string, where N is the alphabet's length.
+ *
+ * This is intended for use with string averaging.
+ * @param n - The baseN number.
+ * @param alphabet - The alphabet to use as a single string.
+ * @returns The baseN number encoded as a string from the alphabet.
+ */
+export function baseToString(n: bigint, alphabet = DEFAULT_ALPHABET): string {
+ // Developer note: the stringToBase() function offsets the character set by 1 so that repeated
+ // characters (ie: "aaaaaa" in a..z) don't come out as zero. We have to reverse this here as
+ // otherwise we'll be wrong in our conversion. Undoing a +1 before an exponent isn't very fun
+ // though, so we rely on a lengthy amount of `x - 1` and integer division rules to reach a
+ // sane state. This also means we have to do rollover detection: see below.
+
+ const len = BigInt(alphabet.length);
+ if (n <= len) {
+ return alphabet[Number(n) - 1] ?? "";
+ }
+
+ let d = n / len;
+ let r = Number(n % len) - 1;
+
+ // Rollover detection: if the remainder is negative, it means that the string needs
+ // to roll over by 1 character downwards (ie: in a..z, the previous to "aaa" would be
+ // "zz").
+ if (r < 0) {
+ d -= BigInt(Math.abs(r)); // abs() is just to be clear what we're doing. Could also `+= r`.
+ r = Number(len) - 1;
+ }
+
+ return baseToString(d, alphabet) + alphabet[r];
+}
+
+/**
+ * Converts a string to a baseN number, where N is the alphabet's length.
+ *
+ * This is intended for use with string averaging.
+ * @param s - The string to convert to a number.
+ * @param alphabet - The alphabet to use as a single string.
+ * @returns The baseN number.
+ */
+export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint {
+ const len = BigInt(alphabet.length);
+
+ // In our conversion to baseN we do a couple performance optimizations to avoid using
+ // excess CPU and such. To create baseN numbers, the input string needs to be reversed
+ // so the exponents stack up appropriately, as the last character in the unreversed
+ // string has less impact than the first character (in "abc" the A is a lot more important
+ // for lexicographic sorts). We also do a trick with the character codes to optimize the
+ // alphabet lookup, avoiding an index scan of `alphabet.indexOf(reversedStr[i])` - we know
+ // that the alphabet and (theoretically) the input string are constrained on character sets
+ // and thus can do simple subtraction to end up with the same result.
+
+ // Developer caution: we carefully cast to BigInt here to avoid losing precision. We cannot
+ // rely on Math.pow() (for example) to be capable of handling our insane numbers.
+
+ let result = BigInt(0);
+ for (let i = s.length - 1, j = BigInt(0); i >= 0; i--, j++) {
+ const charIndex = s.charCodeAt(i) - alphabet.charCodeAt(0);
+
+ // We add 1 to the char index to offset the whole numbering scheme. We unpack this in
+ // the baseToString() function.
+ result += BigInt(1 + charIndex) * len ** j;
+ }
+ return result;
+}
+
+/**
+ * Averages two strings, returning the midpoint between them. This is accomplished by
+ * converting both to baseN numbers (where N is the alphabet's length) then averaging
+ * those before re-encoding as a string.
+ * @param a - The first string.
+ * @param b - The second string.
+ * @param alphabet - The alphabet to use as a single string.
+ * @returns The midpoint between the strings, as a string.
+ */
+export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_ALPHABET): string {
+ const padN = Math.max(a.length, b.length);
+ const baseA = stringToBase(alphabetPad(a, padN, alphabet), alphabet);
+ const baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet);
+ const avg = (baseA + baseB) / BigInt(2);
+
+ // Detect integer division conflicts. This happens when two numbers are divided too close so
+ // we lose a .5 precision. We need to add a padding character in these cases.
+ if (avg === baseA || avg == baseB) {
+ return baseToString(avg, alphabet) + alphabet[0];
+ }
+
+ return baseToString(avg, alphabet);
+}
+
+/**
+ * Finds the next string using the alphabet provided. This is done by converting the
+ * string to a baseN number, where N is the alphabet's length, then adding 1 before
+ * converting back to a string.
+ * @param s - The string to start at.
+ * @param alphabet - The alphabet to use as a single string.
+ * @returns The string which follows the input string.
+ */
+export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string {
+ return baseToString(stringToBase(s, alphabet) + BigInt(1), alphabet);
+}
+
+/**
+ * Finds the previous string using the alphabet provided. This is done by converting the
+ * string to a baseN number, where N is the alphabet's length, then subtracting 1 before
+ * converting back to a string.
+ * @param s - The string to start at.
+ * @param alphabet - The alphabet to use as a single string.
+ * @returns The string which precedes the input string.
+ */
+export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string {
+ return baseToString(stringToBase(s, alphabet) - BigInt(1), alphabet);
+}
+
+/**
+ * Compares strings lexicographically as a sort-safe function.
+ * @param a - The first (reference) string.
+ * @param b - The second (compare) string.
+ * @returns Negative if the reference string is before the compare string;
+ * positive if the reference string is after; and zero if equal.
+ */
+export function lexicographicCompare(a: string, b: string): number {
+ // Dev note: this exists because I'm sad that you can use math operators on strings, so I've
+ // hidden the operation in this function.
+ if (a < b) {
+ return -1;
+ } else if (a > b) {
+ return 1;
+ } else {
+ return 0;
+ }
+}
+
+const collator = new Intl.Collator();
+/**
+ * Performant language-sensitive string comparison
+ * @param a - the first string to compare
+ * @param b - the second string to compare
+ */
+export function compare(a: string, b: string): number {
+ return collator.compare(a, b);
+}
+
+/**
+ * This function is similar to Object.assign() but it assigns recursively and
+ * allows you to ignore nullish values from the source
+ *
+ * @returns the target object
+ */
+export function recursivelyAssign<T1 extends T2, T2 extends Record<string, any>>(
+ target: T1,
+ source: T2,
+ ignoreNullish = false,
+): T1 & T2 {
+ for (const [sourceKey, sourceValue] of Object.entries(source)) {
+ if (target[sourceKey] instanceof Object && sourceValue) {
+ recursivelyAssign(target[sourceKey], sourceValue);
+ continue;
+ }
+ if ((sourceValue !== null && sourceValue !== undefined) || !ignoreNullish) {
+ target[sourceKey as keyof T1] = sourceValue;
+ continue;
+ }
+ }
+ return target as T1 & T2;
+}
+
+function getContentTimestampWithFallback(event: MatrixEvent): number {
+ return M_TIMESTAMP.findIn<number>(event.getContent()) ?? -1;
+}
+
+/**
+ * Sort events by their content m.ts property
+ * Latest timestamp first
+ */
+export function sortEventsByLatestContentTimestamp(left: MatrixEvent, right: MatrixEvent): number {
+ return getContentTimestampWithFallback(right) - getContentTimestampWithFallback(left);
+}
+
+export function isSupportedReceiptType(receiptType: string): boolean {
+ return [ReceiptType.Read, ReceiptType.ReadPrivate].includes(receiptType as ReceiptType);
+}
+
+/**
+ * Determines whether two maps are equal.
+ * @param eq - The equivalence relation to compare values by. Defaults to strict equality.
+ */
+export function mapsEqual<K, V>(x: Map<K, V>, y: Map<K, V>, eq = (v1: V, v2: V): boolean => v1 === v2): boolean {
+ if (x.size !== y.size) return false;
+ for (const [k, v1] of x) {
+ const v2 = y.get(k);
+ if (v2 === undefined || !eq(v1, v2)) return false;
+ }
+ return true;
+}
+
+function processMapToObjectValue(value: any): any {
+ if (value instanceof Map) {
+ // Value is a Map. Recursively map it to an object.
+ return recursiveMapToObject(value);
+ } else if (Array.isArray(value)) {
+ // Value is an Array. Recursively map the value (e.g. to cover Array of Arrays).
+ return value.map((v) => processMapToObjectValue(v));
+ } else {
+ return value;
+ }
+}
+
+/**
+ * Recursively converts Maps to plain objects.
+ * Also supports sub-lists of Maps.
+ */
+export function recursiveMapToObject(map: Map<any, any>): any {
+ const targetMap = new Map();
+
+ for (const [key, value] of map) {
+ targetMap.set(key, processMapToObjectValue(value));
+ }
+
+ return Object.fromEntries(targetMap.entries());
+}
+
+export function unsafeProp<K extends keyof any | undefined>(prop: K): boolean {
+ return prop === "__proto__" || prop === "prototype" || prop === "constructor";
+}
+
+export function safeSet<K extends keyof any>(obj: Record<any, any>, prop: K, value: any): void {
+ if (unsafeProp(prop)) {
+ throw new Error("Trying to modify prototype or constructor");
+ }
+
+ obj[prop] = value;
+}
+
+export function noUnsafeEventProps(event: Partial<IEvent>): boolean {
+ return !(
+ unsafeProp(event.room_id) ||
+ unsafeProp(event.sender) ||
+ unsafeProp(event.user_id) ||
+ unsafeProp(event.event_id)
+ );
+}
+
+export class MapWithDefault<K, V> extends Map<K, V> {
+ public constructor(private createDefault: () => V) {
+ super();
+ }
+
+ /**
+ * Returns the value if the key already exists.
+ * If not, it creates a new value under that key using the ctor callback and returns it.
+ */
+ public getOrCreate(key: K): V {
+ if (!this.has(key)) {
+ this.set(key, this.createDefault());
+ }
+
+ return this.get(key)!;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/audioContext.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/audioContext.ts
new file mode 100644
index 0000000..7cf3ed3
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/audioContext.ts
@@ -0,0 +1,44 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+let audioContext: AudioContext | null = null;
+let refCount = 0;
+
+/**
+ * Acquires a reference to the shared AudioContext.
+ * It's highly recommended to reuse this AudioContext rather than creating your
+ * own, because multiple AudioContexts can be problematic in some browsers.
+ * Make sure to call releaseContext when you're done using it.
+ * @returns The shared AudioContext
+ */
+export const acquireContext = (): AudioContext => {
+ if (audioContext === null) audioContext = new AudioContext();
+ refCount++;
+ return audioContext;
+};
+
+/**
+ * Signals that one of the references to the shared AudioContext has been
+ * released, allowing the context and associated hardware resources to be
+ * cleaned up if nothing else is using it.
+ */
+export const releaseContext = (): void => {
+ refCount--;
+ if (refCount === 0) {
+ audioContext?.close();
+ audioContext = null;
+ }
+};
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/call.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/call.ts
new file mode 100644
index 0000000..cd75c10
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/call.ts
@@ -0,0 +1,2962 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 New Vector Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+Copyright 2021 - 2022 Šimon Brandner <simon.bra.ag@gmail.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * This is an internal module. See {@link createNewMatrixCall} for the public API.
+ */
+
+import { v4 as uuidv4 } from "uuid";
+import { parse as parseSdp, write as writeSdp } from "sdp-transform";
+
+import { logger } from "../logger";
+import * as utils from "../utils";
+import { IContent, MatrixEvent } from "../models/event";
+import { EventType, ToDeviceMessageId } from "../@types/event";
+import { RoomMember } from "../models/room-member";
+import { randomString } from "../randomstring";
+import {
+ MCallReplacesEvent,
+ MCallAnswer,
+ MCallInviteNegotiate,
+ CallCapabilities,
+ SDPStreamMetadataPurpose,
+ SDPStreamMetadata,
+ SDPStreamMetadataKey,
+ MCallSDPStreamMetadataChanged,
+ MCallSelectAnswer,
+ MCAllAssertedIdentity,
+ MCallCandidates,
+ MCallBase,
+ MCallHangupReject,
+} from "./callEventTypes";
+import { CallFeed } from "./callFeed";
+import { MatrixClient } from "../client";
+import { EventEmitterEvents, TypedEventEmitter } from "../models/typed-event-emitter";
+import { DeviceInfo } from "../crypto/deviceinfo";
+import { GroupCallUnknownDeviceError } from "./groupCall";
+import { IScreensharingOpts } from "./mediaHandler";
+import { MatrixError } from "../http-api";
+import { GroupCallStats } from "./stats/groupCallStats";
+
+interface CallOpts {
+ // The room ID for this call.
+ roomId: string;
+ invitee?: string;
+ // The Matrix Client instance to send events to.
+ client: MatrixClient;
+ /**
+ * Whether relay through TURN should be forced.
+ * @deprecated use opts.forceTURN when creating the matrix client
+ * since it's only possible to set this option on outbound calls.
+ */
+ forceTURN?: boolean;
+ // A list of TURN servers.
+ turnServers?: Array<TurnServer>;
+ opponentDeviceId?: string;
+ opponentSessionId?: string;
+ groupCallId?: string;
+}
+
+interface TurnServer {
+ urls: Array<string>;
+ username?: string;
+ password?: string;
+ ttl?: number;
+}
+
+interface AssertedIdentity {
+ id: string;
+ displayName: string;
+}
+
+enum MediaType {
+ AUDIO = "audio",
+ VIDEO = "video",
+}
+
+enum CodecName {
+ OPUS = "opus",
+ // add more as needed
+}
+
+// Used internally to specify modifications to codec parameters in SDP
+interface CodecParamsMod {
+ mediaType: MediaType;
+ codec: CodecName;
+ enableDtx?: boolean; // true to enable discontinuous transmission, false to disable, undefined to leave as-is
+ maxAverageBitrate?: number; // sets the max average bitrate, or undefined to leave as-is
+}
+
+export enum CallState {
+ Fledgling = "fledgling",
+ InviteSent = "invite_sent",
+ WaitLocalMedia = "wait_local_media",
+ CreateOffer = "create_offer",
+ CreateAnswer = "create_answer",
+ Connecting = "connecting",
+ Connected = "connected",
+ Ringing = "ringing",
+ Ended = "ended",
+}
+
+export enum CallType {
+ Voice = "voice",
+ Video = "video",
+}
+
+export enum CallDirection {
+ Inbound = "inbound",
+ Outbound = "outbound",
+}
+
+export enum CallParty {
+ Local = "local",
+ Remote = "remote",
+}
+
+export enum CallEvent {
+ Hangup = "hangup",
+ State = "state",
+ Error = "error",
+ Replaced = "replaced",
+
+ // The value of isLocalOnHold() has changed
+ LocalHoldUnhold = "local_hold_unhold",
+ // The value of isRemoteOnHold() has changed
+ RemoteHoldUnhold = "remote_hold_unhold",
+ // backwards compat alias for LocalHoldUnhold: remove in a major version bump
+ HoldUnhold = "hold_unhold",
+ // Feeds have changed
+ FeedsChanged = "feeds_changed",
+
+ AssertedIdentityChanged = "asserted_identity_changed",
+
+ LengthChanged = "length_changed",
+
+ DataChannel = "datachannel",
+
+ SendVoipEvent = "send_voip_event",
+}
+
+export enum CallErrorCode {
+ /** The user chose to end the call */
+ UserHangup = "user_hangup",
+
+ /** An error code when the local client failed to create an offer. */
+ LocalOfferFailed = "local_offer_failed",
+ /**
+ * An error code when there is no local mic/camera to use. This may be because
+ * the hardware isn't plugged in, or the user has explicitly denied access.
+ */
+ NoUserMedia = "no_user_media",
+
+ /**
+ * Error code used when a call event failed to send
+ * because unknown devices were present in the room
+ */
+ UnknownDevices = "unknown_devices",
+
+ /**
+ * Error code used when we fail to send the invite
+ * for some reason other than there being unknown devices
+ */
+ SendInvite = "send_invite",
+
+ /**
+ * An answer could not be created
+ */
+ CreateAnswer = "create_answer",
+
+ /**
+ * An offer could not be created
+ */
+ CreateOffer = "create_offer",
+
+ /**
+ * Error code used when we fail to send the answer
+ * for some reason other than there being unknown devices
+ */
+ SendAnswer = "send_answer",
+
+ /**
+ * The session description from the other side could not be set
+ */
+ SetRemoteDescription = "set_remote_description",
+
+ /**
+ * The session description from this side could not be set
+ */
+ SetLocalDescription = "set_local_description",
+
+ /**
+ * A different device answered the call
+ */
+ AnsweredElsewhere = "answered_elsewhere",
+
+ /**
+ * No media connection could be established to the other party
+ */
+ IceFailed = "ice_failed",
+
+ /**
+ * The invite timed out whilst waiting for an answer
+ */
+ InviteTimeout = "invite_timeout",
+
+ /**
+ * The call was replaced by another call
+ */
+ Replaced = "replaced",
+
+ /**
+ * Signalling for the call could not be sent (other than the initial invite)
+ */
+ SignallingFailed = "signalling_timeout",
+
+ /**
+ * The remote party is busy
+ */
+ UserBusy = "user_busy",
+
+ /**
+ * We transferred the call off to somewhere else
+ */
+ Transferred = "transferred",
+
+ /**
+ * A call from the same user was found with a new session id
+ */
+ NewSession = "new_session",
+}
+
+/**
+ * The version field that we set in m.call.* events
+ */
+const VOIP_PROTO_VERSION = "1";
+
+/** The fallback ICE server to use for STUN or TURN protocols. */
+const FALLBACK_ICE_SERVER = "stun:turn.matrix.org";
+
+/** The length of time a call can be ringing for. */
+const CALL_TIMEOUT_MS = 60 * 1000; // ms
+/** The time after which we increment callLength */
+const CALL_LENGTH_INTERVAL = 1000; // ms
+/** The time after which we end the call, if ICE got disconnected */
+const ICE_DISCONNECTED_TIMEOUT = 30 * 1000; // ms
+
+export class CallError extends Error {
+ public readonly code: string;
+
+ public constructor(code: CallErrorCode, msg: string, err: Error) {
+ // Still don't think there's any way to have proper nested errors
+ super(msg + ": " + err);
+
+ this.code = code;
+ }
+}
+
+export function genCallID(): string {
+ return Date.now().toString() + randomString(16);
+}
+
+function getCodecParamMods(isPtt: boolean): CodecParamsMod[] {
+ const mods = [
+ {
+ mediaType: "audio",
+ codec: "opus",
+ enableDtx: true,
+ maxAverageBitrate: isPtt ? 12000 : undefined,
+ },
+ ] as CodecParamsMod[];
+
+ return mods;
+}
+
+export interface VoipEvent {
+ type: "toDevice" | "sendEvent";
+ eventType: string;
+ userId?: string;
+ opponentDeviceId?: string;
+ roomId?: string;
+ content: Record<string, unknown>;
+}
+
+/**
+ * These now all have the call object as an argument. Why? Well, to know which call a given event is
+ * about you have three options:
+ * 1. Use a closure as the callback that remembers what call it's listening to. This can be
+ * a pain because you need to pass the listener function again when you remove the listener,
+ * which might be somewhere else.
+ * 2. Use not-very-well-known fact that EventEmitter sets 'this' to the emitter object in the
+ * callback. This doesn't really play well with modern Typescript and eslint and doesn't work
+ * with our pattern of re-emitting events.
+ * 3. Pass the object in question as an argument to the callback.
+ *
+ * Now that we have group calls which have to deal with multiple call objects, this will
+ * become more important, and I think methods 1 and 2 are just going to cause issues.
+ */
+export type CallEventHandlerMap = {
+ [CallEvent.DataChannel]: (channel: RTCDataChannel, call: MatrixCall) => void;
+ [CallEvent.FeedsChanged]: (feeds: CallFeed[], call: MatrixCall) => void;
+ [CallEvent.Replaced]: (newCall: MatrixCall, oldCall: MatrixCall) => void;
+ [CallEvent.Error]: (error: CallError, call: MatrixCall) => void;
+ [CallEvent.RemoteHoldUnhold]: (onHold: boolean, call: MatrixCall) => void;
+ [CallEvent.LocalHoldUnhold]: (onHold: boolean, call: MatrixCall) => void;
+ [CallEvent.LengthChanged]: (length: number, call: MatrixCall) => void;
+ [CallEvent.State]: (state: CallState, oldState: CallState, call: MatrixCall) => void;
+ [CallEvent.Hangup]: (call: MatrixCall) => void;
+ [CallEvent.AssertedIdentityChanged]: (call: MatrixCall) => void;
+ /* @deprecated */
+ [CallEvent.HoldUnhold]: (onHold: boolean) => void;
+ [CallEvent.SendVoipEvent]: (event: VoipEvent, call: MatrixCall) => void;
+};
+
+// The key of the transceiver map (purpose + media type, separated by ':')
+type TransceiverKey = string;
+
+// generates keys for the map of transceivers
+// kind is unfortunately a string rather than MediaType as this is the type of
+// track.kind
+function getTransceiverKey(purpose: SDPStreamMetadataPurpose, kind: TransceiverKey): string {
+ return purpose + ":" + kind;
+}
+
+export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
+ public roomId: string;
+ public callId: string;
+ public invitee?: string;
+ public hangupParty?: CallParty;
+ public hangupReason?: string;
+ public direction?: CallDirection;
+ public ourPartyId: string;
+ public peerConn?: RTCPeerConnection;
+ public toDeviceSeq = 0;
+
+ // whether this call should have push-to-talk semantics
+ // This should be set by the consumer on incoming & outgoing calls.
+ public isPtt = false;
+
+ private _state = CallState.Fledgling;
+ private readonly client: MatrixClient;
+ private readonly forceTURN?: boolean;
+ private readonly turnServers: Array<TurnServer>;
+ // A queue for candidates waiting to go out.
+ // We try to amalgamate candidates into a single candidate message where
+ // possible
+ private candidateSendQueue: Array<RTCIceCandidate> = [];
+ private candidateSendTries = 0;
+ private candidatesEnded = false;
+ private feeds: Array<CallFeed> = [];
+
+ // our transceivers for each purpose and type of media
+ private transceivers = new Map<TransceiverKey, RTCRtpTransceiver>();
+
+ private inviteOrAnswerSent = false;
+ private waitForLocalAVStream = false;
+ private successor?: MatrixCall;
+ private opponentMember?: RoomMember;
+ private opponentVersion?: number | string;
+ // The party ID of the other side: undefined if we haven't chosen a partner
+ // yet, null if we have but they didn't send a party ID.
+ private opponentPartyId: string | null | undefined;
+ private opponentCaps?: CallCapabilities;
+ private iceDisconnectedTimeout?: ReturnType<typeof setTimeout>;
+ private inviteTimeout?: ReturnType<typeof setTimeout>;
+ private readonly removeTrackListeners = new Map<MediaStream, () => void>();
+
+ // The logic of when & if a call is on hold is nontrivial and explained in is*OnHold
+ // This flag represents whether we want the other party to be on hold
+ private remoteOnHold = false;
+
+ // the stats for the call at the point it ended. We can't get these after we
+ // tear the call down, so we just grab a snapshot before we stop the call.
+ // The typescript definitions have this type as 'any' :(
+ private callStatsAtEnd?: any[];
+
+ // Perfect negotiation state: https://www.w3.org/TR/webrtc/#perfect-negotiation-example
+ private makingOffer = false;
+ private ignoreOffer = false;
+
+ private responsePromiseChain?: Promise<void>;
+
+ // If candidates arrive before we've picked an opponent (which, in particular,
+ // will happen if the opponent sends candidates eagerly before the user answers
+ // the call) we buffer them up here so we can then add the ones from the party we pick
+ private remoteCandidateBuffer = new Map<string, RTCIceCandidate[]>();
+
+ private remoteAssertedIdentity?: AssertedIdentity;
+ private remoteSDPStreamMetadata?: SDPStreamMetadata;
+
+ private callLengthInterval?: ReturnType<typeof setInterval>;
+ private callStartTime?: number;
+
+ private opponentDeviceId?: string;
+ private opponentDeviceInfo?: DeviceInfo;
+ private opponentSessionId?: string;
+ public groupCallId?: string;
+
+ // Used to keep the timer for the delay before actually stopping our
+ // video track after muting (see setLocalVideoMuted)
+ private stopVideoTrackTimer?: ReturnType<typeof setTimeout>;
+ // Used to allow connection without Video and Audio. To establish a webrtc connection without media a Data channel is
+ // needed At the moment this property is true if we allow MatrixClient with isVoipWithNoMediaAllowed = true
+ private readonly isOnlyDataChannelAllowed: boolean;
+ private stats: GroupCallStats | undefined;
+
+ /**
+ * Construct a new Matrix Call.
+ * @param opts - Config options.
+ */
+ public constructor(opts: CallOpts) {
+ super();
+
+ this.roomId = opts.roomId;
+ this.invitee = opts.invitee;
+ this.client = opts.client;
+
+ if (!this.client.deviceId) throw new Error("Client must have a device ID to start calls");
+
+ this.forceTURN = opts.forceTURN ?? false;
+ this.ourPartyId = this.client.deviceId;
+ this.opponentDeviceId = opts.opponentDeviceId;
+ this.opponentSessionId = opts.opponentSessionId;
+ this.groupCallId = opts.groupCallId;
+ // Array of Objects with urls, username, credential keys
+ this.turnServers = opts.turnServers || [];
+ if (this.turnServers.length === 0 && this.client.isFallbackICEServerAllowed()) {
+ this.turnServers.push({
+ urls: [FALLBACK_ICE_SERVER],
+ });
+ }
+ for (const server of this.turnServers) {
+ utils.checkObjectHasKeys(server, ["urls"]);
+ }
+ this.callId = genCallID();
+ // If the Client provides calls without audio and video we need a datachannel for a webrtc connection
+ this.isOnlyDataChannelAllowed = this.client.isVoipWithNoMediaAllowed;
+ }
+
+ /**
+ * Place a voice call to this room.
+ * @throws If you have not specified a listener for 'error' events.
+ */
+ public async placeVoiceCall(): Promise<void> {
+ await this.placeCall(true, false);
+ }
+
+ /**
+ * Place a video call to this room.
+ * @throws If you have not specified a listener for 'error' events.
+ */
+ public async placeVideoCall(): Promise<void> {
+ await this.placeCall(true, true);
+ }
+
+ /**
+ * Create a datachannel using this call's peer connection.
+ * @param label - A human readable label for this datachannel
+ * @param options - An object providing configuration options for the data channel.
+ */
+ public createDataChannel(label: string, options: RTCDataChannelInit | undefined): RTCDataChannel {
+ const dataChannel = this.peerConn!.createDataChannel(label, options);
+ this.emit(CallEvent.DataChannel, dataChannel, this);
+ return dataChannel;
+ }
+
+ public getOpponentMember(): RoomMember | undefined {
+ return this.opponentMember;
+ }
+
+ public getOpponentDeviceId(): string | undefined {
+ return this.opponentDeviceId;
+ }
+
+ public getOpponentSessionId(): string | undefined {
+ return this.opponentSessionId;
+ }
+
+ public opponentCanBeTransferred(): boolean {
+ return Boolean(this.opponentCaps && this.opponentCaps["m.call.transferee"]);
+ }
+
+ public opponentSupportsDTMF(): boolean {
+ return Boolean(this.opponentCaps && this.opponentCaps["m.call.dtmf"]);
+ }
+
+ public getRemoteAssertedIdentity(): AssertedIdentity | undefined {
+ return this.remoteAssertedIdentity;
+ }
+
+ public get state(): CallState {
+ return this._state;
+ }
+
+ private set state(state: CallState) {
+ const oldState = this._state;
+ this._state = state;
+ this.emit(CallEvent.State, state, oldState, this);
+ }
+
+ public get type(): CallType {
+ // we may want to look for a video receiver here rather than a track to match the
+ // sender behaviour, although in practice they should be the same thing
+ return this.hasUserMediaVideoSender || this.hasRemoteUserMediaVideoTrack ? CallType.Video : CallType.Voice;
+ }
+
+ public get hasLocalUserMediaVideoTrack(): boolean {
+ return !!this.localUsermediaStream?.getVideoTracks().length;
+ }
+
+ public get hasRemoteUserMediaVideoTrack(): boolean {
+ return this.getRemoteFeeds().some((feed) => {
+ return feed.purpose === SDPStreamMetadataPurpose.Usermedia && feed.stream?.getVideoTracks().length;
+ });
+ }
+
+ public get hasLocalUserMediaAudioTrack(): boolean {
+ return !!this.localUsermediaStream?.getAudioTracks().length;
+ }
+
+ public get hasRemoteUserMediaAudioTrack(): boolean {
+ return this.getRemoteFeeds().some((feed) => {
+ return feed.purpose === SDPStreamMetadataPurpose.Usermedia && !!feed.stream?.getAudioTracks().length;
+ });
+ }
+
+ private get hasUserMediaAudioSender(): boolean {
+ return Boolean(this.transceivers.get(getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "audio"))?.sender);
+ }
+
+ private get hasUserMediaVideoSender(): boolean {
+ return Boolean(this.transceivers.get(getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "video"))?.sender);
+ }
+
+ public get localUsermediaFeed(): CallFeed | undefined {
+ return this.getLocalFeeds().find((feed) => feed.purpose === SDPStreamMetadataPurpose.Usermedia);
+ }
+
+ public get localScreensharingFeed(): CallFeed | undefined {
+ return this.getLocalFeeds().find((feed) => feed.purpose === SDPStreamMetadataPurpose.Screenshare);
+ }
+
+ public get localUsermediaStream(): MediaStream | undefined {
+ return this.localUsermediaFeed?.stream;
+ }
+
+ public get localScreensharingStream(): MediaStream | undefined {
+ return this.localScreensharingFeed?.stream;
+ }
+
+ public get remoteUsermediaFeed(): CallFeed | undefined {
+ return this.getRemoteFeeds().find((feed) => feed.purpose === SDPStreamMetadataPurpose.Usermedia);
+ }
+
+ public get remoteScreensharingFeed(): CallFeed | undefined {
+ return this.getRemoteFeeds().find((feed) => feed.purpose === SDPStreamMetadataPurpose.Screenshare);
+ }
+
+ public get remoteUsermediaStream(): MediaStream | undefined {
+ return this.remoteUsermediaFeed?.stream;
+ }
+
+ public get remoteScreensharingStream(): MediaStream | undefined {
+ return this.remoteScreensharingFeed?.stream;
+ }
+
+ private getFeedByStreamId(streamId: string): CallFeed | undefined {
+ return this.getFeeds().find((feed) => feed.stream.id === streamId);
+ }
+
+ /**
+ * Returns an array of all CallFeeds
+ * @returns CallFeeds
+ */
+ public getFeeds(): Array<CallFeed> {
+ return this.feeds;
+ }
+
+ /**
+ * Returns an array of all local CallFeeds
+ * @returns local CallFeeds
+ */
+ public getLocalFeeds(): Array<CallFeed> {
+ return this.feeds.filter((feed) => feed.isLocal());
+ }
+
+ /**
+ * Returns an array of all remote CallFeeds
+ * @returns remote CallFeeds
+ */
+ public getRemoteFeeds(): Array<CallFeed> {
+ return this.feeds.filter((feed) => !feed.isLocal());
+ }
+
+ private async initOpponentCrypto(): Promise<void> {
+ if (!this.opponentDeviceId) return;
+ if (!this.client.getUseE2eForGroupCall()) return;
+ // It's possible to want E2EE and yet not have the means to manage E2EE
+ // ourselves (for example if the client is a RoomWidgetClient)
+ if (!this.client.isCryptoEnabled()) {
+ // All we know is the device ID
+ this.opponentDeviceInfo = new DeviceInfo(this.opponentDeviceId);
+ return;
+ }
+ // if we've got to this point, we do want to init crypto, so throw if we can't
+ if (!this.client.crypto) throw new Error("Crypto is not initialised.");
+
+ const userId = this.invitee || this.getOpponentMember()?.userId;
+
+ if (!userId) throw new Error("Couldn't find opponent user ID to init crypto");
+
+ const deviceInfoMap = await this.client.crypto.deviceList.downloadKeys([userId], false);
+ this.opponentDeviceInfo = deviceInfoMap.get(userId)?.get(this.opponentDeviceId);
+ if (this.opponentDeviceInfo === undefined) {
+ throw new GroupCallUnknownDeviceError(userId);
+ }
+ }
+
+ /**
+ * Generates and returns localSDPStreamMetadata
+ * @returns localSDPStreamMetadata
+ */
+ private getLocalSDPStreamMetadata(updateStreamIds = false): SDPStreamMetadata {
+ const metadata: SDPStreamMetadata = {};
+ for (const localFeed of this.getLocalFeeds()) {
+ if (updateStreamIds) {
+ localFeed.sdpMetadataStreamId = localFeed.stream.id;
+ }
+
+ metadata[localFeed.sdpMetadataStreamId] = {
+ purpose: localFeed.purpose,
+ audio_muted: localFeed.isAudioMuted(),
+ video_muted: localFeed.isVideoMuted(),
+ };
+ }
+ return metadata;
+ }
+
+ /**
+ * Returns true if there are no incoming feeds,
+ * otherwise returns false
+ * @returns no incoming feeds
+ */
+ public noIncomingFeeds(): boolean {
+ return !this.feeds.some((feed) => !feed.isLocal());
+ }
+
+ private pushRemoteFeed(stream: MediaStream): void {
+ // Fallback to old behavior if the other side doesn't support SDPStreamMetadata
+ if (!this.opponentSupportsSDPStreamMetadata()) {
+ this.pushRemoteFeedWithoutMetadata(stream);
+ return;
+ }
+
+ const userId = this.getOpponentMember()!.userId;
+ const purpose = this.remoteSDPStreamMetadata![stream.id].purpose;
+ const audioMuted = this.remoteSDPStreamMetadata![stream.id].audio_muted;
+ const videoMuted = this.remoteSDPStreamMetadata![stream.id].video_muted;
+
+ if (!purpose) {
+ logger.warn(
+ `Call ${this.callId} pushRemoteFeed() ignoring stream because we didn't get any metadata about it (streamId=${stream.id})`,
+ );
+ return;
+ }
+
+ if (this.getFeedByStreamId(stream.id)) {
+ logger.warn(
+ `Call ${this.callId} pushRemoteFeed() ignoring stream because we already have a feed for it (streamId=${stream.id})`,
+ );
+ return;
+ }
+
+ this.feeds.push(
+ new CallFeed({
+ client: this.client,
+ call: this,
+ roomId: this.roomId,
+ userId,
+ deviceId: this.getOpponentDeviceId(),
+ stream,
+ purpose,
+ audioMuted,
+ videoMuted,
+ }),
+ );
+
+ this.emit(CallEvent.FeedsChanged, this.feeds, this);
+
+ logger.info(
+ `Call ${this.callId} pushRemoteFeed() pushed stream (streamId=${stream.id}, active=${stream.active}, purpose=${purpose})`,
+ );
+ }
+
+ /**
+ * This method is used ONLY if the other client doesn't support sending SDPStreamMetadata
+ */
+ private pushRemoteFeedWithoutMetadata(stream: MediaStream): void {
+ const userId = this.getOpponentMember()!.userId;
+ // We can guess the purpose here since the other client can only send one stream
+ const purpose = SDPStreamMetadataPurpose.Usermedia;
+ const oldRemoteStream = this.feeds.find((feed) => !feed.isLocal())?.stream;
+
+ // Note that we check by ID and always set the remote stream: Chrome appears
+ // to make new stream objects when transceiver directionality is changed and the 'active'
+ // status of streams change - Dave
+ // If we already have a stream, check this stream has the same id
+ if (oldRemoteStream && stream.id !== oldRemoteStream.id) {
+ logger.warn(
+ `Call ${this.callId} pushRemoteFeedWithoutMetadata() ignoring new stream because we already have stream (streamId=${stream.id})`,
+ );
+ return;
+ }
+
+ if (this.getFeedByStreamId(stream.id)) {
+ logger.warn(
+ `Call ${this.callId} pushRemoteFeedWithoutMetadata() ignoring stream because we already have a feed for it (streamId=${stream.id})`,
+ );
+ return;
+ }
+
+ this.feeds.push(
+ new CallFeed({
+ client: this.client,
+ call: this,
+ roomId: this.roomId,
+ audioMuted: false,
+ videoMuted: false,
+ userId,
+ deviceId: this.getOpponentDeviceId(),
+ stream,
+ purpose,
+ }),
+ );
+
+ this.emit(CallEvent.FeedsChanged, this.feeds, this);
+
+ logger.info(
+ `Call ${this.callId} pushRemoteFeedWithoutMetadata() pushed stream (streamId=${stream.id}, active=${stream.active})`,
+ );
+ }
+
+ private pushNewLocalFeed(stream: MediaStream, purpose: SDPStreamMetadataPurpose, addToPeerConnection = true): void {
+ const userId = this.client.getUserId()!;
+
+ // Tracks don't always start off enabled, eg. chrome will give a disabled
+ // audio track if you ask for user media audio and already had one that
+ // you'd set to disabled (presumably because it clones them internally).
+ setTracksEnabled(stream.getAudioTracks(), true);
+ setTracksEnabled(stream.getVideoTracks(), true);
+
+ if (this.getFeedByStreamId(stream.id)) {
+ logger.warn(
+ `Call ${this.callId} pushNewLocalFeed() ignoring stream because we already have a feed for it (streamId=${stream.id})`,
+ );
+ return;
+ }
+
+ this.pushLocalFeed(
+ new CallFeed({
+ client: this.client,
+ roomId: this.roomId,
+ audioMuted: false,
+ videoMuted: false,
+ userId,
+ deviceId: this.getOpponentDeviceId(),
+ stream,
+ purpose,
+ }),
+ addToPeerConnection,
+ );
+ }
+
+ /**
+ * Pushes supplied feed to the call
+ * @param callFeed - to push
+ * @param addToPeerConnection - whether to add the tracks to the peer connection
+ */
+ public pushLocalFeed(callFeed: CallFeed, addToPeerConnection = true): void {
+ if (this.feeds.some((feed) => callFeed.stream.id === feed.stream.id)) {
+ logger.info(
+ `Call ${this.callId} pushLocalFeed() ignoring duplicate local stream (streamId=${callFeed.stream.id})`,
+ );
+ return;
+ }
+
+ this.feeds.push(callFeed);
+
+ if (addToPeerConnection) {
+ for (const track of callFeed.stream.getTracks()) {
+ logger.info(
+ `Call ${this.callId} pushLocalFeed() adding track to peer connection (id=${track.id}, kind=${track.kind}, streamId=${callFeed.stream.id}, streamPurpose=${callFeed.purpose}, enabled=${track.enabled})`,
+ );
+
+ const tKey = getTransceiverKey(callFeed.purpose, track.kind);
+ if (this.transceivers.has(tKey)) {
+ // we already have a sender, so we re-use it. We try to re-use transceivers as much
+ // as possible because they can't be removed once added, so otherwise they just
+ // accumulate which makes the SDP very large very quickly: in fact it only takes
+ // about 6 video tracks to exceed the maximum size of an Olm-encrypted
+ // Matrix event.
+ const transceiver = this.transceivers.get(tKey)!;
+
+ transceiver.sender.replaceTrack(track);
+ // set the direction to indicate we're going to start sending again
+ // (this will trigger the re-negotiation)
+ transceiver.direction = transceiver.direction === "inactive" ? "sendonly" : "sendrecv";
+ } else {
+ // create a new one. We need to use addTrack rather addTransceiver for this because firefox
+ // doesn't yet implement RTCRTPSender.setStreams()
+ // (https://bugzilla.mozilla.org/show_bug.cgi?id=1510802) so we'd have no way to group the
+ // two tracks together into a stream.
+ const newSender = this.peerConn!.addTrack(track, callFeed.stream);
+
+ // now go & fish for the new transceiver
+ const newTransceiver = this.peerConn!.getTransceivers().find((t) => t.sender === newSender);
+ if (newTransceiver) {
+ this.transceivers.set(tKey, newTransceiver);
+ } else {
+ logger.warn(
+ `Call ${this.callId} pushLocalFeed() didn't find a matching transceiver after adding track!`,
+ );
+ }
+ }
+ }
+ }
+
+ logger.info(
+ `Call ${this.callId} pushLocalFeed() pushed stream (id=${callFeed.stream.id}, active=${callFeed.stream.active}, purpose=${callFeed.purpose})`,
+ );
+
+ this.emit(CallEvent.FeedsChanged, this.feeds, this);
+ }
+
+ /**
+ * Removes local call feed from the call and its tracks from the peer
+ * connection
+ * @param callFeed - to remove
+ */
+ public removeLocalFeed(callFeed: CallFeed): void {
+ const audioTransceiverKey = getTransceiverKey(callFeed.purpose, "audio");
+ const videoTransceiverKey = getTransceiverKey(callFeed.purpose, "video");
+
+ for (const transceiverKey of [audioTransceiverKey, videoTransceiverKey]) {
+ // this is slightly mixing the track and transceiver API but is basically just shorthand.
+ // There is no way to actually remove a transceiver, so this just sets it to inactive
+ // (or recvonly) and replaces the source with nothing.
+ if (this.transceivers.has(transceiverKey)) {
+ const transceiver = this.transceivers.get(transceiverKey)!;
+ if (transceiver.sender) this.peerConn!.removeTrack(transceiver.sender);
+ }
+ }
+
+ if (callFeed.purpose === SDPStreamMetadataPurpose.Screenshare) {
+ this.client.getMediaHandler().stopScreensharingStream(callFeed.stream);
+ }
+
+ this.deleteFeed(callFeed);
+ }
+
+ private deleteAllFeeds(): void {
+ for (const feed of this.feeds) {
+ if (!feed.isLocal() || !this.groupCallId) {
+ feed.dispose();
+ }
+ }
+
+ this.feeds = [];
+ this.emit(CallEvent.FeedsChanged, this.feeds, this);
+ }
+
+ private deleteFeedByStream(stream: MediaStream): void {
+ const feed = this.getFeedByStreamId(stream.id);
+ if (!feed) {
+ logger.warn(
+ `Call ${this.callId} deleteFeedByStream() didn't find the feed to delete (streamId=${stream.id})`,
+ );
+ return;
+ }
+ this.deleteFeed(feed);
+ }
+
+ private deleteFeed(feed: CallFeed): void {
+ feed.dispose();
+ this.feeds.splice(this.feeds.indexOf(feed), 1);
+ this.emit(CallEvent.FeedsChanged, this.feeds, this);
+ }
+
+ // The typescript definitions have this type as 'any' :(
+ public async getCurrentCallStats(): Promise<any[] | undefined> {
+ if (this.callHasEnded()) {
+ return this.callStatsAtEnd;
+ }
+
+ return this.collectCallStats();
+ }
+
+ private async collectCallStats(): Promise<any[] | undefined> {
+ // This happens when the call fails before it starts.
+ // For example when we fail to get capture sources
+ if (!this.peerConn) return;
+
+ const statsReport = await this.peerConn.getStats();
+ const stats: any[] = [];
+ statsReport.forEach((item) => {
+ stats.push(item);
+ });
+
+ return stats;
+ }
+
+ /**
+ * Configure this call from an invite event. Used by MatrixClient.
+ * @param event - The m.call.invite event
+ */
+ public async initWithInvite(event: MatrixEvent): Promise<void> {
+ const invite = event.getContent<MCallInviteNegotiate>();
+ this.direction = CallDirection.Inbound;
+
+ // make sure we have valid turn creds. Unless something's gone wrong, it should
+ // poll and keep the credentials valid so this should be instant.
+ const haveTurnCreds = await this.client.checkTurnServers();
+ if (!haveTurnCreds) {
+ logger.warn(
+ `Call ${this.callId} initWithInvite() failed to get TURN credentials! Proceeding with call anyway...`,
+ );
+ }
+
+ const sdpStreamMetadata = invite[SDPStreamMetadataKey];
+ if (sdpStreamMetadata) {
+ this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
+ } else {
+ logger.debug(
+ `Call ${this.callId} initWithInvite() did not get any SDPStreamMetadata! Can not send/receive multiple streams`,
+ );
+ }
+
+ this.peerConn = this.createPeerConnection();
+ // we must set the party ID before await-ing on anything: the call event
+ // handler will start giving us more call events (eg. candidates) so if
+ // we haven't set the party ID, we'll ignore them.
+ this.chooseOpponent(event);
+ await this.initOpponentCrypto();
+ try {
+ await this.peerConn.setRemoteDescription(invite.offer);
+ await this.addBufferedIceCandidates();
+ } catch (e) {
+ logger.debug(`Call ${this.callId} initWithInvite() failed to set remote description`, e);
+ this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false);
+ return;
+ }
+
+ const remoteStream = this.feeds.find((feed) => !feed.isLocal())?.stream;
+
+ // According to previous comments in this file, firefox at some point did not
+ // add streams until media started arriving on them. Testing latest firefox
+ // (81 at time of writing), this is no longer a problem, so let's do it the correct way.
+ //
+ // For example in case of no media webrtc connections like screen share only call we have to allow webrtc
+ // connections without remote media. In this case we always use a data channel. At the moment we allow as well
+ // only data channel as media in the WebRTC connection with this setup here.
+ if (!this.isOnlyDataChannelAllowed && (!remoteStream || remoteStream.getTracks().length === 0)) {
+ logger.error(
+ `Call ${this.callId} initWithInvite() no remote stream or no tracks after setting remote description!`,
+ );
+ this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false);
+ return;
+ }
+
+ this.state = CallState.Ringing;
+
+ if (event.getLocalAge()) {
+ // Time out the call if it's ringing for too long
+ const ringingTimer = setTimeout(() => {
+ if (this.state == CallState.Ringing) {
+ logger.debug(`Call ${this.callId} initWithInvite() invite has expired. Hanging up.`);
+ this.hangupParty = CallParty.Remote; // effectively
+ this.state = CallState.Ended;
+ this.stopAllMedia();
+ if (this.peerConn!.signalingState != "closed") {
+ this.peerConn!.close();
+ }
+ this.stats?.removeStatsReportGatherer(this.callId);
+ this.emit(CallEvent.Hangup, this);
+ }
+ }, invite.lifetime - event.getLocalAge());
+
+ const onState = (state: CallState): void => {
+ if (state !== CallState.Ringing) {
+ clearTimeout(ringingTimer);
+ this.off(CallEvent.State, onState);
+ }
+ };
+ this.on(CallEvent.State, onState);
+ }
+ }
+
+ /**
+ * Configure this call from a hangup or reject event. Used by MatrixClient.
+ * @param event - The m.call.hangup event
+ */
+ public initWithHangup(event: MatrixEvent): void {
+ // perverse as it may seem, sometimes we want to instantiate a call with a
+ // hangup message (because when getting the state of the room on load, events
+ // come in reverse order and we want to remember that a call has been hung up)
+ this.state = CallState.Ended;
+ }
+
+ private shouldAnswerWithMediaType(
+ wantedValue: boolean | undefined,
+ valueOfTheOtherSide: boolean,
+ type: "audio" | "video",
+ ): boolean {
+ if (wantedValue && !valueOfTheOtherSide) {
+ // TODO: Figure out how to do this
+ logger.warn(
+ `Call ${this.callId} shouldAnswerWithMediaType() unable to answer with ${type} because the other side isn't sending it either.`,
+ );
+ return false;
+ } else if (
+ !utils.isNullOrUndefined(wantedValue) &&
+ wantedValue !== valueOfTheOtherSide &&
+ !this.opponentSupportsSDPStreamMetadata()
+ ) {
+ logger.warn(
+ `Call ${this.callId} shouldAnswerWithMediaType() unable to answer with ${type}=${wantedValue} because the other side doesn't support it. Answering with ${type}=${valueOfTheOtherSide}.`,
+ );
+ return valueOfTheOtherSide!;
+ }
+ return wantedValue ?? valueOfTheOtherSide!;
+ }
+
+ /**
+ * Answer a call.
+ */
+ public async answer(audio?: boolean, video?: boolean): Promise<void> {
+ if (this.inviteOrAnswerSent) return;
+ // TODO: Figure out how to do this
+ if (audio === false && video === false) throw new Error("You CANNOT answer a call without media");
+
+ if (!this.localUsermediaStream && !this.waitForLocalAVStream) {
+ const prevState = this.state;
+ const answerWithAudio = this.shouldAnswerWithMediaType(audio, this.hasRemoteUserMediaAudioTrack, "audio");
+ const answerWithVideo = this.shouldAnswerWithMediaType(video, this.hasRemoteUserMediaVideoTrack, "video");
+
+ this.state = CallState.WaitLocalMedia;
+ this.waitForLocalAVStream = true;
+
+ try {
+ const stream = await this.client.getMediaHandler().getUserMediaStream(answerWithAudio, answerWithVideo);
+ this.waitForLocalAVStream = false;
+ const usermediaFeed = new CallFeed({
+ client: this.client,
+ roomId: this.roomId,
+ userId: this.client.getUserId()!,
+ deviceId: this.client.getDeviceId() ?? undefined,
+ stream,
+ purpose: SDPStreamMetadataPurpose.Usermedia,
+ audioMuted: false,
+ videoMuted: false,
+ });
+
+ const feeds = [usermediaFeed];
+
+ if (this.localScreensharingFeed) {
+ feeds.push(this.localScreensharingFeed);
+ }
+
+ this.answerWithCallFeeds(feeds);
+ } catch (e) {
+ if (answerWithVideo) {
+ // Try to answer without video
+ logger.warn(
+ `Call ${this.callId} answer() failed to getUserMedia(), trying to getUserMedia() without video`,
+ );
+ this.state = prevState;
+ this.waitForLocalAVStream = false;
+ await this.answer(answerWithAudio, false);
+ } else {
+ this.getUserMediaFailed(<Error>e);
+ return;
+ }
+ }
+ } else if (this.waitForLocalAVStream) {
+ this.state = CallState.WaitLocalMedia;
+ }
+ }
+
+ public answerWithCallFeeds(callFeeds: CallFeed[]): void {
+ if (this.inviteOrAnswerSent) return;
+
+ this.queueGotCallFeedsForAnswer(callFeeds);
+ }
+
+ /**
+ * Replace this call with a new call, e.g. for glare resolution. Used by
+ * MatrixClient.
+ * @param newCall - The new call.
+ */
+ public replacedBy(newCall: MatrixCall): void {
+ logger.debug(`Call ${this.callId} replacedBy() running (newCallId=${newCall.callId})`);
+ if (this.state === CallState.WaitLocalMedia) {
+ logger.debug(
+ `Call ${this.callId} replacedBy() telling new call to wait for local media (newCallId=${newCall.callId})`,
+ );
+ newCall.waitForLocalAVStream = true;
+ } else if ([CallState.CreateOffer, CallState.InviteSent].includes(this.state)) {
+ if (newCall.direction === CallDirection.Outbound) {
+ newCall.queueGotCallFeedsForAnswer([]);
+ } else {
+ logger.debug(
+ `Call ${this.callId} replacedBy() handing local stream to new call(newCallId=${newCall.callId})`,
+ );
+ newCall.queueGotCallFeedsForAnswer(this.getLocalFeeds().map((feed) => feed.clone()));
+ }
+ }
+ this.successor = newCall;
+ this.emit(CallEvent.Replaced, newCall, this);
+ this.hangup(CallErrorCode.Replaced, true);
+ }
+
+ /**
+ * Hangup a call.
+ * @param reason - The reason why the call is being hung up.
+ * @param suppressEvent - True to suppress emitting an event.
+ */
+ public hangup(reason: CallErrorCode, suppressEvent: boolean): void {
+ if (this.callHasEnded()) return;
+
+ logger.debug(`Call ${this.callId} hangup() ending call (reason=${reason})`);
+ this.terminate(CallParty.Local, reason, !suppressEvent);
+ // We don't want to send hangup here if we didn't even get to sending an invite
+ if ([CallState.Fledgling, CallState.WaitLocalMedia].includes(this.state)) return;
+ const content: IContent = {};
+ // Don't send UserHangup reason to older clients
+ if ((this.opponentVersion && this.opponentVersion !== 0) || reason !== CallErrorCode.UserHangup) {
+ content["reason"] = reason;
+ }
+ this.sendVoipEvent(EventType.CallHangup, content);
+ }
+
+ /**
+ * Reject a call
+ * This used to be done by calling hangup, but is a separate method and protocol
+ * event as of MSC2746.
+ */
+ public reject(): void {
+ if (this.state !== CallState.Ringing) {
+ throw Error("Call must be in 'ringing' state to reject!");
+ }
+
+ if (this.opponentVersion === 0) {
+ logger.info(
+ `Call ${this.callId} reject() opponent version is less than 1: sending hangup instead of reject (opponentVersion=${this.opponentVersion})`,
+ );
+ this.hangup(CallErrorCode.UserHangup, true);
+ return;
+ }
+
+ logger.debug("Rejecting call: " + this.callId);
+ this.terminate(CallParty.Local, CallErrorCode.UserHangup, true);
+ this.sendVoipEvent(EventType.CallReject, {});
+ }
+
+ /**
+ * Adds an audio and/or video track - upgrades the call
+ * @param audio - should add an audio track
+ * @param video - should add an video track
+ */
+ private async upgradeCall(audio: boolean, video: boolean): Promise<void> {
+ // We don't do call downgrades
+ if (!audio && !video) return;
+ if (!this.opponentSupportsSDPStreamMetadata()) return;
+
+ try {
+ logger.debug(`Call ${this.callId} upgradeCall() upgrading call (audio=${audio}, video=${video})`);
+ const getAudio = audio || this.hasLocalUserMediaAudioTrack;
+ const getVideo = video || this.hasLocalUserMediaVideoTrack;
+
+ // updateLocalUsermediaStream() will take the tracks, use them as
+ // replacement and throw the stream away, so it isn't reusable
+ const stream = await this.client.getMediaHandler().getUserMediaStream(getAudio, getVideo, false);
+ await this.updateLocalUsermediaStream(stream, audio, video);
+ } catch (error) {
+ logger.error(`Call ${this.callId} upgradeCall() failed to upgrade the call`, error);
+ this.emit(
+ CallEvent.Error,
+ new CallError(CallErrorCode.NoUserMedia, "Failed to get camera access: ", <Error>error),
+ this,
+ );
+ }
+ }
+
+ /**
+ * Returns true if this.remoteSDPStreamMetadata is defined, otherwise returns false
+ * @returns can screenshare
+ */
+ public opponentSupportsSDPStreamMetadata(): boolean {
+ return Boolean(this.remoteSDPStreamMetadata);
+ }
+
+ /**
+ * If there is a screensharing stream returns true, otherwise returns false
+ * @returns is screensharing
+ */
+ public isScreensharing(): boolean {
+ return Boolean(this.localScreensharingStream);
+ }
+
+ /**
+ * Starts/stops screensharing
+ * @param enabled - the desired screensharing state
+ * @param desktopCapturerSourceId - optional id of the desktop capturer source to use
+ * @returns new screensharing state
+ */
+ public async setScreensharingEnabled(enabled: boolean, opts?: IScreensharingOpts): Promise<boolean> {
+ // Skip if there is nothing to do
+ if (enabled && this.isScreensharing()) {
+ logger.warn(
+ `Call ${this.callId} setScreensharingEnabled() there is already a screensharing stream - there is nothing to do!`,
+ );
+ return true;
+ } else if (!enabled && !this.isScreensharing()) {
+ logger.warn(
+ `Call ${this.callId} setScreensharingEnabled() there already isn't a screensharing stream - there is nothing to do!`,
+ );
+ return false;
+ }
+
+ // Fallback to replaceTrack()
+ if (!this.opponentSupportsSDPStreamMetadata()) {
+ return this.setScreensharingEnabledWithoutMetadataSupport(enabled, opts);
+ }
+
+ logger.debug(`Call ${this.callId} setScreensharingEnabled() running (enabled=${enabled})`);
+ if (enabled) {
+ try {
+ const stream = await this.client.getMediaHandler().getScreensharingStream(opts);
+ if (!stream) return false;
+ this.pushNewLocalFeed(stream, SDPStreamMetadataPurpose.Screenshare);
+ return true;
+ } catch (err) {
+ logger.error(`Call ${this.callId} setScreensharingEnabled() failed to get screen-sharing stream:`, err);
+ return false;
+ }
+ } else {
+ const audioTransceiver = this.transceivers.get(
+ getTransceiverKey(SDPStreamMetadataPurpose.Screenshare, "audio"),
+ );
+ const videoTransceiver = this.transceivers.get(
+ getTransceiverKey(SDPStreamMetadataPurpose.Screenshare, "video"),
+ );
+
+ for (const transceiver of [audioTransceiver, videoTransceiver]) {
+ // this is slightly mixing the track and transceiver API but is basically just shorthand
+ // for removing the sender.
+ if (transceiver && transceiver.sender) this.peerConn!.removeTrack(transceiver.sender);
+ }
+
+ this.client.getMediaHandler().stopScreensharingStream(this.localScreensharingStream!);
+ this.deleteFeedByStream(this.localScreensharingStream!);
+ return false;
+ }
+ }
+
+ /**
+ * Starts/stops screensharing
+ * Should be used ONLY if the opponent doesn't support SDPStreamMetadata
+ * @param enabled - the desired screensharing state
+ * @param desktopCapturerSourceId - optional id of the desktop capturer source to use
+ * @returns new screensharing state
+ */
+ private async setScreensharingEnabledWithoutMetadataSupport(
+ enabled: boolean,
+ opts?: IScreensharingOpts,
+ ): Promise<boolean> {
+ logger.debug(
+ `Call ${this.callId} setScreensharingEnabledWithoutMetadataSupport() running (enabled=${enabled})`,
+ );
+ if (enabled) {
+ try {
+ const stream = await this.client.getMediaHandler().getScreensharingStream(opts);
+ if (!stream) return false;
+
+ const track = stream.getTracks().find((track) => track.kind === "video");
+
+ const sender = this.transceivers.get(
+ getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "video"),
+ )?.sender;
+
+ sender?.replaceTrack(track ?? null);
+
+ this.pushNewLocalFeed(stream, SDPStreamMetadataPurpose.Screenshare, false);
+
+ return true;
+ } catch (err) {
+ logger.error(
+ `Call ${this.callId} setScreensharingEnabledWithoutMetadataSupport() failed to get screen-sharing stream:`,
+ err,
+ );
+ return false;
+ }
+ } else {
+ const track = this.localUsermediaStream?.getTracks().find((track) => track.kind === "video");
+ const sender = this.transceivers.get(
+ getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, "video"),
+ )?.sender;
+ sender?.replaceTrack(track ?? null);
+
+ this.client.getMediaHandler().stopScreensharingStream(this.localScreensharingStream!);
+ this.deleteFeedByStream(this.localScreensharingStream!);
+
+ return false;
+ }
+ }
+
+ /**
+ * Replaces/adds the tracks from the passed stream to the localUsermediaStream
+ * @param stream - to use a replacement for the local usermedia stream
+ */
+ public async updateLocalUsermediaStream(
+ stream: MediaStream,
+ forceAudio = false,
+ forceVideo = false,
+ ): Promise<void> {
+ const callFeed = this.localUsermediaFeed!;
+ const audioEnabled = forceAudio || (!callFeed.isAudioMuted() && !this.remoteOnHold);
+ const videoEnabled = forceVideo || (!callFeed.isVideoMuted() && !this.remoteOnHold);
+ logger.log(
+ `Call ${this.callId} updateLocalUsermediaStream() running (streamId=${stream.id}, audio=${audioEnabled}, video=${videoEnabled})`,
+ );
+ setTracksEnabled(stream.getAudioTracks(), audioEnabled);
+ setTracksEnabled(stream.getVideoTracks(), videoEnabled);
+
+ // We want to keep the same stream id, so we replace the tracks rather
+ // than the whole stream.
+
+ // Firstly, we replace the tracks in our localUsermediaStream.
+ for (const track of this.localUsermediaStream!.getTracks()) {
+ this.localUsermediaStream!.removeTrack(track);
+ track.stop();
+ }
+ for (const track of stream.getTracks()) {
+ this.localUsermediaStream!.addTrack(track);
+ }
+
+ // Then replace the old tracks, if possible.
+ for (const track of stream.getTracks()) {
+ const tKey = getTransceiverKey(SDPStreamMetadataPurpose.Usermedia, track.kind);
+
+ const transceiver = this.transceivers.get(tKey);
+ const oldSender = transceiver?.sender;
+ let added = false;
+ if (oldSender) {
+ try {
+ logger.info(
+ `Call ${this.callId} updateLocalUsermediaStream() replacing track (id=${track.id}, kind=${track.kind}, streamId=${stream.id}, streamPurpose=${callFeed.purpose})`,
+ );
+ await oldSender.replaceTrack(track);
+ // Set the direction to indicate we're going to be sending.
+ // This is only necessary in the cases where we're upgrading
+ // the call to video after downgrading it.
+ transceiver.direction = transceiver.direction === "inactive" ? "sendonly" : "sendrecv";
+ added = true;
+ } catch (error) {
+ logger.warn(
+ `Call ${this.callId} updateLocalUsermediaStream() replaceTrack failed: adding new transceiver instead`,
+ error,
+ );
+ }
+ }
+
+ if (!added) {
+ logger.info(
+ `Call ${this.callId} updateLocalUsermediaStream() adding track to peer connection (id=${track.id}, kind=${track.kind}, streamId=${stream.id}, streamPurpose=${callFeed.purpose})`,
+ );
+
+ const newSender = this.peerConn!.addTrack(track, this.localUsermediaStream!);
+ const newTransceiver = this.peerConn!.getTransceivers().find((t) => t.sender === newSender);
+ if (newTransceiver) {
+ this.transceivers.set(tKey, newTransceiver);
+ } else {
+ logger.warn(
+ `Call ${this.callId} updateLocalUsermediaStream() couldn't find matching transceiver for newly added track!`,
+ );
+ }
+ }
+ }
+ }
+
+ /**
+ * Set whether our outbound video should be muted or not.
+ * @param muted - True to mute the outbound video.
+ * @returns the new mute state
+ */
+ public async setLocalVideoMuted(muted: boolean): Promise<boolean> {
+ logger.log(`Call ${this.callId} setLocalVideoMuted() running ${muted}`);
+
+ // if we were still thinking about stopping and removing the video
+ // track: don't, because we want it back.
+ if (!muted && this.stopVideoTrackTimer !== undefined) {
+ clearTimeout(this.stopVideoTrackTimer);
+ this.stopVideoTrackTimer = undefined;
+ }
+
+ if (!(await this.client.getMediaHandler().hasVideoDevice())) {
+ return this.isLocalVideoMuted();
+ }
+
+ if (!this.hasUserMediaVideoSender && !muted) {
+ this.localUsermediaFeed?.setAudioVideoMuted(null, muted);
+ await this.upgradeCall(false, true);
+ return this.isLocalVideoMuted();
+ }
+
+ // we may not have a video track - if not, re-request usermedia
+ if (!muted && this.localUsermediaStream!.getVideoTracks().length === 0) {
+ const stream = await this.client.getMediaHandler().getUserMediaStream(true, true);
+ await this.updateLocalUsermediaStream(stream);
+ }
+
+ this.localUsermediaFeed?.setAudioVideoMuted(null, muted);
+
+ this.updateMuteStatus();
+ await this.sendMetadataUpdate();
+
+ // if we're muting video, set a timeout to stop & remove the video track so we release
+ // the camera. We wait a short time to do this because when we disable a track, WebRTC
+ // will send black video for it. If we just stop and remove it straight away, the video
+ // will just freeze which means that when we unmute video, the other side will briefly
+ // get a static frame of us from before we muted. This way, the still frame is just black.
+ // A very small delay is not always enough so the theory here is that it needs to be long
+ // enough for WebRTC to encode a frame: 120ms should be long enough even if we're only
+ // doing 10fps.
+ if (muted) {
+ this.stopVideoTrackTimer = setTimeout(() => {
+ for (const t of this.localUsermediaStream!.getVideoTracks()) {
+ t.stop();
+ this.localUsermediaStream!.removeTrack(t);
+ }
+ }, 120);
+ }
+
+ return this.isLocalVideoMuted();
+ }
+
+ /**
+ * Check if local video is muted.
+ *
+ * If there are multiple video tracks, <i>all</i> of the tracks need to be muted
+ * for this to return true. This means if there are no video tracks, this will
+ * return true.
+ * @returns True if the local preview video is muted, else false
+ * (including if the call is not set up yet).
+ */
+ public isLocalVideoMuted(): boolean {
+ return this.localUsermediaFeed?.isVideoMuted() ?? false;
+ }
+
+ /**
+ * Set whether the microphone should be muted or not.
+ * @param muted - True to mute the mic.
+ * @returns the new mute state
+ */
+ public async setMicrophoneMuted(muted: boolean): Promise<boolean> {
+ logger.log(`Call ${this.callId} setMicrophoneMuted() running ${muted}`);
+ if (!(await this.client.getMediaHandler().hasAudioDevice())) {
+ return this.isMicrophoneMuted();
+ }
+
+ if (!muted && (!this.hasUserMediaAudioSender || !this.hasLocalUserMediaAudioTrack)) {
+ await this.upgradeCall(true, false);
+ return this.isMicrophoneMuted();
+ }
+ this.localUsermediaFeed?.setAudioVideoMuted(muted, null);
+ this.updateMuteStatus();
+ await this.sendMetadataUpdate();
+ return this.isMicrophoneMuted();
+ }
+
+ /**
+ * Check if the microphone is muted.
+ *
+ * If there are multiple audio tracks, <i>all</i> of the tracks need to be muted
+ * for this to return true. This means if there are no audio tracks, this will
+ * return true.
+ * @returns True if the mic is muted, else false (including if the call
+ * is not set up yet).
+ */
+ public isMicrophoneMuted(): boolean {
+ return this.localUsermediaFeed?.isAudioMuted() ?? false;
+ }
+
+ /**
+ * @returns true if we have put the party on the other side of the call on hold
+ * (that is, we are signalling to them that we are not listening)
+ */
+ public isRemoteOnHold(): boolean {
+ return this.remoteOnHold;
+ }
+
+ public setRemoteOnHold(onHold: boolean): void {
+ if (this.isRemoteOnHold() === onHold) return;
+ this.remoteOnHold = onHold;
+
+ for (const transceiver of this.peerConn!.getTransceivers()) {
+ // We don't send hold music or anything so we're not actually
+ // sending anything, but sendrecv is fairly standard for hold and
+ // it makes it a lot easier to figure out who's put who on hold.
+ transceiver.direction = onHold ? "sendonly" : "sendrecv";
+ }
+ this.updateMuteStatus();
+ this.sendMetadataUpdate();
+
+ this.emit(CallEvent.RemoteHoldUnhold, this.remoteOnHold, this);
+ }
+
+ /**
+ * Indicates whether we are 'on hold' to the remote party (ie. if true,
+ * they cannot hear us).
+ * @returns true if the other party has put us on hold
+ */
+ public isLocalOnHold(): boolean {
+ if (this.state !== CallState.Connected) return false;
+
+ let callOnHold = true;
+
+ // We consider a call to be on hold only if *all* the tracks are on hold
+ // (is this the right thing to do?)
+ for (const transceiver of this.peerConn!.getTransceivers()) {
+ const trackOnHold = ["inactive", "recvonly"].includes(transceiver.currentDirection!);
+
+ if (!trackOnHold) callOnHold = false;
+ }
+
+ return callOnHold;
+ }
+
+ /**
+ * Sends a DTMF digit to the other party
+ * @param digit - The digit (nb. string - '#' and '*' are dtmf too)
+ */
+ public sendDtmfDigit(digit: string): void {
+ for (const sender of this.peerConn!.getSenders()) {
+ if (sender.track?.kind === "audio" && sender.dtmf) {
+ sender.dtmf.insertDTMF(digit);
+ return;
+ }
+ }
+
+ throw new Error("Unable to find a track to send DTMF on");
+ }
+
+ private updateMuteStatus(): void {
+ const micShouldBeMuted = this.isMicrophoneMuted() || this.remoteOnHold;
+ const vidShouldBeMuted = this.isLocalVideoMuted() || this.remoteOnHold;
+
+ logger.log(
+ `Call ${this.callId} updateMuteStatus stream ${
+ this.localUsermediaStream!.id
+ } micShouldBeMuted ${micShouldBeMuted} vidShouldBeMuted ${vidShouldBeMuted}`,
+ );
+
+ setTracksEnabled(this.localUsermediaStream!.getAudioTracks(), !micShouldBeMuted);
+ setTracksEnabled(this.localUsermediaStream!.getVideoTracks(), !vidShouldBeMuted);
+ }
+
+ public async sendMetadataUpdate(): Promise<void> {
+ await this.sendVoipEvent(EventType.CallSDPStreamMetadataChangedPrefix, {
+ [SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(),
+ });
+ }
+
+ private gotCallFeedsForInvite(callFeeds: CallFeed[], requestScreenshareFeed = false): void {
+ if (this.successor) {
+ this.successor.queueGotCallFeedsForAnswer(callFeeds);
+ return;
+ }
+ if (this.callHasEnded()) {
+ this.stopAllMedia();
+ return;
+ }
+
+ for (const feed of callFeeds) {
+ this.pushLocalFeed(feed);
+ }
+
+ if (requestScreenshareFeed) {
+ this.peerConn!.addTransceiver("video", {
+ direction: "recvonly",
+ });
+ }
+
+ this.state = CallState.CreateOffer;
+
+ logger.debug(`Call ${this.callId} gotUserMediaForInvite() run`);
+ // Now we wait for the negotiationneeded event
+ }
+
+ private async sendAnswer(): Promise<void> {
+ const answerContent = {
+ answer: {
+ sdp: this.peerConn!.localDescription!.sdp,
+ // type is now deprecated as of Matrix VoIP v1, but
+ // required to still be sent for backwards compat
+ type: this.peerConn!.localDescription!.type,
+ },
+ [SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true),
+ } as MCallAnswer;
+
+ answerContent.capabilities = {
+ "m.call.transferee": this.client.supportsCallTransfer,
+ "m.call.dtmf": false,
+ };
+
+ // We have just taken the local description from the peerConn which will
+ // contain all the local candidates added so far, so we can discard any candidates
+ // we had queued up because they'll be in the answer.
+ const discardCount = this.discardDuplicateCandidates();
+ logger.info(
+ `Call ${this.callId} sendAnswer() discarding ${discardCount} candidates that will be sent in answer`,
+ );
+
+ try {
+ await this.sendVoipEvent(EventType.CallAnswer, answerContent);
+ // If this isn't the first time we've tried to send the answer,
+ // we may have candidates queued up, so send them now.
+ this.inviteOrAnswerSent = true;
+ } catch (error) {
+ // We've failed to answer: back to the ringing state
+ this.state = CallState.Ringing;
+ if (error instanceof MatrixError && error.event) this.client.cancelPendingEvent(error.event);
+
+ let code = CallErrorCode.SendAnswer;
+ let message = "Failed to send answer";
+ if ((<Error>error).name == "UnknownDeviceError") {
+ code = CallErrorCode.UnknownDevices;
+ message = "Unknown devices present in the room";
+ }
+ this.emit(CallEvent.Error, new CallError(code, message, <Error>error), this);
+ throw error;
+ }
+
+ // error handler re-throws so this won't happen on error, but
+ // we don't want the same error handling on the candidate queue
+ this.sendCandidateQueue();
+ }
+
+ private queueGotCallFeedsForAnswer(callFeeds: CallFeed[]): void {
+ // Ensure only one negotiate/answer event is being processed at a time.
+ if (this.responsePromiseChain) {
+ this.responsePromiseChain = this.responsePromiseChain.then(() => this.gotCallFeedsForAnswer(callFeeds));
+ } else {
+ this.responsePromiseChain = this.gotCallFeedsForAnswer(callFeeds);
+ }
+ }
+
+ // Enables DTX (discontinuous transmission) on the given session to reduce
+ // bandwidth when transmitting silence
+ private mungeSdp(description: RTCSessionDescriptionInit, mods: CodecParamsMod[]): void {
+ // The only way to enable DTX at this time is through SDP munging
+ const sdp = parseSdp(description.sdp!);
+
+ sdp.media.forEach((media) => {
+ const payloadTypeToCodecMap = new Map<number, string>();
+ const codecToPayloadTypeMap = new Map<string, number>();
+ for (const rtp of media.rtp) {
+ payloadTypeToCodecMap.set(rtp.payload, rtp.codec);
+ codecToPayloadTypeMap.set(rtp.codec, rtp.payload);
+ }
+
+ for (const mod of mods) {
+ if (mod.mediaType !== media.type) continue;
+
+ if (!codecToPayloadTypeMap.has(mod.codec)) {
+ logger.info(
+ `Call ${this.callId} mungeSdp() ignoring SDP modifications for ${mod.codec} as it's not present.`,
+ );
+ continue;
+ }
+
+ const extraConfig: string[] = [];
+ if (mod.enableDtx !== undefined) {
+ extraConfig.push(`usedtx=${mod.enableDtx ? "1" : "0"}`);
+ }
+ if (mod.maxAverageBitrate !== undefined) {
+ extraConfig.push(`maxaveragebitrate=${mod.maxAverageBitrate}`);
+ }
+
+ let found = false;
+ for (const fmtp of media.fmtp) {
+ if (payloadTypeToCodecMap.get(fmtp.payload) === mod.codec) {
+ found = true;
+ fmtp.config += ";" + extraConfig.join(";");
+ }
+ }
+ if (!found) {
+ media.fmtp.push({
+ payload: codecToPayloadTypeMap.get(mod.codec)!,
+ config: extraConfig.join(";"),
+ });
+ }
+ }
+ });
+ description.sdp = writeSdp(sdp);
+ }
+
+ private async createOffer(): Promise<RTCSessionDescriptionInit> {
+ const offer = await this.peerConn!.createOffer();
+ this.mungeSdp(offer, getCodecParamMods(this.isPtt));
+ return offer;
+ }
+
+ private async createAnswer(): Promise<RTCSessionDescriptionInit> {
+ const answer = await this.peerConn!.createAnswer();
+ this.mungeSdp(answer, getCodecParamMods(this.isPtt));
+ return answer;
+ }
+
+ private async gotCallFeedsForAnswer(callFeeds: CallFeed[]): Promise<void> {
+ if (this.callHasEnded()) return;
+
+ this.waitForLocalAVStream = false;
+
+ for (const feed of callFeeds) {
+ this.pushLocalFeed(feed);
+ }
+
+ this.state = CallState.CreateAnswer;
+
+ let answer: RTCSessionDescriptionInit;
+ try {
+ this.getRidOfRTXCodecs();
+ answer = await this.createAnswer();
+ } catch (err) {
+ logger.debug(`Call ${this.callId} gotCallFeedsForAnswer() failed to create answer: `, err);
+ this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, true);
+ return;
+ }
+
+ try {
+ await this.peerConn!.setLocalDescription(answer);
+
+ // make sure we're still going
+ if (this.callHasEnded()) return;
+
+ this.state = CallState.Connecting;
+
+ // Allow a short time for initial candidates to be gathered
+ await new Promise((resolve) => {
+ setTimeout(resolve, 200);
+ });
+
+ // make sure the call hasn't ended before we continue
+ if (this.callHasEnded()) return;
+
+ this.sendAnswer();
+ } catch (err) {
+ logger.debug(`Call ${this.callId} gotCallFeedsForAnswer() error setting local description!`, err);
+ this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true);
+ return;
+ }
+ }
+
+ /**
+ * Internal
+ */
+ private gotLocalIceCandidate = (event: RTCPeerConnectionIceEvent): void => {
+ if (event.candidate) {
+ if (this.candidatesEnded) {
+ logger.warn(
+ `Call ${this.callId} gotLocalIceCandidate() got candidate after candidates have ended - ignoring!`,
+ );
+ return;
+ }
+
+ logger.debug(`Call ${this.callId} got local ICE ${event.candidate.sdpMid} ${event.candidate.candidate}`);
+
+ if (this.callHasEnded()) return;
+
+ // As with the offer, note we need to make a copy of this object, not
+ // pass the original: that broke in Chrome ~m43.
+ if (event.candidate.candidate === "") {
+ this.queueCandidate(null);
+ } else {
+ this.queueCandidate(event.candidate);
+ }
+ }
+ };
+
+ private onIceGatheringStateChange = (event: Event): void => {
+ logger.debug(
+ `Call ${this.callId} onIceGatheringStateChange() ice gathering state changed to ${
+ this.peerConn!.iceGatheringState
+ }`,
+ );
+ if (this.peerConn?.iceGatheringState === "complete") {
+ this.queueCandidate(null);
+ }
+ };
+
+ public async onRemoteIceCandidatesReceived(ev: MatrixEvent): Promise<void> {
+ if (this.callHasEnded()) {
+ //debuglog("Ignoring remote ICE candidate because call has ended");
+ return;
+ }
+
+ const content = ev.getContent<MCallCandidates>();
+ const candidates = content.candidates;
+ if (!candidates) {
+ logger.info(
+ `Call ${this.callId} onRemoteIceCandidatesReceived() ignoring candidates event with no candidates!`,
+ );
+ return;
+ }
+
+ const fromPartyId = content.version === 0 ? null : content.party_id || null;
+
+ if (this.opponentPartyId === undefined) {
+ // we haven't picked an opponent yet so save the candidates
+ if (fromPartyId) {
+ logger.info(
+ `Call ${this.callId} onRemoteIceCandidatesReceived() buffering ${candidates.length} candidates until we pick an opponent`,
+ );
+ const bufferedCandidates = this.remoteCandidateBuffer.get(fromPartyId) || [];
+ bufferedCandidates.push(...candidates);
+ this.remoteCandidateBuffer.set(fromPartyId, bufferedCandidates);
+ }
+ return;
+ }
+
+ if (!this.partyIdMatches(content)) {
+ logger.info(
+ `Call ${this.callId} onRemoteIceCandidatesReceived() ignoring candidates from party ID ${content.party_id}: we have chosen party ID ${this.opponentPartyId}`,
+ );
+
+ return;
+ }
+
+ await this.addIceCandidates(candidates);
+ }
+
+ /**
+ * Used by MatrixClient.
+ */
+ public async onAnswerReceived(event: MatrixEvent): Promise<void> {
+ const content = event.getContent<MCallAnswer>();
+ logger.debug(`Call ${this.callId} onAnswerReceived() running (hangupParty=${content.party_id})`);
+
+ if (this.callHasEnded()) {
+ logger.debug(`Call ${this.callId} onAnswerReceived() ignoring answer because call has ended`);
+ return;
+ }
+
+ if (this.opponentPartyId !== undefined) {
+ logger.info(
+ `Call ${this.callId} onAnswerReceived() ignoring answer from party ID ${content.party_id}: we already have an answer/reject from ${this.opponentPartyId}`,
+ );
+ return;
+ }
+
+ this.chooseOpponent(event);
+ await this.addBufferedIceCandidates();
+
+ this.state = CallState.Connecting;
+
+ const sdpStreamMetadata = content[SDPStreamMetadataKey];
+ if (sdpStreamMetadata) {
+ this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
+ } else {
+ logger.warn(
+ `Call ${this.callId} onAnswerReceived() did not get any SDPStreamMetadata! Can not send/receive multiple streams`,
+ );
+ }
+
+ try {
+ await this.peerConn!.setRemoteDescription(content.answer);
+ } catch (e) {
+ logger.debug(`Call ${this.callId} onAnswerReceived() failed to set remote description`, e);
+ this.terminate(CallParty.Local, CallErrorCode.SetRemoteDescription, false);
+ return;
+ }
+
+ // If the answer we selected has a party_id, send a select_answer event
+ // We do this after setting the remote description since otherwise we'd block
+ // call setup on it
+ if (this.opponentPartyId !== null) {
+ try {
+ await this.sendVoipEvent(EventType.CallSelectAnswer, {
+ selected_party_id: this.opponentPartyId,
+ });
+ } catch (err) {
+ // This isn't fatal, and will just mean that if another party has raced to answer
+ // the call, they won't know they got rejected, so we carry on & don't retry.
+ logger.warn(`Call ${this.callId} onAnswerReceived() failed to send select_answer event`, err);
+ }
+ }
+ }
+
+ public async onSelectAnswerReceived(event: MatrixEvent): Promise<void> {
+ if (this.direction !== CallDirection.Inbound) {
+ logger.warn(
+ `Call ${this.callId} onSelectAnswerReceived() got select_answer for an outbound call: ignoring`,
+ );
+ return;
+ }
+
+ const selectedPartyId = event.getContent<MCallSelectAnswer>().selected_party_id;
+
+ if (selectedPartyId === undefined || selectedPartyId === null) {
+ logger.warn(
+ `Call ${this.callId} onSelectAnswerReceived() got nonsensical select_answer with null/undefined selected_party_id: ignoring`,
+ );
+ return;
+ }
+
+ if (selectedPartyId !== this.ourPartyId) {
+ logger.info(
+ `Call ${this.callId} onSelectAnswerReceived() got select_answer for party ID ${selectedPartyId}: we are party ID ${this.ourPartyId}.`,
+ );
+ // The other party has picked somebody else's answer
+ await this.terminate(CallParty.Remote, CallErrorCode.AnsweredElsewhere, true);
+ }
+ }
+
+ public async onNegotiateReceived(event: MatrixEvent): Promise<void> {
+ const content = event.getContent<MCallInviteNegotiate>();
+ const description = content.description;
+ if (!description || !description.sdp || !description.type) {
+ logger.info(`Call ${this.callId} onNegotiateReceived() ignoring invalid m.call.negotiate event`);
+ return;
+ }
+ // Politeness always follows the direction of the call: in a glare situation,
+ // we pick either the inbound or outbound call, so one side will always be
+ // inbound and one outbound
+ const polite = this.direction === CallDirection.Inbound;
+
+ // Here we follow the perfect negotiation logic from
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Perfect_negotiation
+ const offerCollision =
+ description.type === "offer" && (this.makingOffer || this.peerConn!.signalingState !== "stable");
+
+ this.ignoreOffer = !polite && offerCollision;
+ if (this.ignoreOffer) {
+ logger.info(
+ `Call ${this.callId} onNegotiateReceived() ignoring colliding negotiate event because we're impolite`,
+ );
+ return;
+ }
+
+ const prevLocalOnHold = this.isLocalOnHold();
+
+ const sdpStreamMetadata = content[SDPStreamMetadataKey];
+ if (sdpStreamMetadata) {
+ this.updateRemoteSDPStreamMetadata(sdpStreamMetadata);
+ } else {
+ logger.warn(
+ `Call ${this.callId} onNegotiateReceived() received negotiation event without SDPStreamMetadata!`,
+ );
+ }
+
+ try {
+ await this.peerConn!.setRemoteDescription(description);
+
+ if (description.type === "offer") {
+ let answer: RTCSessionDescriptionInit;
+ try {
+ this.getRidOfRTXCodecs();
+ answer = await this.createAnswer();
+ } catch (err) {
+ logger.debug(`Call ${this.callId} onNegotiateReceived() failed to create answer: `, err);
+ this.terminate(CallParty.Local, CallErrorCode.CreateAnswer, true);
+ return;
+ }
+
+ await this.peerConn!.setLocalDescription(answer);
+
+ this.sendVoipEvent(EventType.CallNegotiate, {
+ description: this.peerConn!.localDescription?.toJSON(),
+ [SDPStreamMetadataKey]: this.getLocalSDPStreamMetadata(true),
+ });
+ }
+ } catch (err) {
+ logger.warn(`Call ${this.callId} onNegotiateReceived() failed to complete negotiation`, err);
+ }
+
+ const newLocalOnHold = this.isLocalOnHold();
+ if (prevLocalOnHold !== newLocalOnHold) {
+ this.emit(CallEvent.LocalHoldUnhold, newLocalOnHold, this);
+ // also this one for backwards compat
+ this.emit(CallEvent.HoldUnhold, newLocalOnHold);
+ }
+ }
+
+ private updateRemoteSDPStreamMetadata(metadata: SDPStreamMetadata): void {
+ this.remoteSDPStreamMetadata = utils.recursivelyAssign(this.remoteSDPStreamMetadata || {}, metadata, true);
+ for (const feed of this.getRemoteFeeds()) {
+ const streamId = feed.stream.id;
+ const metadata = this.remoteSDPStreamMetadata![streamId];
+
+ feed.setAudioVideoMuted(metadata?.audio_muted, metadata?.video_muted);
+ feed.purpose = this.remoteSDPStreamMetadata![streamId]?.purpose;
+ }
+ }
+
+ public onSDPStreamMetadataChangedReceived(event: MatrixEvent): void {
+ const content = event.getContent<MCallSDPStreamMetadataChanged>();
+ const metadata = content[SDPStreamMetadataKey];
+ this.updateRemoteSDPStreamMetadata(metadata);
+ }
+
+ public async onAssertedIdentityReceived(event: MatrixEvent): Promise<void> {
+ const content = event.getContent<MCAllAssertedIdentity>();
+ if (!content.asserted_identity) return;
+
+ this.remoteAssertedIdentity = {
+ id: content.asserted_identity.id,
+ displayName: content.asserted_identity.display_name,
+ };
+ this.emit(CallEvent.AssertedIdentityChanged, this);
+ }
+
+ public callHasEnded(): boolean {
+ // This exists as workaround to typescript trying to be clever and erroring
+ // when putting if (this.state === CallState.Ended) return; twice in the same
+ // function, even though that function is async.
+ return this.state === CallState.Ended;
+ }
+
+ private queueGotLocalOffer(): void {
+ // Ensure only one negotiate/answer event is being processed at a time.
+ if (this.responsePromiseChain) {
+ this.responsePromiseChain = this.responsePromiseChain.then(() => this.wrappedGotLocalOffer());
+ } else {
+ this.responsePromiseChain = this.wrappedGotLocalOffer();
+ }
+ }
+
+ private async wrappedGotLocalOffer(): Promise<void> {
+ this.makingOffer = true;
+ try {
+ // XXX: in what situations do we believe gotLocalOffer actually throws? It appears
+ // to handle most of its exceptions itself and terminate the call. I'm not entirely
+ // sure it would ever throw, so I can't add a test for these lines.
+ // Also the tense is different between "gotLocalOffer" and "getLocalOfferFailed" so
+ // it's not entirely clear whether getLocalOfferFailed is just misnamed or whether
+ // they've been cross-polinated somehow at some point.
+ await this.gotLocalOffer();
+ } catch (e) {
+ this.getLocalOfferFailed(e as Error);
+ return;
+ } finally {
+ this.makingOffer = false;
+ }
+ }
+
+ private async gotLocalOffer(): Promise<void> {
+ logger.debug(`Call ${this.callId} gotLocalOffer() running`);
+
+ if (this.callHasEnded()) {
+ logger.debug(
+ `Call ${this.callId} gotLocalOffer() ignoring newly created offer because the call has ended"`,
+ );
+ return;
+ }
+
+ let offer: RTCSessionDescriptionInit;
+ try {
+ this.getRidOfRTXCodecs();
+ offer = await this.createOffer();
+ } catch (err) {
+ logger.debug(`Call ${this.callId} gotLocalOffer() failed to create offer: `, err);
+ this.terminate(CallParty.Local, CallErrorCode.CreateOffer, true);
+ return;
+ }
+
+ try {
+ await this.peerConn!.setLocalDescription(offer);
+ } catch (err) {
+ logger.debug(`Call ${this.callId} gotLocalOffer() error setting local description!`, err);
+ this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true);
+ return;
+ }
+
+ if (this.peerConn!.iceGatheringState === "gathering") {
+ // Allow a short time for initial candidates to be gathered
+ await new Promise((resolve) => {
+ setTimeout(resolve, 200);
+ });
+ }
+
+ if (this.callHasEnded()) return;
+
+ const eventType = this.state === CallState.CreateOffer ? EventType.CallInvite : EventType.CallNegotiate;
+
+ const content = {
+ lifetime: CALL_TIMEOUT_MS,
+ } as MCallInviteNegotiate;
+
+ if (eventType === EventType.CallInvite && this.invitee) {
+ content.invitee = this.invitee;
+ }
+
+ // clunky because TypeScript can't follow the types through if we use an expression as the key
+ if (this.state === CallState.CreateOffer) {
+ content.offer = this.peerConn!.localDescription?.toJSON();
+ } else {
+ content.description = this.peerConn!.localDescription?.toJSON();
+ }
+
+ content.capabilities = {
+ "m.call.transferee": this.client.supportsCallTransfer,
+ "m.call.dtmf": false,
+ };
+
+ content[SDPStreamMetadataKey] = this.getLocalSDPStreamMetadata(true);
+
+ // Get rid of any candidates waiting to be sent: they'll be included in the local
+ // description we just got and will send in the offer.
+ const discardCount = this.discardDuplicateCandidates();
+ logger.info(
+ `Call ${this.callId} gotLocalOffer() discarding ${discardCount} candidates that will be sent in offer`,
+ );
+
+ try {
+ await this.sendVoipEvent(eventType, content);
+ } catch (error) {
+ logger.error(`Call ${this.callId} gotLocalOffer() failed to send invite`, error);
+ if (error instanceof MatrixError && error.event) this.client.cancelPendingEvent(error.event);
+
+ let code = CallErrorCode.SignallingFailed;
+ let message = "Signalling failed";
+ if (this.state === CallState.CreateOffer) {
+ code = CallErrorCode.SendInvite;
+ message = "Failed to send invite";
+ }
+ if ((<Error>error).name == "UnknownDeviceError") {
+ code = CallErrorCode.UnknownDevices;
+ message = "Unknown devices present in the room";
+ }
+
+ this.emit(CallEvent.Error, new CallError(code, message, <Error>error), this);
+ this.terminate(CallParty.Local, code, false);
+
+ // no need to carry on & send the candidate queue, but we also
+ // don't want to rethrow the error
+ return;
+ }
+
+ this.sendCandidateQueue();
+ if (this.state === CallState.CreateOffer) {
+ this.inviteOrAnswerSent = true;
+ this.state = CallState.InviteSent;
+ this.inviteTimeout = setTimeout(() => {
+ this.inviteTimeout = undefined;
+ if (this.state === CallState.InviteSent) {
+ this.hangup(CallErrorCode.InviteTimeout, false);
+ }
+ }, CALL_TIMEOUT_MS);
+ }
+ }
+
+ private getLocalOfferFailed = (err: Error): void => {
+ logger.error(`Call ${this.callId} getLocalOfferFailed() running`, err);
+
+ this.emit(
+ CallEvent.Error,
+ new CallError(CallErrorCode.LocalOfferFailed, "Failed to get local offer!", err),
+ this,
+ );
+ this.terminate(CallParty.Local, CallErrorCode.LocalOfferFailed, false);
+ };
+
+ private getUserMediaFailed = (err: Error): void => {
+ if (this.successor) {
+ this.successor.getUserMediaFailed(err);
+ return;
+ }
+
+ logger.warn(`Call ${this.callId} getUserMediaFailed() failed to get user media - ending call`, err);
+
+ this.emit(
+ CallEvent.Error,
+ new CallError(
+ CallErrorCode.NoUserMedia,
+ "Couldn't start capturing media! Is your microphone set up and " + "does this app have permission?",
+ err,
+ ),
+ this,
+ );
+ this.terminate(CallParty.Local, CallErrorCode.NoUserMedia, false);
+ };
+
+ private onIceConnectionStateChanged = (): void => {
+ if (this.callHasEnded()) {
+ return; // because ICE can still complete as we're ending the call
+ }
+ logger.debug(
+ `Call ${this.callId} onIceConnectionStateChanged() running (state=${this.peerConn?.iceConnectionState})`,
+ );
+
+ // ideally we'd consider the call to be connected when we get media but
+ // chrome doesn't implement any of the 'onstarted' events yet
+ if (["connected", "completed"].includes(this.peerConn?.iceConnectionState ?? "")) {
+ clearTimeout(this.iceDisconnectedTimeout);
+ this.iceDisconnectedTimeout = undefined;
+ this.state = CallState.Connected;
+
+ if (!this.callLengthInterval && !this.callStartTime) {
+ this.callStartTime = Date.now();
+
+ this.callLengthInterval = setInterval(() => {
+ this.emit(CallEvent.LengthChanged, Math.round((Date.now() - this.callStartTime!) / 1000), this);
+ }, CALL_LENGTH_INTERVAL);
+ }
+ } else if (this.peerConn?.iceConnectionState == "failed") {
+ // Firefox for Android does not yet have support for restartIce()
+ // (the types say it's always defined though, so we have to cast
+ // to prevent typescript from warning).
+ if (this.peerConn?.restartIce as (() => void) | null) {
+ this.candidatesEnded = false;
+ this.peerConn!.restartIce();
+ } else {
+ logger.info(
+ `Call ${this.callId} onIceConnectionStateChanged() hanging up call (ICE failed and no ICE restart method)`,
+ );
+ this.hangup(CallErrorCode.IceFailed, false);
+ }
+ } else if (this.peerConn?.iceConnectionState == "disconnected") {
+ this.iceDisconnectedTimeout = setTimeout(() => {
+ logger.info(
+ `Call ${this.callId} onIceConnectionStateChanged() hanging up call (ICE disconnected for too long)`,
+ );
+ this.hangup(CallErrorCode.IceFailed, false);
+ }, ICE_DISCONNECTED_TIMEOUT);
+ this.state = CallState.Connecting;
+ }
+
+ // In PTT mode, override feed status to muted when we lose connection to
+ // the peer, since we don't want to block the line if they're not saying anything.
+ // Experimenting in Chrome, this happens after 5 or 6 seconds, which is probably
+ // fast enough.
+ if (this.isPtt && ["failed", "disconnected"].includes(this.peerConn!.iceConnectionState)) {
+ for (const feed of this.getRemoteFeeds()) {
+ feed.setAudioVideoMuted(true, true);
+ }
+ }
+ };
+
+ private onSignallingStateChanged = (): void => {
+ logger.debug(`Call ${this.callId} onSignallingStateChanged() running (state=${this.peerConn?.signalingState})`);
+ };
+
+ private onTrack = (ev: RTCTrackEvent): void => {
+ if (ev.streams.length === 0) {
+ logger.warn(
+ `Call ${this.callId} onTrack() called with streamless track streamless (kind=${ev.track.kind})`,
+ );
+ return;
+ }
+
+ const stream = ev.streams[0];
+ this.pushRemoteFeed(stream);
+
+ if (!this.removeTrackListeners.has(stream)) {
+ const onRemoveTrack = (): void => {
+ if (stream.getTracks().length === 0) {
+ logger.info(`Call ${this.callId} onTrack() removing track (streamId=${stream.id})`);
+ this.deleteFeedByStream(stream);
+ stream.removeEventListener("removetrack", onRemoveTrack);
+ this.removeTrackListeners.delete(stream);
+ }
+ };
+ stream.addEventListener("removetrack", onRemoveTrack);
+ this.removeTrackListeners.set(stream, onRemoveTrack);
+ }
+ };
+
+ private onDataChannel = (ev: RTCDataChannelEvent): void => {
+ this.emit(CallEvent.DataChannel, ev.channel, this);
+ };
+
+ /**
+ * This method removes all video/rtx codecs from screensharing video
+ * transceivers. This is necessary since they can cause problems. Without
+ * this the following steps should produce an error:
+ * Chromium calls Firefox
+ * Firefox answers
+ * Firefox starts screen-sharing
+ * Chromium starts screen-sharing
+ * Call crashes for Chromium with:
+ * [96685:23:0518/162603.933321:ERROR:webrtc_video_engine.cc(3296)] RTX codec (PT=97) mapped to PT=96 which is not in the codec list.
+ * [96685:23:0518/162603.933377:ERROR:webrtc_video_engine.cc(1171)] GetChangedRecvParameters called without any video codecs.
+ * [96685:23:0518/162603.933430:ERROR:sdp_offer_answer.cc(4302)] Failed to set local video description recv parameters for m-section with mid='2'. (INVALID_PARAMETER)
+ */
+ private getRidOfRTXCodecs(): void {
+ // RTCRtpReceiver.getCapabilities and RTCRtpSender.getCapabilities don't seem to be supported on FF
+ if (!RTCRtpReceiver.getCapabilities || !RTCRtpSender.getCapabilities) return;
+
+ const recvCodecs = RTCRtpReceiver.getCapabilities("video")!.codecs;
+ const sendCodecs = RTCRtpSender.getCapabilities("video")!.codecs;
+ const codecs = [...sendCodecs, ...recvCodecs];
+
+ for (const codec of codecs) {
+ if (codec.mimeType === "video/rtx") {
+ const rtxCodecIndex = codecs.indexOf(codec);
+ codecs.splice(rtxCodecIndex, 1);
+ }
+ }
+
+ const screenshareVideoTransceiver = this.transceivers.get(
+ getTransceiverKey(SDPStreamMetadataPurpose.Screenshare, "video"),
+ );
+ if (screenshareVideoTransceiver) screenshareVideoTransceiver.setCodecPreferences(codecs);
+ }
+
+ private onNegotiationNeeded = async (): Promise<void> => {
+ logger.info(`Call ${this.callId} onNegotiationNeeded() negotiation is needed!`);
+
+ if (this.state !== CallState.CreateOffer && this.opponentVersion === 0) {
+ logger.info(
+ `Call ${this.callId} onNegotiationNeeded() opponent does not support renegotiation: ignoring negotiationneeded event`,
+ );
+ return;
+ }
+
+ this.queueGotLocalOffer();
+ };
+
+ public onHangupReceived = (msg: MCallHangupReject): void => {
+ logger.debug(`Call ${this.callId} onHangupReceived() running`);
+
+ // party ID must match (our chosen partner hanging up the call) or be undefined (we haven't chosen
+ // a partner yet but we're treating the hangup as a reject as per VoIP v0)
+ if (this.partyIdMatches(msg) || this.state === CallState.Ringing) {
+ // default reason is user_hangup
+ this.terminate(CallParty.Remote, msg.reason || CallErrorCode.UserHangup, true);
+ } else {
+ logger.info(
+ `Call ${this.callId} onHangupReceived() ignoring message from party ID ${msg.party_id}: our partner is ${this.opponentPartyId}`,
+ );
+ }
+ };
+
+ public onRejectReceived = (msg: MCallHangupReject): void => {
+ logger.debug(`Call ${this.callId} onRejectReceived() running`);
+
+ // No need to check party_id for reject because if we'd received either
+ // an answer or reject, we wouldn't be in state InviteSent
+
+ const shouldTerminate =
+ // reject events also end the call if it's ringing: it's another of
+ // our devices rejecting the call.
+ [CallState.InviteSent, CallState.Ringing].includes(this.state) ||
+ // also if we're in the init state and it's an inbound call, since
+ // this means we just haven't entered the ringing state yet
+ (this.state === CallState.Fledgling && this.direction === CallDirection.Inbound);
+
+ if (shouldTerminate) {
+ this.terminate(CallParty.Remote, msg.reason || CallErrorCode.UserHangup, true);
+ } else {
+ logger.debug(`Call ${this.callId} onRejectReceived() called in wrong state (state=${this.state})`);
+ }
+ };
+
+ public onAnsweredElsewhere = (msg: MCallAnswer): void => {
+ logger.debug(`Call ${this.callId} onAnsweredElsewhere() running`);
+ this.terminate(CallParty.Remote, CallErrorCode.AnsweredElsewhere, true);
+ };
+
+ /**
+ * @internal
+ */
+ private async sendVoipEvent(eventType: string, content: object): Promise<void> {
+ const realContent = Object.assign({}, content, {
+ version: VOIP_PROTO_VERSION,
+ call_id: this.callId,
+ party_id: this.ourPartyId,
+ conf_id: this.groupCallId,
+ });
+
+ if (this.opponentDeviceId) {
+ const toDeviceSeq = this.toDeviceSeq++;
+ const content = {
+ ...realContent,
+ device_id: this.client.deviceId,
+ sender_session_id: this.client.getSessionId(),
+ dest_session_id: this.opponentSessionId,
+ seq: toDeviceSeq,
+ [ToDeviceMessageId]: uuidv4(),
+ };
+
+ this.emit(
+ CallEvent.SendVoipEvent,
+ {
+ type: "toDevice",
+ eventType,
+ userId: this.invitee || this.getOpponentMember()?.userId,
+ opponentDeviceId: this.opponentDeviceId,
+ content,
+ },
+ this,
+ );
+
+ const userId = this.invitee || this.getOpponentMember()!.userId;
+ if (this.client.getUseE2eForGroupCall()) {
+ if (!this.opponentDeviceInfo) {
+ logger.warn(`Call ${this.callId} sendVoipEvent() failed: we do not have opponentDeviceInfo`);
+ return;
+ }
+
+ await this.client.encryptAndSendToDevices(
+ [
+ {
+ userId,
+ deviceInfo: this.opponentDeviceInfo,
+ },
+ ],
+ {
+ type: eventType,
+ content,
+ },
+ );
+ } else {
+ await this.client.sendToDevice(
+ eventType,
+ new Map<string, any>([[userId, new Map([[this.opponentDeviceId, content]])]]),
+ );
+ }
+ } else {
+ this.emit(
+ CallEvent.SendVoipEvent,
+ {
+ type: "sendEvent",
+ eventType,
+ roomId: this.roomId,
+ content: realContent,
+ userId: this.invitee || this.getOpponentMember()?.userId,
+ },
+ this,
+ );
+
+ await this.client.sendEvent(this.roomId!, eventType, realContent);
+ }
+ }
+
+ /**
+ * Queue a candidate to be sent
+ * @param content - The candidate to queue up, or null if candidates have finished being generated
+ * and end-of-candidates should be signalled
+ */
+ private queueCandidate(content: RTCIceCandidate | null): void {
+ // We partially de-trickle candidates by waiting for `delay` before sending them
+ // amalgamated, in order to avoid sending too many m.call.candidates events and hitting
+ // rate limits in Matrix.
+ // In practice, it'd be better to remove rate limits for m.call.*
+
+ // N.B. this deliberately lets you queue and send blank candidates, which MSC2746
+ // currently proposes as the way to indicate that candidate gathering is complete.
+ // This will hopefully be changed to an explicit rather than implicit notification
+ // shortly.
+ if (content) {
+ this.candidateSendQueue.push(content);
+ } else {
+ this.candidatesEnded = true;
+ }
+
+ // Don't send the ICE candidates yet if the call is in the ringing state: this
+ // means we tried to pick (ie. started generating candidates) and then failed to
+ // send the answer and went back to the ringing state. Queue up the candidates
+ // to send if we successfully send the answer.
+ // Equally don't send if we haven't yet sent the answer because we can send the
+ // first batch of candidates along with the answer
+ if (this.state === CallState.Ringing || !this.inviteOrAnswerSent) return;
+
+ // MSC2746 recommends these values (can be quite long when calling because the
+ // callee will need a while to answer the call)
+ const delay = this.direction === CallDirection.Inbound ? 500 : 2000;
+
+ if (this.candidateSendTries === 0) {
+ setTimeout(() => {
+ this.sendCandidateQueue();
+ }, delay);
+ }
+ }
+
+ // Discard all non-end-of-candidates messages
+ // Return the number of candidate messages that were discarded.
+ // Call this method before sending an invite or answer message
+ private discardDuplicateCandidates(): number {
+ let discardCount = 0;
+ const newQueue: RTCIceCandidate[] = [];
+
+ for (let i = 0; i < this.candidateSendQueue.length; i++) {
+ const candidate = this.candidateSendQueue[i];
+ if (candidate.candidate === "") {
+ newQueue.push(candidate);
+ } else {
+ discardCount++;
+ }
+ }
+
+ this.candidateSendQueue = newQueue;
+
+ return discardCount;
+ }
+
+ /*
+ * Transfers this call to another user
+ */
+ public async transfer(targetUserId: string): Promise<void> {
+ // Fetch the target user's global profile info: their room avatar / displayname
+ // could be different in whatever room we share with them.
+ const profileInfo = await this.client.getProfileInfo(targetUserId);
+
+ const replacementId = genCallID();
+
+ const body = {
+ replacement_id: genCallID(),
+ target_user: {
+ id: targetUserId,
+ display_name: profileInfo.displayname,
+ avatar_url: profileInfo.avatar_url,
+ },
+ create_call: replacementId,
+ } as MCallReplacesEvent;
+
+ await this.sendVoipEvent(EventType.CallReplaces, body);
+
+ await this.terminate(CallParty.Local, CallErrorCode.Transferred, true);
+ }
+
+ /*
+ * Transfers this call to the target call, effectively 'joining' the
+ * two calls (so the remote parties on each call are connected together).
+ */
+ public async transferToCall(transferTargetCall: MatrixCall): Promise<void> {
+ const targetUserId = transferTargetCall.getOpponentMember()?.userId;
+ const targetProfileInfo = targetUserId ? await this.client.getProfileInfo(targetUserId) : undefined;
+ const opponentUserId = this.getOpponentMember()?.userId;
+ const transfereeProfileInfo = opponentUserId ? await this.client.getProfileInfo(opponentUserId) : undefined;
+
+ const newCallId = genCallID();
+
+ const bodyToTransferTarget = {
+ // the replacements on each side have their own ID, and it's distinct from the
+ // ID of the new call (but we can use the same function to generate it)
+ replacement_id: genCallID(),
+ target_user: {
+ id: opponentUserId,
+ display_name: transfereeProfileInfo?.displayname,
+ avatar_url: transfereeProfileInfo?.avatar_url,
+ },
+ await_call: newCallId,
+ } as MCallReplacesEvent;
+
+ await transferTargetCall.sendVoipEvent(EventType.CallReplaces, bodyToTransferTarget);
+
+ const bodyToTransferee = {
+ replacement_id: genCallID(),
+ target_user: {
+ id: targetUserId,
+ display_name: targetProfileInfo?.displayname,
+ avatar_url: targetProfileInfo?.avatar_url,
+ },
+ create_call: newCallId,
+ } as MCallReplacesEvent;
+
+ await this.sendVoipEvent(EventType.CallReplaces, bodyToTransferee);
+
+ await this.terminate(CallParty.Local, CallErrorCode.Transferred, true);
+ await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Transferred, true);
+ }
+
+ private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean): Promise<void> {
+ if (this.callHasEnded()) return;
+
+ this.hangupParty = hangupParty;
+ this.hangupReason = hangupReason;
+ this.state = CallState.Ended;
+
+ if (this.inviteTimeout) {
+ clearTimeout(this.inviteTimeout);
+ this.inviteTimeout = undefined;
+ }
+ if (this.iceDisconnectedTimeout !== undefined) {
+ clearTimeout(this.iceDisconnectedTimeout);
+ this.iceDisconnectedTimeout = undefined;
+ }
+ if (this.callLengthInterval) {
+ clearInterval(this.callLengthInterval);
+ this.callLengthInterval = undefined;
+ }
+ if (this.stopVideoTrackTimer !== undefined) {
+ clearTimeout(this.stopVideoTrackTimer);
+ this.stopVideoTrackTimer = undefined;
+ }
+
+ for (const [stream, listener] of this.removeTrackListeners) {
+ stream.removeEventListener("removetrack", listener);
+ }
+ this.removeTrackListeners.clear();
+
+ this.callStatsAtEnd = await this.collectCallStats();
+
+ // Order is important here: first we stopAllMedia() and only then we can deleteAllFeeds()
+ this.stopAllMedia();
+ this.deleteAllFeeds();
+
+ if (this.peerConn && this.peerConn.signalingState !== "closed") {
+ this.peerConn.close();
+ }
+ this.stats?.removeStatsReportGatherer(this.callId);
+
+ if (shouldEmit) {
+ this.emit(CallEvent.Hangup, this);
+ }
+
+ this.client.callEventHandler!.calls.delete(this.callId);
+ }
+
+ private stopAllMedia(): void {
+ logger.debug(`Call ${this.callId} stopAllMedia() running`);
+
+ for (const feed of this.feeds) {
+ // Slightly awkward as local feed need to go via the correct method on
+ // the MediaHandler so they get removed from MediaHandler (remote tracks
+ // don't)
+ // NB. We clone local streams when passing them to individual calls in a group
+ // call, so we can (and should) stop the clones once we no longer need them:
+ // the other clones will continue fine.
+ if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Usermedia) {
+ this.client.getMediaHandler().stopUserMediaStream(feed.stream);
+ } else if (feed.isLocal() && feed.purpose === SDPStreamMetadataPurpose.Screenshare) {
+ this.client.getMediaHandler().stopScreensharingStream(feed.stream);
+ } else if (!feed.isLocal()) {
+ logger.debug(`Call ${this.callId} stopAllMedia() stopping stream (streamId=${feed.stream.id})`);
+ for (const track of feed.stream.getTracks()) {
+ track.stop();
+ }
+ }
+ }
+ }
+
+ private checkForErrorListener(): void {
+ if (this.listeners(EventEmitterEvents.Error).length === 0) {
+ throw new Error("You MUST attach an error listener using call.on('error', function() {})");
+ }
+ }
+
+ private async sendCandidateQueue(): Promise<void> {
+ if (this.candidateSendQueue.length === 0 || this.callHasEnded()) {
+ return;
+ }
+
+ const candidates = this.candidateSendQueue;
+ this.candidateSendQueue = [];
+ ++this.candidateSendTries;
+ const content = { candidates: candidates.map((candidate) => candidate.toJSON()) };
+ if (this.candidatesEnded) {
+ // If there are no more candidates, signal this by adding an empty string candidate
+ content.candidates.push({
+ candidate: "",
+ });
+ }
+ logger.debug(`Call ${this.callId} sendCandidateQueue() attempting to send ${candidates.length} candidates`);
+ try {
+ await this.sendVoipEvent(EventType.CallCandidates, content);
+ // reset our retry count if we have successfully sent our candidates
+ // otherwise queueCandidate() will refuse to try to flush the queue
+ this.candidateSendTries = 0;
+
+ // Try to send candidates again just in case we received more candidates while sending.
+ this.sendCandidateQueue();
+ } catch (error) {
+ // don't retry this event: we'll send another one later as we might
+ // have more candidates by then.
+ if (error instanceof MatrixError && error.event) this.client.cancelPendingEvent(error.event);
+
+ // put all the candidates we failed to send back in the queue
+ this.candidateSendQueue.push(...candidates);
+
+ if (this.candidateSendTries > 5) {
+ logger.debug(
+ `Call ${this.callId} sendCandidateQueue() failed to send candidates on attempt ${this.candidateSendTries}. Giving up on this call.`,
+ error,
+ );
+
+ const code = CallErrorCode.SignallingFailed;
+ const message = "Signalling failed";
+
+ this.emit(CallEvent.Error, new CallError(code, message, <Error>error), this);
+ this.hangup(code, false);
+
+ return;
+ }
+
+ const delayMs = 500 * Math.pow(2, this.candidateSendTries);
+ ++this.candidateSendTries;
+ logger.debug(
+ `Call ${this.callId} sendCandidateQueue() failed to send candidates. Retrying in ${delayMs}ms`,
+ error,
+ );
+ setTimeout(() => {
+ this.sendCandidateQueue();
+ }, delayMs);
+ }
+ }
+
+ /**
+ * Place a call to this room.
+ * @throws if you have not specified a listener for 'error' events.
+ * @throws if have passed audio=false.
+ */
+ public async placeCall(audio: boolean, video: boolean): Promise<void> {
+ if (!audio) {
+ throw new Error("You CANNOT start a call without audio");
+ }
+ this.state = CallState.WaitLocalMedia;
+
+ try {
+ const stream = await this.client.getMediaHandler().getUserMediaStream(audio, video);
+
+ // make sure all the tracks are enabled (same as pushNewLocalFeed -
+ // we probably ought to just have one code path for adding streams)
+ setTracksEnabled(stream.getAudioTracks(), true);
+ setTracksEnabled(stream.getVideoTracks(), true);
+
+ const callFeed = new CallFeed({
+ client: this.client,
+ roomId: this.roomId,
+ userId: this.client.getUserId()!,
+ deviceId: this.client.getDeviceId() ?? undefined,
+ stream,
+ purpose: SDPStreamMetadataPurpose.Usermedia,
+ audioMuted: false,
+ videoMuted: false,
+ });
+ await this.placeCallWithCallFeeds([callFeed]);
+ } catch (e) {
+ this.getUserMediaFailed(<Error>e);
+ return;
+ }
+ }
+
+ /**
+ * Place a call to this room with call feed.
+ * @param callFeeds - to use
+ * @throws if you have not specified a listener for 'error' events.
+ * @throws if have passed audio=false.
+ */
+ public async placeCallWithCallFeeds(callFeeds: CallFeed[], requestScreenshareFeed = false): Promise<void> {
+ this.checkForErrorListener();
+ this.direction = CallDirection.Outbound;
+
+ await this.initOpponentCrypto();
+
+ // XXX Find a better way to do this
+ this.client.callEventHandler!.calls.set(this.callId, this);
+
+ // make sure we have valid turn creds. Unless something's gone wrong, it should
+ // poll and keep the credentials valid so this should be instant.
+ const haveTurnCreds = await this.client.checkTurnServers();
+ if (!haveTurnCreds) {
+ logger.warn(
+ `Call ${this.callId} placeCallWithCallFeeds() failed to get TURN credentials! Proceeding with call anyway...`,
+ );
+ }
+
+ // create the peer connection now so it can be gathering candidates while we get user
+ // media (assuming a candidate pool size is configured)
+ this.peerConn = this.createPeerConnection();
+ this.gotCallFeedsForInvite(callFeeds, requestScreenshareFeed);
+ }
+
+ private createPeerConnection(): RTCPeerConnection {
+ const pc = new window.RTCPeerConnection({
+ iceTransportPolicy: this.forceTURN ? "relay" : undefined,
+ iceServers: this.turnServers,
+ iceCandidatePoolSize: this.client.iceCandidatePoolSize,
+ bundlePolicy: "max-bundle",
+ });
+
+ // 'connectionstatechange' would be better, but firefox doesn't implement that.
+ pc.addEventListener("iceconnectionstatechange", this.onIceConnectionStateChanged);
+ pc.addEventListener("signalingstatechange", this.onSignallingStateChanged);
+ pc.addEventListener("icecandidate", this.gotLocalIceCandidate);
+ pc.addEventListener("icegatheringstatechange", this.onIceGatheringStateChange);
+ pc.addEventListener("track", this.onTrack);
+ pc.addEventListener("negotiationneeded", this.onNegotiationNeeded);
+ pc.addEventListener("datachannel", this.onDataChannel);
+
+ this.stats?.addStatsReportGatherer(this.callId, "unknown", pc);
+ return pc;
+ }
+
+ private partyIdMatches(msg: MCallBase): boolean {
+ // They must either match or both be absent (in which case opponentPartyId will be null)
+ // Also we ignore party IDs on the invite/offer if the version is 0, so we must do the same
+ // here and use null if the version is 0 (woe betide any opponent sending messages in the
+ // same call with different versions)
+ const msgPartyId = msg.version === 0 ? null : msg.party_id || null;
+ return msgPartyId === this.opponentPartyId;
+ }
+
+ // Commits to an opponent for the call
+ // ev: An invite or answer event
+ private chooseOpponent(ev: MatrixEvent): void {
+ // I choo-choo-choose you
+ const msg = ev.getContent<MCallInviteNegotiate | MCallAnswer>();
+
+ logger.debug(`Call ${this.callId} chooseOpponent() running (partyId=${msg.party_id})`);
+
+ this.opponentVersion = msg.version;
+ if (this.opponentVersion === 0) {
+ // set to null to indicate that we've chosen an opponent, but because
+ // they're v0 they have no party ID (even if they sent one, we're ignoring it)
+ this.opponentPartyId = null;
+ } else {
+ // set to their party ID, or if they're naughty and didn't send one despite
+ // not being v0, set it to null to indicate we picked an opponent with no
+ // party ID
+ this.opponentPartyId = msg.party_id || null;
+ }
+ this.opponentCaps = msg.capabilities || ({} as CallCapabilities);
+ this.opponentMember = this.client.getRoom(this.roomId)!.getMember(ev.getSender()!) ?? undefined;
+ }
+
+ private async addBufferedIceCandidates(): Promise<void> {
+ const bufferedCandidates = this.remoteCandidateBuffer.get(this.opponentPartyId!);
+ if (bufferedCandidates) {
+ logger.info(
+ `Call ${this.callId} addBufferedIceCandidates() adding ${bufferedCandidates.length} buffered candidates for opponent ${this.opponentPartyId}`,
+ );
+ await this.addIceCandidates(bufferedCandidates);
+ }
+ this.remoteCandidateBuffer.clear();
+ }
+
+ private async addIceCandidates(candidates: RTCIceCandidate[]): Promise<void> {
+ for (const candidate of candidates) {
+ if (
+ (candidate.sdpMid === null || candidate.sdpMid === undefined) &&
+ (candidate.sdpMLineIndex === null || candidate.sdpMLineIndex === undefined)
+ ) {
+ logger.debug(`Call ${this.callId} addIceCandidates() got remote ICE end-of-candidates`);
+ } else {
+ logger.debug(
+ `Call ${this.callId} addIceCandidates() got remote ICE candidate (sdpMid=${candidate.sdpMid}, candidate=${candidate.candidate})`,
+ );
+ }
+
+ try {
+ await this.peerConn!.addIceCandidate(candidate);
+ } catch (err) {
+ if (!this.ignoreOffer) {
+ logger.info(`Call ${this.callId} addIceCandidates() failed to add remote ICE candidate`, err);
+ }
+ }
+ }
+ }
+
+ public get hasPeerConnection(): boolean {
+ return Boolean(this.peerConn);
+ }
+
+ public initStats(stats: GroupCallStats, peerId = "unknown"): void {
+ this.stats = stats;
+ this.stats.start();
+ }
+}
+
+export function setTracksEnabled(tracks: Array<MediaStreamTrack>, enabled: boolean): void {
+ for (const track of tracks) {
+ track.enabled = enabled;
+ }
+}
+
+export function supportsMatrixCall(): boolean {
+ // typeof prevents Node from erroring on an undefined reference
+ if (typeof window === "undefined" || typeof document === "undefined") {
+ // NB. We don't log here as apps try to create a call object as a test for
+ // whether calls are supported, so we shouldn't fill the logs up.
+ return false;
+ }
+
+ // Firefox throws on so little as accessing the RTCPeerConnection when operating in a secure mode.
+ // There's some information at https://bugzilla.mozilla.org/show_bug.cgi?id=1542616 though the concern
+ // is that the browser throwing a SecurityError will brick the client creation process.
+ try {
+ const supported = Boolean(
+ window.RTCPeerConnection ||
+ window.RTCSessionDescription ||
+ window.RTCIceCandidate ||
+ navigator.mediaDevices,
+ );
+ if (!supported) {
+ /* istanbul ignore if */ // Adds a lot of noise to test runs, so disable logging there.
+ if (process.env.NODE_ENV !== "test") {
+ logger.error("WebRTC is not supported in this browser / environment");
+ }
+ return false;
+ }
+ } catch (e) {
+ logger.error("Exception thrown when trying to access WebRTC", e);
+ return false;
+ }
+
+ return true;
+}
+
+/**
+ * DEPRECATED
+ * Use client.createCall()
+ *
+ * Create a new Matrix call for the browser.
+ * @param client - The client instance to use.
+ * @param roomId - The room the call is in.
+ * @param options - DEPRECATED optional options map.
+ * @returns the call or null if the browser doesn't support calling.
+ */
+export function createNewMatrixCall(
+ client: MatrixClient,
+ roomId: string,
+ options?: Pick<CallOpts, "forceTURN" | "invitee" | "opponentDeviceId" | "opponentSessionId" | "groupCallId">,
+): MatrixCall | null {
+ if (!supportsMatrixCall()) return null;
+
+ const optionsForceTURN = options ? options.forceTURN : false;
+
+ const opts: CallOpts = {
+ client: client,
+ roomId: roomId,
+ invitee: options?.invitee,
+ turnServers: client.getTurnServers(),
+ // call level options
+ forceTURN: client.forceTURN || optionsForceTURN,
+ opponentDeviceId: options?.opponentDeviceId,
+ opponentSessionId: options?.opponentSessionId,
+ groupCallId: options?.groupCallId,
+ };
+ const call = new MatrixCall(opts);
+
+ client.reEmitter.reEmit(call, Object.values(CallEvent));
+
+ return call;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventHandler.ts
new file mode 100644
index 0000000..4ee183a
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventHandler.ts
@@ -0,0 +1,425 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixEvent } from "../models/event";
+import { logger } from "../logger";
+import { CallDirection, CallError, CallErrorCode, CallState, createNewMatrixCall, MatrixCall } from "./call";
+import { EventType } from "../@types/event";
+import { ClientEvent, MatrixClient } from "../client";
+import { MCallAnswer, MCallHangupReject } from "./callEventTypes";
+import { GroupCall, GroupCallErrorCode, GroupCallEvent, GroupCallUnknownDeviceError } from "./groupCall";
+import { RoomEvent } from "../models/room";
+
+// Don't ring unless we'd be ringing for at least 3 seconds: the user needs some
+// time to press the 'accept' button
+const RING_GRACE_PERIOD = 3000;
+
+export enum CallEventHandlerEvent {
+ Incoming = "Call.incoming",
+}
+
+export type CallEventHandlerEventHandlerMap = {
+ /**
+ * Fires whenever an incoming call arrives.
+ * @param call - The incoming call.
+ * @example
+ * ```
+ * matrixClient.on("Call.incoming", function(call){
+ * call.answer(); // auto-answer
+ * });
+ * ```
+ */
+ [CallEventHandlerEvent.Incoming]: (call: MatrixCall) => void;
+};
+
+export class CallEventHandler {
+ // XXX: Most of these are only public because of the tests
+ public calls: Map<string, MatrixCall>;
+ public callEventBuffer: MatrixEvent[];
+ public nextSeqByCall: Map<string, number> = new Map();
+ public toDeviceEventBuffers: Map<string, Array<MatrixEvent>> = new Map();
+
+ private client: MatrixClient;
+ private candidateEventsByCall: Map<string, Array<MatrixEvent>>;
+ private eventBufferPromiseChain?: Promise<void>;
+
+ public constructor(client: MatrixClient) {
+ this.client = client;
+ this.calls = new Map<string, MatrixCall>();
+ // The sync code always emits one event at a time, so it will patiently
+ // wait for us to finish processing a call invite before delivering the
+ // next event, even if that next event is a hangup. We therefore accumulate
+ // all our call events and then process them on the 'sync' event, ie.
+ // each time a sync has completed. This way, we can avoid emitting incoming
+ // call events if we get both the invite and answer/hangup in the same sync.
+ // This happens quite often, eg. replaying sync from storage, catchup sync
+ // after loading and after we've been offline for a bit.
+ this.callEventBuffer = [];
+ this.candidateEventsByCall = new Map<string, Array<MatrixEvent>>();
+ }
+
+ public start(): void {
+ this.client.on(ClientEvent.Sync, this.onSync);
+ this.client.on(RoomEvent.Timeline, this.onRoomTimeline);
+ this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
+ }
+
+ public stop(): void {
+ this.client.removeListener(ClientEvent.Sync, this.onSync);
+ this.client.removeListener(RoomEvent.Timeline, this.onRoomTimeline);
+ this.client.removeListener(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
+ }
+
+ private onSync = (): void => {
+ // Process the current event buffer and start queuing into a new one.
+ const currentEventBuffer = this.callEventBuffer;
+ this.callEventBuffer = [];
+
+ // Ensure correct ordering by only processing this queue after the previous one has finished processing
+ if (this.eventBufferPromiseChain) {
+ this.eventBufferPromiseChain = this.eventBufferPromiseChain.then(() =>
+ this.evaluateEventBuffer(currentEventBuffer),
+ );
+ } else {
+ this.eventBufferPromiseChain = this.evaluateEventBuffer(currentEventBuffer);
+ }
+ };
+
+ private async evaluateEventBuffer(eventBuffer: MatrixEvent[]): Promise<void> {
+ await Promise.all(eventBuffer.map((event) => this.client.decryptEventIfNeeded(event)));
+
+ const callEvents = eventBuffer.filter((event) => {
+ const eventType = event.getType();
+ return eventType.startsWith("m.call.") || eventType.startsWith("org.matrix.call.");
+ });
+
+ const ignoreCallIds = new Set<string>();
+
+ // inspect the buffer and mark all calls which have been answered
+ // or hung up before passing them to the call event handler.
+ for (const event of callEvents) {
+ const eventType = event.getType();
+
+ if (eventType === EventType.CallAnswer || eventType === EventType.CallHangup) {
+ ignoreCallIds.add(event.getContent().call_id);
+ }
+ }
+
+ // Process call events in the order that they were received
+ for (const event of callEvents) {
+ const eventType = event.getType();
+ const callId = event.getContent().call_id;
+
+ if (eventType === EventType.CallInvite && ignoreCallIds.has(callId)) {
+ // This call has previously been answered or hung up: ignore it
+ continue;
+ }
+
+ try {
+ await this.handleCallEvent(event);
+ } catch (e) {
+ logger.error("CallEventHandler evaluateEventBuffer() caught exception handling call event", e);
+ }
+ }
+ }
+
+ private onRoomTimeline = (event: MatrixEvent): void => {
+ this.callEventBuffer.push(event);
+ };
+
+ private onToDeviceEvent = (event: MatrixEvent): void => {
+ const content = event.getContent();
+
+ if (!content.call_id) {
+ this.callEventBuffer.push(event);
+ return;
+ }
+
+ if (!this.nextSeqByCall.has(content.call_id)) {
+ this.nextSeqByCall.set(content.call_id, 0);
+ }
+
+ if (content.seq === undefined) {
+ this.callEventBuffer.push(event);
+ return;
+ }
+
+ const nextSeq = this.nextSeqByCall.get(content.call_id) || 0;
+
+ if (content.seq !== nextSeq) {
+ if (!this.toDeviceEventBuffers.has(content.call_id)) {
+ this.toDeviceEventBuffers.set(content.call_id, []);
+ }
+
+ const buffer = this.toDeviceEventBuffers.get(content.call_id)!;
+ const index = buffer.findIndex((e) => e.getContent().seq > content.seq);
+
+ if (index === -1) {
+ buffer.push(event);
+ } else {
+ buffer.splice(index, 0, event);
+ }
+ } else {
+ const callId = content.call_id;
+ this.callEventBuffer.push(event);
+ this.nextSeqByCall.set(callId, content.seq + 1);
+
+ const buffer = this.toDeviceEventBuffers.get(callId);
+
+ let nextEvent = buffer && buffer.shift();
+
+ while (nextEvent && nextEvent.getContent().seq === this.nextSeqByCall.get(callId)) {
+ this.callEventBuffer.push(nextEvent);
+ this.nextSeqByCall.set(callId, nextEvent.getContent().seq + 1);
+ nextEvent = buffer!.shift();
+ }
+ }
+ };
+
+ private async handleCallEvent(event: MatrixEvent): Promise<void> {
+ this.client.emit(ClientEvent.ReceivedVoipEvent, event);
+
+ const content = event.getContent();
+ const callRoomId =
+ event.getRoomId() || this.client.groupCallEventHandler!.getGroupCallById(content.conf_id)?.room?.roomId;
+ const groupCallId = content.conf_id;
+ const type = event.getType() as EventType;
+ const senderId = event.getSender()!;
+ let call = content.call_id ? this.calls.get(content.call_id) : undefined;
+
+ let opponentDeviceId: string | undefined;
+
+ let groupCall: GroupCall | undefined;
+ if (groupCallId) {
+ groupCall = this.client.groupCallEventHandler!.getGroupCallById(groupCallId);
+
+ if (!groupCall) {
+ logger.warn(
+ `CallEventHandler handleCallEvent() could not find a group call - ignoring event (groupCallId=${groupCallId}, type=${type})`,
+ );
+ return;
+ }
+
+ opponentDeviceId = content.device_id;
+
+ if (!opponentDeviceId) {
+ logger.warn(
+ `CallEventHandler handleCallEvent() could not find a device id - ignoring event (senderId=${senderId})`,
+ );
+ groupCall.emit(GroupCallEvent.Error, new GroupCallUnknownDeviceError(senderId));
+ return;
+ }
+
+ if (content.dest_session_id !== this.client.getSessionId()) {
+ logger.warn(
+ "CallEventHandler handleCallEvent() call event does not match current session id - ignoring",
+ );
+ return;
+ }
+ }
+
+ const weSentTheEvent =
+ senderId === this.client.credentials.userId &&
+ (opponentDeviceId === undefined || opponentDeviceId === this.client.getDeviceId()!);
+
+ if (!callRoomId) return;
+
+ if (type === EventType.CallInvite) {
+ // ignore invites you send
+ if (weSentTheEvent) return;
+ // expired call
+ if (event.getLocalAge() > content.lifetime - RING_GRACE_PERIOD) return;
+ // stale/old invite event
+ if (call && call.state === CallState.Ended) return;
+
+ if (call) {
+ logger.warn(
+ `CallEventHandler handleCallEvent() already has a call but got an invite - clobbering (callId=${content.call_id})`,
+ );
+ }
+
+ if (content.invitee && content.invitee !== this.client.getUserId()) {
+ return; // This invite was meant for another user in the room
+ }
+
+ const timeUntilTurnCresExpire = (this.client.getTurnServersExpiry() ?? 0) - Date.now();
+ logger.info(
+ "CallEventHandler handleCallEvent() current turn creds expire in " + timeUntilTurnCresExpire + " ms",
+ );
+ call =
+ createNewMatrixCall(this.client, callRoomId, {
+ forceTURN: this.client.forceTURN,
+ opponentDeviceId,
+ groupCallId,
+ opponentSessionId: content.sender_session_id,
+ }) ?? undefined;
+ if (!call) {
+ logger.log(
+ `CallEventHandler handleCallEvent() this client does not support WebRTC (callId=${content.call_id})`,
+ );
+ // don't hang up the call: there could be other clients
+ // connected that do support WebRTC and declining the
+ // the call on their behalf would be really annoying.
+ return;
+ }
+
+ call.callId = content.call_id;
+ const stats = groupCall?.getGroupCallStats();
+ if (stats) {
+ call.initStats(stats);
+ }
+
+ try {
+ await call.initWithInvite(event);
+ } catch (e) {
+ if (e instanceof CallError) {
+ if (e.code === GroupCallErrorCode.UnknownDevice) {
+ groupCall?.emit(GroupCallEvent.Error, e);
+ } else {
+ logger.error(e);
+ }
+ }
+ }
+ this.calls.set(call.callId, call);
+
+ // if we stashed candidate events for that call ID, play them back now
+ if (this.candidateEventsByCall.get(call.callId)) {
+ for (const ev of this.candidateEventsByCall.get(call.callId)!) {
+ call.onRemoteIceCandidatesReceived(ev);
+ }
+ }
+
+ // Were we trying to call that user (room)?
+ let existingCall: MatrixCall | undefined;
+ for (const thisCall of this.calls.values()) {
+ const isCalling = [CallState.WaitLocalMedia, CallState.CreateOffer, CallState.InviteSent].includes(
+ thisCall.state,
+ );
+
+ if (
+ call.roomId === thisCall.roomId &&
+ thisCall.direction === CallDirection.Outbound &&
+ call.getOpponentMember()?.userId === thisCall.invitee &&
+ isCalling
+ ) {
+ existingCall = thisCall;
+ break;
+ }
+ }
+
+ if (existingCall) {
+ if (existingCall.callId > call.callId) {
+ logger.log(
+ `CallEventHandler handleCallEvent() detected glare - answering incoming call and canceling outgoing call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`,
+ );
+ existingCall.replacedBy(call);
+ } else {
+ logger.log(
+ `CallEventHandler handleCallEvent() detected glare - hanging up incoming call (incomingId=${call.callId}, outgoingId=${existingCall.callId})`,
+ );
+ call.hangup(CallErrorCode.Replaced, true);
+ }
+ } else {
+ this.client.emit(CallEventHandlerEvent.Incoming, call);
+ }
+ return;
+ } else if (type === EventType.CallCandidates) {
+ if (weSentTheEvent) return;
+
+ if (!call) {
+ // store the candidates; we may get a call eventually.
+ if (!this.candidateEventsByCall.has(content.call_id)) {
+ this.candidateEventsByCall.set(content.call_id, []);
+ }
+ this.candidateEventsByCall.get(content.call_id)!.push(event);
+ } else {
+ call.onRemoteIceCandidatesReceived(event);
+ }
+ return;
+ } else if ([EventType.CallHangup, EventType.CallReject].includes(type)) {
+ // Note that we also observe our own hangups here so we can see
+ // if we've already rejected a call that would otherwise be valid
+ if (!call) {
+ // if not live, store the fact that the call has ended because
+ // we're probably getting events backwards so
+ // the hangup will come before the invite
+ call =
+ createNewMatrixCall(this.client, callRoomId, {
+ opponentDeviceId,
+ opponentSessionId: content.sender_session_id,
+ }) ?? undefined;
+ if (call) {
+ call.callId = content.call_id;
+ call.initWithHangup(event);
+ this.calls.set(content.call_id, call);
+ }
+ } else {
+ if (call.state !== CallState.Ended) {
+ if (type === EventType.CallHangup) {
+ call.onHangupReceived(content as MCallHangupReject);
+ } else {
+ call.onRejectReceived(content as MCallHangupReject);
+ }
+
+ // @ts-expect-error typescript thinks the state can't be 'ended' because we're
+ // inside the if block where it wasn't, but it could have changed because
+ // on[Hangup|Reject]Received are side-effecty.
+ if (call.state === CallState.Ended) this.calls.delete(content.call_id);
+ }
+ }
+ return;
+ }
+
+ // The following events need a call and a peer connection
+ if (!call || !call.hasPeerConnection) {
+ logger.info(
+ `CallEventHandler handleCallEvent() discarding possible call event as we don't have a call (type=${type})`,
+ );
+ return;
+ }
+ // Ignore remote echo
+ if (event.getContent().party_id === call.ourPartyId) return;
+
+ switch (type) {
+ case EventType.CallAnswer:
+ if (weSentTheEvent) {
+ if (call.state === CallState.Ringing) {
+ call.onAnsweredElsewhere(content as MCallAnswer);
+ }
+ } else {
+ call.onAnswerReceived(event);
+ }
+ break;
+ case EventType.CallSelectAnswer:
+ call.onSelectAnswerReceived(event);
+ break;
+
+ case EventType.CallNegotiate:
+ call.onNegotiateReceived(event);
+ break;
+
+ case EventType.CallAssertedIdentity:
+ case EventType.CallAssertedIdentityPrefix:
+ call.onAssertedIdentityReceived(event);
+ break;
+
+ case EventType.CallSDPStreamMetadataChanged:
+ case EventType.CallSDPStreamMetadataChangedPrefix:
+ call.onSDPStreamMetadataChangedReceived(event);
+ break;
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventTypes.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventTypes.ts
new file mode 100644
index 0000000..f06ed5b
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callEventTypes.ts
@@ -0,0 +1,92 @@
+// allow non-camelcase as these are events type that go onto the wire
+/* eslint-disable camelcase */
+
+import { CallErrorCode } from "./call";
+
+// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged
+export const SDPStreamMetadataKey = "org.matrix.msc3077.sdp_stream_metadata";
+
+export enum SDPStreamMetadataPurpose {
+ Usermedia = "m.usermedia",
+ Screenshare = "m.screenshare",
+}
+
+export interface SDPStreamMetadataObject {
+ purpose: SDPStreamMetadataPurpose;
+ audio_muted: boolean;
+ video_muted: boolean;
+}
+
+export interface SDPStreamMetadata {
+ [key: string]: SDPStreamMetadataObject;
+}
+
+export interface CallCapabilities {
+ "m.call.transferee": boolean;
+ "m.call.dtmf": boolean;
+}
+
+export interface CallReplacesTarget {
+ id: string;
+ display_name: string;
+ avatar_url: string;
+}
+
+export interface MCallBase {
+ call_id: string;
+ version: string | number;
+ party_id?: string;
+ sender_session_id?: string;
+ dest_session_id?: string;
+}
+
+export interface MCallAnswer extends MCallBase {
+ answer: RTCSessionDescription;
+ capabilities?: CallCapabilities;
+ [SDPStreamMetadataKey]: SDPStreamMetadata;
+}
+
+export interface MCallSelectAnswer extends MCallBase {
+ selected_party_id: string;
+}
+
+export interface MCallInviteNegotiate extends MCallBase {
+ offer: RTCSessionDescription;
+ description: RTCSessionDescription;
+ lifetime: number;
+ capabilities?: CallCapabilities;
+ invitee?: string;
+ sender_session_id?: string;
+ dest_session_id?: string;
+ [SDPStreamMetadataKey]: SDPStreamMetadata;
+}
+
+export interface MCallSDPStreamMetadataChanged extends MCallBase {
+ [SDPStreamMetadataKey]: SDPStreamMetadata;
+}
+
+export interface MCallReplacesEvent extends MCallBase {
+ replacement_id: string;
+ target_user: CallReplacesTarget;
+ create_call: string;
+ await_call: string;
+ target_room: string;
+}
+
+export interface MCAllAssertedIdentity extends MCallBase {
+ asserted_identity: {
+ id: string;
+ display_name: string;
+ avatar_url: string;
+ };
+}
+
+export interface MCallCandidates extends MCallBase {
+ candidates: RTCIceCandidate[];
+}
+
+export interface MCallHangupReject extends MCallBase {
+ reason?: CallErrorCode;
+}
+
+/* eslint-enable camelcase */
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callFeed.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callFeed.ts
new file mode 100644
index 0000000..505cf56
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/callFeed.ts
@@ -0,0 +1,361 @@
+/*
+Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { SDPStreamMetadataPurpose } from "./callEventTypes";
+import { acquireContext, releaseContext } from "./audioContext";
+import { MatrixClient } from "../client";
+import { RoomMember } from "../models/room-member";
+import { logger } from "../logger";
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+import { CallEvent, CallState, MatrixCall } from "./call";
+
+const POLLING_INTERVAL = 200; // ms
+export const SPEAKING_THRESHOLD = -60; // dB
+const SPEAKING_SAMPLE_COUNT = 8; // samples
+
+export interface ICallFeedOpts {
+ client: MatrixClient;
+ roomId?: string;
+ userId: string;
+ deviceId: string | undefined;
+ stream: MediaStream;
+ purpose: SDPStreamMetadataPurpose;
+ /**
+ * Whether or not the remote SDPStreamMetadata says audio is muted
+ */
+ audioMuted: boolean;
+ /**
+ * Whether or not the remote SDPStreamMetadata says video is muted
+ */
+ videoMuted: boolean;
+ /**
+ * The MatrixCall which is the source of this CallFeed
+ */
+ call?: MatrixCall;
+}
+
+export enum CallFeedEvent {
+ NewStream = "new_stream",
+ MuteStateChanged = "mute_state_changed",
+ LocalVolumeChanged = "local_volume_changed",
+ VolumeChanged = "volume_changed",
+ ConnectedChanged = "connected_changed",
+ Speaking = "speaking",
+ Disposed = "disposed",
+}
+
+type EventHandlerMap = {
+ [CallFeedEvent.NewStream]: (stream: MediaStream) => void;
+ [CallFeedEvent.MuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void;
+ [CallFeedEvent.LocalVolumeChanged]: (localVolume: number) => void;
+ [CallFeedEvent.VolumeChanged]: (volume: number) => void;
+ [CallFeedEvent.ConnectedChanged]: (connected: boolean) => void;
+ [CallFeedEvent.Speaking]: (speaking: boolean) => void;
+ [CallFeedEvent.Disposed]: () => void;
+};
+
+export class CallFeed extends TypedEventEmitter<CallFeedEvent, EventHandlerMap> {
+ public stream: MediaStream;
+ public sdpMetadataStreamId: string;
+ public userId: string;
+ public readonly deviceId: string | undefined;
+ public purpose: SDPStreamMetadataPurpose;
+ public speakingVolumeSamples: number[];
+
+ private client: MatrixClient;
+ private call?: MatrixCall;
+ private roomId?: string;
+ private audioMuted: boolean;
+ private videoMuted: boolean;
+ private localVolume = 1;
+ private measuringVolumeActivity = false;
+ private audioContext?: AudioContext;
+ private analyser?: AnalyserNode;
+ private frequencyBinCount?: Float32Array;
+ private speakingThreshold = SPEAKING_THRESHOLD;
+ private speaking = false;
+ private volumeLooperTimeout?: ReturnType<typeof setTimeout>;
+ private _disposed = false;
+ private _connected = false;
+
+ public constructor(opts: ICallFeedOpts) {
+ super();
+
+ this.client = opts.client;
+ this.call = opts.call;
+ this.roomId = opts.roomId;
+ this.userId = opts.userId;
+ this.deviceId = opts.deviceId;
+ this.purpose = opts.purpose;
+ this.audioMuted = opts.audioMuted;
+ this.videoMuted = opts.videoMuted;
+ this.speakingVolumeSamples = new Array(SPEAKING_SAMPLE_COUNT).fill(-Infinity);
+ this.sdpMetadataStreamId = opts.stream.id;
+
+ this.updateStream(null, opts.stream);
+ this.stream = opts.stream; // updateStream does this, but this makes TS happier
+
+ if (this.hasAudioTrack) {
+ this.initVolumeMeasuring();
+ }
+
+ if (opts.call) {
+ opts.call.addListener(CallEvent.State, this.onCallState);
+ this.onCallState(opts.call.state);
+ }
+ }
+
+ public get connected(): boolean {
+ // Local feeds are always considered connected
+ return this.isLocal() || this._connected;
+ }
+
+ private set connected(connected: boolean) {
+ this._connected = connected;
+ this.emit(CallFeedEvent.ConnectedChanged, this.connected);
+ }
+
+ private get hasAudioTrack(): boolean {
+ return this.stream.getAudioTracks().length > 0;
+ }
+
+ private updateStream(oldStream: MediaStream | null, newStream: MediaStream): void {
+ if (newStream === oldStream) return;
+
+ if (oldStream) {
+ oldStream.removeEventListener("addtrack", this.onAddTrack);
+ this.measureVolumeActivity(false);
+ }
+
+ this.stream = newStream;
+ newStream.addEventListener("addtrack", this.onAddTrack);
+
+ if (this.hasAudioTrack) {
+ this.initVolumeMeasuring();
+ } else {
+ this.measureVolumeActivity(false);
+ }
+
+ this.emit(CallFeedEvent.NewStream, this.stream);
+ }
+
+ private initVolumeMeasuring(): void {
+ if (!this.hasAudioTrack) return;
+ if (!this.audioContext) this.audioContext = acquireContext();
+
+ this.analyser = this.audioContext.createAnalyser();
+ this.analyser.fftSize = 512;
+ this.analyser.smoothingTimeConstant = 0.1;
+
+ const mediaStreamAudioSourceNode = this.audioContext.createMediaStreamSource(this.stream);
+ mediaStreamAudioSourceNode.connect(this.analyser);
+
+ this.frequencyBinCount = new Float32Array(this.analyser.frequencyBinCount);
+ }
+
+ private onAddTrack = (): void => {
+ this.emit(CallFeedEvent.NewStream, this.stream);
+ };
+
+ private onCallState = (state: CallState): void => {
+ if (state === CallState.Connected) {
+ this.connected = true;
+ } else if (state === CallState.Connecting) {
+ this.connected = false;
+ }
+ };
+
+ /**
+ * Returns callRoom member
+ * @returns member of the callRoom
+ */
+ public getMember(): RoomMember | null {
+ const callRoom = this.client.getRoom(this.roomId);
+ return callRoom?.getMember(this.userId) ?? null;
+ }
+
+ /**
+ * Returns true if CallFeed is local, otherwise returns false
+ * @returns is local?
+ */
+ public isLocal(): boolean {
+ return (
+ this.userId === this.client.getUserId() &&
+ (this.deviceId === undefined || this.deviceId === this.client.getDeviceId())
+ );
+ }
+
+ /**
+ * Returns true if audio is muted or if there are no audio
+ * tracks, otherwise returns false
+ * @returns is audio muted?
+ */
+ public isAudioMuted(): boolean {
+ return this.stream.getAudioTracks().length === 0 || this.audioMuted;
+ }
+
+ /**
+ * Returns true video is muted or if there are no video
+ * tracks, otherwise returns false
+ * @returns is video muted?
+ */
+ public isVideoMuted(): boolean {
+ // We assume only one video track
+ return this.stream.getVideoTracks().length === 0 || this.videoMuted;
+ }
+
+ public isSpeaking(): boolean {
+ return this.speaking;
+ }
+
+ /**
+ * Replaces the current MediaStream with a new one.
+ * The stream will be different and new stream as remote parties are
+ * concerned, but this can be used for convenience locally to set up
+ * volume listeners automatically on the new stream etc.
+ * @param newStream - new stream with which to replace the current one
+ */
+ public setNewStream(newStream: MediaStream): void {
+ this.updateStream(this.stream, newStream);
+ }
+
+ /**
+ * Set one or both of feed's internal audio and video video mute state
+ * Either value may be null to leave it as-is
+ * @param audioMuted - is the feed's audio muted?
+ * @param videoMuted - is the feed's video muted?
+ */
+ public setAudioVideoMuted(audioMuted: boolean | null, videoMuted: boolean | null): void {
+ if (audioMuted !== null) {
+ if (this.audioMuted !== audioMuted) {
+ this.speakingVolumeSamples.fill(-Infinity);
+ }
+ this.audioMuted = audioMuted;
+ }
+ if (videoMuted !== null) this.videoMuted = videoMuted;
+ this.emit(CallFeedEvent.MuteStateChanged, this.audioMuted, this.videoMuted);
+ }
+
+ /**
+ * Starts emitting volume_changed events where the emitter value is in decibels
+ * @param enabled - emit volume changes
+ */
+ public measureVolumeActivity(enabled: boolean): void {
+ if (enabled) {
+ if (!this.analyser || !this.frequencyBinCount || !this.hasAudioTrack) return;
+
+ this.measuringVolumeActivity = true;
+ this.volumeLooper();
+ } else {
+ this.measuringVolumeActivity = false;
+ this.speakingVolumeSamples.fill(-Infinity);
+ this.emit(CallFeedEvent.VolumeChanged, -Infinity);
+ }
+ }
+
+ public setSpeakingThreshold(threshold: number): void {
+ this.speakingThreshold = threshold;
+ }
+
+ private volumeLooper = (): void => {
+ if (!this.analyser) return;
+
+ if (!this.measuringVolumeActivity) return;
+
+ this.analyser.getFloatFrequencyData(this.frequencyBinCount!);
+
+ let maxVolume = -Infinity;
+ for (const volume of this.frequencyBinCount!) {
+ if (volume > maxVolume) {
+ maxVolume = volume;
+ }
+ }
+
+ this.speakingVolumeSamples.shift();
+ this.speakingVolumeSamples.push(maxVolume);
+
+ this.emit(CallFeedEvent.VolumeChanged, maxVolume);
+
+ let newSpeaking = false;
+
+ for (const volume of this.speakingVolumeSamples) {
+ if (volume > this.speakingThreshold) {
+ newSpeaking = true;
+ break;
+ }
+ }
+
+ if (this.speaking !== newSpeaking) {
+ this.speaking = newSpeaking;
+ this.emit(CallFeedEvent.Speaking, this.speaking);
+ }
+
+ this.volumeLooperTimeout = setTimeout(this.volumeLooper, POLLING_INTERVAL);
+ };
+
+ public clone(): CallFeed {
+ const mediaHandler = this.client.getMediaHandler();
+ const stream = this.stream.clone();
+ logger.log(`CallFeed clone() cloning stream (originalStreamId=${this.stream.id}, newStreamId${stream.id})`);
+
+ if (this.purpose === SDPStreamMetadataPurpose.Usermedia) {
+ mediaHandler.userMediaStreams.push(stream);
+ } else {
+ mediaHandler.screensharingStreams.push(stream);
+ }
+
+ return new CallFeed({
+ client: this.client,
+ roomId: this.roomId,
+ userId: this.userId,
+ deviceId: this.deviceId,
+ stream,
+ purpose: this.purpose,
+ audioMuted: this.audioMuted,
+ videoMuted: this.videoMuted,
+ });
+ }
+
+ public dispose(): void {
+ clearTimeout(this.volumeLooperTimeout);
+ this.stream?.removeEventListener("addtrack", this.onAddTrack);
+ this.call?.removeListener(CallEvent.State, this.onCallState);
+ if (this.audioContext) {
+ this.audioContext = undefined;
+ this.analyser = undefined;
+ releaseContext();
+ }
+ this._disposed = true;
+ this.emit(CallFeedEvent.Disposed);
+ }
+
+ public get disposed(): boolean {
+ return this._disposed;
+ }
+
+ private set disposed(value: boolean) {
+ this._disposed = value;
+ }
+
+ public getLocalVolume(): number {
+ return this.localVolume;
+ }
+
+ public setLocalVolume(localVolume: number): void {
+ this.localVolume = localVolume;
+ this.emit(CallFeedEvent.LocalVolumeChanged, localVolume);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts
new file mode 100644
index 0000000..c0896c4
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCall.ts
@@ -0,0 +1,1598 @@
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+import { CallFeed, SPEAKING_THRESHOLD } from "./callFeed";
+import { MatrixClient, IMyDevice } from "../client";
+import {
+ CallErrorCode,
+ CallEvent,
+ CallEventHandlerMap,
+ CallState,
+ genCallID,
+ MatrixCall,
+ setTracksEnabled,
+ createNewMatrixCall,
+ CallError,
+} from "./call";
+import { RoomMember } from "../models/room-member";
+import { Room } from "../models/room";
+import { RoomStateEvent } from "../models/room-state";
+import { logger } from "../logger";
+import { ReEmitter } from "../ReEmitter";
+import { SDPStreamMetadataPurpose } from "./callEventTypes";
+import { MatrixEvent } from "../models/event";
+import { EventType } from "../@types/event";
+import { CallEventHandlerEvent } from "./callEventHandler";
+import { GroupCallEventHandlerEvent } from "./groupCallEventHandler";
+import { IScreensharingOpts } from "./mediaHandler";
+import { mapsEqual } from "../utils";
+import { GroupCallStats } from "./stats/groupCallStats";
+import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "./stats/statsReport";
+
+export enum GroupCallIntent {
+ Ring = "m.ring",
+ Prompt = "m.prompt",
+ Room = "m.room",
+}
+
+export enum GroupCallType {
+ Video = "m.video",
+ Voice = "m.voice",
+}
+
+export enum GroupCallTerminationReason {
+ CallEnded = "call_ended",
+}
+
+export type CallsByUserAndDevice = Map<string, Map<string, MatrixCall>>;
+
+/**
+ * Because event names are just strings, they do need
+ * to be unique over all event types of event emitter.
+ * Some objects could emit more then one set of events.
+ */
+export enum GroupCallEvent {
+ GroupCallStateChanged = "group_call_state_changed",
+ ActiveSpeakerChanged = "active_speaker_changed",
+ CallsChanged = "calls_changed",
+ UserMediaFeedsChanged = "user_media_feeds_changed",
+ ScreenshareFeedsChanged = "screenshare_feeds_changed",
+ LocalScreenshareStateChanged = "local_screenshare_state_changed",
+ LocalMuteStateChanged = "local_mute_state_changed",
+ ParticipantsChanged = "participants_changed",
+ Error = "group_call_error",
+}
+
+export type GroupCallEventHandlerMap = {
+ [GroupCallEvent.GroupCallStateChanged]: (newState: GroupCallState, oldState: GroupCallState) => void;
+ [GroupCallEvent.ActiveSpeakerChanged]: (activeSpeaker: CallFeed | undefined) => void;
+ [GroupCallEvent.CallsChanged]: (calls: CallsByUserAndDevice) => void;
+ [GroupCallEvent.UserMediaFeedsChanged]: (feeds: CallFeed[]) => void;
+ [GroupCallEvent.ScreenshareFeedsChanged]: (feeds: CallFeed[]) => void;
+ [GroupCallEvent.LocalScreenshareStateChanged]: (
+ isScreensharing: boolean,
+ feed?: CallFeed,
+ sourceId?: string,
+ ) => void;
+ [GroupCallEvent.LocalMuteStateChanged]: (audioMuted: boolean, videoMuted: boolean) => void;
+ [GroupCallEvent.ParticipantsChanged]: (participants: Map<RoomMember, Map<string, ParticipantState>>) => void;
+ /**
+ * Fires whenever an error occurs when call.js encounters an issue with setting up the call.
+ * <p>
+ * The error given will have a code equal to either `MatrixCall.ERR_LOCAL_OFFER_FAILED` or
+ * `MatrixCall.ERR_NO_USER_MEDIA`. `ERR_LOCAL_OFFER_FAILED` is emitted when the local client
+ * fails to create an offer. `ERR_NO_USER_MEDIA` is emitted when the user has denied access
+ * to their audio/video hardware.
+ * @param err - The error raised by MatrixCall.
+ * @example
+ * ```
+ * matrixCall.on("error", function(err){
+ * console.error(err.code, err);
+ * });
+ * ```
+ */
+ [GroupCallEvent.Error]: (error: GroupCallError) => void;
+};
+
+export enum GroupCallStatsReportEvent {
+ ConnectionStats = "GroupCall.connection_stats",
+ ByteSentStats = "GroupCall.byte_sent_stats",
+}
+
+export type GroupCallStatsReportEventHandlerMap = {
+ [GroupCallStatsReportEvent.ConnectionStats]: (report: GroupCallStatsReport<ConnectionStatsReport>) => void;
+ [GroupCallStatsReportEvent.ByteSentStats]: (report: GroupCallStatsReport<ByteSentStatsReport>) => void;
+};
+
+export enum GroupCallErrorCode {
+ NoUserMedia = "no_user_media",
+ UnknownDevice = "unknown_device",
+ PlaceCallFailed = "place_call_failed",
+}
+
+export interface GroupCallStatsReport<T extends ConnectionStatsReport | ByteSentStatsReport> {
+ report: T;
+}
+
+export class GroupCallError extends Error {
+ public code: string;
+
+ public constructor(code: GroupCallErrorCode, msg: string, err?: Error) {
+ // Still don't think there's any way to have proper nested errors
+ if (err) {
+ super(msg + ": " + err);
+ } else {
+ super(msg);
+ }
+
+ this.code = code;
+ }
+}
+
+export class GroupCallUnknownDeviceError extends GroupCallError {
+ public constructor(public userId: string) {
+ super(GroupCallErrorCode.UnknownDevice, "No device found for " + userId);
+ }
+}
+
+export class OtherUserSpeakingError extends Error {
+ public constructor() {
+ super("Cannot unmute: another user is speaking");
+ }
+}
+
+export interface IGroupCallDataChannelOptions {
+ ordered: boolean;
+ maxPacketLifeTime: number;
+ maxRetransmits: number;
+ protocol: string;
+}
+
+export interface IGroupCallRoomState {
+ "m.intent": GroupCallIntent;
+ "m.type": GroupCallType;
+ "io.element.ptt"?: boolean;
+ // TODO: Specify data-channels
+ "dataChannelsEnabled"?: boolean;
+ "dataChannelOptions"?: IGroupCallDataChannelOptions;
+}
+
+export interface IGroupCallRoomMemberFeed {
+ purpose: SDPStreamMetadataPurpose;
+}
+
+export interface IGroupCallRoomMemberDevice {
+ device_id: string;
+ session_id: string;
+ expires_ts: number;
+ feeds: IGroupCallRoomMemberFeed[];
+}
+
+export interface IGroupCallRoomMemberCallState {
+ "m.call_id": string;
+ "m.foci"?: string[];
+ "m.devices": IGroupCallRoomMemberDevice[];
+}
+
+export interface IGroupCallRoomMemberState {
+ "m.calls": IGroupCallRoomMemberCallState[];
+}
+
+export enum GroupCallState {
+ LocalCallFeedUninitialized = "local_call_feed_uninitialized",
+ InitializingLocalCallFeed = "initializing_local_call_feed",
+ LocalCallFeedInitialized = "local_call_feed_initialized",
+ Entered = "entered",
+ Ended = "ended",
+}
+
+export interface ParticipantState {
+ sessionId: string;
+ screensharing: boolean;
+}
+
+interface ICallHandlers {
+ onCallFeedsChanged: (feeds: CallFeed[]) => void;
+ onCallStateChanged: (state: CallState, oldState: CallState | undefined) => void;
+ onCallHangup: (call: MatrixCall) => void;
+ onCallReplaced: (newCall: MatrixCall) => void;
+}
+
+const DEVICE_TIMEOUT = 1000 * 60 * 60; // 1 hour
+
+function getCallUserId(call: MatrixCall): string | null {
+ return call.getOpponentMember()?.userId || call.invitee || null;
+}
+
+export class GroupCall extends TypedEventEmitter<
+ GroupCallEvent | CallEvent | GroupCallStatsReportEvent,
+ GroupCallEventHandlerMap & CallEventHandlerMap & GroupCallStatsReportEventHandlerMap
+> {
+ // Config
+ public activeSpeakerInterval = 1000;
+ public retryCallInterval = 5000;
+ public participantTimeout = 1000 * 15;
+ public pttMaxTransmitTime = 1000 * 20;
+
+ public activeSpeaker?: CallFeed;
+ public localCallFeed?: CallFeed;
+ public localScreenshareFeed?: CallFeed;
+ public localDesktopCapturerSourceId?: string;
+ public readonly userMediaFeeds: CallFeed[] = [];
+ public readonly screenshareFeeds: CallFeed[] = [];
+ public groupCallId: string;
+ public readonly allowCallWithoutVideoAndAudio: boolean;
+
+ private readonly calls = new Map<string, Map<string, MatrixCall>>(); // user_id -> device_id -> MatrixCall
+ private callHandlers = new Map<string, Map<string, ICallHandlers>>(); // user_id -> device_id -> ICallHandlers
+ private activeSpeakerLoopInterval?: ReturnType<typeof setTimeout>;
+ private retryCallLoopInterval?: ReturnType<typeof setTimeout>;
+ private retryCallCounts: Map<string, Map<string, number>> = new Map(); // user_id -> device_id -> count
+ private reEmitter: ReEmitter;
+ private transmitTimer: ReturnType<typeof setTimeout> | null = null;
+ private participantsExpirationTimer: ReturnType<typeof setTimeout> | null = null;
+ private resendMemberStateTimer: ReturnType<typeof setInterval> | null = null;
+ private initWithAudioMuted = false;
+ private initWithVideoMuted = false;
+ private initCallFeedPromise?: Promise<void>;
+
+ private readonly stats: GroupCallStats;
+
+ public constructor(
+ private client: MatrixClient,
+ public room: Room,
+ public type: GroupCallType,
+ public isPtt: boolean,
+ public intent: GroupCallIntent,
+ groupCallId?: string,
+ private dataChannelsEnabled?: boolean,
+ private dataChannelOptions?: IGroupCallDataChannelOptions,
+ isCallWithoutVideoAndAudio?: boolean,
+ ) {
+ super();
+ this.reEmitter = new ReEmitter(this);
+ this.groupCallId = groupCallId ?? genCallID();
+ this.creationTs =
+ room.currentState.getStateEvents(EventType.GroupCallPrefix, this.groupCallId)?.getTs() ?? null;
+ this.updateParticipants();
+
+ room.on(RoomStateEvent.Update, this.onRoomState);
+ this.on(GroupCallEvent.ParticipantsChanged, this.onParticipantsChanged);
+ this.on(GroupCallEvent.GroupCallStateChanged, this.onStateChanged);
+ this.on(GroupCallEvent.LocalScreenshareStateChanged, this.onLocalFeedsChanged);
+ this.allowCallWithoutVideoAndAudio = !!isCallWithoutVideoAndAudio;
+
+ const userID = this.client.getUserId() || "unknown";
+ this.stats = new GroupCallStats(this.groupCallId, userID);
+ this.stats.reports.on(StatsReport.CONNECTION_STATS, this.onConnectionStats);
+ this.stats.reports.on(StatsReport.BYTE_SENT_STATS, this.onByteSentStats);
+ }
+
+ private onConnectionStats = (report: ConnectionStatsReport): void => {
+ // @TODO: Implement data argumentation
+ this.emit(GroupCallStatsReportEvent.ConnectionStats, { report });
+ };
+
+ private onByteSentStats = (report: ByteSentStatsReport): void => {
+ // @TODO: Implement data argumentation
+ this.emit(GroupCallStatsReportEvent.ByteSentStats, { report });
+ };
+
+ public async create(): Promise<GroupCall> {
+ this.creationTs = Date.now();
+ this.client.groupCallEventHandler!.groupCalls.set(this.room.roomId, this);
+ this.client.emit(GroupCallEventHandlerEvent.Outgoing, this);
+
+ const groupCallState: IGroupCallRoomState = {
+ "m.intent": this.intent,
+ "m.type": this.type,
+ "io.element.ptt": this.isPtt,
+ // TODO: Specify data-channels better
+ "dataChannelsEnabled": this.dataChannelsEnabled,
+ "dataChannelOptions": this.dataChannelsEnabled ? this.dataChannelOptions : undefined,
+ };
+
+ await this.client.sendStateEvent(this.room.roomId, EventType.GroupCallPrefix, groupCallState, this.groupCallId);
+
+ return this;
+ }
+
+ private _state = GroupCallState.LocalCallFeedUninitialized;
+
+ /**
+ * The group call's state.
+ */
+ public get state(): GroupCallState {
+ return this._state;
+ }
+
+ private set state(value: GroupCallState) {
+ const prevValue = this._state;
+ if (value !== prevValue) {
+ this._state = value;
+ this.emit(GroupCallEvent.GroupCallStateChanged, value, prevValue);
+ }
+ }
+
+ private _participants = new Map<RoomMember, Map<string, ParticipantState>>();
+
+ /**
+ * The current participants in the call, as a map from members to device IDs
+ * to participant info.
+ */
+ public get participants(): Map<RoomMember, Map<string, ParticipantState>> {
+ return this._participants;
+ }
+
+ private set participants(value: Map<RoomMember, Map<string, ParticipantState>>) {
+ const prevValue = this._participants;
+ const participantStateEqual = (x: ParticipantState, y: ParticipantState): boolean =>
+ x.sessionId === y.sessionId && x.screensharing === y.screensharing;
+ const deviceMapsEqual = (x: Map<string, ParticipantState>, y: Map<string, ParticipantState>): boolean =>
+ mapsEqual(x, y, participantStateEqual);
+
+ // Only update if the map actually changed
+ if (!mapsEqual(value, prevValue, deviceMapsEqual)) {
+ this._participants = value;
+ this.emit(GroupCallEvent.ParticipantsChanged, value);
+ }
+ }
+
+ private _creationTs: number | null = null;
+
+ /**
+ * The timestamp at which the call was created, or null if it has not yet
+ * been created.
+ */
+ public get creationTs(): number | null {
+ return this._creationTs;
+ }
+
+ private set creationTs(value: number | null) {
+ this._creationTs = value;
+ }
+
+ private _enteredViaAnotherSession = false;
+
+ /**
+ * Whether the local device has entered this call via another session, such
+ * as a widget.
+ */
+ public get enteredViaAnotherSession(): boolean {
+ return this._enteredViaAnotherSession;
+ }
+
+ public set enteredViaAnotherSession(value: boolean) {
+ this._enteredViaAnotherSession = value;
+ this.updateParticipants();
+ }
+
+ /**
+ * Executes the given callback on all calls in this group call.
+ * @param f - The callback.
+ */
+ public forEachCall(f: (call: MatrixCall) => void): void {
+ for (const deviceMap of this.calls.values()) {
+ for (const call of deviceMap.values()) f(call);
+ }
+ }
+
+ public getLocalFeeds(): CallFeed[] {
+ const feeds: CallFeed[] = [];
+
+ if (this.localCallFeed) feeds.push(this.localCallFeed);
+ if (this.localScreenshareFeed) feeds.push(this.localScreenshareFeed);
+
+ return feeds;
+ }
+
+ public hasLocalParticipant(): boolean {
+ return (
+ this.participants.get(this.room.getMember(this.client.getUserId()!)!)?.has(this.client.getDeviceId()!) ??
+ false
+ );
+ }
+
+ /**
+ * Determines whether the given call is one that we were expecting to exist
+ * given our knowledge of who is participating in the group call.
+ */
+ private callExpected(call: MatrixCall): boolean {
+ const userId = getCallUserId(call);
+ const member = userId === null ? null : this.room.getMember(userId);
+ const deviceId = call.getOpponentDeviceId();
+ return member !== null && deviceId !== undefined && this.participants.get(member)?.get(deviceId) !== undefined;
+ }
+
+ public async initLocalCallFeed(): Promise<void> {
+ if (this.state !== GroupCallState.LocalCallFeedUninitialized) {
+ throw new Error(`Cannot initialize local call feed in the "${this.state}" state.`);
+ }
+ this.state = GroupCallState.InitializingLocalCallFeed;
+
+ // wraps the real method to serialise calls, because we don't want to try starting
+ // multiple call feeds at once
+ if (this.initCallFeedPromise) return this.initCallFeedPromise;
+
+ try {
+ this.initCallFeedPromise = this.initLocalCallFeedInternal();
+ await this.initCallFeedPromise;
+ } finally {
+ this.initCallFeedPromise = undefined;
+ }
+ }
+
+ private async initLocalCallFeedInternal(): Promise<void> {
+ logger.log(`GroupCall ${this.groupCallId} initLocalCallFeedInternal() running`);
+
+ let stream: MediaStream;
+
+ try {
+ stream = await this.client.getMediaHandler().getUserMediaStream(true, this.type === GroupCallType.Video);
+ } catch (error) {
+ // If is allowed to join a call without a media stream, then we
+ // don't throw an error here. But we need an empty Local Feed to establish
+ // a connection later.
+ if (this.allowCallWithoutVideoAndAudio) {
+ stream = new MediaStream();
+ } else {
+ this.state = GroupCallState.LocalCallFeedUninitialized;
+ throw error;
+ }
+ }
+
+ // The call could've been disposed while we were waiting, and could
+ // also have been started back up again (hello, React 18) so if we're
+ // still in this 'initializing' state, carry on, otherwise bail.
+ if (this._state !== GroupCallState.InitializingLocalCallFeed) {
+ this.client.getMediaHandler().stopUserMediaStream(stream);
+ throw new Error("Group call disposed while gathering media stream");
+ }
+
+ const callFeed = new CallFeed({
+ client: this.client,
+ roomId: this.room.roomId,
+ userId: this.client.getUserId()!,
+ deviceId: this.client.getDeviceId()!,
+ stream,
+ purpose: SDPStreamMetadataPurpose.Usermedia,
+ audioMuted: this.initWithAudioMuted || stream.getAudioTracks().length === 0 || this.isPtt,
+ videoMuted: this.initWithVideoMuted || stream.getVideoTracks().length === 0,
+ });
+
+ setTracksEnabled(stream.getAudioTracks(), !callFeed.isAudioMuted());
+ setTracksEnabled(stream.getVideoTracks(), !callFeed.isVideoMuted());
+
+ this.localCallFeed = callFeed;
+ this.addUserMediaFeed(callFeed);
+
+ this.state = GroupCallState.LocalCallFeedInitialized;
+ }
+
+ public async updateLocalUsermediaStream(stream: MediaStream): Promise<void> {
+ if (this.localCallFeed) {
+ const oldStream = this.localCallFeed.stream;
+ this.localCallFeed.setNewStream(stream);
+ const micShouldBeMuted = this.localCallFeed.isAudioMuted();
+ const vidShouldBeMuted = this.localCallFeed.isVideoMuted();
+ logger.log(
+ `GroupCall ${this.groupCallId} updateLocalUsermediaStream() (oldStreamId=${oldStream.id}, newStreamId=${stream.id}, micShouldBeMuted=${micShouldBeMuted}, vidShouldBeMuted=${vidShouldBeMuted})`,
+ );
+ setTracksEnabled(stream.getAudioTracks(), !micShouldBeMuted);
+ setTracksEnabled(stream.getVideoTracks(), !vidShouldBeMuted);
+ this.client.getMediaHandler().stopUserMediaStream(oldStream);
+ }
+ }
+
+ public async enter(): Promise<void> {
+ if (this.state === GroupCallState.LocalCallFeedUninitialized) {
+ await this.initLocalCallFeed();
+ } else if (this.state !== GroupCallState.LocalCallFeedInitialized) {
+ throw new Error(`Cannot enter call in the "${this.state}" state`);
+ }
+
+ logger.log(`GroupCall ${this.groupCallId} enter() running`);
+ this.state = GroupCallState.Entered;
+
+ this.client.on(CallEventHandlerEvent.Incoming, this.onIncomingCall);
+
+ for (const call of this.client.callEventHandler!.calls.values()) {
+ this.onIncomingCall(call);
+ }
+
+ this.retryCallLoopInterval = setInterval(this.onRetryCallLoop, this.retryCallInterval);
+
+ this.activeSpeaker = undefined;
+ this.onActiveSpeakerLoop();
+ this.activeSpeakerLoopInterval = setInterval(this.onActiveSpeakerLoop, this.activeSpeakerInterval);
+ }
+
+ private dispose(): void {
+ if (this.localCallFeed) {
+ this.removeUserMediaFeed(this.localCallFeed);
+ this.localCallFeed = undefined;
+ }
+
+ if (this.localScreenshareFeed) {
+ this.client.getMediaHandler().stopScreensharingStream(this.localScreenshareFeed.stream);
+ this.removeScreenshareFeed(this.localScreenshareFeed);
+ this.localScreenshareFeed = undefined;
+ this.localDesktopCapturerSourceId = undefined;
+ }
+
+ this.client.getMediaHandler().stopAllStreams();
+
+ if (this.transmitTimer !== null) {
+ clearTimeout(this.transmitTimer);
+ this.transmitTimer = null;
+ }
+
+ if (this.retryCallLoopInterval !== undefined) {
+ clearInterval(this.retryCallLoopInterval);
+ this.retryCallLoopInterval = undefined;
+ }
+
+ if (this.participantsExpirationTimer !== null) {
+ clearTimeout(this.participantsExpirationTimer);
+ this.participantsExpirationTimer = null;
+ }
+
+ if (this.state !== GroupCallState.Entered) {
+ return;
+ }
+
+ this.forEachCall((call) => call.hangup(CallErrorCode.UserHangup, false));
+
+ this.activeSpeaker = undefined;
+ clearInterval(this.activeSpeakerLoopInterval);
+
+ this.retryCallCounts.clear();
+ clearInterval(this.retryCallLoopInterval);
+
+ this.client.removeListener(CallEventHandlerEvent.Incoming, this.onIncomingCall);
+ this.stats.stop();
+ }
+
+ public leave(): void {
+ this.dispose();
+ this.state = GroupCallState.LocalCallFeedUninitialized;
+ }
+
+ public async terminate(emitStateEvent = true): Promise<void> {
+ this.dispose();
+
+ this.room.off(RoomStateEvent.Update, this.onRoomState);
+ this.client.groupCallEventHandler!.groupCalls.delete(this.room.roomId);
+ this.client.emit(GroupCallEventHandlerEvent.Ended, this);
+ this.state = GroupCallState.Ended;
+
+ if (emitStateEvent) {
+ const existingStateEvent = this.room.currentState.getStateEvents(
+ EventType.GroupCallPrefix,
+ this.groupCallId,
+ )!;
+
+ await this.client.sendStateEvent(
+ this.room.roomId,
+ EventType.GroupCallPrefix,
+ {
+ ...existingStateEvent.getContent(),
+ "m.terminated": GroupCallTerminationReason.CallEnded,
+ },
+ this.groupCallId,
+ );
+ }
+ }
+
+ /*
+ * Local Usermedia
+ */
+
+ public isLocalVideoMuted(): boolean {
+ if (this.localCallFeed) {
+ return this.localCallFeed.isVideoMuted();
+ }
+
+ return true;
+ }
+
+ public isMicrophoneMuted(): boolean {
+ if (this.localCallFeed) {
+ return this.localCallFeed.isAudioMuted();
+ }
+
+ return true;
+ }
+
+ /**
+ * Sets the mute state of the local participants's microphone.
+ * @param muted - Whether to mute the microphone
+ * @returns Whether muting/unmuting was successful
+ */
+ public async setMicrophoneMuted(muted: boolean): Promise<boolean> {
+ // hasAudioDevice can block indefinitely if the window has lost focus,
+ // and it doesn't make much sense to keep a device from being muted, so
+ // we always allow muted = true changes to go through
+ if (!muted && !(await this.client.getMediaHandler().hasAudioDevice())) {
+ return false;
+ }
+
+ const sendUpdatesBefore = !muted && this.isPtt;
+
+ // set a timer for the maximum transmit time on PTT calls
+ if (this.isPtt) {
+ // Set or clear the max transmit timer
+ if (!muted && this.isMicrophoneMuted()) {
+ this.transmitTimer = setTimeout(() => {
+ this.setMicrophoneMuted(true);
+ }, this.pttMaxTransmitTime);
+ } else if (muted && !this.isMicrophoneMuted()) {
+ if (this.transmitTimer !== null) clearTimeout(this.transmitTimer);
+ this.transmitTimer = null;
+ }
+ }
+
+ this.forEachCall((call) => call.localUsermediaFeed?.setAudioVideoMuted(muted, null));
+
+ const sendUpdates = async (): Promise<void> => {
+ const updates: Promise<void>[] = [];
+ this.forEachCall((call) => updates.push(call.sendMetadataUpdate()));
+
+ await Promise.all(updates).catch((e) =>
+ logger.info(
+ `GroupCall ${this.groupCallId} setMicrophoneMuted() failed to send some metadata updates`,
+ e,
+ ),
+ );
+ };
+
+ if (sendUpdatesBefore) await sendUpdates();
+
+ if (this.localCallFeed) {
+ logger.log(
+ `GroupCall ${this.groupCallId} setMicrophoneMuted() (streamId=${this.localCallFeed.stream.id}, muted=${muted})`,
+ );
+
+ // We needed this here to avoid an error in case user join a call without a device.
+ // I can not use .then .catch functions because linter :-(
+ try {
+ if (!muted) {
+ const stream = await this.client
+ .getMediaHandler()
+ .getUserMediaStream(true, !this.localCallFeed.isVideoMuted());
+ if (stream === null) {
+ // if case permission denied to get a stream stop this here
+ /* istanbul ignore next */
+ logger.log(
+ `GroupCall ${this.groupCallId} setMicrophoneMuted() no device to receive local stream, muted=${muted}`,
+ );
+ return false;
+ }
+ }
+ } catch (e) {
+ /* istanbul ignore next */
+ logger.log(
+ `GroupCall ${this.groupCallId} setMicrophoneMuted() no device or permission to receive local stream, muted=${muted}`,
+ );
+ return false;
+ }
+
+ this.localCallFeed.setAudioVideoMuted(muted, null);
+ // I don't believe its actually necessary to enable these tracks: they
+ // are the one on the GroupCall's own CallFeed and are cloned before being
+ // given to any of the actual calls, so these tracks don't actually go
+ // anywhere. Let's do it anyway to avoid confusion.
+ setTracksEnabled(this.localCallFeed.stream.getAudioTracks(), !muted);
+ } else {
+ logger.log(`GroupCall ${this.groupCallId} setMicrophoneMuted() no stream muted (muted=${muted})`);
+ this.initWithAudioMuted = muted;
+ }
+
+ this.forEachCall((call) =>
+ setTracksEnabled(call.localUsermediaFeed!.stream.getAudioTracks(), !muted && this.callExpected(call)),
+ );
+ this.emit(GroupCallEvent.LocalMuteStateChanged, muted, this.isLocalVideoMuted());
+
+ if (!sendUpdatesBefore) await sendUpdates();
+
+ return true;
+ }
+
+ /**
+ * Sets the mute state of the local participants's video.
+ * @param muted - Whether to mute the video
+ * @returns Whether muting/unmuting was successful
+ */
+ public async setLocalVideoMuted(muted: boolean): Promise<boolean> {
+ // hasAudioDevice can block indefinitely if the window has lost focus,
+ // and it doesn't make much sense to keep a device from being muted, so
+ // we always allow muted = true changes to go through
+ if (!muted && !(await this.client.getMediaHandler().hasVideoDevice())) {
+ return false;
+ }
+
+ if (this.localCallFeed) {
+ /* istanbul ignore next */
+ logger.log(
+ `GroupCall ${this.groupCallId} setLocalVideoMuted() (stream=${this.localCallFeed.stream.id}, muted=${muted})`,
+ );
+
+ try {
+ const stream = await this.client.getMediaHandler().getUserMediaStream(true, !muted);
+ await this.updateLocalUsermediaStream(stream);
+ this.localCallFeed.setAudioVideoMuted(null, muted);
+ setTracksEnabled(this.localCallFeed.stream.getVideoTracks(), !muted);
+ } catch (_) {
+ // No permission to video device
+ /* istanbul ignore next */
+ logger.log(
+ `GroupCall ${this.groupCallId} setLocalVideoMuted() no device or permission to receive local stream, muted=${muted}`,
+ );
+ return false;
+ }
+ } else {
+ logger.log(`GroupCall ${this.groupCallId} setLocalVideoMuted() no stream muted (muted=${muted})`);
+ this.initWithVideoMuted = muted;
+ }
+
+ const updates: Promise<unknown>[] = [];
+ this.forEachCall((call) => updates.push(call.setLocalVideoMuted(muted)));
+ await Promise.all(updates);
+
+ // We setTracksEnabled again, independently from the call doing it
+ // internally, since we might not be expecting the call
+ this.forEachCall((call) =>
+ setTracksEnabled(call.localUsermediaFeed!.stream.getVideoTracks(), !muted && this.callExpected(call)),
+ );
+
+ this.emit(GroupCallEvent.LocalMuteStateChanged, this.isMicrophoneMuted(), muted);
+
+ return true;
+ }
+
+ public async setScreensharingEnabled(enabled: boolean, opts: IScreensharingOpts = {}): Promise<boolean> {
+ if (enabled === this.isScreensharing()) {
+ return enabled;
+ }
+
+ if (enabled) {
+ try {
+ logger.log(
+ `GroupCall ${this.groupCallId} setScreensharingEnabled() is asking for screensharing permissions`,
+ );
+ const stream = await this.client.getMediaHandler().getScreensharingStream(opts);
+
+ for (const track of stream.getTracks()) {
+ const onTrackEnded = (): void => {
+ this.setScreensharingEnabled(false);
+ track.removeEventListener("ended", onTrackEnded);
+ };
+
+ track.addEventListener("ended", onTrackEnded);
+ }
+
+ logger.log(
+ `GroupCall ${this.groupCallId} setScreensharingEnabled() granted screensharing permissions. Setting screensharing enabled on all calls`,
+ );
+
+ this.localDesktopCapturerSourceId = opts.desktopCapturerSourceId;
+ this.localScreenshareFeed = new CallFeed({
+ client: this.client,
+ roomId: this.room.roomId,
+ userId: this.client.getUserId()!,
+ deviceId: this.client.getDeviceId()!,
+ stream,
+ purpose: SDPStreamMetadataPurpose.Screenshare,
+ audioMuted: false,
+ videoMuted: false,
+ });
+ this.addScreenshareFeed(this.localScreenshareFeed);
+
+ this.emit(
+ GroupCallEvent.LocalScreenshareStateChanged,
+ true,
+ this.localScreenshareFeed,
+ this.localDesktopCapturerSourceId,
+ );
+
+ // TODO: handle errors
+ this.forEachCall((call) => call.pushLocalFeed(this.localScreenshareFeed!.clone()));
+
+ return true;
+ } catch (error) {
+ if (opts.throwOnFail) throw error;
+ logger.error(
+ `GroupCall ${this.groupCallId} setScreensharingEnabled() enabling screensharing error`,
+ error,
+ );
+ this.emit(
+ GroupCallEvent.Error,
+ new GroupCallError(
+ GroupCallErrorCode.NoUserMedia,
+ "Failed to get screen-sharing stream: ",
+ error as Error,
+ ),
+ );
+ return false;
+ }
+ } else {
+ this.forEachCall((call) => {
+ if (call.localScreensharingFeed) call.removeLocalFeed(call.localScreensharingFeed);
+ });
+ this.client.getMediaHandler().stopScreensharingStream(this.localScreenshareFeed!.stream);
+ this.removeScreenshareFeed(this.localScreenshareFeed!);
+ this.localScreenshareFeed = undefined;
+ this.localDesktopCapturerSourceId = undefined;
+ this.emit(GroupCallEvent.LocalScreenshareStateChanged, false, undefined, undefined);
+ return false;
+ }
+ }
+
+ public isScreensharing(): boolean {
+ return !!this.localScreenshareFeed;
+ }
+
+ /*
+ * Call Setup
+ *
+ * There are two different paths for calls to be created:
+ * 1. Incoming calls triggered by the Call.incoming event.
+ * 2. Outgoing calls to the initial members of a room or new members
+ * as they are observed by the RoomState.members event.
+ */
+
+ private onIncomingCall = (newCall: MatrixCall): void => {
+ // The incoming calls may be for another room, which we will ignore.
+ if (newCall.roomId !== this.room.roomId) {
+ return;
+ }
+
+ if (newCall.state !== CallState.Ringing) {
+ logger.warn(
+ `GroupCall ${this.groupCallId} onIncomingCall() incoming call no longer in ringing state - ignoring`,
+ );
+ return;
+ }
+
+ if (!newCall.groupCallId || newCall.groupCallId !== this.groupCallId) {
+ logger.log(
+ `GroupCall ${this.groupCallId} onIncomingCall() ignored because it doesn't match the current group call`,
+ );
+ newCall.reject();
+ return;
+ }
+
+ const opponentUserId = newCall.getOpponentMember()?.userId;
+ if (opponentUserId === undefined) {
+ logger.warn(`GroupCall ${this.groupCallId} onIncomingCall() incoming call with no member - ignoring`);
+ return;
+ }
+
+ const deviceMap = this.calls.get(opponentUserId) ?? new Map<string, MatrixCall>();
+ const prevCall = deviceMap.get(newCall.getOpponentDeviceId()!);
+
+ if (prevCall?.callId === newCall.callId) return;
+
+ logger.log(
+ `GroupCall ${this.groupCallId} onIncomingCall() incoming call (userId=${opponentUserId}, callId=${newCall.callId})`,
+ );
+
+ if (prevCall) prevCall.hangup(CallErrorCode.Replaced, false);
+
+ this.initCall(newCall);
+
+ const feeds = this.getLocalFeeds().map((feed) => feed.clone());
+ if (!this.callExpected(newCall)) {
+ // Disable our tracks for users not explicitly participating in the
+ // call but trying to receive the feeds
+ for (const feed of feeds) {
+ setTracksEnabled(feed.stream.getAudioTracks(), false);
+ setTracksEnabled(feed.stream.getVideoTracks(), false);
+ }
+ }
+ newCall.answerWithCallFeeds(feeds);
+
+ deviceMap.set(newCall.getOpponentDeviceId()!, newCall);
+ this.calls.set(opponentUserId, deviceMap);
+ this.emit(GroupCallEvent.CallsChanged, this.calls);
+ };
+
+ /**
+ * Determines whether a given participant expects us to call them (versus
+ * them calling us).
+ * @param userId - The participant's user ID.
+ * @param deviceId - The participant's device ID.
+ * @returns Whether we need to place an outgoing call to the participant.
+ */
+ private wantsOutgoingCall(userId: string, deviceId: string): boolean {
+ const localUserId = this.client.getUserId()!;
+ const localDeviceId = this.client.getDeviceId()!;
+ return (
+ // If a user's ID is less than our own, they'll call us
+ userId >= localUserId &&
+ // If this is another one of our devices, compare device IDs to tell whether it'll call us
+ (userId !== localUserId || deviceId > localDeviceId)
+ );
+ }
+
+ /**
+ * Places calls to all participants that we're responsible for calling.
+ */
+ private placeOutgoingCalls(): void {
+ let callsChanged = false;
+
+ for (const [{ userId }, participantMap] of this.participants) {
+ const callMap = this.calls.get(userId) ?? new Map<string, MatrixCall>();
+
+ for (const [deviceId, participant] of participantMap) {
+ const prevCall = callMap.get(deviceId);
+
+ if (
+ prevCall?.getOpponentSessionId() !== participant.sessionId &&
+ this.wantsOutgoingCall(userId, deviceId)
+ ) {
+ callsChanged = true;
+
+ if (prevCall !== undefined) {
+ logger.debug(
+ `GroupCall ${this.groupCallId} placeOutgoingCalls() replacing call (userId=${userId}, deviceId=${deviceId}, callId=${prevCall.callId})`,
+ );
+ prevCall.hangup(CallErrorCode.NewSession, false);
+ }
+
+ const newCall = createNewMatrixCall(this.client, this.room.roomId, {
+ invitee: userId,
+ opponentDeviceId: deviceId,
+ opponentSessionId: participant.sessionId,
+ groupCallId: this.groupCallId,
+ });
+
+ if (newCall === null) {
+ logger.error(
+ `GroupCall ${this.groupCallId} placeOutgoingCalls() failed to create call (userId=${userId}, device=${deviceId})`,
+ );
+ callMap.delete(deviceId);
+ } else {
+ this.initCall(newCall);
+ callMap.set(deviceId, newCall);
+
+ logger.debug(
+ `GroupCall ${this.groupCallId} placeOutgoingCalls() placing call (userId=${userId}, deviceId=${deviceId}, sessionId=${participant.sessionId})`,
+ );
+
+ newCall
+ .placeCallWithCallFeeds(
+ this.getLocalFeeds().map((feed) => feed.clone()),
+ participant.screensharing,
+ )
+ .then(() => {
+ if (this.dataChannelsEnabled) {
+ newCall.createDataChannel("datachannel", this.dataChannelOptions);
+ }
+ })
+ .catch((e) => {
+ logger.warn(
+ `GroupCall ${this.groupCallId} placeOutgoingCalls() failed to place call (userId=${userId})`,
+ e,
+ );
+
+ if (e instanceof CallError && e.code === GroupCallErrorCode.UnknownDevice) {
+ this.emit(GroupCallEvent.Error, e);
+ } else {
+ this.emit(
+ GroupCallEvent.Error,
+ new GroupCallError(
+ GroupCallErrorCode.PlaceCallFailed,
+ `Failed to place call to ${userId}`,
+ ),
+ );
+ }
+
+ newCall.hangup(CallErrorCode.SignallingFailed, false);
+ if (callMap.get(deviceId) === newCall) callMap.delete(deviceId);
+ });
+ }
+ }
+ }
+
+ if (callMap.size > 0) {
+ this.calls.set(userId, callMap);
+ } else {
+ this.calls.delete(userId);
+ }
+ }
+
+ if (callsChanged) this.emit(GroupCallEvent.CallsChanged, this.calls);
+ }
+
+ /*
+ * Room Member State
+ */
+
+ private getMemberStateEvents(): MatrixEvent[];
+ private getMemberStateEvents(userId: string): MatrixEvent | null;
+ private getMemberStateEvents(userId?: string): MatrixEvent[] | MatrixEvent | null {
+ return userId === undefined
+ ? this.room.currentState.getStateEvents(EventType.GroupCallMemberPrefix)
+ : this.room.currentState.getStateEvents(EventType.GroupCallMemberPrefix, userId);
+ }
+
+ private onRetryCallLoop = (): void => {
+ let needsRetry = false;
+
+ for (const [{ userId }, participantMap] of this.participants) {
+ const callMap = this.calls.get(userId);
+ let retriesMap = this.retryCallCounts.get(userId);
+
+ for (const [deviceId, participant] of participantMap) {
+ const call = callMap?.get(deviceId);
+ const retries = retriesMap?.get(deviceId) ?? 0;
+
+ if (
+ call?.getOpponentSessionId() !== participant.sessionId &&
+ this.wantsOutgoingCall(userId, deviceId) &&
+ retries < 3
+ ) {
+ if (retriesMap === undefined) {
+ retriesMap = new Map();
+ this.retryCallCounts.set(userId, retriesMap);
+ }
+ retriesMap.set(deviceId, retries + 1);
+ needsRetry = true;
+ }
+ }
+ }
+
+ if (needsRetry) this.placeOutgoingCalls();
+ };
+
+ private initCall(call: MatrixCall): void {
+ const opponentMemberId = getCallUserId(call);
+
+ if (!opponentMemberId) {
+ throw new Error("Cannot init call without user id");
+ }
+
+ const onCallFeedsChanged = (): void => this.onCallFeedsChanged(call);
+ const onCallStateChanged = (state: CallState, oldState?: CallState): void =>
+ this.onCallStateChanged(call, state, oldState);
+ const onCallHangup = this.onCallHangup;
+ const onCallReplaced = (newCall: MatrixCall): void => this.onCallReplaced(call, newCall);
+
+ let deviceMap = this.callHandlers.get(opponentMemberId);
+ if (deviceMap === undefined) {
+ deviceMap = new Map();
+ this.callHandlers.set(opponentMemberId, deviceMap);
+ }
+
+ deviceMap.set(call.getOpponentDeviceId()!, {
+ onCallFeedsChanged,
+ onCallStateChanged,
+ onCallHangup,
+ onCallReplaced,
+ });
+
+ call.on(CallEvent.FeedsChanged, onCallFeedsChanged);
+ call.on(CallEvent.State, onCallStateChanged);
+ call.on(CallEvent.Hangup, onCallHangup);
+ call.on(CallEvent.Replaced, onCallReplaced);
+
+ call.isPtt = this.isPtt;
+
+ this.reEmitter.reEmit(call, Object.values(CallEvent));
+
+ call.initStats(this.stats);
+
+ onCallFeedsChanged();
+ }
+
+ private disposeCall(call: MatrixCall, hangupReason: CallErrorCode): void {
+ const opponentMemberId = getCallUserId(call);
+ const opponentDeviceId = call.getOpponentDeviceId()!;
+
+ if (!opponentMemberId) {
+ throw new Error("Cannot dispose call without user id");
+ }
+
+ const deviceMap = this.callHandlers.get(opponentMemberId)!;
+ const { onCallFeedsChanged, onCallStateChanged, onCallHangup, onCallReplaced } =
+ deviceMap.get(opponentDeviceId)!;
+
+ call.removeListener(CallEvent.FeedsChanged, onCallFeedsChanged);
+ call.removeListener(CallEvent.State, onCallStateChanged);
+ call.removeListener(CallEvent.Hangup, onCallHangup);
+ call.removeListener(CallEvent.Replaced, onCallReplaced);
+
+ deviceMap.delete(opponentMemberId);
+ if (deviceMap.size === 0) this.callHandlers.delete(opponentMemberId);
+
+ if (call.hangupReason === CallErrorCode.Replaced) {
+ return;
+ }
+
+ const usermediaFeed = this.getUserMediaFeed(opponentMemberId, opponentDeviceId);
+
+ if (usermediaFeed) {
+ this.removeUserMediaFeed(usermediaFeed);
+ }
+
+ const screenshareFeed = this.getScreenshareFeed(opponentMemberId, opponentDeviceId);
+
+ if (screenshareFeed) {
+ this.removeScreenshareFeed(screenshareFeed);
+ }
+ }
+
+ private onCallFeedsChanged = (call: MatrixCall): void => {
+ const opponentMemberId = getCallUserId(call);
+ const opponentDeviceId = call.getOpponentDeviceId()!;
+
+ if (!opponentMemberId) {
+ throw new Error("Cannot change call feeds without user id");
+ }
+
+ const currentUserMediaFeed = this.getUserMediaFeed(opponentMemberId, opponentDeviceId);
+ const remoteUsermediaFeed = call.remoteUsermediaFeed;
+ const remoteFeedChanged = remoteUsermediaFeed !== currentUserMediaFeed;
+
+ if (remoteFeedChanged) {
+ if (!currentUserMediaFeed && remoteUsermediaFeed) {
+ this.addUserMediaFeed(remoteUsermediaFeed);
+ } else if (currentUserMediaFeed && remoteUsermediaFeed) {
+ this.replaceUserMediaFeed(currentUserMediaFeed, remoteUsermediaFeed);
+ } else if (currentUserMediaFeed && !remoteUsermediaFeed) {
+ this.removeUserMediaFeed(currentUserMediaFeed);
+ }
+ }
+
+ const currentScreenshareFeed = this.getScreenshareFeed(opponentMemberId, opponentDeviceId);
+ const remoteScreensharingFeed = call.remoteScreensharingFeed;
+ const remoteScreenshareFeedChanged = remoteScreensharingFeed !== currentScreenshareFeed;
+
+ if (remoteScreenshareFeedChanged) {
+ if (!currentScreenshareFeed && remoteScreensharingFeed) {
+ this.addScreenshareFeed(remoteScreensharingFeed);
+ } else if (currentScreenshareFeed && remoteScreensharingFeed) {
+ this.replaceScreenshareFeed(currentScreenshareFeed, remoteScreensharingFeed);
+ } else if (currentScreenshareFeed && !remoteScreensharingFeed) {
+ this.removeScreenshareFeed(currentScreenshareFeed);
+ }
+ }
+ };
+
+ private onCallStateChanged = (call: MatrixCall, state: CallState, _oldState: CallState | undefined): void => {
+ if (state === CallState.Ended) return;
+
+ const audioMuted = this.localCallFeed!.isAudioMuted();
+
+ if (call.localUsermediaStream && call.isMicrophoneMuted() !== audioMuted) {
+ call.setMicrophoneMuted(audioMuted);
+ }
+
+ const videoMuted = this.localCallFeed!.isVideoMuted();
+
+ if (call.localUsermediaStream && call.isLocalVideoMuted() !== videoMuted) {
+ call.setLocalVideoMuted(videoMuted);
+ }
+
+ const opponentUserId = call.getOpponentMember()?.userId;
+ if (state === CallState.Connected && opponentUserId) {
+ const retriesMap = this.retryCallCounts.get(opponentUserId);
+ retriesMap?.delete(call.getOpponentDeviceId()!);
+ if (retriesMap?.size === 0) this.retryCallCounts.delete(opponentUserId);
+ }
+ };
+
+ private onCallHangup = (call: MatrixCall): void => {
+ if (call.hangupReason === CallErrorCode.Replaced) return;
+
+ const opponentUserId = call.getOpponentMember()?.userId ?? this.room.getMember(call.invitee!)!.userId;
+ const deviceMap = this.calls.get(opponentUserId);
+
+ // Sanity check that this call is in fact in the map
+ if (deviceMap?.get(call.getOpponentDeviceId()!) === call) {
+ this.disposeCall(call, call.hangupReason as CallErrorCode);
+ deviceMap.delete(call.getOpponentDeviceId()!);
+ if (deviceMap.size === 0) this.calls.delete(opponentUserId);
+ this.emit(GroupCallEvent.CallsChanged, this.calls);
+ }
+ };
+
+ private onCallReplaced = (prevCall: MatrixCall, newCall: MatrixCall): void => {
+ const opponentUserId = prevCall.getOpponentMember()!.userId;
+
+ let deviceMap = this.calls.get(opponentUserId);
+ if (deviceMap === undefined) {
+ deviceMap = new Map();
+ this.calls.set(opponentUserId, deviceMap);
+ }
+
+ prevCall.hangup(CallErrorCode.Replaced, false);
+ this.initCall(newCall);
+ deviceMap.set(prevCall.getOpponentDeviceId()!, newCall);
+ this.emit(GroupCallEvent.CallsChanged, this.calls);
+ };
+
+ /*
+ * UserMedia CallFeed Event Handlers
+ */
+
+ public getUserMediaFeed(userId: string, deviceId: string): CallFeed | undefined {
+ return this.userMediaFeeds.find((f) => f.userId === userId && f.deviceId! === deviceId);
+ }
+
+ private addUserMediaFeed(callFeed: CallFeed): void {
+ this.userMediaFeeds.push(callFeed);
+ callFeed.measureVolumeActivity(true);
+ this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds);
+ }
+
+ private replaceUserMediaFeed(existingFeed: CallFeed, replacementFeed: CallFeed): void {
+ const feedIndex = this.userMediaFeeds.findIndex(
+ (f) => f.userId === existingFeed.userId && f.deviceId! === existingFeed.deviceId,
+ );
+
+ if (feedIndex === -1) {
+ throw new Error("Couldn't find user media feed to replace");
+ }
+
+ this.userMediaFeeds.splice(feedIndex, 1, replacementFeed);
+
+ existingFeed.dispose();
+ replacementFeed.measureVolumeActivity(true);
+ this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds);
+ }
+
+ private removeUserMediaFeed(callFeed: CallFeed): void {
+ const feedIndex = this.userMediaFeeds.findIndex(
+ (f) => f.userId === callFeed.userId && f.deviceId! === callFeed.deviceId,
+ );
+
+ if (feedIndex === -1) {
+ throw new Error("Couldn't find user media feed to remove");
+ }
+
+ this.userMediaFeeds.splice(feedIndex, 1);
+
+ callFeed.dispose();
+ this.emit(GroupCallEvent.UserMediaFeedsChanged, this.userMediaFeeds);
+
+ if (this.activeSpeaker === callFeed) {
+ this.activeSpeaker = this.userMediaFeeds[0];
+ this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker);
+ }
+ }
+
+ private onActiveSpeakerLoop = (): void => {
+ let topAvg: number | undefined = undefined;
+ let nextActiveSpeaker: CallFeed | undefined = undefined;
+
+ for (const callFeed of this.userMediaFeeds) {
+ if (callFeed.isLocal() && this.userMediaFeeds.length > 1) continue;
+
+ const total = callFeed.speakingVolumeSamples.reduce(
+ (acc, volume) => acc + Math.max(volume, SPEAKING_THRESHOLD),
+ );
+ const avg = total / callFeed.speakingVolumeSamples.length;
+
+ if (!topAvg || avg > topAvg) {
+ topAvg = avg;
+ nextActiveSpeaker = callFeed;
+ }
+ }
+
+ if (nextActiveSpeaker && this.activeSpeaker !== nextActiveSpeaker && topAvg && topAvg > SPEAKING_THRESHOLD) {
+ this.activeSpeaker = nextActiveSpeaker;
+ this.emit(GroupCallEvent.ActiveSpeakerChanged, this.activeSpeaker);
+ }
+ };
+
+ /*
+ * Screenshare Call Feed Event Handlers
+ */
+
+ public getScreenshareFeed(userId: string, deviceId: string): CallFeed | undefined {
+ return this.screenshareFeeds.find((f) => f.userId === userId && f.deviceId! === deviceId);
+ }
+
+ private addScreenshareFeed(callFeed: CallFeed): void {
+ this.screenshareFeeds.push(callFeed);
+ this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds);
+ }
+
+ private replaceScreenshareFeed(existingFeed: CallFeed, replacementFeed: CallFeed): void {
+ const feedIndex = this.screenshareFeeds.findIndex(
+ (f) => f.userId === existingFeed.userId && f.deviceId! === existingFeed.deviceId,
+ );
+
+ if (feedIndex === -1) {
+ throw new Error("Couldn't find screenshare feed to replace");
+ }
+
+ this.screenshareFeeds.splice(feedIndex, 1, replacementFeed);
+
+ existingFeed.dispose();
+ this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds);
+ }
+
+ private removeScreenshareFeed(callFeed: CallFeed): void {
+ const feedIndex = this.screenshareFeeds.findIndex(
+ (f) => f.userId === callFeed.userId && f.deviceId! === callFeed.deviceId,
+ );
+
+ if (feedIndex === -1) {
+ throw new Error("Couldn't find screenshare feed to remove");
+ }
+
+ this.screenshareFeeds.splice(feedIndex, 1);
+
+ callFeed.dispose();
+ this.emit(GroupCallEvent.ScreenshareFeedsChanged, this.screenshareFeeds);
+ }
+
+ /**
+ * Recalculates and updates the participant map to match the room state.
+ */
+ private updateParticipants(): void {
+ const localMember = this.room.getMember(this.client.getUserId()!)!;
+ if (!localMember) {
+ // The client hasn't fetched enough of the room state to get our own member
+ // event. This probably shouldn't happen, but sanity check & exit for now.
+ logger.warn(
+ `GroupCall ${this.groupCallId} updateParticipants() tried to update participants before local room member is available`,
+ );
+ return;
+ }
+
+ if (this.participantsExpirationTimer !== null) {
+ clearTimeout(this.participantsExpirationTimer);
+ this.participantsExpirationTimer = null;
+ }
+
+ if (this.state === GroupCallState.Ended) {
+ this.participants = new Map();
+ return;
+ }
+
+ const participants = new Map<RoomMember, Map<string, ParticipantState>>();
+ const now = Date.now();
+ const entered = this.state === GroupCallState.Entered || this.enteredViaAnotherSession;
+ let nextExpiration = Infinity;
+
+ for (const e of this.getMemberStateEvents()) {
+ const member = this.room.getMember(e.getStateKey()!);
+ const content = e.getContent<Record<any, unknown>>();
+ const calls: Record<any, unknown>[] = Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
+ const call = calls.find((call) => call["m.call_id"] === this.groupCallId);
+ const devices: Record<any, unknown>[] = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
+
+ // Filter out invalid and expired devices
+ let validDevices = devices.filter(
+ (d) =>
+ typeof d.device_id === "string" &&
+ typeof d.session_id === "string" &&
+ typeof d.expires_ts === "number" &&
+ d.expires_ts > now &&
+ Array.isArray(d.feeds),
+ ) as unknown as IGroupCallRoomMemberDevice[];
+
+ // Apply local echo for the unentered case
+ if (!entered && member?.userId === this.client.getUserId()!) {
+ validDevices = validDevices.filter((d) => d.device_id !== this.client.getDeviceId()!);
+ }
+
+ // Must have a connected device and be joined to the room
+ if (validDevices.length > 0 && member?.membership === "join") {
+ const deviceMap = new Map<string, ParticipantState>();
+ participants.set(member, deviceMap);
+
+ for (const d of validDevices) {
+ deviceMap.set(d.device_id, {
+ sessionId: d.session_id,
+ screensharing: d.feeds.some((f) => f.purpose === SDPStreamMetadataPurpose.Screenshare),
+ });
+ if (d.expires_ts < nextExpiration) nextExpiration = d.expires_ts;
+ }
+ }
+ }
+
+ // Apply local echo for the entered case
+ if (entered) {
+ let deviceMap = participants.get(localMember);
+ if (deviceMap === undefined) {
+ deviceMap = new Map();
+ participants.set(localMember, deviceMap);
+ }
+
+ if (!deviceMap.has(this.client.getDeviceId()!)) {
+ deviceMap.set(this.client.getDeviceId()!, {
+ sessionId: this.client.getSessionId(),
+ screensharing: this.getLocalFeeds().some((f) => f.purpose === SDPStreamMetadataPurpose.Screenshare),
+ });
+ }
+ }
+
+ this.participants = participants;
+ if (nextExpiration < Infinity) {
+ this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), nextExpiration - now);
+ }
+ }
+
+ /**
+ * Updates the local user's member state with the devices returned by the given function.
+ * @param fn - A function from the current devices to the new devices. If it
+ * returns null, the update will be skipped.
+ * @param keepAlive - Whether the request should outlive the window.
+ */
+ private async updateDevices(
+ fn: (devices: IGroupCallRoomMemberDevice[]) => IGroupCallRoomMemberDevice[] | null,
+ keepAlive = false,
+ ): Promise<void> {
+ const now = Date.now();
+ const localUserId = this.client.getUserId()!;
+
+ const event = this.getMemberStateEvents(localUserId);
+ const content = event?.getContent<Record<any, unknown>>() ?? {};
+ const calls: Record<any, unknown>[] = Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
+
+ let call: Record<any, unknown> | null = null;
+ const otherCalls: Record<any, unknown>[] = [];
+ for (const c of calls) {
+ if (c["m.call_id"] === this.groupCallId) {
+ call = c;
+ } else {
+ otherCalls.push(c);
+ }
+ }
+ if (call === null) call = {};
+
+ const devices: Record<any, unknown>[] = Array.isArray(call["m.devices"]) ? call["m.devices"] : [];
+
+ // Filter out invalid and expired devices
+ const validDevices = devices.filter(
+ (d) =>
+ typeof d.device_id === "string" &&
+ typeof d.session_id === "string" &&
+ typeof d.expires_ts === "number" &&
+ d.expires_ts > now &&
+ Array.isArray(d.feeds),
+ ) as unknown as IGroupCallRoomMemberDevice[];
+
+ const newDevices = fn(validDevices);
+ if (newDevices === null) return;
+
+ const newCalls = [...(otherCalls as unknown as IGroupCallRoomMemberCallState[])];
+ if (newDevices.length > 0) {
+ newCalls.push({
+ ...call,
+ "m.call_id": this.groupCallId,
+ "m.devices": newDevices,
+ });
+ }
+
+ const newContent: IGroupCallRoomMemberState = { "m.calls": newCalls };
+
+ await this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, newContent, localUserId, {
+ keepAlive,
+ });
+ }
+
+ private async addDeviceToMemberState(): Promise<void> {
+ await this.updateDevices((devices) => [
+ ...devices.filter((d) => d.device_id !== this.client.getDeviceId()!),
+ {
+ device_id: this.client.getDeviceId()!,
+ session_id: this.client.getSessionId(),
+ expires_ts: Date.now() + DEVICE_TIMEOUT,
+ feeds: this.getLocalFeeds().map((feed) => ({ purpose: feed.purpose })),
+ // TODO: Add data channels
+ },
+ ]);
+ }
+
+ private async updateMemberState(): Promise<void> {
+ // Clear the old update interval before proceeding
+ if (this.resendMemberStateTimer !== null) {
+ clearInterval(this.resendMemberStateTimer);
+ this.resendMemberStateTimer = null;
+ }
+
+ if (this.state === GroupCallState.Entered) {
+ // Add the local device
+ await this.addDeviceToMemberState();
+
+ // Resend the state event every so often so it doesn't become stale
+ this.resendMemberStateTimer = setInterval(async () => {
+ logger.log(`GroupCall ${this.groupCallId} updateMemberState() resending call member state"`);
+ try {
+ await this.addDeviceToMemberState();
+ } catch (e) {
+ logger.error(
+ `GroupCall ${this.groupCallId} updateMemberState() failed to resend call member state`,
+ e,
+ );
+ }
+ }, (DEVICE_TIMEOUT * 3) / 4);
+ } else {
+ // Remove the local device
+ await this.updateDevices(
+ (devices) => devices.filter((d) => d.device_id !== this.client.getDeviceId()!),
+ true,
+ );
+ }
+ }
+
+ /**
+ * Cleans up our member state by filtering out logged out devices, inactive
+ * devices, and our own device (if we know we haven't entered).
+ */
+ public async cleanMemberState(): Promise<void> {
+ const { devices: myDevices } = await this.client.getDevices();
+ const deviceMap = new Map<string, IMyDevice>(myDevices.map((d) => [d.device_id, d]));
+
+ // updateDevices takes care of filtering out inactive devices for us
+ await this.updateDevices((devices) => {
+ const newDevices = devices.filter((d) => {
+ const device = deviceMap.get(d.device_id);
+ return (
+ device?.last_seen_ts !== undefined &&
+ !(
+ d.device_id === this.client.getDeviceId()! &&
+ this.state !== GroupCallState.Entered &&
+ !this.enteredViaAnotherSession
+ )
+ );
+ });
+
+ // Skip the update if the devices are unchanged
+ return newDevices.length === devices.length ? null : newDevices;
+ });
+ }
+
+ private onRoomState = (): void => this.updateParticipants();
+
+ private onParticipantsChanged = (): void => {
+ // Re-run setTracksEnabled on all calls, so that participants that just
+ // left get denied access to our media, and participants that just
+ // joined get granted access
+ this.forEachCall((call) => {
+ const expected = this.callExpected(call);
+ for (const feed of call.getLocalFeeds()) {
+ setTracksEnabled(feed.stream.getAudioTracks(), !feed.isAudioMuted() && expected);
+ setTracksEnabled(feed.stream.getVideoTracks(), !feed.isVideoMuted() && expected);
+ }
+ });
+
+ if (this.state === GroupCallState.Entered) this.placeOutgoingCalls();
+ };
+
+ private onStateChanged = (newState: GroupCallState, oldState: GroupCallState): void => {
+ if (
+ newState === GroupCallState.Entered ||
+ oldState === GroupCallState.Entered ||
+ newState === GroupCallState.Ended
+ ) {
+ // We either entered, left, or ended the call
+ this.updateParticipants();
+ this.updateMemberState().catch((e) =>
+ logger.error(
+ `GroupCall ${this.groupCallId} onStateChanged() failed to update member state devices"`,
+ e,
+ ),
+ );
+ }
+ };
+
+ private onLocalFeedsChanged = (): void => {
+ if (this.state === GroupCallState.Entered) {
+ this.updateMemberState().catch((e) =>
+ logger.error(
+ `GroupCall ${this.groupCallId} onLocalFeedsChanged() failed to update member state feeds`,
+ e,
+ ),
+ );
+ }
+ };
+
+ public getGroupCallStats(): GroupCallStats {
+ return this.stats;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCallEventHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCallEventHandler.ts
new file mode 100644
index 0000000..08487bd
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/groupCallEventHandler.ts
@@ -0,0 +1,232 @@
+/*
+Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixEvent } from "../models/event";
+import { MatrixClient, ClientEvent } from "../client";
+import { GroupCall, GroupCallIntent, GroupCallType, IGroupCallDataChannelOptions } from "./groupCall";
+import { Room } from "../models/room";
+import { RoomState, RoomStateEvent } from "../models/room-state";
+import { RoomMember } from "../models/room-member";
+import { logger } from "../logger";
+import { EventType } from "../@types/event";
+import { SyncState } from "../sync";
+
+export enum GroupCallEventHandlerEvent {
+ Incoming = "GroupCall.incoming",
+ Outgoing = "GroupCall.outgoing",
+ Ended = "GroupCall.ended",
+ Participants = "GroupCall.participants",
+}
+
+export type GroupCallEventHandlerEventHandlerMap = {
+ [GroupCallEventHandlerEvent.Incoming]: (call: GroupCall) => void;
+ [GroupCallEventHandlerEvent.Outgoing]: (call: GroupCall) => void;
+ [GroupCallEventHandlerEvent.Ended]: (call: GroupCall) => void;
+ [GroupCallEventHandlerEvent.Participants]: (participants: RoomMember[], call: GroupCall) => void;
+};
+
+interface RoomDeferred {
+ prom: Promise<void>;
+ resolve?: () => void;
+}
+
+export class GroupCallEventHandler {
+ public groupCalls = new Map<string, GroupCall>(); // roomId -> GroupCall
+
+ // All rooms we know about and whether we've seen a 'Room' event
+ // for them. The promise will be fulfilled once we've processed that
+ // event which means we're "up to date" on what calls are in a room
+ // and get
+ private roomDeferreds = new Map<string, RoomDeferred>();
+
+ public constructor(private client: MatrixClient) {}
+
+ public async start(): Promise<void> {
+ // We wait until the client has started syncing for real.
+ // This is because we only support one call at a time, and want
+ // the latest. We therefore want the latest state of the room before
+ // we create a group call for the room so we can be fairly sure that
+ // the group call we create is really the latest one.
+ if (this.client.getSyncState() !== SyncState.Syncing) {
+ logger.debug("GroupCallEventHandler start() waiting for client to start syncing");
+ await new Promise<void>((resolve) => {
+ const onSync = (): void => {
+ if (this.client.getSyncState() === SyncState.Syncing) {
+ this.client.off(ClientEvent.Sync, onSync);
+ return resolve();
+ }
+ };
+ this.client.on(ClientEvent.Sync, onSync);
+ });
+ }
+
+ const rooms = this.client.getRooms();
+
+ for (const room of rooms) {
+ this.createGroupCallForRoom(room);
+ }
+
+ this.client.on(ClientEvent.Room, this.onRoomsChanged);
+ this.client.on(RoomStateEvent.Events, this.onRoomStateChanged);
+ }
+
+ public stop(): void {
+ this.client.removeListener(RoomStateEvent.Events, this.onRoomStateChanged);
+ }
+
+ private getRoomDeferred(roomId: string): RoomDeferred {
+ let deferred = this.roomDeferreds.get(roomId);
+ if (deferred === undefined) {
+ let resolveFunc: () => void;
+ deferred = {
+ prom: new Promise<void>((resolve) => {
+ resolveFunc = resolve;
+ }),
+ };
+ deferred.resolve = resolveFunc!;
+ this.roomDeferreds.set(roomId, deferred);
+ }
+
+ return deferred;
+ }
+
+ public waitUntilRoomReadyForGroupCalls(roomId: string): Promise<void> {
+ return this.getRoomDeferred(roomId).prom;
+ }
+
+ public getGroupCallById(groupCallId: string): GroupCall | undefined {
+ return [...this.groupCalls.values()].find((groupCall) => groupCall.groupCallId === groupCallId);
+ }
+
+ private createGroupCallForRoom(room: Room): void {
+ const callEvents = room.currentState.getStateEvents(EventType.GroupCallPrefix);
+ const sortedCallEvents = callEvents.sort((a, b) => b.getTs() - a.getTs());
+
+ for (const callEvent of sortedCallEvents) {
+ const content = callEvent.getContent();
+
+ if (content["m.terminated"] || callEvent.isRedacted()) {
+ continue;
+ }
+
+ logger.debug(
+ `GroupCallEventHandler createGroupCallForRoom() choosing group call from possible calls (stateKey=${callEvent.getStateKey()}, ts=${callEvent.getTs()}, roomId=${
+ room.roomId
+ }, numOfPossibleCalls=${callEvents.length})`,
+ );
+
+ this.createGroupCallFromRoomStateEvent(callEvent);
+ break;
+ }
+
+ logger.info(`GroupCallEventHandler createGroupCallForRoom() processed room (roomId=${room.roomId})`);
+ this.getRoomDeferred(room.roomId).resolve!();
+ }
+
+ private createGroupCallFromRoomStateEvent(event: MatrixEvent): GroupCall | undefined {
+ const roomId = event.getRoomId();
+ const content = event.getContent();
+
+ const room = this.client.getRoom(roomId);
+
+ if (!room) {
+ logger.warn(
+ `GroupCallEventHandler createGroupCallFromRoomStateEvent() couldn't find room for call (roomId=${roomId})`,
+ );
+ return;
+ }
+
+ const groupCallId = event.getStateKey();
+
+ const callType = content["m.type"];
+
+ if (!Object.values(GroupCallType).includes(callType)) {
+ logger.warn(
+ `GroupCallEventHandler createGroupCallFromRoomStateEvent() received invalid call type (type=${callType}, roomId=${roomId})`,
+ );
+ return;
+ }
+
+ const callIntent = content["m.intent"];
+
+ if (!Object.values(GroupCallIntent).includes(callIntent)) {
+ logger.warn(`Received invalid group call intent (type=${callType}, roomId=${roomId})`);
+ return;
+ }
+
+ const isPtt = Boolean(content["io.element.ptt"]);
+
+ let dataChannelOptions: IGroupCallDataChannelOptions | undefined;
+
+ if (content?.dataChannelsEnabled && content?.dataChannelOptions) {
+ // Pull out just the dataChannelOptions we want to support.
+ const { ordered, maxPacketLifeTime, maxRetransmits, protocol } = content.dataChannelOptions;
+ dataChannelOptions = { ordered, maxPacketLifeTime, maxRetransmits, protocol };
+ }
+
+ const groupCall = new GroupCall(
+ this.client,
+ room,
+ callType,
+ isPtt,
+ callIntent,
+ groupCallId,
+ // Because without Media section a WebRTC connection is not possible, so need a RTCDataChannel to set up a
+ // no media WebRTC connection anyway.
+ content?.dataChannelsEnabled || this.client.isVoipWithNoMediaAllowed,
+ dataChannelOptions,
+ this.client.isVoipWithNoMediaAllowed,
+ );
+
+ this.groupCalls.set(room.roomId, groupCall);
+ this.client.emit(GroupCallEventHandlerEvent.Incoming, groupCall);
+
+ return groupCall;
+ }
+
+ private onRoomsChanged = (room: Room): void => {
+ this.createGroupCallForRoom(room);
+ };
+
+ private onRoomStateChanged = (event: MatrixEvent, state: RoomState): void => {
+ const eventType = event.getType();
+
+ if (eventType === EventType.GroupCallPrefix) {
+ const groupCallId = event.getStateKey();
+ const content = event.getContent();
+
+ const currentGroupCall = this.groupCalls.get(state.roomId);
+
+ if (!currentGroupCall && !content["m.terminated"] && !event.isRedacted()) {
+ this.createGroupCallFromRoomStateEvent(event);
+ } else if (currentGroupCall && currentGroupCall.groupCallId === groupCallId) {
+ if (content["m.terminated"] || event.isRedacted()) {
+ currentGroupCall.terminate(false);
+ } else if (content["m.type"] !== currentGroupCall.type) {
+ // TODO: Handle the callType changing when the room state changes
+ logger.warn(
+ `GroupCallEventHandler onRoomStateChanged() currently does not support changing type (roomId=${state.roomId})`,
+ );
+ }
+ } else if (currentGroupCall && currentGroupCall.groupCallId !== groupCallId) {
+ // TODO: Handle new group calls and multiple group calls
+ logger.warn(
+ `GroupCallEventHandler onRoomStateChanged() currently does not support multiple calls (roomId=${state.roomId})`,
+ );
+ }
+ }
+ };
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/mediaHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/mediaHandler.ts
new file mode 100644
index 0000000..7f65835
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/mediaHandler.ts
@@ -0,0 +1,469 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017 New Vector Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+Copyright 2021 - 2022 Šimon Brandner <simon.bra.ag@gmail.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { TypedEventEmitter } from "../models/typed-event-emitter";
+import { GroupCallType, GroupCallState } from "../webrtc/groupCall";
+import { logger } from "../logger";
+import { MatrixClient } from "../client";
+
+export enum MediaHandlerEvent {
+ LocalStreamsChanged = "local_streams_changed",
+}
+
+export type MediaHandlerEventHandlerMap = {
+ [MediaHandlerEvent.LocalStreamsChanged]: () => void;
+};
+
+export interface IScreensharingOpts {
+ desktopCapturerSourceId?: string;
+ audio?: boolean;
+ // For electron screen capture, there are very few options for detecting electron
+ // apart from inspecting the user agent or just trying getDisplayMedia() and
+ // catching the failure, so we do the latter - this flag tells the function to just
+ // throw an error so we can catch it in this case, rather than logging and emitting.
+ throwOnFail?: boolean;
+}
+
+export interface AudioSettings {
+ autoGainControl: boolean;
+ echoCancellation: boolean;
+ noiseSuppression: boolean;
+}
+
+export class MediaHandler extends TypedEventEmitter<
+ MediaHandlerEvent.LocalStreamsChanged,
+ MediaHandlerEventHandlerMap
+> {
+ private audioInput?: string;
+ private audioSettings?: AudioSettings;
+ private videoInput?: string;
+ private localUserMediaStream?: MediaStream;
+ public userMediaStreams: MediaStream[] = [];
+ public screensharingStreams: MediaStream[] = [];
+
+ // Promise chain to serialise calls to getMediaStream
+ private getMediaStreamPromise?: Promise<MediaStream>;
+
+ public constructor(private client: MatrixClient) {
+ super();
+ }
+
+ public restoreMediaSettings(audioInput: string, videoInput: string): void {
+ this.audioInput = audioInput;
+ this.videoInput = videoInput;
+ }
+
+ /**
+ * Set an audio input device to use for MatrixCalls
+ * @param deviceId - the identifier for the device
+ * undefined treated as unset
+ */
+ public async setAudioInput(deviceId: string): Promise<void> {
+ logger.info(`MediaHandler setAudioInput() running (deviceId=${deviceId})`);
+
+ if (this.audioInput === deviceId) return;
+
+ this.audioInput = deviceId;
+ await this.updateLocalUsermediaStreams();
+ }
+
+ /**
+ * Set audio settings for MatrixCalls
+ * @param opts - audio options to set
+ */
+ public async setAudioSettings(opts: AudioSettings): Promise<void> {
+ logger.info(`MediaHandler setAudioSettings() running (opts=${JSON.stringify(opts)})`);
+
+ this.audioSettings = Object.assign({}, opts) as AudioSettings;
+ await this.updateLocalUsermediaStreams();
+ }
+
+ /**
+ * Set a video input device to use for MatrixCalls
+ * @param deviceId - the identifier for the device
+ * undefined treated as unset
+ */
+ public async setVideoInput(deviceId: string): Promise<void> {
+ logger.info(`MediaHandler setVideoInput() running (deviceId=${deviceId})`);
+
+ if (this.videoInput === deviceId) return;
+
+ this.videoInput = deviceId;
+ await this.updateLocalUsermediaStreams();
+ }
+
+ /**
+ * Set media input devices to use for MatrixCalls
+ * @param audioInput - the identifier for the audio device
+ * @param videoInput - the identifier for the video device
+ * undefined treated as unset
+ */
+ public async setMediaInputs(audioInput: string, videoInput: string): Promise<void> {
+ logger.log(`MediaHandler setMediaInputs() running (audioInput: ${audioInput} videoInput: ${videoInput})`);
+ this.audioInput = audioInput;
+ this.videoInput = videoInput;
+ await this.updateLocalUsermediaStreams();
+ }
+
+ /*
+ * Requests new usermedia streams and replace the old ones
+ */
+ public async updateLocalUsermediaStreams(): Promise<void> {
+ if (this.userMediaStreams.length === 0) return;
+
+ const callMediaStreamParams: Map<string, { audio: boolean; video: boolean }> = new Map();
+ for (const call of this.client.callEventHandler!.calls.values()) {
+ callMediaStreamParams.set(call.callId, {
+ audio: call.hasLocalUserMediaAudioTrack,
+ video: call.hasLocalUserMediaVideoTrack,
+ });
+ }
+
+ for (const stream of this.userMediaStreams) {
+ logger.log(`MediaHandler updateLocalUsermediaStreams() stopping all tracks (streamId=${stream.id})`);
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+ }
+
+ this.userMediaStreams = [];
+ this.localUserMediaStream = undefined;
+
+ for (const call of this.client.callEventHandler!.calls.values()) {
+ if (call.callHasEnded() || !callMediaStreamParams.has(call.callId)) {
+ continue;
+ }
+
+ const { audio, video } = callMediaStreamParams.get(call.callId)!;
+
+ logger.log(
+ `MediaHandler updateLocalUsermediaStreams() calling getUserMediaStream() (callId=${call.callId})`,
+ );
+ const stream = await this.getUserMediaStream(audio, video);
+
+ if (call.callHasEnded()) {
+ continue;
+ }
+
+ await call.updateLocalUsermediaStream(stream);
+ }
+
+ for (const groupCall of this.client.groupCallEventHandler!.groupCalls.values()) {
+ if (!groupCall.localCallFeed) {
+ continue;
+ }
+
+ logger.log(
+ `MediaHandler updateLocalUsermediaStreams() calling getUserMediaStream() (groupCallId=${groupCall.groupCallId})`,
+ );
+ const stream = await this.getUserMediaStream(true, groupCall.type === GroupCallType.Video);
+
+ if (groupCall.state === GroupCallState.Ended) {
+ continue;
+ }
+
+ await groupCall.updateLocalUsermediaStream(stream);
+ }
+
+ this.emit(MediaHandlerEvent.LocalStreamsChanged);
+ }
+
+ public async hasAudioDevice(): Promise<boolean> {
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ return devices.filter((device) => device.kind === "audioinput").length > 0;
+ } catch (err) {
+ logger.log(`MediaHandler hasAudioDevice() calling navigator.mediaDevices.enumerateDevices with error`, err);
+ return false;
+ }
+ }
+
+ public async hasVideoDevice(): Promise<boolean> {
+ try {
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ return devices.filter((device) => device.kind === "videoinput").length > 0;
+ } catch (err) {
+ logger.log(`MediaHandler hasVideoDevice() calling navigator.mediaDevices.enumerateDevices with error`, err);
+ return false;
+ }
+ }
+
+ /**
+ * @param audio - should have an audio track
+ * @param video - should have a video track
+ * @param reusable - is allowed to be reused by the MediaHandler
+ * @returns based on passed parameters
+ */
+ public async getUserMediaStream(audio: boolean, video: boolean, reusable = true): Promise<MediaStream> {
+ // Serialise calls, othertwise we can't sensibly re-use the stream
+ if (this.getMediaStreamPromise) {
+ this.getMediaStreamPromise = this.getMediaStreamPromise.then(() => {
+ return this.getUserMediaStreamInternal(audio, video, reusable);
+ });
+ } else {
+ this.getMediaStreamPromise = this.getUserMediaStreamInternal(audio, video, reusable);
+ }
+
+ return this.getMediaStreamPromise;
+ }
+
+ private async getUserMediaStreamInternal(audio: boolean, video: boolean, reusable: boolean): Promise<MediaStream> {
+ const shouldRequestAudio = audio && (await this.hasAudioDevice());
+ const shouldRequestVideo = video && (await this.hasVideoDevice());
+
+ let stream: MediaStream;
+
+ let canReuseStream = true;
+ if (this.localUserMediaStream) {
+ // This figures out if we can reuse the current localUsermediaStream
+ // based on whether or not the "mute state" (presence of tracks of a
+ // given kind) matches what is being requested
+ if (shouldRequestAudio !== this.localUserMediaStream.getAudioTracks().length > 0) {
+ canReuseStream = false;
+ }
+ if (shouldRequestVideo !== this.localUserMediaStream.getVideoTracks().length > 0) {
+ canReuseStream = false;
+ }
+
+ // This code checks that the device ID is the same as the localUserMediaStream stream, but we update
+ // the localUserMediaStream whenever the device ID changes (apart from when restoring) so it's not
+ // clear why this would ever be different, unless there's a race.
+ if (
+ shouldRequestAudio &&
+ this.localUserMediaStream.getAudioTracks()[0]?.getSettings()?.deviceId !== this.audioInput
+ ) {
+ canReuseStream = false;
+ }
+ if (
+ shouldRequestVideo &&
+ this.localUserMediaStream.getVideoTracks()[0]?.getSettings()?.deviceId !== this.videoInput
+ ) {
+ canReuseStream = false;
+ }
+ } else {
+ canReuseStream = false;
+ }
+
+ if (!canReuseStream) {
+ const constraints = this.getUserMediaContraints(shouldRequestAudio, shouldRequestVideo);
+ stream = await navigator.mediaDevices.getUserMedia(constraints);
+ logger.log(
+ `MediaHandler getUserMediaStreamInternal() calling getUserMediaStream (streamId=${
+ stream.id
+ }, shouldRequestAudio=${shouldRequestAudio}, shouldRequestVideo=${shouldRequestVideo}, constraints=${JSON.stringify(
+ constraints,
+ )})`,
+ );
+
+ for (const track of stream.getTracks()) {
+ const settings = track.getSettings();
+
+ if (track.kind === "audio") {
+ this.audioInput = settings.deviceId!;
+ } else if (track.kind === "video") {
+ this.videoInput = settings.deviceId!;
+ }
+ }
+
+ if (reusable) {
+ this.localUserMediaStream = stream;
+ }
+ } else {
+ stream = this.localUserMediaStream!.clone();
+ logger.log(
+ `MediaHandler getUserMediaStreamInternal() cloning (oldStreamId=${this.localUserMediaStream?.id} newStreamId=${stream.id} shouldRequestAudio=${shouldRequestAudio} shouldRequestVideo=${shouldRequestVideo})`,
+ );
+
+ if (!shouldRequestAudio) {
+ for (const track of stream.getAudioTracks()) {
+ stream.removeTrack(track);
+ }
+ }
+
+ if (!shouldRequestVideo) {
+ for (const track of stream.getVideoTracks()) {
+ stream.removeTrack(track);
+ }
+ }
+ }
+
+ if (reusable) {
+ this.userMediaStreams.push(stream);
+ }
+
+ this.emit(MediaHandlerEvent.LocalStreamsChanged);
+
+ return stream;
+ }
+
+ /**
+ * Stops all tracks on the provided usermedia stream
+ */
+ public stopUserMediaStream(mediaStream: MediaStream): void {
+ logger.log(`MediaHandler stopUserMediaStream() stopping (streamId=${mediaStream.id})`);
+ for (const track of mediaStream.getTracks()) {
+ track.stop();
+ }
+
+ const index = this.userMediaStreams.indexOf(mediaStream);
+
+ if (index !== -1) {
+ logger.debug(
+ `MediaHandler stopUserMediaStream() splicing usermedia stream out stream array (streamId=${mediaStream.id})`,
+ mediaStream.id,
+ );
+ this.userMediaStreams.splice(index, 1);
+ }
+
+ this.emit(MediaHandlerEvent.LocalStreamsChanged);
+
+ if (this.localUserMediaStream === mediaStream) {
+ this.localUserMediaStream = undefined;
+ }
+ }
+
+ /**
+ * @param desktopCapturerSourceId - sourceId for Electron DesktopCapturer
+ * @param reusable - is allowed to be reused by the MediaHandler
+ * @returns based on passed parameters
+ */
+ public async getScreensharingStream(opts: IScreensharingOpts = {}, reusable = true): Promise<MediaStream> {
+ let stream: MediaStream;
+
+ if (this.screensharingStreams.length === 0) {
+ const screenshareConstraints = this.getScreenshareContraints(opts);
+
+ if (opts.desktopCapturerSourceId) {
+ // We are using Electron
+ logger.debug(
+ `MediaHandler getScreensharingStream() calling getUserMedia() (opts=${JSON.stringify(opts)})`,
+ );
+ stream = await navigator.mediaDevices.getUserMedia(screenshareConstraints);
+ } else {
+ // We are not using Electron
+ logger.debug(
+ `MediaHandler getScreensharingStream() calling getDisplayMedia() (opts=${JSON.stringify(opts)})`,
+ );
+ stream = await navigator.mediaDevices.getDisplayMedia(screenshareConstraints);
+ }
+ } else {
+ const matchingStream = this.screensharingStreams[this.screensharingStreams.length - 1];
+ logger.log(`MediaHandler getScreensharingStream() cloning (streamId=${matchingStream.id})`);
+ stream = matchingStream.clone();
+ }
+
+ if (reusable) {
+ this.screensharingStreams.push(stream);
+ }
+
+ this.emit(MediaHandlerEvent.LocalStreamsChanged);
+
+ return stream;
+ }
+
+ /**
+ * Stops all tracks on the provided screensharing stream
+ */
+ public stopScreensharingStream(mediaStream: MediaStream): void {
+ logger.debug(`MediaHandler stopScreensharingStream() stopping stream (streamId=${mediaStream.id})`);
+ for (const track of mediaStream.getTracks()) {
+ track.stop();
+ }
+
+ const index = this.screensharingStreams.indexOf(mediaStream);
+
+ if (index !== -1) {
+ logger.debug(`MediaHandler stopScreensharingStream() splicing stream out (streamId=${mediaStream.id})`);
+ this.screensharingStreams.splice(index, 1);
+ }
+
+ this.emit(MediaHandlerEvent.LocalStreamsChanged);
+ }
+
+ /**
+ * Stops all local media tracks
+ */
+ public stopAllStreams(): void {
+ for (const stream of this.userMediaStreams) {
+ logger.log(`MediaHandler stopAllStreams() stopping (streamId=${stream.id})`);
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+ }
+
+ for (const stream of this.screensharingStreams) {
+ for (const track of stream.getTracks()) {
+ track.stop();
+ }
+ }
+
+ this.userMediaStreams = [];
+ this.screensharingStreams = [];
+ this.localUserMediaStream = undefined;
+
+ this.emit(MediaHandlerEvent.LocalStreamsChanged);
+ }
+
+ private getUserMediaContraints(audio: boolean, video: boolean): MediaStreamConstraints {
+ const isWebkit = !!navigator.webkitGetUserMedia;
+
+ return {
+ audio: audio
+ ? {
+ deviceId: this.audioInput ? { ideal: this.audioInput } : undefined,
+ autoGainControl: this.audioSettings ? { ideal: this.audioSettings.autoGainControl } : undefined,
+ echoCancellation: this.audioSettings ? { ideal: this.audioSettings.echoCancellation } : undefined,
+ noiseSuppression: this.audioSettings ? { ideal: this.audioSettings.noiseSuppression } : undefined,
+ }
+ : false,
+ video: video
+ ? {
+ deviceId: this.videoInput ? { ideal: this.videoInput } : undefined,
+ /* We want 640x360. Chrome will give it only if we ask exactly,
+ FF refuses entirely if we ask exactly, so have to ask for ideal
+ instead
+ XXX: Is this still true?
+ */
+ width: isWebkit ? { exact: 640 } : { ideal: 640 },
+ height: isWebkit ? { exact: 360 } : { ideal: 360 },
+ }
+ : false,
+ };
+ }
+
+ private getScreenshareContraints(opts: IScreensharingOpts): DesktopCapturerConstraints {
+ const { desktopCapturerSourceId, audio } = opts;
+ if (desktopCapturerSourceId) {
+ return {
+ audio: audio ?? false,
+ video: {
+ mandatory: {
+ chromeMediaSource: "desktop",
+ chromeMediaSourceId: desktopCapturerSourceId,
+ },
+ },
+ };
+ } else {
+ return {
+ audio: audio ?? false,
+ video: true,
+ };
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStats.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStats.ts
new file mode 100644
index 0000000..dbde6e5
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStats.ts
@@ -0,0 +1,47 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { TransportStats } from "./transportStats";
+import { Bitrate } from "./media/mediaTrackStats";
+
+export interface ConnectionStatsBandwidth {
+ /**
+ * bytes per second
+ */
+ download: number;
+ /**
+ * bytes per second
+ */
+ upload: number;
+}
+
+export interface ConnectionStatsBitrate extends Bitrate {
+ audio?: Bitrate;
+ video?: Bitrate;
+}
+
+export interface PacketLoos {
+ total: number;
+ download: number;
+ upload: number;
+}
+
+export class ConnectionStats {
+ public bandwidth: ConnectionStatsBitrate = {} as ConnectionStatsBitrate;
+ public bitrate: ConnectionStatsBitrate = {} as ConnectionStatsBitrate;
+ public packetLoss: PacketLoos = {} as PacketLoos;
+ public transport: TransportStats[] = [];
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStatsReporter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStatsReporter.ts
new file mode 100644
index 0000000..c43b9b4
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/connectionStatsReporter.ts
@@ -0,0 +1,28 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import { Bitrate } from "./media/mediaTrackStats";
+
+export class ConnectionStatsReporter {
+ public static buildBandwidthReport(now: RTCIceCandidatePairStats): Bitrate {
+ const availableIncomingBitrate = now.availableIncomingBitrate;
+ const availableOutgoingBitrate = now.availableOutgoingBitrate;
+
+ return {
+ download: availableIncomingBitrate ? Math.round(availableIncomingBitrate / 1000) : 0,
+ upload: availableOutgoingBitrate ? Math.round(availableOutgoingBitrate / 1000) : 0,
+ };
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/groupCallStats.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/groupCallStats.ts
new file mode 100644
index 0000000..6d8c566
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/groupCallStats.ts
@@ -0,0 +1,64 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import { StatsReportGatherer } from "./statsReportGatherer";
+import { StatsReportEmitter } from "./statsReportEmitter";
+
+export class GroupCallStats {
+ private timer: undefined | ReturnType<typeof setTimeout>;
+ private readonly gatherers: Map<string, StatsReportGatherer> = new Map<string, StatsReportGatherer>();
+ public readonly reports = new StatsReportEmitter();
+
+ public constructor(private groupCallId: string, private userId: string, private interval: number = 10000) {}
+
+ public start(): void {
+ if (this.timer === undefined) {
+ this.timer = setInterval(() => {
+ this.processStats();
+ }, this.interval);
+ }
+ }
+
+ public stop(): void {
+ if (this.timer !== undefined) {
+ clearInterval(this.timer);
+ this.gatherers.forEach((c) => c.stopProcessingStats());
+ }
+ }
+
+ public hasStatsReportGatherer(callId: string): boolean {
+ return this.gatherers.has(callId);
+ }
+
+ public addStatsReportGatherer(callId: string, userId: string, peerConnection: RTCPeerConnection): boolean {
+ if (this.hasStatsReportGatherer(callId)) {
+ return false;
+ }
+ this.gatherers.set(callId, new StatsReportGatherer(callId, userId, peerConnection, this.reports));
+ return true;
+ }
+
+ public removeStatsReportGatherer(callId: string): boolean {
+ return this.gatherers.delete(callId);
+ }
+
+ public getStatsReportGatherer(callId: string): StatsReportGatherer | undefined {
+ return this.hasStatsReportGatherer(callId) ? this.gatherers.get(callId) : undefined;
+ }
+
+ private processStats(): void {
+ this.gatherers.forEach((c) => c.processStats(this.groupCallId, this.userId));
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaSsrcHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaSsrcHandler.ts
new file mode 100644
index 0000000..e606051
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaSsrcHandler.ts
@@ -0,0 +1,57 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { parse as parseSdp } from "sdp-transform";
+
+export type Mid = string;
+export type Ssrc = string;
+export type MapType = "local" | "remote";
+
+export class MediaSsrcHandler {
+ private readonly ssrcToMid = { local: new Map<Mid, Ssrc[]>(), remote: new Map<Mid, Ssrc[]>() };
+
+ public findMidBySsrc(ssrc: Ssrc, type: "local" | "remote"): Mid | undefined {
+ let mid: Mid | undefined;
+ this.ssrcToMid[type].forEach((ssrcs, m) => {
+ if (ssrcs.find((s) => s == ssrc)) {
+ mid = m;
+ return;
+ }
+ });
+ return mid;
+ }
+
+ public parse(description: string, type: MapType): void {
+ const sdp = parseSdp(description);
+ const ssrcToMid = new Map<Mid, Ssrc[]>();
+ sdp.media.forEach((m) => {
+ if ((!!m.mid && m.type === "video") || m.type === "audio") {
+ const ssrcs: Ssrc[] = [];
+ m.ssrcs?.forEach((ssrc) => {
+ if (ssrc.attribute === "cname") {
+ ssrcs.push(`${ssrc.id}`);
+ }
+ });
+ ssrcToMid.set(`${m.mid}`, ssrcs);
+ }
+ });
+ this.ssrcToMid[type] = ssrcToMid;
+ }
+
+ public getSsrcToMidMap(type: MapType): Map<Mid, Ssrc[]> {
+ return this.ssrcToMid[type];
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackHandler.ts
new file mode 100644
index 0000000..32580b1
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackHandler.ts
@@ -0,0 +1,71 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export type TrackId = string;
+
+export class MediaTrackHandler {
+ public constructor(private readonly pc: RTCPeerConnection) {}
+
+ public getLocalTracks(kind: "audio" | "video"): MediaStreamTrack[] {
+ const isNotNullAndKind = (track: MediaStreamTrack | null): boolean => {
+ return track !== null && track.kind === kind;
+ };
+ // @ts-ignore The linter don't get it
+ return this.pc
+ .getTransceivers()
+ .filter((t) => t.currentDirection === "sendonly" || t.currentDirection === "sendrecv")
+ .filter((t) => t.sender !== null)
+ .map((t) => t.sender)
+ .map((s) => s.track)
+ .filter(isNotNullAndKind);
+ }
+
+ public getTackById(trackId: string): MediaStreamTrack | undefined {
+ return this.pc
+ .getTransceivers()
+ .map((t) => {
+ if (t?.sender.track !== null && t.sender.track.id === trackId) {
+ return t.sender.track;
+ }
+ if (t?.receiver.track !== null && t.receiver.track.id === trackId) {
+ return t.receiver.track;
+ }
+ return undefined;
+ })
+ .find((t) => t !== undefined);
+ }
+
+ public getLocalTrackIdByMid(mid: string): string | undefined {
+ const transceiver = this.pc.getTransceivers().find((t) => t.mid === mid);
+ if (transceiver !== undefined && !!transceiver.sender && !!transceiver.sender.track) {
+ return transceiver.sender.track.id;
+ }
+ return undefined;
+ }
+
+ public getRemoteTrackIdByMid(mid: string): string | undefined {
+ const transceiver = this.pc.getTransceivers().find((t) => t.mid === mid);
+ if (transceiver !== undefined && !!transceiver.receiver && !!transceiver.receiver.track) {
+ return transceiver.receiver.track.id;
+ }
+ return undefined;
+ }
+
+ public getActiveSimulcastStreams(): number {
+ //@TODO implement this right.. Check how many layer configured
+ return 3;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStats.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStats.ts
new file mode 100644
index 0000000..69ee9bd
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStats.ts
@@ -0,0 +1,104 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { TrackId } from "./mediaTrackHandler";
+
+export interface PacketLoss {
+ packetsTotal: number;
+ packetsLost: number;
+ isDownloadStream: boolean;
+}
+
+export interface Bitrate {
+ /**
+ * bytes per second
+ */
+ download: number;
+ /**
+ * bytes per second
+ */
+ upload: number;
+}
+
+export interface Resolution {
+ width: number;
+ height: number;
+}
+
+export type TrackStatsType = "local" | "remote";
+
+export class MediaTrackStats {
+ private loss: PacketLoss = { packetsTotal: 0, packetsLost: 0, isDownloadStream: false };
+ private bitrate: Bitrate = { download: 0, upload: 0 };
+ private resolution: Resolution = { width: -1, height: -1 };
+ private framerate = 0;
+ private codec = "";
+
+ public constructor(
+ public readonly trackId: TrackId,
+ public readonly type: TrackStatsType,
+ public readonly kind: "audio" | "video",
+ ) {}
+
+ public getType(): TrackStatsType {
+ return this.type;
+ }
+
+ public setLoss(loos: PacketLoss): void {
+ this.loss = loos;
+ }
+
+ public getLoss(): PacketLoss {
+ return this.loss;
+ }
+
+ public setResolution(resolution: Resolution): void {
+ this.resolution = resolution;
+ }
+
+ public getResolution(): Resolution {
+ return this.resolution;
+ }
+
+ public setFramerate(framerate: number): void {
+ this.framerate = framerate;
+ }
+
+ public getFramerate(): number {
+ return this.framerate;
+ }
+
+ public setBitrate(bitrate: Bitrate): void {
+ this.bitrate = bitrate;
+ }
+
+ public getBitrate(): Bitrate {
+ return this.bitrate;
+ }
+
+ public setCodec(codecShortType: string): boolean {
+ this.codec = codecShortType;
+ return true;
+ }
+
+ public getCodec(): string {
+ return this.codec;
+ }
+
+ public resetBitrate(): void {
+ this.bitrate = { download: 0, upload: 0 };
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStatsHandler.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStatsHandler.ts
new file mode 100644
index 0000000..6fb119c
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/media/mediaTrackStatsHandler.ts
@@ -0,0 +1,86 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import { TrackID } from "../statsReport";
+import { MediaTrackStats } from "./mediaTrackStats";
+import { MediaTrackHandler } from "./mediaTrackHandler";
+import { MediaSsrcHandler } from "./mediaSsrcHandler";
+
+export class MediaTrackStatsHandler {
+ private readonly track2stats = new Map<TrackID, MediaTrackStats>();
+
+ public constructor(
+ public readonly mediaSsrcHandler: MediaSsrcHandler,
+ public readonly mediaTrackHandler: MediaTrackHandler,
+ ) {}
+
+ /**
+ * Find tracks by rtc stats
+ * Argument report is any because the stats api is not consistent:
+ * For example `trackIdentifier`, `mid` not existing in every implementations
+ * https://www.w3.org/TR/webrtc-stats/#dom-rtcinboundrtpstreamstats
+ * https://developer.mozilla.org/en-US/docs/Web/API/RTCInboundRtpStreamStats
+ */
+ public findTrack2Stats(report: any, type: "remote" | "local"): MediaTrackStats | undefined {
+ let trackID;
+ if (report.trackIdentifier) {
+ trackID = report.trackIdentifier;
+ } else if (report.mid) {
+ trackID =
+ type === "remote"
+ ? this.mediaTrackHandler.getRemoteTrackIdByMid(report.mid)
+ : this.mediaTrackHandler.getLocalTrackIdByMid(report.mid);
+ } else if (report.ssrc) {
+ const mid = this.mediaSsrcHandler.findMidBySsrc(report.ssrc, type);
+ if (!mid) {
+ return undefined;
+ }
+ trackID =
+ type === "remote"
+ ? this.mediaTrackHandler.getRemoteTrackIdByMid(report.mid)
+ : this.mediaTrackHandler.getLocalTrackIdByMid(report.mid);
+ }
+
+ if (!trackID) {
+ return undefined;
+ }
+
+ let trackStats = this.track2stats.get(trackID);
+
+ if (!trackStats) {
+ const track = this.mediaTrackHandler.getTackById(trackID);
+ if (track !== undefined) {
+ const kind: "audio" | "video" = track.kind === "audio" ? track.kind : "video";
+ trackStats = new MediaTrackStats(trackID, type, kind);
+ this.track2stats.set(trackID, trackStats);
+ } else {
+ return undefined;
+ }
+ }
+ return trackStats;
+ }
+
+ public findLocalVideoTrackStats(report: any): MediaTrackStats | undefined {
+ const localVideoTracks = this.mediaTrackHandler.getLocalTracks("video");
+ if (localVideoTracks.length === 0) {
+ return undefined;
+ }
+ return this.findTrack2Stats(report, "local");
+ }
+
+ public getTrack2stats(): Map<TrackID, MediaTrackStats> {
+ return this.track2stats;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReport.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReport.ts
new file mode 100644
index 0000000..56d6c4b
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReport.ts
@@ -0,0 +1,56 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ConnectionStatsBandwidth, ConnectionStatsBitrate, PacketLoos } from "./connectionStats";
+import { TransportStats } from "./transportStats";
+import { Resolution } from "./media/mediaTrackStats";
+
+export enum StatsReport {
+ CONNECTION_STATS = "StatsReport.connection_stats",
+ BYTE_SENT_STATS = "StatsReport.byte_sent_stats",
+}
+
+export type TrackID = string;
+export type ByteSend = number;
+
+export interface ByteSentStatsReport extends Map<TrackID, ByteSend> {
+ // is a map: `local trackID` => byte send
+}
+
+export interface ConnectionStatsReport {
+ bandwidth: ConnectionStatsBandwidth;
+ bitrate: ConnectionStatsBitrate;
+ packetLoss: PacketLoos;
+ resolution: ResolutionMap;
+ framerate: FramerateMap;
+ codec: CodecMap;
+ transport: TransportStats[];
+}
+
+export interface ResolutionMap {
+ local: Map<TrackID, Resolution>;
+ remote: Map<TrackID, Resolution>;
+}
+
+export interface FramerateMap {
+ local: Map<TrackID, number>;
+ remote: Map<TrackID, number>;
+}
+
+export interface CodecMap {
+ local: Map<TrackID, string>;
+ remote: Map<TrackID, string>;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportBuilder.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportBuilder.ts
new file mode 100644
index 0000000..c1af471
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportBuilder.ts
@@ -0,0 +1,110 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import { CodecMap, ConnectionStatsReport, FramerateMap, ResolutionMap, TrackID } from "./statsReport";
+import { MediaTrackStats, Resolution } from "./media/mediaTrackStats";
+
+export class StatsReportBuilder {
+ public static build(stats: Map<TrackID, MediaTrackStats>): ConnectionStatsReport {
+ const report = {} as ConnectionStatsReport;
+
+ // process stats
+ const totalPackets = {
+ download: 0,
+ upload: 0,
+ };
+ const lostPackets = {
+ download: 0,
+ upload: 0,
+ };
+ let bitrateDownload = 0;
+ let bitrateUpload = 0;
+ const resolutions: ResolutionMap = {
+ local: new Map<TrackID, Resolution>(),
+ remote: new Map<TrackID, Resolution>(),
+ };
+ const framerates: FramerateMap = { local: new Map<TrackID, number>(), remote: new Map<TrackID, number>() };
+ const codecs: CodecMap = { local: new Map<TrackID, string>(), remote: new Map<TrackID, string>() };
+
+ let audioBitrateDownload = 0;
+ let audioBitrateUpload = 0;
+ let videoBitrateDownload = 0;
+ let videoBitrateUpload = 0;
+
+ for (const [trackId, trackStats] of stats) {
+ // process packet loss stats
+ const loss = trackStats.getLoss();
+ const type = loss.isDownloadStream ? "download" : "upload";
+
+ totalPackets[type] += loss.packetsTotal;
+ lostPackets[type] += loss.packetsLost;
+
+ // process bitrate stats
+ bitrateDownload += trackStats.getBitrate().download;
+ bitrateUpload += trackStats.getBitrate().upload;
+
+ // collect resolutions and framerates
+ if (trackStats.kind === "audio") {
+ audioBitrateDownload += trackStats.getBitrate().download;
+ audioBitrateUpload += trackStats.getBitrate().upload;
+ } else {
+ videoBitrateDownload += trackStats.getBitrate().download;
+ videoBitrateUpload += trackStats.getBitrate().upload;
+ }
+
+ resolutions[trackStats.getType()].set(trackId, trackStats.getResolution());
+ framerates[trackStats.getType()].set(trackId, trackStats.getFramerate());
+ codecs[trackStats.getType()].set(trackId, trackStats.getCodec());
+
+ trackStats.resetBitrate();
+ }
+
+ report.bitrate = {
+ upload: bitrateUpload,
+ download: bitrateDownload,
+ };
+
+ report.bitrate.audio = {
+ upload: audioBitrateUpload,
+ download: audioBitrateDownload,
+ };
+
+ report.bitrate.video = {
+ upload: videoBitrateUpload,
+ download: videoBitrateDownload,
+ };
+
+ report.packetLoss = {
+ total: StatsReportBuilder.calculatePacketLoss(
+ lostPackets.download + lostPackets.upload,
+ totalPackets.download + totalPackets.upload,
+ ),
+ download: StatsReportBuilder.calculatePacketLoss(lostPackets.download, totalPackets.download),
+ upload: StatsReportBuilder.calculatePacketLoss(lostPackets.upload, totalPackets.upload),
+ };
+ report.framerate = framerates;
+ report.resolution = resolutions;
+ report.codec = codecs;
+ return report;
+ }
+
+ private static calculatePacketLoss(lostPackets: number, totalPackets: number): number {
+ if (!totalPackets || totalPackets <= 0 || !lostPackets || lostPackets <= 0) {
+ return 0;
+ }
+
+ return Math.round((lostPackets / totalPackets) * 100);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportEmitter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportEmitter.ts
new file mode 100644
index 0000000..cf01470
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportEmitter.ts
@@ -0,0 +1,33 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { TypedEventEmitter } from "../../models/typed-event-emitter";
+import { ByteSentStatsReport, ConnectionStatsReport, StatsReport } from "./statsReport";
+
+export type StatsReportHandlerMap = {
+ [StatsReport.BYTE_SENT_STATS]: (report: ByteSentStatsReport) => void;
+ [StatsReport.CONNECTION_STATS]: (report: ConnectionStatsReport) => void;
+};
+
+export class StatsReportEmitter extends TypedEventEmitter<StatsReport, StatsReportHandlerMap> {
+ public emitByteSendReport(byteSentStats: ByteSentStatsReport): void {
+ this.emit(StatsReport.BYTE_SENT_STATS, byteSentStats);
+ }
+
+ public emitConnectionStatsReport(report: ConnectionStatsReport): void {
+ this.emit(StatsReport.CONNECTION_STATS, report);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportGatherer.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportGatherer.ts
new file mode 100644
index 0000000..769ba6e
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsReportGatherer.ts
@@ -0,0 +1,183 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ConnectionStats } from "./connectionStats";
+import { StatsReportEmitter } from "./statsReportEmitter";
+import { ByteSend, ByteSentStatsReport, TrackID } from "./statsReport";
+import { ConnectionStatsReporter } from "./connectionStatsReporter";
+import { TransportStatsReporter } from "./transportStatsReporter";
+import { MediaSsrcHandler } from "./media/mediaSsrcHandler";
+import { MediaTrackHandler } from "./media/mediaTrackHandler";
+import { MediaTrackStatsHandler } from "./media/mediaTrackStatsHandler";
+import { TrackStatsReporter } from "./trackStatsReporter";
+import { StatsReportBuilder } from "./statsReportBuilder";
+import { StatsValueFormatter } from "./statsValueFormatter";
+
+export class StatsReportGatherer {
+ private isActive = true;
+ private previousStatsReport: RTCStatsReport | undefined;
+ private currentStatsReport: RTCStatsReport | undefined;
+ private readonly connectionStats = new ConnectionStats();
+
+ private readonly trackStats: MediaTrackStatsHandler;
+
+ // private readonly ssrcToMid = { local: new Map<Mid, Ssrc[]>(), remote: new Map<Mid, Ssrc[]>() };
+
+ public constructor(
+ public readonly callId: string,
+ public readonly remoteUserId: string,
+ private readonly pc: RTCPeerConnection,
+ private readonly emitter: StatsReportEmitter,
+ private readonly isFocus = true,
+ ) {
+ pc.addEventListener("signalingstatechange", this.onSignalStateChange.bind(this));
+ this.trackStats = new MediaTrackStatsHandler(new MediaSsrcHandler(), new MediaTrackHandler(pc));
+ }
+
+ public async processStats(groupCallId: string, localUserId: string): Promise<boolean> {
+ if (this.isActive) {
+ const statsPromise = this.pc.getStats();
+ if (typeof statsPromise?.then === "function") {
+ return statsPromise
+ .then((report) => {
+ // @ts-ignore
+ this.currentStatsReport = typeof report?.result === "function" ? report.result() : report;
+ try {
+ this.processStatsReport(groupCallId, localUserId);
+ } catch (error) {
+ this.isActive = false;
+ return false;
+ }
+
+ this.previousStatsReport = this.currentStatsReport;
+ return true;
+ })
+ .catch((error) => {
+ this.handleError(error);
+ return false;
+ });
+ }
+ this.isActive = false;
+ }
+ return Promise.resolve(false);
+ }
+
+ private processStatsReport(groupCallId: string, localUserId: string): void {
+ const byteSentStats: ByteSentStatsReport = new Map<TrackID, ByteSend>();
+
+ this.currentStatsReport?.forEach((now) => {
+ const before = this.previousStatsReport ? this.previousStatsReport.get(now.id) : null;
+ // RTCIceCandidatePairStats - https://w3c.github.io/webrtc-stats/#candidatepair-dict*
+ if (now.type === "candidate-pair" && now.nominated && now.state === "succeeded") {
+ this.connectionStats.bandwidth = ConnectionStatsReporter.buildBandwidthReport(now);
+ this.connectionStats.transport = TransportStatsReporter.buildReport(
+ this.currentStatsReport,
+ now,
+ this.connectionStats.transport,
+ this.isFocus,
+ );
+
+ // RTCReceivedRtpStreamStats
+ // https://w3c.github.io/webrtc-stats/#receivedrtpstats-dict*
+ // RTCSentRtpStreamStats
+ // https://w3c.github.io/webrtc-stats/#sentrtpstats-dict*
+ } else if (now.type === "inbound-rtp" || now.type === "outbound-rtp") {
+ const trackStats = this.trackStats.findTrack2Stats(
+ now,
+ now.type === "inbound-rtp" ? "remote" : "local",
+ );
+ if (!trackStats) {
+ return;
+ }
+
+ if (before) {
+ TrackStatsReporter.buildPacketsLost(trackStats, now, before);
+ }
+
+ // Get the resolution and framerate for only remote video sources here. For the local video sources,
+ // 'track' stats will be used since they have the updated resolution based on the simulcast streams
+ // currently being sent. Promise based getStats reports three 'outbound-rtp' streams and there will be
+ // more calculations needed to determine what is the highest resolution stream sent by the client if the
+ // 'outbound-rtp' stats are used.
+ if (now.type === "inbound-rtp") {
+ TrackStatsReporter.buildFramerateResolution(trackStats, now);
+ if (before) {
+ TrackStatsReporter.buildBitrateReceived(trackStats, now, before);
+ }
+ } else if (before) {
+ byteSentStats.set(trackStats.trackId, StatsValueFormatter.getNonNegativeValue(now.bytesSent));
+ TrackStatsReporter.buildBitrateSend(trackStats, now, before);
+ }
+ TrackStatsReporter.buildCodec(this.currentStatsReport, trackStats, now);
+ } else if (now.type === "track" && now.kind === "video" && !now.remoteSource) {
+ const trackStats = this.trackStats.findLocalVideoTrackStats(now);
+ if (!trackStats) {
+ return;
+ }
+ TrackStatsReporter.buildFramerateResolution(trackStats, now);
+ TrackStatsReporter.calculateSimulcastFramerate(
+ trackStats,
+ now,
+ before,
+ this.trackStats.mediaTrackHandler.getActiveSimulcastStreams(),
+ );
+ }
+ });
+
+ this.emitter.emitByteSendReport(byteSentStats);
+ this.processAndEmitReport();
+ }
+
+ public setActive(isActive: boolean): void {
+ this.isActive = isActive;
+ }
+
+ public getActive(): boolean {
+ return this.isActive;
+ }
+
+ private handleError(_: any): void {
+ this.isActive = false;
+ }
+
+ private processAndEmitReport(): void {
+ const report = StatsReportBuilder.build(this.trackStats.getTrack2stats());
+
+ this.connectionStats.bandwidth = report.bandwidth;
+ this.connectionStats.bitrate = report.bitrate;
+ this.connectionStats.packetLoss = report.packetLoss;
+
+ this.emitter.emitConnectionStatsReport({
+ ...report,
+ transport: this.connectionStats.transport,
+ });
+
+ this.connectionStats.transport = [];
+ }
+
+ public stopProcessingStats(): void {}
+
+ private onSignalStateChange(): void {
+ if (this.pc.signalingState === "stable") {
+ if (this.pc.currentRemoteDescription) {
+ this.trackStats.mediaSsrcHandler.parse(this.pc.currentRemoteDescription.sdp, "remote");
+ }
+ if (this.pc.currentLocalDescription) {
+ this.trackStats.mediaSsrcHandler.parse(this.pc.currentLocalDescription.sdp, "local");
+ }
+ }
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsValueFormatter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsValueFormatter.ts
new file mode 100644
index 0000000..c658fa6
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/statsValueFormatter.ts
@@ -0,0 +1,27 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+ http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+export class StatsValueFormatter {
+ public static getNonNegativeValue(imput: any): number {
+ let value = imput;
+
+ if (typeof value !== "number") {
+ value = Number(value);
+ }
+
+ if (isNaN(value)) {
+ return 0;
+ }
+
+ return Math.max(0, value);
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/trackStatsReporter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/trackStatsReporter.ts
new file mode 100644
index 0000000..1f6fcd6
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/trackStatsReporter.ts
@@ -0,0 +1,117 @@
+import { MediaTrackStats } from "./media/mediaTrackStats";
+import { StatsValueFormatter } from "./statsValueFormatter";
+
+export class TrackStatsReporter {
+ public static buildFramerateResolution(trackStats: MediaTrackStats, now: any): void {
+ const resolution = {
+ height: now.frameHeight,
+ width: now.frameWidth,
+ };
+ const frameRate = now.framesPerSecond;
+
+ if (resolution.height && resolution.width) {
+ trackStats.setResolution(resolution);
+ }
+ trackStats.setFramerate(Math.round(frameRate || 0));
+ }
+
+ public static calculateSimulcastFramerate(trackStats: MediaTrackStats, now: any, before: any, layer: number): void {
+ let frameRate = trackStats.getFramerate();
+ if (!frameRate) {
+ if (before) {
+ const timeMs = now.timestamp - before.timestamp;
+
+ if (timeMs > 0 && now.framesSent) {
+ const numberOfFramesSinceBefore = now.framesSent - before.framesSent;
+
+ frameRate = (numberOfFramesSinceBefore / timeMs) * 1000;
+ }
+ }
+
+ if (!frameRate) {
+ return;
+ }
+ }
+
+ // Reset frame rate to 0 when video is suspended as a result of endpoint falling out of last-n.
+ frameRate = layer ? Math.round(frameRate / layer) : 0;
+ trackStats.setFramerate(frameRate);
+ }
+
+ public static buildCodec(report: RTCStatsReport | undefined, trackStats: MediaTrackStats, now: any): void {
+ const codec = report?.get(now.codecId);
+
+ if (codec) {
+ /**
+ * The mime type has the following form: video/VP8 or audio/ISAC,
+ * so we what to keep just the type after the '/', audio and video
+ * keys will be added on the processing side.
+ */
+ const codecShortType = codec.mimeType.split("/")[1];
+
+ codecShortType && trackStats.setCodec(codecShortType);
+ }
+ }
+
+ public static buildBitrateReceived(trackStats: MediaTrackStats, now: any, before: any): void {
+ trackStats.setBitrate({
+ download: TrackStatsReporter.calculateBitrate(
+ now.bytesReceived,
+ before.bytesReceived,
+ now.timestamp,
+ before.timestamp,
+ ),
+ upload: 0,
+ });
+ }
+
+ public static buildBitrateSend(trackStats: MediaTrackStats, now: any, before: any): void {
+ trackStats.setBitrate({
+ download: 0,
+ upload: this.calculateBitrate(now.bytesSent, before.bytesSent, now.timestamp, before.timestamp),
+ });
+ }
+
+ public static buildPacketsLost(trackStats: MediaTrackStats, now: any, before: any): void {
+ const key = now.type === "outbound-rtp" ? "packetsSent" : "packetsReceived";
+
+ let packetsNow = now[key];
+ if (!packetsNow || packetsNow < 0) {
+ packetsNow = 0;
+ }
+
+ const packetsBefore = StatsValueFormatter.getNonNegativeValue(before[key]);
+ const packetsDiff = Math.max(0, packetsNow - packetsBefore);
+
+ const packetsLostNow = StatsValueFormatter.getNonNegativeValue(now.packetsLost);
+ const packetsLostBefore = StatsValueFormatter.getNonNegativeValue(before.packetsLost);
+ const packetsLostDiff = Math.max(0, packetsLostNow - packetsLostBefore);
+
+ trackStats.setLoss({
+ packetsTotal: packetsDiff + packetsLostDiff,
+ packetsLost: packetsLostDiff,
+ isDownloadStream: now.type !== "outbound-rtp",
+ });
+ }
+
+ private static calculateBitrate(
+ bytesNowAny: any,
+ bytesBeforeAny: any,
+ nowTimestamp: number,
+ beforeTimestamp: number,
+ ): number {
+ const bytesNow = StatsValueFormatter.getNonNegativeValue(bytesNowAny);
+ const bytesBefore = StatsValueFormatter.getNonNegativeValue(bytesBeforeAny);
+ const bytesProcessed = Math.max(0, bytesNow - bytesBefore);
+
+ const timeMs = nowTimestamp - beforeTimestamp;
+ let bitrateKbps = 0;
+
+ if (timeMs > 0) {
+ // TODO is there any reason to round here?
+ bitrateKbps = Math.round((bytesProcessed * 8) / timeMs);
+ }
+
+ return bitrateKbps;
+ }
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStats.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStats.ts
new file mode 100644
index 0000000..2b6e975
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStats.ts
@@ -0,0 +1,26 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export interface TransportStats {
+ ip: string;
+ type: string;
+ localIp: string;
+ isFocus: boolean;
+ localCandidateType: string;
+ remoteCandidateType: string;
+ networkType: string;
+ rtt: number;
+}
diff --git a/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStatsReporter.ts b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStatsReporter.ts
new file mode 100644
index 0000000..d419a73
--- /dev/null
+++ b/includes/external/matrix/node_modules/matrix-js-sdk/src/webrtc/stats/transportStatsReporter.ts
@@ -0,0 +1,48 @@
+import { TransportStats } from "./transportStats";
+
+export class TransportStatsReporter {
+ public static buildReport(
+ report: RTCStatsReport | undefined,
+ now: RTCIceCandidatePairStats,
+ conferenceStatsTransport: TransportStats[],
+ isFocus: boolean,
+ ): TransportStats[] {
+ const localUsedCandidate = report?.get(now.localCandidateId);
+ const remoteUsedCandidate = report?.get(now.remoteCandidateId);
+
+ // RTCIceCandidateStats
+ // https://w3c.github.io/webrtc-stats/#icecandidate-dict*
+ if (remoteUsedCandidate && localUsedCandidate) {
+ const remoteIpAddress =
+ remoteUsedCandidate.ip !== undefined ? remoteUsedCandidate.ip : remoteUsedCandidate.address;
+ const remotePort = remoteUsedCandidate.port;
+ const ip = `${remoteIpAddress}:${remotePort}`;
+
+ const localIpAddress =
+ localUsedCandidate.ip !== undefined ? localUsedCandidate.ip : localUsedCandidate.address;
+ const localPort = localUsedCandidate.port;
+ const localIp = `${localIpAddress}:${localPort}`;
+
+ const type = remoteUsedCandidate.protocol;
+
+ // Save the address unless it has been saved already.
+ if (
+ !conferenceStatsTransport.some(
+ (t: TransportStats) => t.ip === ip && t.type === type && t.localIp === localIp,
+ )
+ ) {
+ conferenceStatsTransport.push({
+ ip,
+ type,
+ localIp,
+ isFocus,
+ localCandidateType: localUsedCandidate.candidateType,
+ remoteCandidateType: remoteUsedCandidate.candidateType,
+ networkType: localUsedCandidate.networkType,
+ rtt: now.currentRoundTripTime ? now.currentRoundTripTime * 1000 : NaN,
+ } as TransportStats);
+ }
+ }
+ return conferenceStatsTransport;
+ }
+}